feat: Auf Touch öffnet der einfache Tipp das Knoten-Fenster (D52)

Ein `title` braucht einen Zeiger — auf dem Telefon war die Beschreibung
(D40) damit gar nicht zu sehen, und seit D48 hängt an jedem der 172
Plan-Knoten eine. Der einfache Tipp zeigt jetzt denselben Inhalt als
Fenster.

Die eine echte Frage war der Link: Ein Knoten mit URL belegt den
einfachen Klick vollständig (SPEC §6), ein Tipp kann nicht beides tun.
Entschieden: Auf Touch öffnet der Tipp immer das Fenster, der Link steht
darin als Knopf. Damit hat die Geste dort eine Bedeutung, und die
Tooltips sind auf allen Knoten erreichbar. SPEC §6 bekommt dafür eine
Touch-Ausnahme.

Es hängt an den Touch-Ereignissen, nicht an einer Media Query — ein
Gerät mit beidem verhält sich dadurch ohne Sonderfall richtig: Finger
öffnet das Fenster, Maus öffnet den Link und zeigt den Tooltip.

Die drei Touch-Gesten unterscheiden sich am vorhandenen Zustand, ohne
neues Merkerfeld (armedEl gesetzt ⇒ Sprung, Timer läuft ⇒ Tipp, beides
weg ⇒ gescrollt). Ausgenommen ist das Falt-Zeichen: Das Fenster braucht
preventDefault(), das sonst den Falt-Klick verschluckt.

Das Fenster nutzt, was ein `title` nicht kann: echte Trennlinie statt 24
`─` (der title wird an genau diesem Strich zerlegt, TIP_RULE exportiert
— ein zweites data-Attribut hätte 20 kB DOM-Text verdoppelt), Absätze
aus Leerzeilen statt harter Quell-Umbrüche, Knöpfe. Der Sprung-Hinweis
nennt hier den langen Druck (neuer i18n-Schlüssel jumpHintTouch, plus
tipClose und tipOpenLink, je 9 Sprachen).

position:fixed auf <body>: in #out erbte es dessen zoom und würde von
dessen overflow beschnitten (D50). Zu macht es alles, was seine Aussage
hinfällig macht — Tipp daneben, zweiter Tipp, Esc, ×, Diagramm-Scroll,
Bereichswechsel, Sprung in den Text, render().

Nachgemessen (375x812): Wurzelknoten 336x220 px ganz im Bild, ein Absatz
ohne harte Umbrüche, Fakten mit Langdruck-Hinweis, Link-Knopf, Ring am
Knoten, URL nicht geöffnet; Knoten ohne Beschreibung 39 px ohne
Trennstrich; Spitze 0 px von der Knotenmitte. Echte Wischgeste
(scrollLeft 0 -> 300) schließt. Schreibtisch unverändert: Klick öffnet
den Link, Fenster bleibt zu. Plan: 150 Knoten, 0 Warnungen, 24
Stationen. 240 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 07:37:49 +02:00
co-authored by Claude Opus 5
parent b6e83b4f2a
commit b2248c45bc
8 changed files with 358 additions and 8 deletions
+102
View File
@@ -3347,3 +3347,105 @@ synthetisches `.blur()` löst im Automaten keinen aus. Gegenprobe am
ebenfalls nichts, es ist also die Werkzeuggrenze und keine Regression. Dieselbe ebenfalls nichts, es ist also die Werkzeuggrenze und keine Regression. Dieselbe
Lehre wie in D25 und D17-Nachtrag 4 — was die Geräteumgebung stellt, beweist Lehre wie in D25 und D17-Nachtrag 4 — was die Geräteumgebung stellt, beweist
der Emulator nicht. der Emulator nicht.
## D52 — Auf Touch öffnet der einfache Tipp das Knoten-Fenster, nicht den Link
Die Beschreibungen (D40) lebten im `title` — und ein `title` braucht einen
Zeiger. D40 hat das als bekannte Grenze notiert („auf Touch-Geräten gibt es
keine Tooltips — dort bleibt der Text vorerst nur im `aria-label`; ein Pop-up
kann später ergänzt werden"). Auf dem Telefon war die Beschreibung damit **gar
nicht** zu sehen, und seit D48 hängt an jedem der 172 Plan-Knoten eine.
**Die eine echte Frage war der Link.** Ein Knoten mit URL ist ein `<a>` und
belegt den einfachen Klick vollständig (§6, D6); ein Tipp kann nicht beides
tun. Entschieden (Nutzer): **Auf Touch öffnet der Tipp immer das Fenster, und
der Link steht darin als Knopf.** Damit hat die Geste dort **eine** Bedeutung —
„ansehen" —, und die Tooltips sind auf **allen** Knoten erreichbar. Preis:
SPEC §6 bekommt eine Touch-Ausnahme, und der Link kostet einen zweiten Tipp.
**Verworfen:**
- **Nur unverlinkte Knoten** — hätte §6 unangetastet gelassen, aber derselbe
Tipp hätte je nach Knoten Verschiedenes getan. Genau das hat D25 für diese
Geste ausdrücklich abgelehnt, und ausgerechnet die verlinkten Knoten (oft die
interessanten) wären ohne Beschreibung geblieben.
- **Erster Tipp Fenster, zweiter Tipp Link** — spart den Knopf, führt aber eine
unsichtbare Zusatzregel ein: Nichts zeigt an, dass ein zweiter Tipp etwas
anderes tut.
**Es hängt an den Touch-Ereignissen, nicht an einer Media Query.** Damit
verhält sich ein Gerät mit **beidem** (Touch-Notebook) richtig, ohne
Sonderfall: Der Finger öffnet das Fenster, die Maus öffnet weiterhin den Link
und zeigt den Tooltip. Nachgemessen am Schreibtisch: Klick auf den verlinkten
Wurzelknoten öffnet den Link, das Fenster bleibt zu, der `title` steht
unverändert am Knoten.
**Die drei Touch-Gesten unterscheiden sich am vorhandenen Zustand, ohne neues
Merkerfeld.** Der lange Druck (D25) setzt nach 500 ms `armedEl`, und jedes
`touchmove` räumt den Timer weg. Bei `touchend` gilt also: `armedEl` gesetzt ⇒
Sprung; Timer läuft noch ⇒ kurzer Tipp ohne Wischen ⇒ Fenster; beides weg ⇒ es
wurde gescrollt ⇒ nichts. **Ausgenommen ist das Falt-Zeichen** (D38): Der Tipp
darauf muss weiter umklappen, und weil das Fenster `preventDefault()` braucht
(sonst öffnete der Link-Knoten zusätzlich seine URL), hätte es den folgenden
Klick sonst verschluckt — Falten wäre auf Touch unbedienbar geworden.
Nachgemessen: Tipp auf das ▾ des Wurzelknotens 149 → 1 Knoten, kein Fenster.
**Es ist ein Fenster, kein zweiter Tooltip — also nutzt es, was ein `title`
nicht kann.** Der Trennstrich zwischen Beschreibung und Kurz-Fakten musste
dort aus 24 `─` gebaut werden (D40-Nachtrag), weil `title` kein Markup kennt;
hier ist er eine echte Linie, und ohne Beschreibung entfällt er ganz. Zerlegt
wird der `title` **an genau diesem Strich** (`TIP_RULE`, jetzt exportiert). Die
Alternative wäre ein zweites data-Attribut mit derselben Beschreibung gewesen —
im Werkbaum-Plan rund 20 kB DOM-Text als reine Verdopplung.
**Einzelne Zeilenumbrüche werden zu Leerzeichen, Leerzeilen zu Absätzen.** Das
ist die Lesart von SPEC §1 („Leerzeilen bleiben als Absatztrenner"), und es war
im Fenster sofort zu sehen: Die Beschreibungen im Plan sind bei ~76 Zeichen
umgebrochen, und in einem 336 px breiten Fenster stand der Text dadurch
ausgefranst („…a textual notation for work breakdown / structures, a browser
editor…"). Der `title` zeigt die harten Umbrüche weiterhin — dort haben wir
keine Wahl.
**Der Sprung-Hinweis nennt hier den langen Druck.** Der Tooltip endet mit
„Alt+Klick: zur Zeile im Text"; Alt gibt es auf dem Telefon nicht. Neuer
i18n-Schlüssel `jumpHintTouch` in allen neun Sprachen, plus `tipClose` und
`tipOpenLink`.
**`position:fixed` auf `<body>`, nicht in `#out`.** Ein Kind von `#out` erbte
dessen CSS-`zoom` (der Fenstertext skalierte mit dem Diagramm) und würde von
dessen `overflow` beschnitten — die Falle aus D50. Gesetzt wird aus
`getBoundingClientRect()`, das den Zoom schon enthält; waagerecht an den
Fensterrand geklemmt, nach oben ausweichend, wenn unten kein Platz ist. Die
Spitze bleibt über `--tipx` am Knoten, auch wenn geklemmt wurde (nachgemessen:
0 px Abweichung von der Knotenmitte im Normalfall).
**Zu macht es alles, was seine Aussage hinfällig macht:** Tipp daneben, zweiter
Tipp auf denselben Knoten, Esc, das ×, **Scrollen des Diagramms** (das Fenster
ist `fixed`, der Knoten wandert — es zeigte danach auf etwas anderes), der
**Bereichswechsel** auf Mobil (das Ziel ist dann `display:none`), der **Sprung
in den Text** (er führt weg) und jeder **Neubau** (er ersetzt das Element, an
dem es hängt). Die letzten drei fehlten in der ersten Fassung und fielen erst
im Durchspielen auf.
**Nachgemessen** (375 × 812, mitgelieferter Werkbaum-Plan): Tipp auf den
Wurzelknoten öffnet 336 × 220 px vollständig im Bild, mit Beschreibung als
einem Absatz ohne harte Umbrüche, Fakten `#wb · in Arbeit · Langer Druck: zur
Zeile im Text` und Knopf „↗ Link öffnen"; der Knoten trägt den Petrol-Ring, die
URL wird **nicht** geöffnet. Knoten ohne Beschreibung: nur die Faktenzeile,
39 px, ohne Trennstrich. Zweiter Tipp, Esc, Tipp daneben und eine **echte**
Wischgeste (`scrollLeft` 0 → 300) schließen; der lange Druck springt und
schließt mit.
**Werkzeuggrenze, wie in D25 und D17-Nachtrag 4:** Synthetische `TouchEvent`s
beweisen nur die eigene Ereignis-Logik — dass der Tipp den Link unterdrückt,
dass die drei Gesten auseinandergehalten werden, dass Position und Inhalt
stimmen. Sie beweisen **nicht**, wie ein echter Finger mit dem 300-ms-Klick,
dem Doppeltipp-Zoom und der Textauswahl des Systems zusammenspielt. Zwei
Messungen liefen deshalb auffällig ins Leere und sind hier festgehalten, damit
sie niemand für Befunde nimmt: Ein programmatisch gesetztes `scrollLeft` löste
**kein** `scroll`-Ereignis aus (die D33-Falle — geprüft wurde daraufhin mit
einer echten Wischgeste), und ein synthetischer Tipp trifft einen Knoten auch
dann, wenn er 8968 px **außerhalb** des Bildes liegt — dort zeigte das Fenster
folgerichtig auf nichts. Ein Finger kann das nicht.
**Nicht im Druck** (`.nodetip` und der `.tipped`-Ring ausgeblendet) und nicht
im Grafikexport — Bedienung, keine Aussage über den Plan; der Export liest
ohnehin nur `#out`.
+25
View File
@@ -286,6 +286,11 @@ effektiv nicht weiter sein als das, was er braucht.
- Ein nacktes `https://…`-Token macht den ganzen Knoten klickbar - Ein nacktes `https://…`-Token macht den ganzen Knoten klickbar
(neuer Tab, `rel="noopener"`); Kennzeichnung mit ↗ hinter dem Label. (neuer Tab, `rel="noopener"`); Kennzeichnung mit ↗ hinter dem Label.
- **Ausnahme auf Touch-Geräten:** Dort öffnet der einfache Tipp nicht die URL,
sondern das Knoten-Fenster (§9) — der Link steht darin als eigener Knopf. Ohne
Zeiger gibt es keinen Tooltip, und ein einziger Tipp kann nicht beides tun;
„ansehen" ist die häufigere Absicht, und der Link bleibt einen Tipp entfernt.
Siehe D52.
## 7. Personen-Tags ## 7. Personen-Tags
@@ -330,6 +335,26 @@ spiegelt das `"`-Zeichen der Notation. Die Marke erscheint **nicht** im
Grafikexport: Der Text selbst kann dort nicht angezeigt werden, eine Marke Grafikexport: Der Text selbst kann dort nicht angezeigt werden, eine Marke
ohne Ziel wäre Rauschen. Siehe D40. ohne Ziel wäre Rauschen. Siehe D40.
**Auf Touch-Geräten zeigt der einfache Tipp auf einen Knoten dasselbe als
Fenster** — ohne Zeiger gibt es keinen Tooltip, die Beschreibung wäre dort sonst
gar nicht zu sehen. Es ist derselbe Inhalt, nur mit dem, was ein `title` nicht
kann: Die Beschreibung steht als Absatz, die Kurz-Fakten dahinter durch eine
echte Linie abgesetzt.
- Das Fenster hängt am angetippten Knoten (kleine Spitze zu ihm hin) und weicht
nach oben aus, wenn unten kein Platz ist. Der Knoten trägt, solange es offen
ist, einen Petrol-Ring.
- Es schließt beim Tipp daneben, beim erneuten Tipp auf denselben Knoten, mit
**Esc**, über sein ×, beim Scrollen des Diagramms und bei jedem Neubau.
- Ein **verlinkter** Knoten (§6) bekommt darin einen ↗-Knopf, der die URL im
neuen Tab öffnet — auf Touch ist das der Weg zum Link.
- Der **Sprung-Hinweis** nennt hier den langen Druck statt Alt+Klick (§9,
Sprung zwischen Diagramm und Text) — Alt gibt es auf dem Telefon nicht.
- Reine Bedienhilfe: nicht im Grafikexport, nicht im Druck. Auf Geräten mit
Zeiger ändert sich nichts, dort bleibt es beim Tooltip.
Siehe D52.
**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen — **Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen —
das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status
**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten **weiter** ist als der effektive (der Knoten wird von Abhängigkeiten
+6
View File
@@ -59,6 +59,7 @@
- [^] #ed.i18n.auto: Default taken from the browser (XS) - [^] #ed.i18n.auto: Default taken from the browser (XS)
- [^] #ed.a11y: Accessibility: aria labels, focus order (S) - [^] #ed.a11y: Accessibility: aria labels, focus order (S)
- [^] #ed.mobile: Works on a phone (S) - [^] #ed.mobile: Works on a phone (S)
- [x] #ed.mobile.tip: A tap on a node opens its tooltip as a window (XS) %% no pointer, no tooltip
- [^] #ed.docs: Several documents in one editor (M) - [^] #ed.docs: Several documents in one editor (M)
- [^] #ed.docs.picker: Switcher in the editor title bar (S) - [^] #ed.docs.picker: Switcher in the editor title bar (S)
- [^] #ed.docs.url: Load a document from ?sourceUrl= (S) - [^] #ed.docs.url: Load a document from ?sourceUrl= (S)
@@ -441,6 +442,11 @@
Below 640 px exactly one area is visible, diagram or text, and a button Below 640 px exactly one area is visible, diagram or text, and a button
switches between them. Any split would leave both halves too small to use. switches between them. Any split would leave both halves too small to use.
#ed.mobile.tip
A tooltip needs a pointer, so without one the description could not be seen
at all. A tap opens the same content as a window instead; on a linked node
the link becomes a button inside it, because one tap cannot do both.
#ed.docs #ed.docs
Several notation texts side by side, kept in the browser. Each is only text Several notation texts side by side, kept in the browser. Each is only text
plus a name — no invented structure format, so this maps onto a server later plus a name — no invented structure format, so this maps onto a server later
+13
View File
@@ -532,3 +532,16 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
(das einzige Element ohne feste Größe) auf einen 2-px-Strich, während sein (das einzige Element ohne feste Größe) auf einen 2-px-Strich, während sein
Icon darüber hinausragt. Das sieht aus wie ein Trennstrich und ist Icon darüber hinausragt. Das sieht aus wie ein Trennstrich und ist
unbenutzbar. unbenutzbar.
- **Die drei Touch-Gesten am Knoten (D25, D38, D52)** teilen sich `touchstart`/
`touchend` und werden am vorhandenen Zustand unterschieden: `armedEl` gesetzt
(500 ms um) ⇒ Sprung, Timer läuft noch ⇒ kurzer Tipp ⇒ Knoten-Fenster, beides
weg (jedes `touchmove` räumt auf) ⇒ es wurde gescrollt ⇒ nichts. Kein eigenes
Merkerfeld nötig. Wer hier eine vierte Geste einhängt, denkt an das
**Falt-Zeichen**: Es ist ausgenommen, weil das Fenster `preventDefault()`
braucht (sonst öffnet ein Link-Knoten zusätzlich seine URL) — und das
verschluckt den Klick, mit dem gefaltet wird.
- **Das Knoten-Fenster liegt auf `<body>`, nicht in `#out` (D52):** In `#out`
erbte es dessen CSS-`zoom` und würde von dessen `overflow` beschnitten (D50).
Es schließt bei allem, was seine Aussage hinfällig macht — Tipp daneben,
zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text,
`render()`. Die letzten drei sind beim Bauen zuerst vergessen worden.
+10
View File
@@ -229,6 +229,16 @@
<footer class="site-footer"><a href="https://github.com/mhoennig/werkbaum" target="_blank" rel="noopener">Werkbaum</a>&nbsp;<a class="ver" href="https://github.com/mhoennig/werkbaum/commit/main" target="_blank" rel="noopener">1.0</a> · <a href="llms.md" target="_blank" rel="noopener" title="Werkbaum-Notation für KI-Agenten · Werkbaum notation for AI agents (Markdown)">llms.md</a> © 2026 <a href="https://michael.hoennig.de" target="_blank" rel="noopener">Michael Hönnig</a> · <span class="vibe" title="Fable 5 and Opus 4.8">Built with Claude Code</span> <a href="../LICENSE" target="_blank" rel="noopener">License</a> · <a href="https://michael.hoennig.de/imprint.html" target="_blank" rel="noopener" data-i18n="imprint">Impressum</a> · <a id="privacyLink" href="https://michael.hoennig.de/datenschutzerklaerung.html" target="_blank" rel="noopener" data-i18n="privacy">Datenschutz</a></footer> <footer class="site-footer"><a href="https://github.com/mhoennig/werkbaum" target="_blank" rel="noopener">Werkbaum</a>&nbsp;<a class="ver" href="https://github.com/mhoennig/werkbaum/commit/main" target="_blank" rel="noopener">1.0</a> · <a href="llms.md" target="_blank" rel="noopener" title="Werkbaum-Notation für KI-Agenten · Werkbaum notation for AI agents (Markdown)">llms.md</a> © 2026 <a href="https://michael.hoennig.de" target="_blank" rel="noopener">Michael Hönnig</a> · <span class="vibe" title="Fable 5 and Opus 4.8">Built with Claude Code</span> <a href="../LICENSE" target="_blank" rel="noopener">License</a> · <a href="https://michael.hoennig.de/imprint.html" target="_blank" rel="noopener" data-i18n="imprint">Impressum</a> · <a id="privacyLink" href="https://michael.hoennig.de/datenschutzerklaerung.html" target="_blank" rel="noopener" data-i18n="privacy">Datenschutz</a></footer>
<!-- Knoten-Fenster für Touch-Geräte (D52): Ohne Zeiger gibt es keinen Tooltip.
Es liegt auf <body> und ist `position:fixed` — in `#out` hinein würde es
dessen CSS-`zoom` erben (der Text skalierte mit) und von dessen `overflow`
beschnitten (die Falle aus D50). Inhalt wird bei jedem Öffnen ersetzt. -->
<div id="nodeTip" class="nodetip" role="dialog" aria-modal="false" hidden>
<button type="button" class="nodetip-x" id="nodeTipClose" data-i18n-title="tipClose" data-i18n-aria="tipClose" title="Schließen" aria-label="Schließen">×</button>
<div class="nodetip-body" id="nodeTipBody"></div>
<span class="nodetip-arrow" aria-hidden="true"></span>
</div>
<script type="module" src="./src/app.js"></script> <script type="module" src="./src/app.js"></script>
</body> </body>
</html> </html>
+135 -3
View File
@@ -1,7 +1,7 @@
import './style.css'; 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 } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning } from './warnings.js'; import { formatWarning } 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).
@@ -84,6 +84,9 @@ let foldOverrides = new Map(), foldByLine = new Map();
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */ render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */
function render(){ function render(){
/* Das Knoten-Fenster (D52) hängt an einem Element, das der Neubau ersetzt
und seine Position ist ohnehin gemessen, also gleich hinfällig. */
closeNodeTip();
const parsed = parse(src.value); const parsed = parse(src.value);
let roots = parsed.roots; let roots = parsed.roots;
const showDiscarded = discardedShown(); const showDiscarded = discardedShown();
@@ -865,6 +868,7 @@ src.addEventListener('pointerdown', () => keyboardOnJump(false));
function jumpToLine(line){ function jumpToLine(line){
const r = lineRange(line); const r = lineRange(line);
if(!r) return; if(!r) return;
closeNodeTip(); /* der Sprung führt weg vom Knoten (D52) */
revealEditor(); revealEditor();
keyboardOnJump(true); keyboardOnJump(true);
src.focus({preventScroll: true}); src.focus({preventScroll: true});
@@ -1096,15 +1100,20 @@ out.addEventListener('keydown', e => {
der Zielknoten bekommt den Petrol-Ring (scharf"). Der folgende Klick wird der Zielknoten bekommt den Petrol-Ring (scharf"). Der folgende Klick wird
unterdrückt, sonst öffnete ein Link-Knoten zusätzlich seine URL; das unterdrückt, sonst öffnete ein Link-Knoten zusätzlich seine URL; das
Kontextmenü/Callout ebenso (die Geste ist hier vergeben). */ Kontextmenü/Callout ebenso (die Geste ist hier vergeben). */
let pressTimer = null, armedEl = null; let pressTimer = null, armedEl = null, pressNode = null;
function disarmPress(){ function disarmPress(){
if(pressTimer){ clearTimeout(pressTimer); pressTimer = null; } if(pressTimer){ clearTimeout(pressTimer); pressTimer = null; }
if(armedEl){ armedEl.classList.remove('armed'); armedEl = null; } if(armedEl){ armedEl.classList.remove('armed'); armedEl = null; }
pressNode = null;
} }
out.addEventListener('touchstart', e => { out.addEventListener('touchstart', e => {
disarmPress(); disarmPress();
const el = nodeFromEvent(e); const el = nodeFromEvent(e);
if(!el) return; if(!el) return;
/* Ein Tipp auf das Falt-Zeichen klappt um (D38) das darf der kurze Tipp
nicht abfangen, sonst wäre Falten auf Touch nicht mehr zu bedienen. */
if(e.target && e.target.closest && e.target.closest('.fold')) return;
pressNode = el;
pressTimer = setTimeout(() => { pressTimer = setTimeout(() => {
pressTimer = null; pressTimer = null;
armedEl = el; armedEl = el;
@@ -1114,14 +1123,106 @@ out.addEventListener('touchstart', e => {
out.addEventListener('touchmove', disarmPress, {passive: true}); out.addEventListener('touchmove', disarmPress, {passive: true});
out.addEventListener('touchend', e => { out.addEventListener('touchend', e => {
const el = armedEl; const el = armedEl;
/* Kurzer Tipp: der Timer läuft noch (nach 500 ms wäre `armedEl` gesetzt) und
es wurde nicht gewischt (jedes `touchmove` räumt den Timer weg). Der
Zustand unterscheidet die drei Gesten also ohne eigenes Merkerfeld. */
const tap = !el && pressTimer && pressNode;
const tapped = pressNode;
disarmPress(); disarmPress();
if(!el) return; if(el){
e.preventDefault(); /* unterdrückt den nachfolgenden Klick/Link */ e.preventDefault(); /* unterdrückt den nachfolgenden Klick/Link */
jumpToLine(+el.dataset.line); /* echte Nutzergeste -> der Fokus bleibt */ jumpToLine(+el.dataset.line); /* echte Nutzergeste -> der Fokus bleibt */
return;
}
if(!tap) return;
/* Der einfache Tipp zeigt den Tooltip als Fenster (SPEC §6/§9, D52). Ohne
`preventDefault()` öffnete ein Link-Knoten zusätzlich seine URL auf Touch
ist der Link stattdessen ein Knopf IM Fenster. */
e.preventDefault();
toggleNodeTip(tapped);
}, {passive: false}); }, {passive: false});
out.addEventListener('touchcancel', disarmPress); out.addEventListener('touchcancel', disarmPress);
out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDefault(); }); out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDefault(); });
/* ---------- Knoten-Fenster auf Touch (SPEC §6/§9, D52) ----------
Ein `title` braucht einen Zeiger; ohne Zeiger wäre die Beschreibung (D40) gar
nicht zu sehen. Das Fenster zeigt denselben Inhalt, zerlegt am Trennstrich
des Tooltips (TIP_RULE) in Beschreibung und Kurz-Fakten dort, wo der
Tooltip 24 `` malen musste, steht hier eine echte Linie. Kein zweites
data-Attribut mit derselben Beschreibung: Das verdoppelte im Werkbaum-Plan
rund 20 kB DOM-Text für nichts. */
const nodeTip = document.getElementById('nodeTip');
const nodeTipBody = document.getElementById('nodeTipBody');
let tipNode = null;
function closeNodeTip(){
if(!tipNode) return;
tipNode.classList.remove('tipped');
tipNode = null;
nodeTip.hidden = true;
}
function toggleNodeTip(el){
if(tipNode === el){ closeNodeTip(); return; } /* zweiter Tipp schließt */
closeNodeTip();
const title = el.getAttribute('title') || '';
const sep = '\n\n' + TIP_RULE + '\n';
const i = title.indexOf(sep);
const desc = i >= 0 ? title.slice(0, i) : '';
/* Der Sprung-Hinweis nennt im Tooltip Alt+Klick; hier gibt es kein Alt. */
const facts = (i >= 0 ? title.slice(i + sep.length) : title)
.replace(t('jumpHint'), t('jumpHintTouch'));
const href = el.tagName === 'A' ? el.getAttribute('href') : null;
/* Absätze: Leerzeilen trennen (SPEC §1), einzelne Zeilenumbrüche sind bloß
der Umbruch der Quelldatei und werden zu Leerzeichen. Der `title` kann das
nicht er zeigt die harten Umbrüche und sah im schmalen Fenster
ausgefranst aus (im Werkbaum-Plan bricht jede Beschreibung bei ~76
Zeichen). Hier wird umgebrochen, wie es das Fenster braucht. */
const paras = desc.split(/\n{2,}/)
.map(p => p.replace(/\s*\n\s*/g, ' ').trim()).filter(Boolean);
nodeTipBody.innerHTML =
(paras.length ? `<div class="nodetip-desc">${paras.map(p => `<p>${esc(p)}</p>`).join('')}</div>` : '') +
(facts ? `<div class="nodetip-facts">${esc(facts)}</div>` : '') +
(href ? `<a class="nodetip-link" href="${esc(href)}" target="_blank" rel="noopener">↗ ${esc(t('tipOpenLink'))}</a>` : '');
tipNode = el;
el.classList.add('tipped');
nodeTip.hidden = false;
placeNodeTip(el);
}
/* Setzt das Fenster unter den Knoten, bei Platzmangel darüber; waagerecht auf
die Knotenmitte, geklemmt an den Fensterrand. Die Spitze bleibt über
`--tipx` am Knoten, auch wenn das Fenster geklemmt wurde. */
function placeNodeTip(el){
const b = el.getBoundingClientRect();
const gap = 10, pad = 8;
nodeTip.classList.remove('above');
nodeTip.style.left = '0px'; /* erst messen, dann setzen */
nodeTip.style.top = '0px';
const w = nodeTip.offsetWidth, h = nodeTip.offsetHeight;
const above = b.bottom + gap + h > window.innerHeight && b.top - gap - h > 0;
if(above) nodeTip.classList.add('above');
const anchorX = b.left + b.width / 2;
const left = Math.max(pad, Math.min(anchorX - w / 2, window.innerWidth - w - pad));
nodeTip.style.left = left + 'px';
nodeTip.style.top = (above ? b.top - gap - h : b.bottom + gap) + 'px';
/* Spitze relativ zum Fenster, aber innerhalb seiner Rundungen gehalten. */
nodeTip.style.setProperty('--tipx',
Math.max(12, Math.min(anchorX - left, w - 12)).toFixed(1) + 'px');
}
document.getElementById('nodeTipClose').addEventListener('click', closeNodeTip);
/* Tipp/Klick daneben schließt — der Link-Knopf im Fenster aber nicht. */
document.addEventListener('pointerdown', e => {
if(!tipNode || nodeTip.contains(e.target)) return;
if(e.target && e.target.closest && e.target.closest('.node') === tipNode) return;
closeNodeTip();
}, true);
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeNodeTip(); });
/* Beim Scrollen des Diagramms wandert der Knoten, das `position:fixed`-Fenster
nicht es zeigte dann auf etwas anderes. Also zumachen. */
document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true});
/* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser /* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser
Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9): Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9):
Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen
@@ -1265,6 +1366,9 @@ function applyMobilePane(){
document.body.classList.toggle('pane-text', mobilePane === 'text'); document.body.classList.toggle('pane-text', mobilePane === 'text');
} }
function setMobilePane(pane, save){ function setMobilePane(pane, save){
/* Das Knoten-Fenster (D52) zeigt auf einen Knoten im Diagramm wechselt der
Bereich, ist das Ziel weg (`display:none`). */
closeNodeTip();
mobilePane = pane; mobilePane = pane;
applyMobilePane(); applyMobilePane();
/* Ein Panel mit `display:none` misst sich zu **null**. Alles, was aus der /* Ein Panel mit `display:none` misst sich zu **null**. Alles, was aus der
@@ -1552,6 +1656,10 @@ const I18N = {
paneToDiagram:"Zum Diagramm wechseln", paneToDiagram:"Zum Diagramm wechseln",
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.", ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
jumpHint:"Alt+Klick: zur Zeile im Text", jumpHint:"Alt+Klick: zur Zeile im Text",
/* Auf Touch nennt das Knoten-Fenster (D52) den langen Druck — Alt gibt es dort nicht. */
jumpHintTouch:"Langer Druck: zur Zeile im Text",
tipClose:"Schließen",
tipOpenLink:"Link öffnen",
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.", padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
padEdit:"Pad zum Bearbeiten öffnen", padEdit:"Pad zum Bearbeiten öffnen",
padRefresh:"Vom Pad neu laden", padRefresh:"Vom Pad neu laden",
@@ -1634,6 +1742,9 @@ const I18N = {
paneToDiagram:"Switch to the diagram", paneToDiagram:"Switch to the diagram",
ghostTooltip:"From size M upward, an item should be broken down further.", ghostTooltip:"From size M upward, an item should be broken down further.",
jumpHint:"Alt+click: jump to the line in the text", jumpHint:"Alt+click: jump to the line in the text",
jumpHintTouch:"Long press: jump to the line in the text",
tipClose:"Close",
tipOpenLink:"Open link",
padReadonly:"Edited in the pad — read-only here.", padReadonly:"Edited in the pad — read-only here.",
padEdit:"Open the pad to edit", padEdit:"Open the pad to edit",
padRefresh:"Reload from the pad", padRefresh:"Reload from the pad",
@@ -1716,6 +1827,9 @@ const I18N = {
paneToDiagram:"Cambiar al diagrama", paneToDiagram:"Cambiar al diagrama",
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.", ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
jumpHint:"Alt+clic: ir a la línea en el texto", jumpHint:"Alt+clic: ir a la línea en el texto",
jumpHintTouch:"Pulsación larga: ir a la línea en el texto",
tipClose:"Cerrar",
tipOpenLink:"Abrir enlace",
padReadonly:"Se edita en el pad — aquí solo lectura.", padReadonly:"Se edita en el pad — aquí solo lectura.",
padEdit:"Abrir el pad para editar", padEdit:"Abrir el pad para editar",
padRefresh:"Recargar desde el pad", padRefresh:"Recargar desde el pad",
@@ -1798,6 +1912,9 @@ const I18N = {
paneToDiagram:"Passer au diagramme", paneToDiagram:"Passer au diagramme",
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.", ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
jumpHint:"Alt+clic : aller à la ligne dans le texte", jumpHint:"Alt+clic : aller à la ligne dans le texte",
jumpHintTouch:"Appui long : aller à la ligne dans le texte",
tipClose:"Fermer",
tipOpenLink:"Ouvrir le lien",
padReadonly:"Modifié dans le pad — lecture seule ici.", padReadonly:"Modifié dans le pad — lecture seule ici.",
padEdit:"Ouvrir le pad pour modifier", padEdit:"Ouvrir le pad pour modifier",
padRefresh:"Recharger depuis le pad", padRefresh:"Recharger depuis le pad",
@@ -1880,6 +1997,9 @@ const I18N = {
paneToDiagram:"Przełącz na diagram", paneToDiagram:"Przełącz na diagram",
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.", ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście", jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
jumpHintTouch:"Długie przytrzymanie: przejdź do wiersza w tekście",
tipClose:"Zamknij",
tipOpenLink:"Otwórz link",
padReadonly:"Edytowane w padzie — tu tylko do czytania.", padReadonly:"Edytowane w padzie — tu tylko do czytania.",
padEdit:"Otwórz pad do edycji", padEdit:"Otwórz pad do edycji",
padRefresh:"Wczytaj ponownie z padu", padRefresh:"Wczytaj ponownie z padu",
@@ -1962,6 +2082,9 @@ const I18N = {
paneToDiagram:"Перейти к диаграмме", paneToDiagram:"Перейти к диаграмме",
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.", ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
jumpHint:"Alt+клик: перейти к строке в тексте", jumpHint:"Alt+клик: перейти к строке в тексте",
jumpHintTouch:"Долгое нажатие: перейти к строке в тексте",
tipClose:"Закрыть",
tipOpenLink:"Открыть ссылку",
padReadonly:"Редактируется в паде — здесь только чтение.", padReadonly:"Редактируется в паде — здесь только чтение.",
padEdit:"Открыть пад для редактирования", padEdit:"Открыть пад для редактирования",
padRefresh:"Обновить из пада", padRefresh:"Обновить из пада",
@@ -2044,6 +2167,9 @@ const I18N = {
paneToDiagram:"आरेख पर जाएँ", paneToDiagram:"आरेख पर जाएँ",
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।", ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ", jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
jumpHintTouch:"देर तक दबाएँ: टेक्स्ट में उस पंक्ति पर जाएँ",
tipClose:"बंद करें",
tipOpenLink:"लिंक खोलें",
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।", padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
padEdit:"संपादित करने के लिए पैड खोलें", padEdit:"संपादित करने के लिए पैड खोलें",
padRefresh:"पैड से फिर लोड करें", padRefresh:"पैड से फिर लोड करें",
@@ -2133,6 +2259,9 @@ const I18N = {
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计", implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
ghostTooltip:"从 M 号起,元素应进一步细分。", ghostTooltip:"从 M 号起,元素应进一步细分。",
jumpHint:"Alt+点击:跳转到文本中的该行", jumpHint:"Alt+点击:跳转到文本中的该行",
jumpHintTouch:"长按:跳转到文本中的该行",
tipClose:"关闭",
tipOpenLink:"打开链接",
padReadonly:"在 Pad 中编辑 — 此处只读。", padReadonly:"在 Pad 中编辑 — 此处只读。",
padEdit:"打开 Pad 进行编辑", padEdit:"打开 Pad 进行编辑",
padRefresh:"从 Pad 重新加载", padRefresh:"从 Pad 重新加载",
@@ -2215,6 +2344,9 @@ const I18N = {
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う", implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。", ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
jumpHint:"Alt+クリック:テキストの該当行へ移動", jumpHint:"Alt+クリック:テキストの該当行へ移動",
jumpHintTouch:"長押し:テキストの該当行へ移動",
tipClose:"閉じる",
tipOpenLink:"リンクを開く",
padReadonly:"パッドで編集します — ここでは読み取り専用です。", padReadonly:"パッドで編集します — ここでは読み取り専用です。",
padEdit:"編集するにはパッドを開く", padEdit:"編集するにはパッドを開く",
padRefresh:"パッドから再読み込み", padRefresh:"パッドから再読み込み",
+5 -2
View File
@@ -49,8 +49,11 @@ export function esc(s){
function attr(s){ return esc(String(s)).replace(/"/g,'&quot;'); } function attr(s){ return esc(String(s)).replace(/"/g,'&quot;'); }
/* Trennstrich im Knoten-Tooltip zwischen Beschreibung und Kurz-Fakten (D40). /* Trennstrich im Knoten-Tooltip zwischen Beschreibung und Kurz-Fakten (D40).
Box-Drawing-Zeichen statt Bindestrichen: `` stößt gapless aneinander und Box-Drawing-Zeichen statt Bindestrichen: `` stößt gapless aneinander und
liest sich als Linie, `---` als Text. */ liest sich als Linie, `---` als Text. Exportiert, weil das Touch-Fenster
const TIP_RULE = '─'.repeat(24); (D52) den `title` daran wieder in seine zwei Teile zerlegt dort GIBT es
Markup, der Strich wird zur echten Linie. Eine zweite Kopie der
Beschreibung als data-Attribut wäre der einzige andere Weg gewesen. */
export const TIP_RULE = '─'.repeat(24);
/* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige + /* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige +
Link. Die visuellen Badges (Größe, Tags, ) sind aria-hidden ihre Link. Die visuellen Badges (Größe, Tags, ) sind aria-hidden ihre
+60 -1
View File
@@ -815,6 +815,64 @@
/* Stationspunkt: groß, aber blass gefüllt — der Knotentext bleibt lesbar */ /* Stationspunkt: groß, aber blass gefüllt — der Knotentext bleibt lesbar */
.cheap-dot{fill:var(--or);fill-opacity:.2;stroke:var(--or);stroke-opacity:.35;stroke-width:1.5} .cheap-dot{fill:var(--or);fill-opacity:.2;stroke:var(--or);stroke-opacity:.35;stroke-width:1.5}
/* ---------- Knoten-Fenster für Touch (SPEC §6/§9, D52) ----------
Auf einem Zeigergerät zeigt der `title` den Tooltip; ohne Zeiger gibt es
keinen. Der einfache Tipp öffnet deshalb dieses Fenster mit demselben
Inhalt und mit dem, was ein `title` nicht kann: Absatz, echte Trennlinie,
Knöpfe. `position:fixed` (JS setzt left/top aus `getBoundingClientRect()`,
die den `zoom` von #out schon enthält): Ein Kind von #out erbte dessen
`zoom` und würde von dessen `overflow` beschnitten (D50). */
.nodetip{
position:fixed;z-index:60;max-width:min(21rem, calc(100vw - 24px));
background:var(--card);color:var(--ink);
border:1px solid rgba(36,52,71,.15);border-radius:10px;
box-shadow:0 8px 24px rgba(36,52,71,.3);
padding:10px 30px 10px 12px;font-size:.78rem;line-height:1.45;
}
.nodetip[hidden]{display:none}
.nodetip-x{
position:absolute;top:2px;right:2px;width:26px;height:26px;
display:flex;align-items:center;justify-content:center;
background:none;border:0;border-radius:6px;cursor:pointer;
color:var(--muted);font-size:1.05rem;line-height:1;
}
.nodetip-x:hover{color:var(--ink);background:rgba(36,52,71,.06)}
/* Absätze kommen aus Leerzeilen der Notation (§1); JS baut sie zu <p>. */
.nodetip-desc p + p{margin-top:.55em}
/* Die echte Linie an der Stelle, an der der Tooltip 24 `─` zeichnen musste. */
.nodetip-facts{
margin-top:8px;padding-top:7px;border-top:1px solid rgba(36,52,71,.15);
color:var(--muted);font-size:.72rem;
}
/* Ohne Beschreibung gibt es nichts zu trennen der Strich stünde sonst als
erste Zeile im Fenster. Dieselbe Regel wie im Tooltip (D40-Nachtrag). */
.nodetip-facts:first-child{margin-top:0;padding-top:0;border-top:0}
.nodetip-link{
display:inline-flex;align-items:center;gap:5px;margin-top:9px;
padding:5px 10px;border:1px solid var(--or);border-radius:7px;
color:var(--or);text-decoration:none;font-size:.74rem;font-weight:500;
}
/* Die Spitze zeigt auf den Knoten ohne sie wäre bei dicht stehenden Knoten
nicht zu sehen, welcher gemeint ist. Ein gedrehtes Quadrat mit zwei Kanten:
so erbt es Rahmen UND Schatten des Fensters. JS setzt --tipx (Spitze) und
dreht sie per .above nach unten, wenn das Fenster über dem Knoten sitzt. */
.nodetip-arrow{
position:absolute;top:-6px;left:var(--tipx, 50%);width:11px;height:11px;
margin-left:-5.5px;background:var(--card);
border-left:1px solid rgba(36,52,71,.15);
border-top:1px solid rgba(36,52,71,.15);
transform:rotate(45deg);
}
.nodetip.above .nodetip-arrow{
top:auto;bottom:-6px;
border:0;border-right:1px solid rgba(36,52,71,.15);
border-bottom:1px solid rgba(36,52,71,.15);
}
/* Solange das Fenster offen ist, trägt sein Knoten einen Petrol-Ring wie
der scharfe" Knoten beim langen Druck (D25). #out-Präfix nötig, weil
`ul.or .node{box-shadow:none}` spezifischer ist. */
#out .node.tipped{box-shadow:0 0 0 3px rgba(15,118,110,.55)}
/* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */ /* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px} ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
ul.and::before{ ul.and::before{
@@ -1210,7 +1268,7 @@
@media print{ @media print{
html,body{height:auto!important;overflow:visible!important;background:#fff!important} html,body{height:auto!important;overflow:visible!important;background:#fff!important}
header,.panel.editor,.gutter,.panel.right .panel-head, header,.panel.editor,.gutter,.panel.right .panel-head,
.warnings,.zoomctl,.site-footer{display:none!important} .warnings,.zoomctl,.site-footer,.nodetip{display:none!important}
.app,.app.side{display:block!important;max-width:none!important;width:auto!important; .app,.app.side{display:block!important;max-width:none!important;width:auto!important;
height:auto!important;margin:0!important;gap:0!important} height:auto!important;margin:0!important;gap:0!important}
.panel.right{grid-area:auto!important;background:#fff!important;border:0!important; .panel.right{grid-area:auto!important;background:#fff!important;border:0!important;
@@ -1228,6 +1286,7 @@
.node.current{transform:none!important;animation:none!important} .node.current{transform:none!important;animation:none!important}
.node.current::after{display:none!important} .node.current::after{display:none!important}
.node.fresh{box-shadow:none!important} /* „neu seit dem letzten Besuch" ist persönlich (D28) */ .node.fresh{box-shadow:none!important} /* „neu seit dem letzten Besuch" ist persönlich (D28) */
.node.tipped{box-shadow:none!important} /* offenes Knoten-Fenster ist Bedienung (D52) */
/* Faltung (D38): das offener Knoten ist Bedienelement nicht drucken; /* Faltung (D38): das offener Knoten ist Bedienelement nicht drucken;
das n" eingeklappter Knoten ist Kennzeichnung und bleibt. */ das n" eingeklappter Knoten ist Kennzeichnung und bleibt. */
.node:not(.folded) .fold{display:none!important} .node:not(.folded) .fold{display:none!important}