feat: Neuigkeiten — der Stern wandert in die Kopfzeile und bekommt ein Popup

Der „Was ist neu?"-Knopf stand im Diagramm-Kopf und war verborgen, solange es
nichts gab — er konnte also nur etwas über das offene Dokument sagen, und
meistens sagte er gar nichts. Jetzt steht er permanent in der oberen
Bedienleiste und öffnet ein Popup mit der Chronik der letzten Tage; je Tag
führt ein Link die betroffenen Knoten im Diagramm vor (dieselbe gelbe
Kranz-Ansicht wie D28, nur mit einer anderen Frage).

Zwei Quellen, jede in ihrer Rolle: docs/CHANGELOG.md sagt, WAS geschehen ist
(englisch — das Popup ist Oberfläche in neun Sprachen, die Commit-Betreffs
sind deutsch); die git-Historie des mitgelieferten Plans sagt, WELCHE Knoten
sich bewegt haben. Beides wird zur Bauzeit eingelesen (Vite-Plugin), zur
Laufzeit lädt Werkbaum weiterhin nichts nach.

Bernstein heißt ungesehen, Petrol heißt „wird gerade vorgeführt". Der
Besuchsvergleich (D28) steht als abgesetzter Abschnitt zuoberst im Popup und
trägt den „gesehen"-Knopf, den vorher der Knopf selbst war.

SPEC §9 (Neuigkeiten) und D58; 20 neue Tests, davon einer auf der
ausgelieferten CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 12:50:13 +02:00
co-authored by Claude Opus 5
parent 71bfb62b35
commit 36154f6f68
12 changed files with 914 additions and 39 deletions
+4
View File
@@ -19,6 +19,10 @@ Integrations-Backend.
Eintrag begründen, alte Einträge nie löschen. Besonders D13 (Backend-Stack) Eintrag begründen, alte Einträge nie löschen. Besonders D13 (Backend-Stack)
und D14 (Parser-Hoheit) beachten. und D14 (Parser-Hoheit) beachten.
- Ziele: docs/ROADMAP.md · Offene Arbeit: docs/TASKS.md (Checkboxen pflegen). - Ziele: docs/ROADMAP.md · Offene Arbeit: docs/TASKS.md (Checkboxen pflegen).
- Änderungen: @docs/CHANGELOG.md — **jedes Feature und jeder behobene Fehler
bekommt dort eine Zeile**, englisch, ein Satz, unter der Überschrift des
Tages (`## JJJJ-MM-TT`). Die Datei speist das Neuigkeiten-Popup im Editor
(D58); ohne die Zeile geschieht die Änderung für den Benutzer unsichtbar.
## Querschnitts-Konventionen ## Querschnitts-Konventionen
- Doku auf Deutsch. Die Editor-UI ist mehrsprachig (DE/EN/ES/FR direkt, - Doku auf Deutsch. Die Editor-UI ist mehrsprachig (DE/EN/ES/FR direkt,
+99
View File
@@ -0,0 +1,99 @@
# Changelog
What changed, newest first. This file feeds the **What's new** popup in the
editor (the star in the header, D58): every `## YYYY-MM-DD` heading opens a day,
every bullet under it becomes one note. Anything else here — like this
paragraph — is read by people, not by the build.
**English, deliberately.** The project's documentation is German (CLAUDE.md),
but this file is shown *inside the product*, whose interface speaks nine
languages. Like the shipped plan and `llms.md` it is a delivered artefact with a
worldwide audience (D22, D43). Keep the notes short — one line, what changed,
from the reader's side rather than the commit's. `Backticks` become code in the
popup; no other markup is honoured, so write plain sentences.
The node highlighting per day does **not** come from here: it is computed from
the git history of `docs/examples/werkbaum.werkbaum`. A day can therefore carry
a link without having a note (someone forgot to write one) — but never the
reverse.
## 2026-08-24
- A star in the header opens What's new: the changes of the last few days, and a link per day that shows the nodes it touched in the diagram
- A warning triangle replaces the question mark as the pointer over faulty line numbers
- The node window replaces the browser tooltip everywhere — at the pointer, on keyboard focus and on touch
- A `#` button in the diagram header puts node IDs in front of the titles
- Typing `#.kc` under `#prod-stage` now expands to `#prod-stage.kc` when you leave the line
- A camera button next to the history saves a snapshot on demand
- Fix: the manual snapshot button confirmed without saving anything while nothing had changed yet
- Warning line numbers carry their message as a tooltip
- `llms.txt` now points at the notation guide, and `llms.md` is served as UTF-8
## 2026-08-23
- Every node of the shipped Werkbaum plan carries an ID and a description
- One button steps from station to station along the cheapest path
- The cheapest path shows the open front: what is done costs nothing any more
- A new document starts by asking for its name
- The text area no longer wraps lines — the indentation keeps carrying the hierarchy
- Node IDs read `#auth: Title` now, with an optional separating colon
- Fold marks moved behind the status box, so the boxes stay aligned
- Fix: the document and download menus open again on small screens
## 2026-08-22
- Node IDs (`#auth`) name a node across the whole document
- Dependencies (`:#auth,#api`) point across the tree, cycles allowed
- The node colour shows the effective status — what a node waits for holds it back
- Dependency links are drawn as thin dotted curves behind the nodes
- Subtrees fold and unfold, in the diagram and as `>` / `<` in the text
- Exactly-one groups (`=`) with a "1" plaque on the collector rail
- Node descriptions, as `"` lines or as blocks behind a `---` divider
- The cheapest path counts shared dependencies once — and says so when it has to guess
- "Restore original" brings a shipped document back to the delivered state
- `llms.md` explains the notation to AI agents
## 2026-08-16
- The example plan is called `werkbaum.werkbaum` and describes Werkbaum itself
## 2026-08-05
- Line numbers next to the text, warnings marked in the strip
- Alt+click in the text centres the node of the caret line
## 2026-07-30
- The focus mark `!!!` got a teal crown of its own
- Research on updating by itself: what an Etherpad instance can and cannot do
- An IntelliJ plugin recorded as an idea in the plan
## 2026-07-27
- `+` marks an optional node — an extra, neither required nor an alternative
- Consecutive optional leaves cascade into a staircase instead of taking a column each
- "What's new" highlights what went into production since your last visit
- A shared Etherpad can be watched, embedded and reloaded
## 2026-07-25
- The notation text can be loaded from a URL with `?sourceUrl=`
## 2026-07-23
- Several documents side by side, switched from the editor's title bar
- The interface language follows the browser, German as the fallback
- High risk `[!]` carries a warning triangle
## 2026-07-22
- IBM Plex is embedded locally — no request to Google, no IP address to third parties
- Zoom controls for the diagram
- Accessibility: spoken labels per node, focus order, live region for warnings
## 2026-07-21
- First version: text notation, parser, and a diagram in three layout modes
- Status boxes, t-shirt sizes, people tags, bare URLs and `%%` comments
- The cheapest path through the tree, with a metro-style line through the open leaves
- Print stylesheet and SVG/PNG export of the diagram
+108
View File
@@ -4077,3 +4077,111 @@ mit synthetischem `focusin` öffnete es sofort. Erst das Fronten des Tabs samt
echtem Klick und echter Tab-Taste hat es bewiesen. Wie D25 (synthetische echtem Klick und echter Tab-Taste hat es bewiesen. Wie D25 (synthetische
`TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53 (synthetisches `TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53 (synthetisches
Strg+Z): Was die Umgebung stellt, stellt der Emulator nicht. Strg+Z): Was die Umgebung stellt, stellt der Emulator nicht.
## D58 — Neuigkeiten: der Stern wandert in die Kopfzeile und bekommt ein Popup
Der „Was ist neu?"-Knopf (D28) stand im Diagramm-Kopf und war **verborgen,
solange es nichts gab** — er konnte also nur etwas über das gerade offene
Dokument sagen, und meistens sagte er gar nichts. Jetzt steht er permanent in
der oberen Bedienleiste, zeigt ein Popup mit der Chronik der letzten Tage, und
jeder Tag führt seine Knoten im Diagramm vor.
**Zwei Aussagen, ein Knopf.** Die **Chronik** ist allgemein („was ist am
Produkt geschehen"), der **Besuchsvergleich** persönlich („was ist seit deinem
letzten Besuch live gegangen"). Sie zu trennen hieße, zwei Knöpfe in eine Zeile
zu setzen, die D56 gerade erst auf zehn Elemente zurechtgemessen hat — und für
den Betrachter sind es ohnehin dieselbe Frage in zwei Zeitmaßstäben. Der
Besuchsvergleich steht als abgesetzter Abschnitt **zuoberst** im Popup und
trägt den „gesehen"-Knopf, den vorher der Knopf selbst war.
**Bernstein heißt ungesehen, Petrol heißt „wird vorgeführt".** Zwei Zustände,
zwei Farben, beide schon vergeben: Bernstein ist die Farbe des Strahlenkranzes
am Knoten (D28) — Knopf und Knoten sagen damit dasselbe —, Petrol die für
Interaktion (D32). Ein dritter Kanal war nicht nötig.
**Aufgeschlagen heißt gelesen.** Der Deckel wandert beim Öffnen auf den
**neuesten gelisteten Tag**, nicht auf „heute": Ein Datum aus der Uhr des
Betrachters verglichen mit einem Datum aus dem Build ginge schief, sobald die
Uhren auseinanderliegen. Der Besuchsvergleich behält seinen eigenen Knopf — er
hat eine andere Basis (den Text der zuletzt gesehenen Fassung, D28).
**Woher die Daten kommen: zwei Quellen, jede in ihrer Rolle.**
- `docs/CHANGELOG.md`**was** geschehen ist, ein englischer Satz je Änderung.
- Die git-Historie von `docs/examples/werkbaum.werkbaum` → **welche Knoten**
sich an diesem Tag bewegt haben (neu oder mit anderem Status).
Beides wird **zur Bauzeit** eingelesen und als virtuelles Modul eingebettet
(Vite-Plugin). Zur Laufzeit gibt es kein git und keinen Server, der nachliefern
könnte (D11/D19), und nachladen würde D20 brechen. Der Preis ist benannt: Der
Dev-Server liest einmal beim Start, neue Einträge erscheinen nach einem
Neustart.
**Die Notizen kommen NICHT aus den Commit-Betreffs** — obwohl sie dort stünden
und die erste Fassung sie genau so gezogen hat (samt Filter für Bau-, Test- und
Beförderungs-Commits). Der Nutzer hat die richtige Frage gestellt: *„Sind die
Neuigkeiten nun in allen unterstützten Sprachen? Da hätte ich eine Rückfrage
erwartet."* Die Betreffs sind **deutsch** (CLAUDE.md: Doku auf Deutsch), das
Popup aber ist Produkt-Oberfläche in neun Sprachen — ein japanischer Besucher
hätte einen japanischen Rahmen um deutsche Sätze bekommen. Entschieden
(Nutzer): eine **gepflegte englische Changelog-Datei**, wie der mitgelieferte
Plan und `llms.md` ausgeliefertes Artefakt mit weltweitem Publikum (D22, D43).
Verworfen waren: **deutsch lassen und benennen** (billig, aber acht der neun
Sprachen lesen es nicht) und **die Notizen aus den Knoten-Labels des Plans
ableiten** (schon englisch, keine Pflege, Text und Hervorhebung sagten
zwangsläufig dasselbe — aber „Fold marks → fertig" ist eine Statusmeldung, kein
Satz, und Tage ohne Plan-Änderung fielen ganz weg). Der Preis der gewählten
Lösung ist ehrlich zu nennen: **eine Datei mehr, die beim Bauen eines Features
mitgeschrieben werden muss** — als Regel in CLAUDE.md festgehalten, sonst
veraltet sie still.
**Der Link je Tag nennt die Zahl der Knoten, die es HEUTE noch gibt.** Die
Schlüssel sind Label-Pfade (dieselbe Identität wie D28/D38); ein seither
umbenannter Knoten ist nicht mehr zu treffen. Gezählt wird deshalb gegen den
aktuellen Plan — im mitgelieferten Stand sind das beim 22.08. dreißig statt der
zweiunddreißig gespeicherten. Ein Link, der „32 Knoten" verspricht und 30
zeigt, wäre eine kleine Lüge an einer Stelle, an der es nichts kostet, die
Wahrheit zu sagen.
**Ein Tag kann einen Link ohne Notizen haben, aber nie umgekehrt.** Der
Beförderungs-Commit (D30) ist der Regelfall: An einem reinen Deploy-Tag bewegt
sich der Plan, ohne dass jemand einen Changelog-Eintrag schreibt. Ohne die
Vereinigung beider Quellen fiele ausgerechnet der Tag stumm unter den Tisch, an
dem etwas in Produktion gegangen ist.
**Die Vorführung ersetzt den Besuchsvergleich, statt neben ihm zu stehen.** Es
ist dieselbe Ansicht (gelber Kranz), nur mit einer anderen Frage; zwei
gleichzeitige Mengen im selben Kanal wären nicht auseinanderzuhalten.
Umgeschaltet wird dabei auf den mitgelieferten Plan — dessen Knoten sind
gemeint. **Reihenfolge beachtet:** `switchDoc()` räumt einen vorgeführten Tag
ausdrücklich weg (in einem anderen Dokument zeigten seine Schlüssel ins Leere),
also muss erst gewechselt und dann gesetzt werden. In der anderen Reihenfolge
löschte der Wechsel gerade den Tag, den man zeigen wollte — beim Bauen
hineingelaufen.
**Nicht persistiert.** Der Faltzustand steht im Text (D38), der Besuchsstand im
localStorage (D28) — eine vorgeführte Chronik ist weder das eine noch das
andere. Sie endet mit der Sitzung, mit dem Dokumentwechsel oder mit dem zweiten
Druck.
**Auf dem Telefon ist der Bezug die Werkzeugleiste, nicht der Knopf.** Das
Popup ist 351 px breit, der Knopf 30 — an ihm ausgerichtet begann es bei
**116 px**, also außerhalb des Bildes (nachgemessen bei 375 px). `body.mobile
.newswrap{position:static}` macht die `.header-tools` zum Bezug, deren rechter
Rand auch der der Seite ist: danach 17 bis 368 px. Ein `overflow` an der
Kopfzeile als Ausweg verbietet sich — das klippt genau diese Menüs (D50).
**Backticks werden zu Code-Stücken**, und zwar **nach** dem Escapen: Was der
Ersetzung vorliegt, ist bereits harmloser Text, der Weg zu eigenem Markup
bleibt also verschlossen. Ohne die Ersetzung stünde `` `#auth` `` mit nackten
Backticks im Popup und läse sich wie ein Tippfehler.
**Nachgemessen** (Werkbaum-Plan, Dev-Server): 12 Tage im Popup, davon einer
(28.07.) ohne Notizen mit Link — der Deploy-Tag. Der Link des 24.08. hebt 12
Knoten hervor, holt den ersten in die Mitte und färbt den Knopf petrol; ein
zweiter Druck stellt die 11 Knoten des Besuchsvergleichs wieder her; „gesehen"
löscht sie und nimmt dem Knopf das Bernstein. Bei 375 px liegt das Popup
vollständig im Bild und wird an beiden Kanten getroffen (`elementFromPoint`),
die Kopfzeile bleibt einreihig. 20 neue Tests in `frontend/tests/news.test.js`,
darunter einer, der die **ausgelieferte** `docs/CHANGELOG.md` liest — ist sie
unlesbar, stünde das Popup sonst leer da, ohne dass es jemand merkt.
+38 -2
View File
@@ -650,12 +650,48 @@ Bei Dokumenten, die von außen kommen (mitgeliefert, per `?sourceUrl=` oder
gezeigt, was sich seit dem letzten Besuch getan hat. **„Neu" heißt: neu in 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 Produktion** — ein Knoten trägt jetzt `[^]` und tat es in der zuletzt gesehenen
Fassung nicht. Solche Knoten bekommen einen **gelben Strahlenkranz** nach außen 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 Füllung bleibt die Statusfarbe aus §4). Die Anzahl steht am
die Anzahl und bestätigt per Klick; danach ist die aktuelle Fassung die neue **Neuigkeiten-Knopf** in der Kopfzeile (siehe unten), bestätigt wird im Popup;
danach ist die aktuelle Fassung die neue
Vergleichsbasis. Beim ersten Ansehen eines Dokuments leuchtet nichts. Der Kranz Vergleichsbasis. Beim ersten Ansehen eines Dokuments leuchtet nichts. Der Kranz
erscheint weder im Druck noch im Grafikexport — er hängt am persönlichen erscheint weder im Druck noch im Grafikexport — er hängt am persönlichen
Besuchsstand. Siehe D28. Besuchsstand. Siehe D28.
### Neuigkeiten (Stern in der Kopfzeile)
Ein **Stern-Knopf in der oberen Bedienleiste** ist immer sichtbar und öffnet ein
Popup mit den Änderungen der letzten Tage — je Tag ein Datum und ein paar kurze
Notizen. Er trägt zwei Aussagen, die zusammengehören:
- **Die Chronik** (allgemein): was am Produkt geschehen ist. Die Notizen stehen
in `docs/CHANGELOG.md`, die Knoten je Tag kommen aus der Versionsgeschichte
des mitgelieferten Plans; beides wird **beim Bauen** eingelesen und
eingebettet (zur Laufzeit lädt Werkbaum nichts nach, D20).
- **Der Besuchsvergleich** (persönlich): „Was ist neu?" des aktiven Dokuments
(oben) — als abgesetzter Abschnitt zuoberst im Popup, mit dem Knopf
„gesehen".
**Bernstein heißt ungesehen** — dieselben Töne wie der Strahlenkranz am Knoten,
damit Knopf und Knoten dasselbe sagen. Er färbt sich, solange es unangesehene
Tage gibt oder das aktive Dokument neue Knoten hat; die Zahl daneben ist die der
neuen Knoten. Aufgeschlagen heißt gelesen: Das Öffnen des Popups merkt den
neuesten gelisteten Tag als gesehen.
**Jeder Tag mit Knotenänderungen trägt einen Link**, der genau diese Knoten im
Diagramm in der „Was ist neu?"-Ansicht vorführt (gelber Kranz, §9) — dieselbe
Ansicht, nur mit einer anderen Frage: „was geschah am 24.08." statt „was ist
seit deinem letzten Besuch live gegangen". Dabei wird auf den mitgelieferten
Plan umgeschaltet, denn dessen Knoten sind gemeint; der Knopf steht dann in
Petrol („wird gerade vorgeführt") und ein zweiter Druck hebt es wieder auf.
Genannt wird die Zahl der Knoten, die es **heute noch gibt** — ein seither
umbenannter Knoten ist nicht mehr zu treffen, und der Link verspricht nichts,
was er nicht halten kann. Die Vorführung ist Sitzungssache und wird nicht
gemerkt.
Die **Notizen sind englisch**, auch wenn die Oberfläche in einer anderen Sprache
steht: `docs/CHANGELOG.md` ist ein ausgeliefertes Artefakt mit weltweitem
Publikum, wie der mitgelieferte Plan und `llms.md` (§13). Übersetzt ist alles
übrige — Titel, Knöpfe und die Datumsangaben. Siehe D58.
### 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):
+7
View File
@@ -80,6 +80,7 @@
- [^] #ed.snaps.manual: Save a state by hand, before a larger change (XS) %% ten minutes is the wrong beat for that moment - [^] #ed.snaps.manual: Save a state by hand, before a larger change (XS) %% ten minutes is the wrong beat for that moment
- [ ] #ed.files: Open and save .werkbaum files (S) - [ ] #ed.files: Open and save .werkbaum files (S)
+ [^] #ed.fresh: Show what is new since your last visit (S) + [^] #ed.fresh: Show what is new since your last visit (S)
- [x] #ed.fresh.news: A star in the header, with the last few days (S) %% see D58
+ [?] #ed.percolor: A pastel colour per person (S) + [?] #ed.percolor: A pastel colour per person (S)
- [?] #ed.dates: Dates and milestones (M) - [?] #ed.dates: Dates and milestones (M)
| [?] #ed.dates.attr: An attribute in the line (S) | [?] #ed.dates.attr: An attribute in the line (S)
@@ -555,6 +556,12 @@
your last visit get a yellow halo. New means live, not "line added" — a line your last visit get a yellow halo. New means live, not "line added" — a line
diff would be mostly noise. diff would be mostly noise.
#ed.fresh.news
A star that is always in the header opens a popup with the changes of the
last few days; every day can show its nodes in the diagram, in the very same
halo view. Notes come from docs/CHANGELOG.md, the nodes from the plan's git
history, both read at build time.
#ed.percolor #ed.percolor
Give every @name a colour derived from the name itself, so the same person Give every @name a colour derived from the name itself, so the same person
is recognizable across the tree without looking anything up. is recognizable across the tree without looking anything up.
+12
View File
@@ -526,6 +526,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Hash-Rückfall, der Marker-Pfad ist nur zu prüfen, wenn man einen echten Hash-Rückfall, der Marker-Pfad ist nur zu prüfen, wenn man einen echten
`commit/<sha>` in `index.html` einspritzt (danach zurücknehmen!) und `commit/<sha>` in `index.html` einspritzt (danach zurücknehmen!) und
`window.fetch` überschreibt. `window.fetch` überschreibt.
- **Neuigkeiten (D58):** `NEWS` kommt aus dem virtuellen Modul
`virtual:werkbaum-news` (Vite-Plugin in `vite.config.js`), gefüllt zur
**Bauzeit** aus `docs/CHANGELOG.md` (die Notizen) und der git-Historie des
mitgelieferten Plans (die Knoten je Tag). Die Regeln stehen in
`scripts/news.mjs` und sind getestet; `app.js` verdrahtet nur. Drei Dinge, die
beim Anfassen auffallen: Der **Dev-Server liest einmal beim Start** — neue
Changelog-Zeilen erscheinen erst nach einem Neustart, nicht per HMR. Ein
vorgeführter Tag muss **nach** `switchDoc()` gesetzt werden, denn das räumt
ihn ausdrücklich weg. Und die Backtick-Ersetzung läuft **nach** `esc()`, damit
aus einer Changelog-Zeile kein Markup werden kann. Ein Feature ohne Zeile in
`docs/CHANGELOG.md` geschieht für den Benutzer unsichtbar (Regel in der
Wurzel-CLAUDE.md).
- **Titelzeilen tragen die Aufklapp-Menüs — kein `overflow` daran (D50):** - **Titelzeilen tragen die Aufklapp-Menüs — kein `overflow` daran (D50):**
`#docMenu` und `.dlmenu` hängen als absolut positionierte Kinder im `#docMenu` und `.dlmenu` hängen als absolut positionierte Kinder im
`.panel-head`. Ein `overflow` dort macht ihn zum Scroll-Container und klippt `.panel-head`. Ein `overflow` dort macht ihn zum Scroll-Container und klippt
+15 -6
View File
@@ -23,6 +23,17 @@
<span data-i18n-title="brandTooltip" title="„Werkbaum“ bedeutet so viel wie Werk-Baum — der Baum des Projektstrukturplans (WBS).">Werk<span style="color:var(--or)">baum</span></span> <span data-i18n-title="brandTooltip" title="„Werkbaum“ bedeutet so viel wie Werk-Baum — der Baum des Projektstrukturplans (WBS).">Werk<span style="color:var(--or)">baum</span></span>
</h1> </h1>
<div class="header-tools"> <div class="header-tools">
<!-- Neuigkeiten (D58): der Stern aus dem Diagramm-Kopf, jetzt permanent.
Sein Popup zeigt die Änderungen der letzten Tage (aus der git-Historie,
zur Bauzeit eingelesen); je Tag führt ein Link die betroffenen Knoten
in der „Was ist neu?"-Ansicht (D28) im Diagramm vor. -->
<span class="newswrap">
<button type="button" id="newsBtn" class="fsbtn newsbtn" aria-haspopup="dialog" aria-expanded="false" data-i18n-title="newsTooltip" data-i18n-aria="newsTooltip" title="Neuigkeiten" aria-label="Neuigkeiten">
<svg viewBox="0 0 24 24" width="16" height="16" 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" hidden>0</span>
</button>
<div class="newsmenu" id="newsMenu" role="dialog" data-i18n-aria="newsTitle" aria-label="Neuigkeiten" hidden></div>
</span>
<button type="button" id="fsToggle" class="fsbtn" aria-pressed="false" data-i18n-title="fullscreenTooltip" data-i18n-aria="fullscreenTooltip" title="Vollbild Panels nutzen die ganze Fensterbreite" aria-label="Vollbild Panels nutzen die ganze Fensterbreite"> <button type="button" id="fsToggle" class="fsbtn" aria-pressed="false" data-i18n-title="fullscreenTooltip" data-i18n-aria="fullscreenTooltip" title="Vollbild Panels nutzen die ganze Fensterbreite" aria-label="Vollbild Panels nutzen die ganze Fensterbreite">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg> <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button> </button>
@@ -178,15 +189,13 @@
</button> </button>
<!-- Von Station zu Station des günstigsten Pfads (SPEC §9, D47). Kein <!-- Von Station zu Station des günstigsten Pfads (SPEC §9, D47). Kein
Umschalter, sondern eine Bewegung: jeder Druck holt die nächste noch Umschalter, sondern eine Bewegung: jeder Druck holt die nächste noch
offene Station in die Mitte. Verborgen, solange es keine gibt (wie offene Station in die Mitte. Verborgen, solange es keine gibt. -->
der „Was ist neu?"-Knopf daneben). -->
<button type="button" class="copybtn" id="leanNextBtn" title="Zur nächsten Station des günstigsten Pfads" aria-label="Zur nächsten Station des günstigsten Pfads" hidden> <button type="button" class="copybtn" id="leanNextBtn" title="Zur nächsten Station des günstigsten Pfads" aria-label="Zur nächsten Station des günstigsten Pfads" 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="M2.5 12h5" stroke-dasharray="2.5 2.5"/><circle cx="11" cy="12" r="3"/><path d="M15 12h6M18.5 9.5l2.5 2.5-2.5 2.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"><path d="M2.5 12h5" stroke-dasharray="2.5 2.5"/><circle cx="11" cy="12" r="3"/><path d="M15 12h6M18.5 9.5l2.5 2.5-2.5 2.5"/></svg>
</button> </button>
<button type="button" class="copybtn freshbtn" id="freshBtn" title="Neu in Produktion" aria-label="Neu in Produktion" hidden> <!-- Der Stern stand hier bis D58 („Was ist neu?", D28) und ist in die
<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> Kopfzeile gewandert: Er sagt jetzt nicht nur etwas über das aktive
<span class="freshcount" id="freshCount" aria-hidden="true">0</span> Dokument, sondern über das Produkt — und ist deshalb immer da. -->
</button>
<!-- Faltung für den ganzen Baum (SPEC §9, D44): ein Umschalter wie die <!-- Faltung für den ganzen Baum (SPEC §9, D44): ein Umschalter wie die
beiden Nachbarn. Gedrückt = alles ab Größe M abwärts ist zugeklappt. --> beiden Nachbarn. Gedrückt = alles ab Größe M abwärts ist zugeklappt. -->
<button type="button" class="copybtn togglebtn" id="foldSmallBtn" aria-pressed="false" data-i18n-title="foldSmallTooltip" data-i18n-aria="foldSmallTooltip" title="Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf" aria-label="Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf"> <button type="button" class="copybtn togglebtn" id="foldSmallBtn" aria-pressed="false" data-i18n-title="foldSmallTooltip" data-i18n-aria="foldSmallTooltip" title="Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf" aria-label="Knoten der Größe M und kleiner zuklappen erneut drücken klappt alle wieder auf">
+252 -27
View File
@@ -6,6 +6,10 @@ import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js'; import { padUrls } from './remote.js';
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
from './snapshots.js'; from './snapshots.js';
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
vite.config.js). Zur Laufzeit gibt es kein git und keinen Server, der
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
import NEWS from 'virtual:werkbaum-news';
/* Werkbaum, mit Werkbaum geplant als mitgeliefertes Dokument Werkbank" (D27). /* Werkbaum, mit Werkbaum geplant als mitgeliefertes Dokument Werkbank" (D27).
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen
@@ -76,6 +80,12 @@ let sourceWarning = null;
Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde; Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde;
er wird erst beim Bestätigen fortgeschrieben. */ er wird erst beim Bestätigen fortgeschrieben. */
let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = null; let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = null;
/* Führt das Neuigkeiten-Popup gerade einen Tag vor (D58)? Dann tritt dessen
Knotenmenge an die Stelle des Besuchsvergleichs `newsDay` ist das Datum
(auch die Marke im Popup), `newsKeySet` die Label-Pfade. Sitzungssache: Der
Faltzustand steht im Text (D38), der Besuchsstand im localStorage eine
vorgeführte Chronik ist weder das eine noch das andere. */
let newsDay = null, newsKeySet = null;
/* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des /* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des
Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei
jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den
@@ -118,17 +128,26 @@ function render(){
if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]); if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]);
} }
out.classList.toggle('cheap-on', cheapPathOn); out.classList.toggle('cheap-on', cheapPathOn);
/* 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();
/* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit /* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit
hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei
`freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */ `freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */
const initFold = initialCollapsed(roots, true); const initFold = initialCollapsed(roots, true);
const keys = nodeKeys(roots); const keys = nodeKeys(roots);
/* 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).
Führt das Neuigkeiten-Popup gerade einen Tag vor (D58), gilt dessen
Knotenmenge STATT des Vergleichs mit dem letzten Besuch: Es ist dieselbe
Ansicht, nur mit einer anderen Frage was geschah am 24.08." statt
was ist seit deinem letzten Besuch live gegangen". */
if(newsKeySet){
freshSet = new Set();
keys.forEach((key, n) => { if(newsKeySet.has(key)) freshSet.add(n); });
} else {
freshSet = (freshDocId === activeId && freshPrevRoots)
? freshProdSet(freshPrevRoots, roots) : new Set();
}
const collapsedSet = new Set(); const collapsedSet = new Set();
let foldSmallExact = true, foldSmallAny = false; let foldSmallExact = true, foldSmallAny = false;
foldByLine = new Map(); foldByLine = new Map();
@@ -1353,7 +1372,7 @@ document.addEventListener('pointerdown', e => {
if(e.target && e.target.closest && e.target.closest('.node') === tipNode) return; if(e.target && e.target.closest && e.target.closest('.node') === tipNode) return;
closeNodeTip(); closeNodeTip();
}, true); }, true);
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeNodeTip(); }); document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip(); closeNewsMenu(); } });
/* Beim Scrollen des Diagramms wandert der Knoten, das `position:fixed`-Fenster /* Beim Scrollen des Diagramms wandert der Knoten, das `position:fixed`-Fenster
nicht es zeigte dann auf etwas anderes. Also zumachen. */ nicht es zeigte dann auf etwas anderes. Also zumachen. */
document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true}); document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true});
@@ -1884,7 +1903,16 @@ 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.", freshTooltip:"Neu in Produktion seit dem letzten Ansehen: {n}.",
newsTooltip:"Neuigkeiten",
newsTitle:"Neuigkeiten",
newsEmpty:"Keine Einträge.",
newsUnseen:"Neuigkeiten aus {n} Tagen, die du noch nicht angesehen hast.",
newsSince:"Seit deinem letzten Besuch: {n} neu in Produktion.",
newsSeen:"gesehen",
newsShow:"{n} Knoten im Diagramm zeigen",
newsShowOff:"Hervorhebung aufheben",
newsShowing:"Neuigkeiten vom {d} werden im Diagramm gezeigt.",
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",
@@ -1974,7 +2002,16 @@ 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.", freshTooltip:"New in production since you last looked: {n}.",
newsTooltip:"What's new",
newsTitle:"What's new",
newsEmpty:"Nothing recorded.",
newsUnseen:"News from {n} days you haven't looked at yet.",
newsSince:"Since your last visit: {n} new in production.",
newsSeen:"seen",
newsShow:"Show {n} nodes in the diagram",
newsShowOff:"Clear the highlight",
newsShowing:"Showing the changes of {d} in the diagram.",
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",
@@ -2064,7 +2101,16 @@ 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.", freshTooltip:"Nuevo en producción desde la última vez: {n}.",
newsTooltip:"Novedades",
newsTitle:"Novedades",
newsEmpty:"Sin entradas.",
newsUnseen:"Novedades de {n} días que aún no has visto.",
newsSince:"Desde tu última visita: {n} nuevo en producción.",
newsSeen:"visto",
newsShow:"Mostrar {n} nodos en el diagrama",
newsShowOff:"Quitar el resaltado",
newsShowing:"Se muestran en el diagrama los cambios del {d}.",
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",
@@ -2154,7 +2200,16 @@ 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.", freshTooltip:"Nouveau en production depuis votre dernière visite : {n}.",
newsTooltip:"Nouveautés",
newsTitle:"Nouveautés",
newsEmpty:"Aucune entrée.",
newsUnseen:"Nouveautés de {n} jours que vous n'avez pas encore consultées.",
newsSince:"Depuis votre dernière visite : {n} nouveau en production.",
newsSeen:"vu",
newsShow:"Afficher {n} nœuds dans le diagramme",
newsShowOff:"Retirer la mise en évidence",
newsShowing:"Les changements du {d} sont affichés dans le diagramme.",
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",
@@ -2244,7 +2299,16 @@ 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.", freshTooltip:"Nowe na produkcji od ostatniego razu: {n}.",
newsTooltip:"Nowości",
newsTitle:"Nowości",
newsEmpty:"Brak wpisów.",
newsUnseen:"Nowości z {n} dni, których jeszcze nie oglądałeś.",
newsSince:"Od twojej ostatniej wizyty: {n} nowe na produkcji.",
newsSeen:"zobaczone",
newsShow:"Pokaż {n} węzłów na diagramie",
newsShowOff:"Usuń podświetlenie",
newsShowing:"Na diagramie pokazane są zmiany z {d}.",
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",
@@ -2334,7 +2398,16 @@ const I18N = {
agenda:"Легенда", discarded:"отклонённые", agenda:"Легенда", discarded:"отклонённые",
gutterTooltip:"Потяните, чтобы изменить размер; двойной щелчок сбрасывает", gutterAria:"Изменить размер областей", gutterTooltip:"Потяните, чтобы изменить размер; двойной щелчок сбрасывает", gutterAria:"Изменить размер областей",
hintGutterAria:"Изменить размер редактора и легенды", hintGutterAria:"Изменить размер редактора и легенды",
freshTooltip:"Новое в продакшене с прошлого раза: {n}. Нажмите, чтобы отметить как просмотренное.", freshTooltip:"Новое в продакшене с прошлого раза: {n}.",
newsTooltip:"Новости",
newsTitle:"Новости",
newsEmpty:"Записей нет.",
newsUnseen:"Новости за {n} дней, которые вы ещё не просматривали.",
newsSince:"С вашего последнего визита: {n} новых в продакшене.",
newsSeen:"просмотрено",
newsShow:"Показать {n} узлов на диаграмме",
newsShowOff:"Снять выделение",
newsShowing:"На диаграмме показаны изменения от {d}.",
modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором", modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором",
modeKompakt:"Компактно – всё вниз, экономит место", modeKompakt:"Компактно – всё вниз, экономит место",
modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором", modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором",
@@ -2424,7 +2497,16 @@ const I18N = {
agenda:"लेजेंड", discarded:"अस्वीकृत", agenda:"लेजेंड", discarded:"अस्वीकृत",
gutterTooltip:"आकार बदलने के लिए खींचें, डबल-क्लिक रीसेट करता है", gutterAria:"क्षेत्रों का आकार बदलें", gutterTooltip:"आकार बदलने के लिए खींचें, डबल-क्लिक रीसेट करता है", gutterAria:"क्षेत्रों का आकार बदलें",
hintGutterAria:"संपादक और लेजेंड का आकार बदलें", hintGutterAria:"संपादक और लेजेंड का आकार बदलें",
freshTooltip:"पिछली बार से उत्पादन में नया: {n}. देखा गया चिह्नित करने के लिए क्लिक करें।", freshTooltip:"पिछली बार से उत्पादन में नया: {n}।",
newsTooltip:"नया क्या है",
newsTitle:"नया क्या है",
newsEmpty:"कोई प्रविष्टि नहीं।",
newsUnseen:"{n} दिनों की खबरें, जो आपने अभी तक नहीं देखीं।",
newsSince:"आपकी पिछली यात्रा से: {n} उत्पादन में नए।",
newsSeen:"देखा गया",
newsShow:"आरेख में {n} नोड दिखाएँ",
newsShowOff:"हाइलाइट हटाएँ",
newsShowing:"आरेख में {d} के बदलाव दिखाए जा रहे हैं।",
modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख", modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख",
modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है", modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है",
modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख", modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख",
@@ -2514,7 +2596,16 @@ const I18N = {
agenda:"图例", discarded:"已放弃", agenda:"图例", discarded:"已放弃",
gutterTooltip:"拖动可调整大小,双击可重置", gutterAria:"调整区域大小", gutterTooltip:"拖动可调整大小,双击可重置", gutterAria:"调整区域大小",
hintGutterAria:"调整编辑器和图例大小", hintGutterAria:"调整编辑器和图例大小",
freshTooltip:"自上次查看以来新上线:{n}。点击标记为已查看。", freshTooltip:"自上次查看以来新上线:{n}。",
newsTooltip:"最新动态",
newsTitle:"最新动态",
newsEmpty:"暂无记录。",
newsUnseen:"有 {n} 天的动态你还没有看过。",
newsSince:"自你上次访问以来:{n} 项新上线。",
newsSeen:"已查看",
newsShow:"在图中显示 {n} 个节点",
newsShowOff:"取消高亮",
newsShowing:"正在图中显示 {d} 的变更。",
modeHorizontal:"横向——组织结构图,图表在编辑器上方", modeHorizontal:"横向——组织结构图,图表在编辑器上方",
modeKompakt:"紧凑——全部向下,节省空间", modeKompakt:"紧凑——全部向下,节省空间",
modeVertikal:"纵向——树向右展开,图表在编辑器旁边", modeVertikal:"纵向——树向右展开,图表在编辑器旁边",
@@ -2604,7 +2695,16 @@ const I18N = {
agenda:"凡例", discarded:"破棄", agenda:"凡例", discarded:"破棄",
gutterTooltip:"ドラッグでサイズ変更、ダブルクリックでリセット", gutterAria:"領域のサイズを変更", gutterTooltip:"ドラッグでサイズ変更、ダブルクリックでリセット", gutterAria:"領域のサイズを変更",
hintGutterAria:"エディターと凡例のサイズを変更", hintGutterAria:"エディターと凡例のサイズを変更",
freshTooltip:"前回以降に本番化されたもの:{n} 件。クリックで既読にします。", freshTooltip:"前回以降に本番化されたもの:{n} 件。",
newsTooltip:"更新情報",
newsTitle:"更新情報",
newsEmpty:"項目はありません。",
newsUnseen:"まだ見ていない {n} 日分の更新があります。",
newsSince:"前回の訪問以降:{n} 件が本番化されました。",
newsSeen:"確認済み",
newsShow:"図に {n} 件のノードを表示",
newsShowOff:"ハイライトを解除",
newsShowing:"図に {d} の変更を表示しています。",
modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上", modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上",
modeKompakt:"コンパクト — すべて下方向、省スペース", modeKompakt:"コンパクト — すべて下方向、省スペース",
modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横", modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横",
@@ -2703,6 +2803,10 @@ function applyLang(l){
} }
render(); render();
updateDocName(); /* Titelzeilen-Tooltip nach dem data-i18n-title-Durchlauf setzen */ updateDocName(); /* Titelzeilen-Tooltip nach dem data-i18n-title-Durchlauf setzen */
/* Das Neuigkeiten-Popup wird bei jedem Öffnen gebaut steht es gerade
offen, muss es die neue Sprache jetzt bekommen (auch die Datumsangaben,
die `toLocaleDateString` liefert). */
if(newsMenu && !newsMenu.hidden) renderNewsMenu();
try{ localStorage.setItem('werkbaum-lang', l); }catch(_){} try{ localStorage.setItem('werkbaum-lang', l); }catch(_){}
} }
const langsel = document.querySelector('.langsel'); const langsel = document.querySelector('.langsel');
@@ -3177,19 +3281,136 @@ function acknowledgeFresh(){
freshPrevRoots = null; freshPrevRoots = null;
render(); /* render() bildet die Menge neu und meldet dem Knopf */ render(); /* render() bildet die Menge neu und meldet dem Knopf */
} }
const freshBtn = document.getElementById('freshBtn'); /* ---------- Neuigkeiten (D58) ----------
Der Stern aus dem Diagramm-Kopf steht jetzt permanent in der Kopfzeile und
trägt zwei Aussagen, die zusammengehören: die **Chronik** aus der
git-Historie (`NEWS`, zur Bauzeit eingelesen) und den persönlichen
Besuchsvergleich des aktiven Dokuments (D28). Bernstein heißt ungesehen"
für beides. */
const LS_NEWS_SEEN = 'werkbaum-news-seen';
const newsBtn = document.getElementById('newsBtn');
const newsMenu = document.getElementById('newsMenu');
const freshCount = document.getElementById('freshCount'); const freshCount = document.getElementById('freshCount');
function updateFreshBtn(){
const n = freshDocId === activeId ? freshSet.size : 0; function newsSeenDate(){
if(!freshBtn) return; try{ return localStorage.getItem(LS_NEWS_SEEN) || ''; }catch(_){ 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 unseenDays(){
const seen = newsSeenDate();
return NEWS.filter(e => e.date > seen).length;
}
/* Datum in der Sprache der Oberfläche. `T00:00` ist Pflicht: Ein blankes
`new Date('2026-08-24')` liest der Browser als UTC-Mitternacht und zeigt
westlich davon den Vortag. */
function newsDateLabel(iso){
const d = new Date(iso + 'T00:00');
try{ return d.toLocaleDateString(lang, {day: 'numeric', month: 'long', year: 'numeric'}); }
catch(_){ return iso; }
}
/* Welche Schlüssel gibt es im mitgelieferten Plan überhaupt noch? Ein Label
kann seit damals umbenannt worden sein dann trifft sein Schlüssel nichts
mehr, und der Link verspräche etwas, das er nicht halten kann. Einmal je
Aufklappen, nicht einmal je Tag: Der Plan hat 900 Zeilen. */
function planKeySet(){
const doc = docs.find(d => d.id === WERKBAUM_ID);
return doc ? new Set(nodeKeys(parse(doc.text).roots).values()) : new Set();
}
function updateFreshBtn(){
if(!newsBtn) return;
const n = (!newsKeySet && freshDocId === activeId) ? freshSet.size : 0;
const offen = unseenDays();
freshCount.hidden = !n;
if(n) freshCount.textContent = String(n);
newsBtn.classList.toggle('unseen', !!(n || offen));
newsBtn.classList.toggle('active', !!newsDay);
const tip = newsDay ? t('newsShowing', {d: newsDateLabel(newsDay)})
: n ? t('freshTooltip', {n: n})
: offen ? t('newsUnseen', {n: offen})
: t('newsTooltip');
newsBtn.title = tip;
newsBtn.setAttribute('aria-label', tip);
}
function renderNewsMenu(){
const seit = (!newsKeySet && freshDocId === activeId && freshSet.size)
? `<div class="newssince"><span>${esc(t('newsSince', {n: freshSet.size}))}</span>`
+ `<button type="button" class="newsseen" id="newsSeenBtn">${esc(t('newsSeen'))}</button></div>`
: '';
const vorhanden = NEWS.some(e => e.keys.length) ? planKeySet() : new Set();
const tage = NEWS.map(e => {
const treffer = e.keys.filter(k => vorhanden.has(k)).length;
const an = newsDay === e.date;
/* `\`#auth\`` im Changelog wird zu einem Code-Stück die Notizen nennen
Notation, und nackte Backticks läsen sich wie ein Tippfehler. Ersetzt
wird NACH dem Escapen, damit der Weg zu eigenem Markup verschlossen
bleibt: Was hier eintrifft, ist bereits harmloser Text. */
const notiz = l => esc(l).replace(/`([^`]+)`/g, '<code>$1</code>');
const lines = e.lines.length
? `<ul class="newslines">${e.lines.map(l => `<li>${notiz(l)}</li>`).join('')}</ul>` : '';
const knopf = treffer
? `<button type="button" class="newsshow" data-news-day="${esc(e.date)}"`
+ ` aria-pressed="${an ? 'true' : 'false'}">`
+ `${esc(an ? t('newsShowOff') : t('newsShow', {n: treffer}))}</button>` : '';
return `<div class="newsday"><div class="newsdate">${esc(newsDateLabel(e.date))}</div>`
+ lines + knopf + '</div>';
}).join('');
newsMenu.innerHTML = `<div class="newshead"><span>${esc(t('newsTitle'))}</span>`
+ `<button type="button" class="newsclose" id="newsCloseBtn"`
+ ` title="${esc(t('tipClose'))}" aria-label="${esc(t('tipClose'))}">×</button></div>`
+ seit + (tage || `<div class="newsempty">${esc(t('newsEmpty'))}</div>`);
}
function openNewsMenu(){
renderNewsMenu();
newsMenu.hidden = false;
newsBtn.setAttribute('aria-expanded', 'true');
/* Aufgeschlagen heißt gelesen: Der Deckel wandert auf den neuesten Tag der
Liste, nicht auf heute" sonst hinge er an der Uhr des Betrachters. */
if(NEWS.length){ try{ localStorage.setItem(LS_NEWS_SEEN, NEWS[0].date); }catch(_){} }
updateFreshBtn();
}
function closeNewsMenu(){
if(!newsMenu || newsMenu.hidden) return;
newsMenu.hidden = true;
newsBtn.setAttribute('aria-expanded', 'false');
}
/* Einen Tag im Diagramm vorführen. Die Schlüssel sind Label-Pfade des
MITGELIEFERTEN Plans steht ein anderes Dokument vorn, wird gewechselt;
ohne das zeigte die Ansicht auf nichts. */
function showNewsDay(date){
const e = NEWS.find(x => x.date === date);
if(!e) return;
/* Erst wechseln, dann setzen: `switchDoc()` räumt einen vorgeführten Tag
ausdrücklich weg in der anderen Reihenfolge löschte es gerade den, den
wir zeigen wollen. */
if(activeId !== WERKBAUM_ID && docs.some(d => d.id === WERKBAUM_ID)) switchDoc(WERKBAUM_ID);
newsDay = date;
newsKeySet = new Set(e.keys);
render();
closeNewsMenu();
const erster = out.querySelector('.node.fresh');
if(erster) erster.scrollIntoView({block: 'center', inline: 'center'});
}
function clearNewsDay(){
if(!newsDay) return;
newsDay = null; newsKeySet = null;
render();
}
newsBtn.addEventListener('click', () => {
if(newsMenu.hidden) openNewsMenu(); else closeNewsMenu();
});
newsMenu.addEventListener('click', e => {
const tag = e.target.closest('[data-news-day]');
if(tag){ const d = tag.getAttribute('data-news-day');
if(newsDay === d){ clearNewsDay(); renderNewsMenu(); } else showNewsDay(d);
return; }
if(e.target.closest('#newsSeenBtn')){ acknowledgeFresh(); renderNewsMenu(); return; }
if(e.target.closest('#newsCloseBtn')) closeNewsMenu();
});
document.addEventListener('pointerdown', e => {
if(!newsMenu || newsMenu.hidden) return;
if(!e.target.closest('#newsMenu') && !e.target.closest('#newsBtn')) closeNewsMenu();
});
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der /* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
@@ -3201,6 +3422,10 @@ function switchDoc(id){
flushActive(); flushActive();
activeId = id; activeId = id;
foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */ foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */
/* Ein vorgeführter Neuigkeiten-Tag (D58) gehört dem mitgelieferten Plan
in einem anderen Dokument zeigten seine Schlüssel ins Leere. Kein
`clearNewsDay()`: `loadActiveIntoEditor()` zeichnet gleich selbst. */
newsDay = null; newsKeySet = null;
loadActiveIntoEditor(); loadActiveIntoEditor();
persistDocs(); persistDocs();
} }
+65 -3
View File
@@ -87,6 +87,63 @@
.fsbtn svg{display:block} .fsbtn svg{display:block}
.fsbtn:hover{border-color:var(--or);color:var(--or)} .fsbtn:hover{border-color:var(--or);color:var(--or)}
.fsbtn.active{background:var(--or);color:#fff;border-color:var(--or)} .fsbtn.active{background:var(--or);color:#fff;border-color:var(--or)}
/* Neuigkeiten (D58). Der Knopf steht permanent in der Kopfzeile; **bernstein**
heißt da ist etwas, das du noch nicht gesehen hast" dieselben Töne, die
der Strahlenkranz am Knoten trägt (D28), damit Knopf und Knoten dasselbe
sagen. Petrol (`.active`) heißt etwas anderes: Es wird gerade ein
bestimmter Tag im Diagramm vorgeführt. */
.newswrap{position:relative;display:inline-flex;flex:0 0 auto}
.newsbtn{width:auto;min-width:30px;padding:0 6px;gap:2px}
.newsbtn.unseen{color:#A16207;border-color:#FACC15;background:#FEFCE8}
.newsbtn.unseen:hover{color:#854D0E;border-color:#EAB308}
.newsbtn .freshcount[hidden]{display:none}
.newsmenu{
position:absolute;top:calc(100% + 6px);right:0;z-index:60;
width:min(420px,calc(100vw - 24px));max-height:min(70vh,520px);overflow:auto;
background:var(--card);border:1px solid rgba(36,52,71,.15);border-radius:10px;
padding:10px 12px;box-shadow:0 8px 24px rgba(36,52,71,.22);
text-transform:none;letter-spacing:0;font-family:'IBM Plex Sans',sans-serif;
text-align:left;color:var(--ink);
}
.newsmenu[hidden]{display:none}
.newshead{
display:flex;align-items:center;gap:8px;
font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);
}
.newshead .newsclose{
margin-left:auto;border:0;background:transparent;cursor:pointer;
color:var(--muted);font-size:1rem;line-height:1;padding:2px 4px;border-radius:6px;
}
.newshead .newsclose:hover{color:var(--ink);background:rgba(36,52,71,.07)}
/* Seit deinem letzten Besuch" (D28) die persönliche Hälfte, deshalb
bernstein abgesetzt und oben, vor der allgemeinen Chronik. */
.newssince{
display:flex;align-items:center;gap:8px;flex-wrap:wrap;
margin-top:8px;padding:8px 10px;border-radius:8px;
background:#FEFCE8;border:1px solid #FACC15;color:#854D0E;font-size:.82rem;
}
.newssince .newsseen{
margin-left:auto;border:1px solid rgba(133,77,14,.35);border-radius:7px;
background:transparent;color:inherit;cursor:pointer;
padding:3px 9px;font-family:inherit;font-size:.78rem;
}
.newssince .newsseen:hover{background:rgba(133,77,14,.1)}
.newsday{margin-top:10px;padding-top:9px;border-top:1px solid rgba(36,52,71,.12)}
.newsdate{font-weight:600;font-size:.84rem}
.newslines{margin:5px 0 0;padding-left:17px;font-size:.82rem;line-height:1.55;color:var(--ink)}
.newslines li{margin:1px 0}
.newslines code{
font-family:'IBM Plex Mono',monospace;font-size:.92em;
background:rgba(36,52,71,.07);border-radius:4px;padding:0 3px;
}
.newsshow{
margin-top:6px;border:1px solid rgba(36,52,71,.2);border-radius:7px;
background:var(--card);color:var(--muted);cursor:pointer;
padding:4px 9px;font-family:inherit;font-size:.78rem;
}
.newsshow:hover{border-color:var(--or);color:var(--or)}
.newsshow[aria-pressed="true"]{background:var(--or);color:#fff;border-color:var(--or)}
.newsempty{margin-top:8px;color:var(--muted);font-size:.82rem}
/* Vollbild: Panels nutzen fast die ganze Fensterbreite; es bleibt ein /* Vollbild: Panels nutzen fast die ganze Fensterbreite; es bleibt ein
schmaler Rand (halber Panel-Zwischenraum = 7 px), damit es nicht gedrängt schmaler Rand (halber Panel-Zwischenraum = 7 px), damit es nicht gedrängt
wirkt. */ wirkt. */
@@ -263,9 +320,8 @@
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, /* Neuheiten-Knopf im Diagramm-Kopf (D28): Sternchen + Anzahl, nur sichtbar,
wenn es etwas gibt. Gelb wie der Strahlenkranz am Knoten. */ wenn es etwas gibt. Gelb wie der Strahlenkranz am Knoten. */
.freshbtn{position:relative;color:#A16207;border-color:#FACC15;background:#FEFCE8} /* Die Zahl der neuen Knoten sitzt seit D58 am Neuigkeiten-Knopf in der
.freshbtn:hover{color:#854D0E;border-color:#EAB308} Kopfzeile (die bernsteinfarbene Fassung steht dort bei `.newsbtn.unseen`). */
.freshbtn[hidden]{display:none}
.freshcount{ .freshcount{
font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600; font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;
margin-left:3px;vertical-align:2px; margin-left:3px;vertical-align:2px;
@@ -1306,6 +1362,12 @@
statt die Zeile zu verbreitern); nach der Auswahl klappt sie wieder ein statt die Zeile zu verbreitern); nach der Auswahl klappt sie wieder ein
(JS). Das entfällt hier die aktive Sprache ist selbst der Auslöser. */ (JS). Das entfällt hier die aktive Sprache ist selbst der Auslöser. */
body.mobile .header-tools{position:relative} body.mobile .header-tools{position:relative}
/* Das Neuigkeiten-Popup ist breiter als sein Knopf. Am Knopf ausgerichtet
(`.newswrap{position:relative}`) begänne es auf dem Telefon bei 116 px,
also außerhalb des Bildes nachgemessen bei 375 px. Hier zählt deshalb
die **Werkzeugleiste** als Bezug: Ihr rechter Rand ist auch der der Seite.
Kein `overflow` an der Kopfzeile als Ausweg das klippt die Menüs (D50). */
body.mobile .newswrap{position:static}
body.mobile .langsel button[data-lang]{display:none} body.mobile .langsel button[data-lang]{display:none}
body.mobile .langsel button[data-lang].active{display:inline-block} body.mobile .langsel button[data-lang].active{display:inline-block}
body.mobile .langmore{display:none} body.mobile .langmore{display:none}
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseChangelog, changedKeys, attachKeys, parseLog }
from '../../scripts/news.mjs';
/* Neuigkeiten (D58). Geprüft ist die entscheidbare Hälfte was aus dem
Changelog eine Notiz wird und welche Knoten sich an einem Tag bewegt haben.
Was git und Dateisystem liefern, steht in `collectNews()` und bleibt Sache
des Builds. */
const st = (...paare) => new Map(paare);
describe('parseChangelog — Notizen aus docs/CHANGELOG.md', () => {
const md = [
'# Changelog', '', 'Fließtext, der niemanden interessiert.', '- keine Notiz, kein Tag offen',
'', '## 2026-08-24', '', '- Erste Notiz', '* Zweite Notiz', 'kein Aufzählungszeichen',
'', '## 2026-08-23', '', '- Ältere Notiz', '',
].join('\n');
it('liest Tage und Notizen, neueste zuerst', () => {
expect(parseChangelog(md)).toEqual([
{date: '2026-08-24', lines: ['Erste Notiz', 'Zweite Notiz']},
{date: '2026-08-23', lines: ['Ältere Notiz']},
]);
});
/* Die Datei erklärt oben, was sie ist — das darf nicht als Notiz enden. */
it('überliest alles vor dem ersten Tag', () => {
expect(parseChangelog(md)[0].lines).not.toContain('keine Notiz, kein Tag offen');
});
it('beendet einen Tag an der nächsten Überschrift', () => {
const x = '## 2026-08-24\n- drin\n## Anhang\n- draußen\n';
expect(parseChangelog(x)).toEqual([{date: '2026-08-24', lines: ['drin']}]);
});
it('lässt einen Tag ohne Notizen weg', () => {
expect(parseChangelog('## 2026-08-24\n\n## 2026-08-23\n- da\n'))
.toEqual([{date: '2026-08-23', lines: ['da']}]);
});
it('führt zwei Abschnitte desselben Tages zusammen', () => {
expect(parseChangelog('## 2026-08-24\n- a\n\n## 2026-08-24\n- b\n')[0].lines)
.toEqual(['a', 'b']);
});
it('deckelt die Tage', () => {
const x = '## 2026-08-24\n- a\n## 2026-08-23\n- b\n';
expect(parseChangelog(x, {maxDays: 1}).map(e => e.date)).toEqual(['2026-08-24']);
});
it('verträgt Leeres', () => {
expect(parseChangelog('')).toEqual([]);
expect(parseChangelog(undefined)).toEqual([]);
});
/* Die ausgelieferte Datei selbst: Sie ist die Quelle des Popups ist sie
unlesbar, steht das Popup leer da, ohne dass es jemand merkt. */
it('liest die echte docs/CHANGELOG.md', () => {
const echt = parseChangelog(readFileSync(new URL('../../docs/CHANGELOG.md', import.meta.url), 'utf8'));
expect(echt.length).toBeGreaterThan(3);
expect(echt[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(echt.every(e => e.lines.length > 0)).toBe(true);
/* Absteigend sortiert — das Popup zeigt sie in dieser Reihenfolge. */
expect([...echt].sort((a, b) => a.date < b.date ? 1 : -1)).toEqual(echt);
});
});
describe('changedKeys — was sich im Plan bewegt hat', () => {
it('meldet neue Knoten und geänderten Status', () => {
const prev = st(['> A', 'geplant'], ['> B', 'fertig']);
const curr = st(['> A', 'fertig'], ['> B', 'fertig'], ['> C', 'idee']);
expect(changedKeys(prev, curr).sort()).toEqual(['> A', '> C']);
});
/* Wie beim Erstkontakt in D28: Ohne Vergleichsfassung leuchtet nichts. */
it('gibt ohne Vorfassung nichts zurück', () => {
expect(changedKeys(null, st(['> A', 'fertig']))).toEqual([]);
});
/* Ein entfernter Knoten ist im Diagramm nicht mehr da es gäbe nichts
hervorzuheben. */
it('meldet entfernte Knoten nicht', () => {
expect(changedKeys(st(['> A', 'fertig']), st())).toEqual([]);
});
});
describe('attachKeys — Tageseinträge und Plan-Fassungen zusammenführen', () => {
const versionen = [
{date: '2026-08-22', status: st(['> A', 'geplant'])},
{date: '2026-08-23', status: st(['> A', 'fertig'])},
{date: '2026-08-24', status: st(['> A', 'prod'])},
];
it('hängt die Knoten an den Tag', () => {
const e = attachKeys([{date: '2026-08-24', lines: ['x']}], versionen);
expect(e[0]).toEqual({date: '2026-08-24', lines: ['x'], keys: ['> A']});
});
it('gibt einem Tag ohne Plan-Änderung eine leere Menge', () => {
const e = attachKeys([{date: '2026-08-25', lines: ['x']}], versionen);
expect(e[0].keys).toEqual([]);
});
/* Der Deploy-Tag (D30) oder ein vergessener Changelog-Eintrag: Der Link
führt trotzdem vor, was sich bewegt hat. */
it('legt für einen reinen Plan-Tag einen eigenen Eintrag an', () => {
const e = attachKeys([{date: '2026-08-24', lines: ['x']}], versionen);
expect(e.map(x => x.date)).toEqual(['2026-08-24', '2026-08-23']);
expect(e[1]).toEqual({date: '2026-08-23', lines: [], keys: ['> A']});
});
it('nimmt die erste Fassung nicht als Änderung', () => {
const e = attachKeys([], [versionen[0]]);
expect(e).toEqual([]);
});
it('deckelt auch nach dem Zusammenführen', () => {
const e = attachKeys([{date: '2026-08-24', lines: ['x']}], versionen, {maxDays: 1});
expect(e.map(x => x.date)).toEqual(['2026-08-24']);
});
});
describe('parseLog — git-Ausgabe zerlegen', () => {
const REC = '\x1e', F = '\x1f';
it('liest Kennung, Datum und Betreff', () => {
const raw = `${REC}abc${F}2026-08-24${F}feat: X${REC}def${F}2026-08-23${F}fix: Y`;
expect(parseLog(raw)).toEqual([
{sha: 'abc', date: '2026-08-24', subject: 'feat: X', files: []},
{sha: 'def', date: '2026-08-23', subject: 'fix: Y', files: []},
]);
});
/* Mit `--name-only` hängt git die Dateinamen hinter die Formatzeile sie
gehören zu diesem Commit, weil der Satztrenner vorn steht. */
it('sammelt die Dateinamen aus --name-only', () => {
const raw = `${REC}abc${F}2026-08-24${F}rename\n\ndocs/examples/alt.werkbaum\n`;
expect(parseLog(raw)[0].files).toEqual(['docs/examples/alt.werkbaum']);
});
it('verträgt leere Ausgabe', () => {
expect(parseLog('')).toEqual([]);
expect(parseLog(REC + '\n')).toEqual([]);
});
});
+38 -1
View File
@@ -1,6 +1,8 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { viteSingleFile } from 'vite-plugin-singlefile'; import { viteSingleFile } from 'vite-plugin-singlefile';
import { collectNews, CHANGELOG_FILE } from '../scripts/news.mjs';
// Bettet das Favicon (../docs/brand/favicon.svg, außerhalb des Roots) als // Bettet das Favicon (../docs/brand/favicon.svg, außerhalb des Roots) als
// data:-URI direkt in <link rel="icon"> ein, damit die gebaute Datei wirklich // data:-URI direkt in <link rel="icon"> ein, damit die gebaute Datei wirklich
@@ -20,6 +22,41 @@ function inlineFavicon() {
}; };
} }
// Neuigkeiten (D58): ../docs/CHANGELOG.md (die Notizen) und die git-Historie
// (die Knoten je Tag) werden zur BAUZEIT eingelesen und als virtuelles Modul
// eingebettet — zur Laufzeit gibt es kein git und keinen Server (D11/D19).
// Die Regeln stehen in ../scripts/news.mjs (getestet), hier nur die
// Beschaffung. Scheitert git (kein Repo, flacher Klon, Tarball), bleibt die
// Liste leer statt den Build zu zerreißen: Das Popup sagt dann, dass es nichts
// zu zeigen gibt. Der Dev-Server liest einmal beim Start — neue Einträge
// erscheinen nach einem Neustart.
const NEWS_ID = 'virtual:werkbaum-news';
function newsData() {
const RES = '\0' + NEWS_ID;
return {
name: 'werkbaum-news',
resolveId: id => (id === NEWS_ID ? RES : null),
async load(id) {
if (id !== RES) return null;
let news = [];
try {
const cwd = new URL('..', import.meta.url);
const { parse } = await import('./src/parser.js');
const { statusByKey } = await import('./src/model.js');
news = collectNews({
run: args => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64e6 }),
changelog: readFileSync(new URL('../' + CHANGELOG_FILE, import.meta.url), 'utf8'),
parsePlan: text => parse(text).roots,
statusByKey,
});
} catch (e) {
this.warn(`Neuigkeiten nicht lesbar (${e.message}) — Popup bleibt leer.`);
}
return `export default ${JSON.stringify(news)};`;
},
};
}
// Werkbaum bleibt bewusst eine einzelne, self-contained Datei (file://-tauglich, // Werkbaum bleibt bewusst eine einzelne, self-contained Datei (file://-tauglich,
// D16). Vite dient nur als Bündler/Testrunner (D19): `vite build` inlint alle // D16). Vite dient nur als Bündler/Testrunner (D19): `vite build` inlint alle
// Module + CSS in dist/index.html; im Dev-Server (`vite`) werden sie einzeln // Module + CSS in dist/index.html; im Dev-Server (`vite`) werden sie einzeln
@@ -27,7 +64,7 @@ function inlineFavicon() {
// ../docs/brand/ ausliefern (liegt außerhalb des Projekt-Roots frontend/). // ../docs/brand/ ausliefern (liegt außerhalb des Projekt-Roots frontend/).
export default defineConfig({ export default defineConfig({
root: '.', root: '.',
plugins: [inlineFavicon(), viteSingleFile()], plugins: [inlineFavicon(), newsData(), viteSingleFile()],
server: { port: 8137, strictPort: true, fs: { allow: ['..'] } }, server: { port: 8137, strictPort: true, fs: { allow: ['..'] } },
build: { build: {
// Alles inlinen -> keine externen Assets, eine Datei. // Alles inlinen -> keine externen Assets, eine Datei.
+130
View File
@@ -0,0 +1,130 @@
/* Neuigkeiten für das Popup (D58) aus zwei Quellen, jede in ihrer Rolle:
*
* docs/CHANGELOG.md WAS geschehen ist, als englischer Satz je Änderung.
* git-Historie WELCHE Knoten des mitgelieferten Plans sich an
* diesem Tag bewegt haben (für die Vorführung im
* Diagramm, Was ist neu?"-Ansicht, D28).
*
* Warum die Notizen nicht aus den Commit-Betreffs kommen, obwohl sie dort
* stünden: Die Betreffs sind **deutsch** (CLAUDE.md: Doku auf Deutsch), das
* Popup aber ist Produkt-Oberfläche in neun Sprachen. Der Changelog ist
* englisch wie der mitgelieferte Plan und `llms.md` ausgelieferte Artefakte
* mit weltweitem Publikum (D22, D43). Preis: eine Datei, die beim Bauen eines
* Features mitgeschrieben werden muss.
*
* Zwei Hälften, nach der Hausregel getrennt (frontend/CLAUDE.md): Was
* ENTSCHEIDBAR ist, steht hier als reine Funktion und ist getestet
* (frontend/tests/news.test.js); was git und Dateisystem liefern, steht in
* `collectNews()` ganz unten und läuft nur zur Bauzeit (Vite-Plugin).
*
* Das Ergebnis ist ein Array, neueste zuerst:
* [{date: '2026-08-24', lines: ['…', ], keys: ['… > Notation', ]}]
* `keys` sind Label-Pfade dieselbe Identität wie Was ist neu?" (D28).
*/
export const PLAN_FILE = 'docs/examples/werkbaum.werkbaum';
export const CHANGELOG_FILE = 'docs/CHANGELOG.md';
export const MAX_DAYS = 20; /* so weit reicht das Popup zurück */
const RE_DAY = /^##\s+(\d{4}-\d{2}-\d{2})\s*$/;
const RE_ITEM = /^[-*]\s+(.*\S)\s*$/;
/* `docs/CHANGELOG.md` Tageseinträge, neueste zuerst. Erkannt wird genau
zweierlei: eine Überschrift `## JJJJ-MM-TT` eröffnet einen Tag, eine
Aufzählungszeile darunter ist eine Notiz. Alles andere ist Fließtext für
Menschen und wird überlesen so kann die Datei oben erklären, was sie ist,
ohne dass daraus Notizen werden. Kein Deckel je Tag: Die Datei ist gepflegt,
ihr Autor entscheidet, wie viel ein Tag hergibt. */
export function parseChangelog(md, opts){
const {maxDays = MAX_DAYS} = opts || {};
const byDay = new Map();
let day = null;
for(const raw of String(md || '').split('\n')){
const kopf = raw.match(RE_DAY);
if(kopf){ day = kopf[1]; if(!byDay.has(day)) byDay.set(day, []); continue; }
if(/^#{1,6}\s/.test(raw)){ day = null; continue; } /* andere Überschrift beendet den Tag */
if(!day) continue;
const item = raw.match(RE_ITEM);
if(item) byDay.get(day).push(item[1]);
}
return [...byDay.entries()]
.filter(([, lines]) => lines.length)
.sort((a, b) => a[0] < b[0] ? 1 : -1)
.slice(0, maxDays)
.map(([date, lines]) => ({date, lines}));
}
/* Knoten, die sich zwischen zwei Fassungen des Plans bewegt haben: neu
hinzugekommen oder mit anderem Status. `prev == null` (die erste bekannte
Fassung) ergibt die leere Menge dieselbe Zurückhaltung wie beim
Erstkontakt in D28, sonst leuchtete der halbe Plan als Änderung von damals". */
export function changedKeys(prev, curr){
if(!prev) return [];
const out = [];
for(const [key, st] of curr){
if(!prev.has(key) || prev.get(key) !== st) out.push(key);
}
return out;
}
/* Tageseinträge + Fassungen des Plans (chronologisch, je `{date, status}` mit
`status` = Map Schlüssel Status-Key) dieselben Einträge mit `keys`.
Ein Tag, an dem sich nur der Plan bewegt hat, bekommt einen **eigenen**
Eintrag ohne Zeilen typischerweise der Deploy-Tag (D30) oder einer, an dem
der Changelog vergessen wurde. Der Link führt dort trotzdem vor, was sich
bewegt hat; ohne diese Vereinigung fiele der Tag stumm unter den Tisch. */
export function attachKeys(entries, versions, opts){
const {maxDays = MAX_DAYS} = opts || {};
const keysByDay = new Map();
for(let i = 0; i < versions.length; i++){
const k = changedKeys(i ? versions[i - 1].status : null, versions[i].status);
if(k.length) keysByDay.set(versions[i].date, k);
}
const out = entries.map(e => ({...e, keys: keysByDay.get(e.date) || []}));
const haben = new Set(out.map(e => e.date));
for(const [date, keys] of keysByDay){
if(!haben.has(date)) out.push({date, lines: [], keys});
}
return out.sort((a, b) => a.date < b.date ? 1 : -1).slice(0, maxDays);
}
/* ---------- ab hier git und Dateisystem (nur zur Bauzeit) ---------- */
const SEP_FIELD = '\x1f', SEP_REC = '\x1e';
/* Der Satztrenner steht **vorn**, nicht hinten: Mit `--name-only` hängt git die
Dateinamen hinter die Formatzeile, und nur so gehören sie beim Zerlegen zum
richtigen Commit. */
export const LOG_FORMAT = `--format=${SEP_REC}%H${SEP_FIELD}%cd${SEP_FIELD}%s`;
export function parseLog(raw){
return String(raw).split(SEP_REC).filter(r => r.trim()).map(r => {
const [kopf, ...rest] = r.split('\n');
const [sha, date, subject] = kopf.split(SEP_FIELD);
return {sha, date, subject: subject || '', files: rest.filter(l => l.trim())};
});
}
/* `run`, `changelog` und `parsePlan` werden hereingereicht, damit diese
Funktion ohne child_process, ohne Dateisystem und ohne den Parser prüfbar
bleibt. */
export function collectNews({run, changelog, parsePlan, statusByKey, planFile = PLAN_FILE, opts} = {}){
const entries = parseChangelog(changelog, opts);
/* Je Tag, an dem der Plan angefasst wurde, zählt sein LETZTER Stand.
`--follow` reicht über die Umbenennung hinweg (`example-werkbaum.werkbaum`
`werkbaum.werkbaum`, 16.08.) ohne das begänne die Geschichte des Plans
dort, und der erste Tag danach hätte keine Vergleichsfassung. Deshalb
`--name-only`: Vor der Umbenennung heißt die Datei anders, und `git show`
braucht den Namen, den sie **in diesem Commit** trug. */
const planLog = parseLog(run(
['log', '--follow', '--name-only', '--date=short', LOG_FORMAT, '--', planFile]));
const lastOfDay = new Map(); /* neueste zuerst ⇒ der erste gewinnt */
for(const c of planLog) if(!lastOfDay.has(c.date)) lastOfDay.set(c.date, c);
const versions = [...lastOfDay.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1)
.map(([date, c]) => ({
date,
status: statusByKey(parsePlan(run(['show', `${c.sha}:${c.files[0] || planFile}`]))),
}));
return attachKeys(entries, versions, opts);
}