feat: Cursor-Zeile hebt sich aus der Ebene, plus Puls beim Zeilenwechsel

Die Hervorhebung der Cursor-Zeile (D25) war zu leise. Sie benutzte denselben
Kanal, den schon jeder Knoten belegt — jeder Status hat einen Rahmen (SPEC §4);
ein weiterer Ring darunter ist ein Unterschied im Grad, nicht in der Art.

Dazu kommt jetzt Tiefe: Schlagschatten + scale(1.04). Der einzige Kanal, den im
Diagramm noch nichts belegt, also ohne Konflikt mit einer Farbcodierung. Um die
Mitte skaliert, damit die Knotenmitte erhalten bleibt (alignStems, Stationspunkte
messen genau die — nachgemessen dx=dy=0). Beim Zeilenwechsel meldet sie sich
zusätzlich mit einem einmaligen Puls (Hüpfer + auslaufender Ring auf ::after);
kein Blinken, prefers-reduced-motion schaltet ihn ab.

Behoben dabei: Die Pfad-Inversion (D18) blasste den Ring auf 0,32 aus — die
Ausnahme, die .fresh (D28) und .focusmark (D32) längst haben, fehlte für
.current als einziger, obwohl der Fall der häufigste ist (Umschalter default an,
im Beispiel 7 von 18 Knoten betroffen).

diagramToSvg() muss die Erhebung abschalten (Klasse `exporting`): Der Export
zieht die Live-Geometrie per getBoundingClientRect() nach, anders als beim
box-shadow schlägt eine Vergrößerung dort durch. Nachgemessen: 155,6 statt
161,9 px. Im Druck fällt beides ebenso weg.

SPEC §9 und D25 (Nachtrag) nachgezogen; llms.md unberührt (Notation, keine
Editor-UI, D43).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-23 07:24:04 +02:00
co-authored by Claude Fable 5
parent 618149a36d
commit 099ee87bc1
5 changed files with 190 additions and 17 deletions
+16 -1
View File
@@ -211,7 +211,22 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`syncCaret()` setzt die Klasse `current` auf den Knoten der Cursor-Zeile;
`render()` stellt sie nach jedem Neubau wieder her (ohne zu scrollen). Die
CSS-Regel braucht den `#out`-Präfix (`ul.or .node{box-shadow:none}` ist
spezifischer). Auffindbarkeit: `setAltMode()` setzt bei gedrückter Alt-Taste
spezifischer) **und** — wie `.fresh`/`.focusmark` — die Ausnahme
`.cheap-on .node:not(.cheap).current{opacity:1;filter:none}`; sie fehlte lange
als einzige, obwohl der Fall der häufigste ist (Pfad-Umschalter default an,
jeder `+`-Knoten und jede nicht gewählte Alternative betroffen).
Zum Ring kommen **Schlagschatten + `scale(1.04)`** (D25-Nachtrag): Tiefe ist der
einzige noch freie Kanal. Um die **Mitte** skalieren ist Bedingung — `alignStems()`
und die Stationspunkte messen die Knotenmitte, die dadurch unverändert bleibt.
**`diagramToSvg()` muss die Erhebung abschalten** (Klasse `exporting` an `#out`,
am Ende wieder ab): Der Export zieht die Live-Geometrie per
`getBoundingClientRect()` nach, anders als beim `box-shadow` schlägt die
Vergrößerung dort durch. Der **Puls** (`pulse`, Hüpfer + Ring auf dem freien
`.node::after`) wird nur bei geändertem `caretLine` gesetzt — sonst pulst es bei
jedem Tastendruck — und braucht das Lesen von `offsetWidth` als Neustart-Trick,
weil dieselbe Aufgabe die Klasse vorher entfernt. Nicht über `box-shadow`
animieren: Das löschte für die Dauer des Pulses den gelben bzw. Petrol-Kranz der
Kombinationen. Auffindbarkeit: `setAltMode()` setzt bei gedrückter Alt-Taste
die Klasse `alt` an `#out` (Cursor + Ring am Knoten unter dem Zeiger) — der
`blur`-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazu
`jumpHint` im Knoten-Tooltip und `hint_jump` als letzte Zeile der Legende.
+21 -4
View File
@@ -394,6 +394,13 @@ out.addEventListener('focusout', () => setTimeout(drawDepLinks, 0));
(und = durchgezogen Tinte, oder = gestrichelt Grau) neu gezogen und treffen
so garantiert die Knoten — unabhängig vom Darstellungsmodus. */
function diagramToSvg(){
/* Der Export misst die **Live-Geometrie**. Den Ring der Cursor-Zeile liest er
nie aus (`box-shadow` steht nicht in der Liste), ihre Erhebung
(D25-Nachtrag) schlüge aber über `getBoundingClientRect()` durch und
exportierte genau einen Knoten 4 % zu groß. Neutralisiert wird sie per
Klasse statt durch Abnehmen von `.current`/`.pulse` — so reißt der Export
keine laufende Puls-Animation ab und startet sie hinterher nicht neu. */
out.classList.add('exporting');
const treeRect = out.getBoundingClientRect();
const PAD = 24;
const W = Math.ceil(treeRect.width) + PAD*2;
@@ -570,6 +577,7 @@ function diagramToSvg(){
});
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="'IBM Plex Sans',system-ui,sans-serif">${parts.join('')}</svg>`;
out.classList.remove('exporting');
return {svg, W, H};
}
function svgToPng(svg, W, H, scale){
@@ -925,9 +933,9 @@ out.addEventListener('touchcancel', disarmPress);
out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDefault(); });
/* Text -> Diagramm: Knoten der Cursor-Zeile hervorheben (`caretLine` steht oben). */
function highlightCurrentNode(scroll){
function highlightCurrentNode(moved){
markCurrentLineNo(); /* die eine Stelle, an der die Cursor-Zeile neu gesetzt wird */
if(currentNodeEl) currentNodeEl.classList.remove('current');
if(currentNodeEl) currentNodeEl.classList.remove('current', 'pulse');
currentNodeEl = caretLine == null
? null
: out.querySelector('.node[data-line="' + caretLine + '"]');
@@ -936,8 +944,17 @@ function highlightCurrentNode(scroll){
/* Die Cursor-Zeile ist die zweite Lesart von „ausgewählt" — ihre
Abhängigkeits-Kanten hervorheben (D41). */
drawDepLinks();
/* Nur beim Zeilenwechsel scrollen, sonst ruckelte das Diagramm beim Tippen. */
if(scroll) currentNodeEl.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'});
/* Beides nur beim **Zeilenwechsel**: Scrollen ruckelte sonst bei jedem
Tastendruck, und der Puls (D25-Nachtrag) flackerte beim Tippen. */
if(!moved) return;
/* Neustart der Animation erzwingen: Steht der Cursor wieder auf demselben
Knoten (Zeile mit Knoten -> Zeile ohne -> zurück), wurde `pulse` oben in
DERSELBEN Aufgabe entfernt — der Browser sieht dann keinen Klassenwechsel
und startet nichts. Das Lesen von `offsetWidth` erzwingt den Zwischenstand. */
currentNodeEl.classList.remove('pulse');
void currentNodeEl.offsetWidth;
currentNodeEl.classList.add('pulse');
currentNodeEl.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'});
}
function caretLineOf(){
return src.value.slice(0, src.selectionStart).split('\n').length;
+66 -9
View File
@@ -578,11 +578,21 @@
im Druck. Der `#out`-Präfix ist nötig: `ul.or .node{box-shadow:none}`
(weiter unten) ist spezifischer als `.node.current` und schluckte den Ring
sonst überall unterhalb einer any-of-Gruppe. */
/* Die Fokusmarke `!!!` (SPEC §1) trägt bewusst DIESELBE Hervorhebung: Es gibt
nur einen Begriff „hier schauen", und ein dritter Ring neben Fokusring,
Strahlenkranz und Cursor-Ring wäre ein Zeichen zu viel. Der Unterschied
steht im `aria-label`. */
#out .node.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink)}
/* Der Ring allein war zu leise (D25-Nachtrag): Er benutzt denselben Kanal, den
schon jeder Knoten belegt — **jeder Status hat einen Rahmen** (SPEC §4). Ein
weiterer Ring in einem Feld aus lauter gerahmten Kästchen ist ein Unterschied
im Grad, nicht in der Art. Deshalb kommt **Tiefe** dazu: Schlagschatten plus
leichte Vergrößerung. Das ist der einzige Kanal, den im Diagramm noch nichts
belegt (nichts anderes wirft Schatten oder hebt sich), kollidiert also mit
keiner Farbcodierung und wirkt über allen acht Statusfarben gleich.
`transform` ändert **kein Layout** — die Linien bleiben stehen; und weil um
die **Mitte** skaliert wird, bleibt auch die Knotenmitte unverändert, auf die
`alignStems()` und `drawCheapPath()` messen. `z-index`, weil Schatten und
vergrößerter Knoten AUSSERHALB der Box liegen (dasselbe wie bei `.fresh`). */
#out .node.current{
box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink),0 10px 20px -6px rgba(36,52,71,.5);
transform:scale(1.04);z-index:3;
}
/* Fokusmarke `!!!` (SPEC §1, D32): **Strahlenkranz in Petrol**, nach dem Muster
der Neuheiten-Markierung (D28), nur in der Akzentfarbe der App statt in Gelb.
Der geteilte Ring mit der Cursor-Zeile war zu leise — „das erkennt man zu
@@ -598,8 +608,10 @@
obwohl die Geometrie dieselbe war. Der Ring bleibt dunkles Petrol und ordnet
die Marke der Akzentfarbe zu; das Leuchten macht die Reichweite. */
#out .node.focusmark{box-shadow:0 0 0 3px var(--or),0 0 20px 7px rgba(20,184,166,.85);z-index:2}
/* Beides zugleich: Cursor-Zeile innen (Tinte), fremde Marke außen (Petrol). */
#out .node.focusmark.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink),0 0 20px 7px rgba(20,184,166,.85)}
/* Beides zugleich: Cursor-Zeile innen (Tinte), fremde Marke außen (Petrol).
Die Erhebung der Cursor-Zeile gilt in jeder Kombination — der Schlagschatten
wird deshalb hier mit angehängt (das `transform` erbt die Regel oben). */
#out .node.focusmark.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink),0 0 20px 7px rgba(20,184,166,.85),0 10px 20px -6px rgba(36,52,71,.5);z-index:3}
/* Neu in Produktion seit dem letzten Besuch (D28): gelber Strahlenkranz um den
Knoten. Bewusst ein Schein nach AUSSEN statt Farbe im Knoten — die Füllung
gehört dem Status (SPEC §4) und bleibt lesbar. Kein Blinken: WCAG 2.2.2/2.3.1
@@ -610,13 +622,44 @@
Kante stehen — sieht aus wie ein halber Hintergrund statt eines Rahmens). */
#out .node.fresh{box-shadow:0 0 0 2px #FACC15,0 0 16px 5px rgba(234,179,8,.65);z-index:2}
/* Beides zugleich: Cursor-Zeile innen (Tinte), Neuheit außen (gelb). */
#out .node.fresh.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink),0 0 18px 6px rgba(234,179,8,.65)}
#out .node.fresh.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink),0 0 18px 6px rgba(234,179,8,.65),0 10px 20px -6px rgba(36,52,71,.5);z-index:3}
/* Neu in Produktion UND jemand zeigt darauf: Ring in Petrol (der bewusste
Zuruf, innen), Kranz in Gelb (die stehende Nachricht, außen). Zwei Scheine
nach außen gingen nicht — einer muss der Ring sein. Steht bewusst **nach**
`.node.focusmark.current`: gleiche Spezifität (drei Klassen), und wenn alles
drei zutrifft, soll Zuruf + Neuheit gewinnen, nicht die eigene Cursor-Zeile. */
#out .node.fresh.focusmark{box-shadow:0 0 0 3px var(--or),0 0 20px 7px rgba(234,179,8,.65);z-index:2}
/* Puls beim Zeilenwechsel (D25-Nachtrag): Man verliert den Knoten beim
**Bewegen**, nicht im Stillstand — also meldet er sich genau dann einmal.
Einmalig, kein Blinken: WCAG 2.2.2 zielt auf Blinkendes, 2.3.1 auf Flackern
über 3 Hz; ein einzelner Durchlauf ist keins von beidem (vgl. D28).
Ausgelöst wird er in `highlightCurrentNode()` **nur** bei geändertem
`caretLine` — sonst pulste er bei jedem Tastendruck.
Bewusst NICHT über `box-shadow` animiert: die Kombinationen mit `.fresh` und
`.focusmark` haben je eigene Schatten-Listen, eine Animation darauf ließe den
gelben bzw. petrolfarbenen Kranz für die Dauer des Pulses verschwinden.
Stattdessen zwei Kanäle, die niemand sonst benutzt: ein kurzer Hüpfer im
`transform` und ein auslaufender Ring auf dem freien `::after`
(`::before` gehört dem Optional-Kreis, D29). */
@keyframes nodeLift{0%{transform:scale(1.04)}40%{transform:scale(1.11)}100%{transform:scale(1.04)}}
@keyframes nodeRipple{from{transform:scale(1);opacity:.5}to{transform:scale(1.4);opacity:0}}
#out .node.current.pulse{animation:nodeLift .36s ease-out 1}
/* `both` ist Pflicht: ohne Fill-Mode schnappt der Ring am Ende auf die
Anfangsdeckkraft zurück und bliebe stehen. */
#out .node.current.pulse::after{
content:'';position:absolute;inset:-3px;border-radius:calc(var(--r) + 3px);
border:2px solid var(--ink);pointer-events:none;
animation:nodeRipple .45s ease-out 1 both;
}
@media (prefers-reduced-motion: reduce){
#out .node.current.pulse{animation:none}
#out .node.current.pulse::after{display:none}
}
/* Während `diagramToSvg()` misst: Erhebung und Puls abschalten — der Export
zeichnet aus der Live-Geometrie nach, und die Vergrößerung ist eine
Editierhilfe, keine Aussage über den Plan (D25). */
#out.exporting .node.current{transform:none;animation:none}
#out.exporting .node.current::after{display:none}
/* Alt gedrückt = Sprung-Geste scharf: Cursor an allen Knoten, Petrol-Ring am
Knoten unter dem Zeiger. Auf verlinkten Knoten ist das die einzige
Rückmeldung, weil dort der einfache Klick dem Link gehört (D25). */
@@ -696,6 +739,16 @@
der wahrscheinlichsten Gründe, überhaupt zu zeigen. Anders als beim
optionalen Knoten (D29), wo das Zurücktreten die Aussage IST. */
.cheap-on .node:not(.cheap).focusmark{opacity:1;filter:none}
/* Und dasselbe für die Cursor-Zeile (D25-Nachtrag) — die Ausnahme fehlte hier
als einzige, obwohl der Fall der häufigste ist: Der Umschalter ist
voreingestellt an, und jeder optionale Knoten und jede nicht gewählte
Alternative ist „nicht auf dem Pfad" (im mitgelieferten Beispiel 7 von 18
Knoten). Gemessen auf `+ [?] Dark mode`: Deckkraft 0,32 — der Ring war dort
praktisch unsichtbar. Wo der Cursor steht, muss sichtbar bleiben, egal wie
der Pfad entschieden hat; anders als beim optionalen Knoten (D29), wo das
Zurücktreten die Aussage IST, ist hier gar keine Aussage über den Plan
gemeint — es ist eine Editierhilfe. */
.cheap-on .node:not(.cheap).current{opacity:1;filter:none}
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)}
/* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch
@@ -1078,8 +1131,12 @@
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
/* Knoten nicht über den Seitenrand zerschneiden */
#out li{break-inside:avoid}
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1) */
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1).
Bei der Cursor-Zeile gehören Erhebung und Puls mit dazu — sonst stünde ein
Knoten im Druck grundlos vergrößert und schief zu seiner Anschlusslinie. */
.node.current,.node.focusmark{box-shadow:none!important}
.node.current{transform:none!important;animation:none!important}
.node.current::after{display:none!important}
.node.fresh{box-shadow:none!important} /* „neu seit dem letzten Besuch" ist persönlich (D28) */
/* Faltung (D38): das ▾ offener Knoten ist Bedienelement — nicht drucken;
das „▸ n" eingeklappter Knoten ist Kennzeichnung und bleibt. */