feat(editor): Warn-Zeilennummer trägt ihre Meldung als Tooltip

Der Streifen sagte bisher nur, DASS eine Zeile eine Warnung hat; welche,
stand allein unter dem Diagramm — man sah die orange Zahl und suchte dann
in der Liste die passende Zeilennummer.

- warnings.js: `warningText()` neben `formatWarning()`, beide aus demselben
  `switch` (`build(w, t, esc)`) — der Warnungsbereich ist HTML, ein `title`
  ist Klartext. Ohne die Trennung stünde dort „Drag & Drop“.
- app.js: `lineNoWarn` ist jetzt Map Zeile -> Text; mehrere Warnungen einer
  Zeile stehen im Tooltip untereinander. Titel verschwinden mit der Warnung.
- style.css: Der Kasten der Warn-Zahl reicht über die ganze Streifenbreite
  (Ziffern bleiben punktgenau stehen, nachgemessen 21,14 px), `cursor:help`.

Nachgemessen (4 Warnungen in 3 Zeilen): Tooltips wortgleich mit dem
Warnungsbereich, `&`/`"` unverfälscht, keine Zahl ohne Warnung mit `title`;
nach dem Beheben verschwinden Klasse und Titel mit. Werkbaum-Plan: 0.

SPEC §9 (Zeilennummern) + D33-Nachtrag; Plan: #ed.lineno.tip [x].
243 Tests grün (3 neue).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 10:41:05 +02:00
co-authored by Claude Opus 5
parent 0bead77e7f
commit cb966e4e20
8 changed files with 133 additions and 7 deletions
+52
View File
@@ -1843,6 +1843,58 @@ will. Der Streifen ist so schmal wie die Ziffern es verlangen
gar nicht. `scrollEditorToOffset()` zieht die Zahlen deshalb selbst gleich mit, gar nicht. `scrollEditorToOffset()` zieht die Zahlen deshalb selbst gleich mit,
statt sich auf das Ereignis zu verlassen. statt sich auf das Ereignis zu verlassen.
**Nachtrag — die Warn-Zahl trägt ihre Meldung als Tooltip.** Der Streifen sagte
bisher nur *dass* eine Zeile eine Warnung hat; *welche*, stand allein unter dem
Diagramm. Das ist der halbe Weg: Man sieht die orange Zahl, sucht dann in der
Liste die passende Zeilennummer und liest dort. Der Tooltip schließt genau
diese Lücke, und er kostet nichts Neues — die Meldung existiert schon, sie wird
nur ein zweites Mal ausgegeben.
**Wörtlich dieselbe Meldung, aus derselben Quelle.** `formatWarning()` ist seit
jeher die eine Stelle, die die Warnungstypen kennt; sie bleibt es. Nur der
**Ausgang** ist ein anderer: Der Warnungsbereich ist HTML, ein `title` ist
Klartext. Deshalb gibt es jetzt `warningText()` daneben, gebaut aus demselben
`switch` mit einem anderen Escaper. Ohne die Trennung stünde im Tooltip
wörtlich `Drag &amp; Drop` — und Labels mit `&`, `<` oder `"` sind keine
Ausnahme, sondern der Alltag (der Prüf-Plan trug „Plan mit "Drag & Drop"“
genau deswegen). Ein zweiter, handgeschriebener Formatierer wäre die
naheliegende Alternative gewesen und die schlechtere: zwei Stellen, die
dieselben elf Typen kennen müssen, und die eine veraltet.
**Mehrere Warnungen einer Zeile stehen untereinander.** Sie sind ohnehin je
eine eigene Meldung (D35 begründet das für `xorConflict`: die Warnung zeigt auf
die Zeile, die man ansehen muss); im Tooltip getrennt durch `\n`, was ein
`title` als einzige Auszeichnung kann. Der Präfix „Zeile 12: “ bleibt darin
stehen, obwohl er neben der Zahl redundant ist: Ihn zu entfernen hieße, eine
lokalisierte Vorlage in neun Sprachen zu zerschneiden — für ein paar
gesparte Zeichen an einer Stelle, an der die Wortgleichheit mit dem
Warnungsbereich mehr wert ist.
**Der Kasten der Warn-Zahl reicht über die ganze Streifenbreite.** Die Zahlen
stehen `position:absolute; right:6px` und sind damit nur so breit wie ihre
Ziffern — auf dem Telefon rund 7 px. Ein Tooltip an einem so kleinen Ziel ist
praktisch nicht zu treffen. Warn-Zahlen bekommen deshalb `left:0;right:0` plus
`padding-right:6px`: Die **Ziffern bleiben punktgenau stehen** (nachgemessen:
rechte Kante 21,14 px, gleichauf mit einer gewöhnlichen Zahl), nur die
unsichtbare Fläche wächst. `cursor:help` sagt, dass es dort etwas zu lesen
gibt — die einzige Auffindbarkeit, die ein Tooltip haben kann (die Lehre aus
D25).
**Kein Ersatz für den Warnungsbereich, und kein Zweitweg für Screenreader.**
Der Streifen ist `aria-hidden` (D33: reine Lesehilfe) — ein `title` darin ist
für einen Screenreader ohnehin nicht da, und das soll so bleiben: Die
Live-Region meldet neue Warnungen von selbst (§9), ein zweiter Kanal läse sie
doppelt vor. Auf Touch gibt es keinen Tooltip; anders als bei den
Knotenbeschreibungen (D52) fehlt dort aber nichts, denn die vollständige
Meldung steht sichtbar unter dem Diagramm.
**Nachgemessen** an einem Plan mit vier Warnungen in drei Zeilen: Die drei
Zahlen tragen genau die vier Meldungen des Warnungsbereichs, Zeile 2 beide
untereinander, `&` und `"` unverfälscht; keine Zahl ohne Warnung trägt einen
`title`. Nach dem Beheben zweier Warnungen verschwinden Klasse **und** Titel
mit (Zeile 3 ganz, Zeile 2 von zwei Meldungen auf eine). Im mitgelieferten
Werkbaum-Plan (941 Zeilen, 155 Knoten, 0 Warnungen): 0 Tooltips.
## D34 — Abhängigkeiten, IDs, XOR, Falten, Beschreibungen: erst reserviert, dann gebaut ## D34 — Abhängigkeiten, IDs, XOR, Falten, Beschreibungen: erst reserviert, dann gebaut
Fünf Erweiterungen auf einmal — Knoten-**IDs** (`#auth`), **Abhängigkeiten** Fünf Erweiterungen auf einmal — Knoten-**IDs** (`#auth`), **Abhängigkeiten**
(`:#auth,#api`) samt effektivem Status, **XOR** (`x`), ein- und ausklappbare (`:#auth,#api`) samt effektivem Status, **XOR** (`x`), ein- und ausklappbare
+6
View File
@@ -718,6 +718,12 @@ im Text abzählen.
waagerechte Verschiebung zurück — er zeigt auf eine Zeile, nicht auf ihr Ende. waagerechte Verschiebung zurück — er zeigt auf eine Zeile, nicht auf ihr Ende.
- Die **Cursor-Zeile** und Zeilen mit einer **Warnung** heben sich ab (Warnung in - Die **Cursor-Zeile** und Zeilen mit einer **Warnung** heben sich ab (Warnung in
`--warn`, `#B45309`) — genau die beiden Fälle, für die man hinsieht. `--warn`, `#B45309`) — genau die beiden Fälle, für die man hinsieht.
- Die Zahl einer Warn-Zeile trägt die **Meldung als Tooltip** — wörtlich
dieselbe, die unter dem Diagramm im Warnungsbereich steht; mehrere Warnungen
einer Zeile stehen darin untereinander. Der Streifen bleibt `aria-hidden`:
Für Screenreader ist die Live-Region des Warnungsbereichs die Quelle, der
Tooltip erspart nur den Weg dorthin. Ohne Zeiger gibt es ihn nicht (wie
überall) — die Meldung steht dort ohnehin vollständig. Siehe D33-Nachtrag.
- Reine Lesehilfe: kein Bestandteil des Notationstexts, nicht im Grafikexport - Reine Lesehilfe: kein Bestandteil des Notationstexts, nicht im Grafikexport
und nicht im Druck (dort ist ohnehin nur das Diagramm zu sehen). und nicht im Druck (dort ist ohnehin nur das Diagramm zu sehen).
+6
View File
@@ -66,6 +66,7 @@
- [^] #ed.docs.restore: Restore a shipped document from the menu (XS) - [^] #ed.docs.restore: Restore a shipped document from the menu (XS)
- [^] #ed.jump: Jump between diagram and text (S) - [^] #ed.jump: Jump between diagram and text (S)
- [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them - [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them
- [x] #ed.lineno.tip: The warning is the tooltip of its number (XS) %% no hunting in the list
- [^] #ed.fold: Collapsible subtrees (M) - [^] #ed.fold: Collapsible subtrees (M)
- [^] #ed.fold.marks: Initial state from the text: > collapses, < re-opens (S) - [^] #ed.fold.marks: Initial state from the text: > collapses, < re-opens (S)
- [^] #ed.fold.click: Fold and unfold in the diagram (S) - [^] #ed.fold.click: Fold and unfold in the diagram (S)
@@ -479,6 +480,11 @@
same ones the warnings name. Measured rather than calculated: a wrapped line same ones the warnings name. Measured rather than calculated: a wrapped line
keeps exactly one number. keeps exactly one number.
#ed.lineno.tip
An amber number says that a line has a warning; its tooltip says which one,
word for word as it stands below the diagram. Otherwise you read the number,
then hunt for it in the list.
#ed.fold #ed.fold
A large plan is unreadable unless parts of it can be put away. Folding is A large plan is unreadable unless parts of it can be put away. Folding is
view only — warnings from folded subtrees are still reported, because they view only — warnings from folded subtrees are still reported, because they
+4
View File
@@ -55,6 +55,10 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
neutral. `render()` in app.js führt Parser- und Renderer-Warnungen zusammen neutral. `render()` in app.js führt Parser- und Renderer-Warnungen zusammen
(nach Zeile sortiert) und zeigt sie via `formatWarning` (warnings.js). Neue (nach Zeile sortiert) und zeigt sie via `formatWarning` (warnings.js). Neue
Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen. Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen.
- **Zwei Senken, ein `switch`:** `formatWarning()` liefert HTML (Warnungsbereich),
`warningText()` Klartext (der `title` der Zeilennummer, D33-Nachtrag); beide
bauen auf `build(w, t, esc)` auf. Einen neuen Typ **nur dort** eintragen —
einen zweiten Formatierer danebenzustellen heißt, dass einer veraltet.
- Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/ - Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/
Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`, Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`,
`computeCheapSet`, `cheapCls`), `render.js` (HTML-String via `computeCheapSet`, `cheapCls`), `render.js` (HTML-String via
+20 -5
View File
@@ -2,7 +2,7 @@ import './style.css';
import { parse, setFoldMark } from './parser.js'; import { parse, setFoldMark } from './parser.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM } from './model.js'; import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, atMostM } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js'; import { padUrls } from './remote.js';
/* 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
@@ -156,8 +156,16 @@ function render(){
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0)); warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join(''); warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join('');
/* Der Zeilennummern-Streifen zeigt genau die Zeilen an, die hier genannt /* Der Zeilennummern-Streifen zeigt genau die Zeilen an, die hier genannt
werden deshalb hängt er an derselben Warnungsliste (D33). */ werden deshalb hängt er an derselben Warnungsliste (D33). Der Text
lineNoWarn = new Set(warnings.map(w => w.line).filter(Boolean)); wandert gleich mit in den `title` der Zahl (D33-Nachtrag); mehrere
Warnungen einer Zeile stehen dort untereinander. */
lineNoWarn = new Map();
for(const w of warnings){
if(!w.line) continue;
const vorher = lineNoWarn.get(w.line);
const text = warningText(w, t);
lineNoWarn.set(w.line, vorher ? vorher + '\n' + text : text);
}
renderLineNos(); renderLineNos();
applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */ applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */
alignStems(); alignStems();
@@ -832,7 +840,7 @@ function lineTops(){
in den Textfluss lässt sich nichts einfügen, ein `<textarea>` kennt kein in den Textfluss lässt sich nichts einfügen, ein `<textarea>` kennt kein
Markup. Gescrollt wird er nicht selbst, sondern gegen `src.scrollTop` Markup. Gescrollt wird er nicht selbst, sondern gegen `src.scrollTop`
verschoben; so kann er nie auseinanderlaufen. */ verschoben; so kann er nie auseinanderlaufen. */
let lineNoWarn = new Set(); let lineNoWarn = new Map(); /* Zeile -> Warnungstext(e), siehe render() */
function renderLineNos(){ function renderLineNos(){
const tops = lineTops(); const tops = lineTops();
/* Der Marker ist ein Inline-Kasten und steht in seiner Zeilenbox mittig sein /* Der Marker ist ein Inline-Kasten und steht in seiner Zeilenbox mittig sein
@@ -856,7 +864,14 @@ function renderLineNos(){
const s = lineNoInner.children[i], n = i + 1; const s = lineNoInner.children[i], n = i + 1;
if(s.textContent !== String(n)) s.textContent = n; if(s.textContent !== String(n)) s.textContent = n;
s.style.top = (tops[i] - drop) + 'px'; s.style.top = (tops[i] - drop) + 'px';
s.classList.toggle('warn', lineNoWarn.has(n)); /* Die Warnung steht auch im Warnungsbereich unter dem Diagramm der
Tooltip erspart nur den Weg dorthin und das Suchen der Zeilennummer.
Kein Ersatz: Der Streifen ist `aria-hidden`, für Screenreader bleibt die
Live-Region die Quelle (D33-Nachtrag). */
const warn = lineNoWarn.get(n);
s.classList.toggle('warn', !!warn);
if(warn){ if(s.title !== warn) s.title = warn; }
else if(s.title) s.removeAttribute('title');
} }
markCurrentLineNo(); markCurrentLineNo();
syncLineNoScroll(); syncLineNoScroll();
+9 -1
View File
@@ -544,7 +544,15 @@
/* Cursor-Zeile und Zeilen mit Warnung heben sich ab genau die beiden Fälle, /* Cursor-Zeile und Zeilen mit Warnung heben sich ab genau die beiden Fälle,
für die man im Streifen überhaupt nachsieht. */ für die man im Streifen überhaupt nachsieht. */
.lineno-inner span.cur{opacity:1;color:var(--ink);font-weight:600} .lineno-inner span.cur{opacity:1;color:var(--ink);font-weight:600}
.lineno-inner span.warn{opacity:1;color:var(--warn);font-weight:600} /* Die Zahl einer Warn-Zeile trägt die Meldung als Tooltip (D33-Nachtrag).
Dafür reicht ihr Kasten über die **ganze** Streifenbreite statt nur über
die Ziffern: Ein zwei Zeichen breites Ziel trifft man sonst kaum. Die
Ziffern selbst bleiben, wo sie sind (rechtsbündig, 6 px Abstand), und
`cursor:help` sagt, dass es hier etwas zu lesen gibt. */
.lineno-inner span.warn{
opacity:1;color:var(--warn);font-weight:600;
left:0;right:0;padding-right:6px;text-align:right;cursor:help;
}
.hint{ .hint{
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18); padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
font-size:.8rem;color:var(--muted);line-height:1.7; font-size:.8rem;color:var(--muted);line-height:1.7;
+14
View File
@@ -43,6 +43,20 @@ import { esc } from './render.js';
/* Strukturierte Warnung -> lokalisierter Anzeigetext (HTML-escaped Daten). /* Strukturierte Warnung -> lokalisierter Anzeigetext (HTML-escaped Daten).
`t` ist die i18n-Funktion (key, vars) -> String. */ `t` ist die i18n-Funktion (key, vars) -> String. */
export function formatWarning(w, t){ export function formatWarning(w, t){
return build(w, t, esc);
}
/* Dasselbe als **Klartext**, ohne HTML-Escaping für Senken, die kein HTML
sind: der `title` der Zeilennummer (D33-Nachtrag). Dort stünde sonst
Drag &amp; Drop statt Drag & Drop; die Vorlagen selbst enthalten kein
Markup, escaped werden ohnehin nur die eingesetzten Daten. */
export function warningText(w, t){
return build(w, t, s => String(s));
}
/* Der Parameter verdeckt das importierte `esc` bewusst: So bleibt der Rumpf
unverändert und es gibt nur **eine** Stelle, die die Typen kennt. */
function build(w, t, esc){
switch(w.type){ switch(w.type){
case 'mixedGate': case 'mixedGate':
return t('mixedWarn', {line: w.line, label: esc(w.label)}); return t('mixedWarn', {line: w.line, label: esc(w.label)});
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatWarning } from '../src/warnings.js'; import { formatWarning, warningText } from '../src/warnings.js';
/* Stub bildet key + interpolierte Variablen sichtbar ab, damit wir prüfen /* Stub bildet key + interpolierte Variablen sichtbar ab, damit wir prüfen
können, welche Werte (inkl. Escaping) formatWarning durchreicht. */ können, welche Werte (inkl. Escaping) formatWarning durchreicht. */
@@ -40,3 +40,24 @@ describe('formatWarning — vereinheitlichtes Warnungs-Modell', () => {
expect(formatWarning({type: 'x'}, t)).toBe('x (?)'); expect(formatWarning({type: 'x'}, t)).toBe('x (?)');
}); });
}); });
/* Der `title` der Zeilennummer ist kein HTML dort stünde sonst wörtlich
Drag &amp; Drop (D33-Nachtrag). Dieselben Typen, dieselbe Vorlage, nur
ohne Escaping. */
describe('warningText — dieselbe Meldung als Klartext', () => {
it('lässt das Label unangetastet', () => {
expect(warningText({type: 'mixedGate', line: 7, label: 'A & <B>'}, t))
.toBe('mixedWarn|{"line":7,"label":"A & <B>"}');
});
it('lässt die URL unangetastet', () => {
expect(warningText({type: 'sourceLoad', url: 'https://a/?x=1&y=2', error: 'HTTP 404'}, t))
.toBe('sourceLoadWarn|{"url":"https://a/?x=1&y=2","error":"HTTP 404"}');
});
it('deckt dieselben Typen ab wie formatWarning', () => {
expect(warningText({type: 'descStray', line: 4}, t)).toBe('descStrayWarn|{"line":4}');
expect(warningText({type: 'cheapApprox'}, t)).toBe('cheapApproxWarn|undefined');
expect(warningText({type: 'was?', line: 9}, t)).toBe('was? (9)');
});
});