diff --git a/CLAUDE.md b/CLAUDE.md index a61a3b2..19bf0db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,10 @@ Integrations-Backend. Eintrag begründen, alte Einträge nie löschen. Besonders D13 (Backend-Stack) und D14 (Parser-Hoheit) beachten. - 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 - Doku auf Deutsch. Die Editor-UI ist mehrsprachig (DE/EN/ES/FR direkt, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..d372f5d --- /dev/null +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index daacbd8..ca7e72b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 `TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53 (synthetisches 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. diff --git a/docs/SPEC.md b/docs/SPEC.md index 4462fdf..c0ea1b1 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 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 +(die Füllung bleibt die Statusfarbe aus §4). Die Anzahl steht am +**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 erscheint weder im Druck noch im Grafikexport — er hängt am persönlichen 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 Jeder Knoten kennt seine Zeilennummer im Notationstext; beide Richtungen sind verknüpft (siehe D25): diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 26cfa67..d36be00 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -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.files: Open and save .werkbaum files (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.dates: Dates and milestones (M) | [?] #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 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 Give every @name a colour derived from the name itself, so the same person is recognizable across the tree without looking anything up. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 9d9e3c4..bc996b4 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 `commit/` in `index.html` einspritzt (danach zurücknehmen!) und `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):** `#docMenu` und `.dlmenu` hängen als absolut positionierte Kinder im `.panel-head`. Ein `overflow` dort macht ihn zum Scroll-Container und klippt diff --git a/frontend/index.html b/frontend/index.html index 3ca57fc..5d32b2e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,6 +23,17 @@ Werkbaum
+ + + + + @@ -178,15 +189,13 @@ + offene Station in die Mitte. Verborgen, solange es keine gibt. --> - +
` + : ''; + 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, '$1'); + const lines = e.lines.length + ? `` : ''; + const knopf = treffer + ? `` : ''; + return `
${esc(newsDateLabel(e.date))}
` + + lines + knopf + '
'; + }).join(''); + newsMenu.innerHTML = `
${esc(t('newsTitle'))}` + + `
` + + seit + (tage || `
${esc(t('newsEmpty'))}
`); +} +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 : ''; /* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der @@ -3201,6 +3422,10 @@ function switchDoc(id){ flushActive(); activeId = id; 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(); persistDocs(); } diff --git a/frontend/src/style.css b/frontend/src/style.css index 0b0ea3a..d6fc4a3 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -87,6 +87,63 @@ .fsbtn svg{display:block} .fsbtn:hover{border-color:var(--or);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 schmaler Rand (halber Panel-Zwischenraum = 7 px), damit es nicht gedrängt wirkt. */ @@ -263,9 +320,8 @@ 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} + /* Die Zahl der neuen Knoten sitzt seit D58 am Neuigkeiten-Knopf in der + Kopfzeile (die bernsteinfarbene Fassung steht dort bei `.newsbtn.unseen`). */ .freshcount{ font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600; margin-left:3px;vertical-align:2px; @@ -1306,6 +1362,12 @@ 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. */ 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].active{display:inline-block} body.mobile .langmore{display:none} diff --git a/frontend/tests/news.test.js b/frontend/tests/news.test.js new file mode 100644 index 0000000..765b111 --- /dev/null +++ b/frontend/tests/news.test.js @@ -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([]); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index cfe1a4a..4cadecc 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { defineConfig } from 'vitest/config'; 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 // data:-URI direkt in 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, // 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 @@ -27,7 +64,7 @@ function inlineFavicon() { // ../docs/brand/ ausliefern (liegt außerhalb des Projekt-Roots frontend/). export default defineConfig({ root: '.', - plugins: [inlineFavicon(), viteSingleFile()], + plugins: [inlineFavicon(), newsData(), viteSingleFile()], server: { port: 8137, strictPort: true, fs: { allow: ['..'] } }, build: { // Alles inlinen -> keine externen Assets, eine Datei. diff --git a/scripts/news.mjs b/scripts/news.mjs new file mode 100644 index 0000000..07d3ee0 --- /dev/null +++ b/scripts/news.mjs @@ -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); +}