notation: !!! als Fokusmarke — ein geteilter Zeigefinger im Text

Beim gemeinsamen Arbeiten an einem Pad (D31) fehlte das Naheliegendste: „schau
mal hier". Etherpad zeigt die Cursor der anderen im Pad, aber Werkbaum kommt
nicht an sie heran — ein fremdstämmiger Rahmen gibt keinen DOM-Zugriff, der
Klartext-Export überträgt nur Text. Eine Marke IM Text ist damit nicht der
Notbehelf, sondern der einzige Weg. Und sie hat etwas, das ein Cursor nicht hat:
alle sehen dieselbe Stelle.

Erkannt wird `!!!` nur ALLEINSTEHEND (`(^|\s)!!!(?=\s|$)`), sonst verlöre jedes
„Achtung!!!" seine Ausrufezeichen und niemand fände den Grund. Bewusst ohne
Lookbehind — Safari kennt es erst ab 16.4, und die Notation soll nicht an einer
Browserversion hängen. Position in der Extraktion (SPEC §1): nach den Tags; der
Kommentar fällt vorher weg, eine Marke hinter `%%` wirkt also nicht.

Darstellung ist DIESELBE wie die der Cursor-Zeile (D25), nicht eine vierte:
neben Fokusrahmen, Strahlenkranz (D28) und Cursor-Ring wäre ein weiterer Ring
ein Zeichen zu viel — es gibt nur einen Begriff „hier schauen". Preis: Im Bild
ist nicht zu unterscheiden, ob die eigene Cursor-Zeile oder eine fremde Marke
leuchtet; für Screenreader benennt `a11yFocusMark` es ausdrücklich.

Ins Bild geholt wird nur bei ÄNDERUNG der Marke, Schlüssel ist der Label-Text
statt der Zeilennummer (Umsortieren im Pad soll nicht neu scrollen). Ohne diese
Bedingung zöge jeder Neubau den Blick zurück und man käme nicht weg. Beides
geprüft: gleiche Marke -> Scrollstand bleibt 0, Marke gewandert -> springt auf
242 und markiert den neuen Knoten.

Beim Hinsehen gefunden: Die Pfad-Inversion verschluckte den Zeigefinger. Der
erste markierte Knoten im Test war eine nicht gewählte any-of-Alternative
(„Headless CMS") — `opacity:.32; saturate(.4)`, der Ring praktisch unsichtbar.
Der Zeiger zeigte auf nichts. Dieselbe Ausnahme wie für D28 behebt es, und hier
ist sie zwingender: „Sollten wir diese verworfene Alternative noch einmal
ansehen?" ist einer der wahrscheinlichsten Gründe, überhaupt zu zeigen. Anders
als beim optionalen Knoten (D29), wo das Zurücktreten die Aussage IST.

Nicht im Druck und nicht im Grafikexport (wie die Cursor-Zeile): Die Marke sagt
„schau jetzt hierhin", nicht „so ist der Plan". Nicht ins kanonische Beispiel
(SPEC §10) aufgenommen — das ist zugleich Test-Fixture, ein dauerhafter
Zeigefinger darin wäre eine Aussage, die niemand gemacht hat.

Verworfen war `%%!` (Kommentar-Variante, bliebe harmlos liegen und könnte die
Begründung tragen) — der Nutzer wählte `!!!`, weil es im Moment des Zeigens
schnell zu tippen ist, und genau dann will man nicht über Syntax nachdenken.
Der Preis steht in D32: die Marke bleibt sichtbar stehen, bis jemand sie löscht;
Werkbaum kann sie nicht entfernen (kein Schreibzugriff auf das Pad).

SPEC §1/§9 zuerst, dann D32, dann Code. 15 neue Tests (86 -> 101), Legende und
i18n in allen 9 Sprachen.
This commit is contained in:
mhoennig
2026-07-30 13:30:50 +02:00
parent 0a08706877
commit 816a77d9e7
12 changed files with 334 additions and 19 deletions
+18 -1
View File
@@ -121,8 +121,25 @@ Getestet gegen Etherpad: Der Klartext-Export sendet
zurück — führende Leerzeichen, `-`/`+`/`|`, Statusboxen und `%%` überleben zurück — führende Leerzeichen, `-`/`+`/`|`, Statusboxen und `%%` überleben
Etherpads Speichermodell, das `-` wird nicht zur Aufzählung umgedeutet. Etherpads Speichermodell, das `-` wird nicht zur Aufzählung umgedeutet.
Das Pad lässt sich auch **im Editor-Panel einbetten**: Ein Wähler in der
Titelzeile schaltet reihum zwischen *Pad und Text* (durch einen Splitter frei
geteilt), *nur Pad* und *nur Text*. Der schmal gezogene Textspiegel behält seinen
Zweck — der Sprung zwischen Diagramm und Text arbeitet auf ihm, und in *nur Pad*
holt ein Sprung ihn selbst zurück. Der Rahmen wird nur geladen, solange er
sichtbar ist, denn ein geladenes Pad verbindet sich und zeigt dich in dessen
Anwesenden-Liste.
**Der gemeinsame Zeigefinger:** Schreib `!!!` in eine Zeile, und dieser Knoten
wird hervorgehoben und ins Bild geholt — bei **allen**, die auf das Pad schauen.
Das kann ein Cursor nicht. Erkannt nur als alleinstehendes Token, `Achtung!!!`
bleibt also ein gewöhnliches Label. Die Marke bleibt stehen, bis jemand sie
löscht.
**Bedenke:** Der Plantext liegt damit auf fremder Infrastruktur, und ein Pad ist **Bedenke:** Der Plantext liegt damit auf fremder Infrastruktur, und ein Pad ist
für jeden lesbar, der die Adresse kennt. Siehe `docs/DECISIONS.md` D31. für jeden lesbar, der die Adresse kennt. Im eingebetteten Rahmen wird Etherpads
Autoren-Cookie (`SameSite=Lax`) nicht mitgesendet — man gilt bei jedem Laden als
neuer Autor, was nur serverseitig zu beheben ist (`cookie.sameSite: "None"`).
Siehe `docs/DECISIONS.md` D31 und D32.
### Lokal ausführen ### Lokal ausführen
+16 -1
View File
@@ -116,8 +116,23 @@ Verified against Etherpad: the plain-text export sends
leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage
model, and `-` is not turned into a bullet list. model, and `-` is not turned into a bullet list.
The pad can also be **embedded** in the editor panel: a selector in the title bar
cycles through *pad and text* (split by a draggable divider), *pad only* and *text
only*. The narrow text mirror keeps its purpose — the jump between diagram and
text works on it, and in *pad only* a jump brings it back by itself. The frame is
only loaded while it is visible, because a loaded pad connects and shows you in
the pad's list of people.
**A shared pointer:** write `!!!` on a line and that node is highlighted and
scrolled into view — for **everyone** looking at the pad, which is something a
cursor cannot do. Recognised only as a standalone token, so `Careful!!!` stays an
ordinary label. It stays in the text until someone deletes it.
**Be aware:** your plan text then lives on third-party infrastructure, and a pad **Be aware:** your plan text then lives on third-party infrastructure, and a pad
is readable by anyone who knows its address. See `docs/DECISIONS.md` D31. is readable by anyone who knows its address. In an embedded frame Etherpad's
author cookie (`SameSite=Lax`) is not sent, so you count as a new author on every
load — fixable only on the server (`cookie.sameSite: "None"`). See
`docs/DECISIONS.md` D31 and D32.
### Running it locally ### Running it locally
+74
View File
@@ -1164,3 +1164,77 @@ Name und Farbe halten nicht. Reparieren lässt sich das nur **serverseitig**
Auf kleinem Bildschirm wurde „beide" nachgemessen statt geschätzt: bei 375 × 812 Auf kleinem Bildschirm wurde „beide" nachgemessen statt geschätzt: bei 375 × 812
bleiben Pad 317 px und Spiegel 180 px, per Splitter verschiebbar — knapp, aber bleiben Pad 317 px und Spiegel 180 px, per Splitter verschiebbar — knapp, aber
brauchbar. Eine Sonderregel für Mobil braucht es deshalb nicht. brauchbar. Eine Sonderregel für Mobil braucht es deshalb nicht.
## D32 — `!!!` als Fokusmarke: ein geteilter Zeigefinger im Text
Beim gemeinsamen Arbeiten an einem Pad (D31) fehlt das Naheliegendste: „schau
mal hier". Etherpad zeigt die Cursor der anderen im **Pad**, aber Werkbaum
kommt nicht an sie heran — ein fremdstämmiger Rahmen gibt keinen DOM-Zugriff,
und der Klartext-Export überträgt ohnehin nur Text. Eine Marke **im Text** ist
damit nicht der Notbehelf, sondern der einzige Weg.
Sie hat sogar etwas, das ein Cursor nicht hat: **alle sehen dieselbe Stelle.**
Ein Cursor ist privat, `!!!` ist eine Aussage im gemeinsamen Dokument.
**Syntax `!!!`.** Vorgeschlagen und **verworfen** war `%%!` — eine
Kommentar-Variante (`- [ ] Backend %%! schau mal hier`). Deren Vorzug: Bleibt die
Marke liegen, ist sie harmlos, denn `%%` ist längst ein Kommentar (SPEC §8);
jedes andere Werkzeug und jeder Leser überliest sie, und der Resttext hätte die
Begründung tragen können. Der Nutzer hat `!!!` gewählt, „am einfachsten schnell
mal zu tippen" — und das ist das entscheidende Kriterium, weil der Moment des
Zeigens genau der Moment ist, in dem man **nicht** über Syntax nachdenken will.
Bewusst in Kauf genommen: Die Marke bleibt sichtbar im Plan stehen, bis jemand
sie löscht (Werkbaum kann sie nicht entfernen — auf das Pad gibt es keinen
Schreibzugriff), und in einer committeten Datei sieht sie nach einer Aussage über
den Knoten aus. Ebenfalls verworfen: `@@` als Pseudo-Tag — `@` bedeutet in dieser
Notation „zuständig" (SPEC §7), ein `@@` daneben lädt zur Verwechslung ein.
**Nur alleinstehend.** Erkannt wird `!!!` am Zeilenanfang/-ende oder von Leerraum
umgeben. Ohne diese Einschränkung verlöre jedes `Achtung!!!` seine
Ausrufezeichen — und niemand fände den Grund, weil die Zeile im Diagramm nur
stumm anders aussieht. `!!` und `!!!!` zählen nicht, `!!!` innerhalb einer URL
bleibt Teil der URL. Regex bewusst **ohne Lookbehind** (`(^|\s)!!!(?=\s|$)`,
führender Leerraum wird mitgefangen und wieder eingesetzt): Safari kennt
Lookbehind erst ab 16.4, und die Notation soll nicht an einer Browserversion
hängen.
Position in der Extraktionsreihenfolge (SPEC §1): **nach** den Tags, vor dem
Label. Kollisionsfrei, weil `!!!` weder `@` noch Klammern noch `:` enthält. Der
Kommentar fällt als Erstes weg — eine Marke **im** Kommentar wirkt deshalb nicht,
was richtig ist: Was hinter `%%` steht, ist Beiwerk.
**Darstellung: dieselbe wie die Cursor-Zeile** (weißer Halo + Ring in Tinte,
D25), plus Scrollen ins Bild. Erwogen war ein eigenes Aussehen (Petrol-Ring mit
Sprechblasen-Spitze); der Nutzer entschied für die vorhandene Hervorhebung. Das
ist die sparsamere Wahl: Es gibt nur **einen** Begriff „hier schauen", und neben
Fokusrahmen (Petrol), Strahlenkranz (gelb, D28) und Cursor-Ring wäre ein vierter
Ring ein Zeichen zu viel. **Preis:** Im Bild ist nicht zu unterscheiden, ob ein
Knoten wegen der eigenen Cursor-Zeile oder wegen einer fremden Marke leuchtet.
Für Screenreader wird der Unterschied benannt (`a11yFocusMark`, „hierhin
schauen"), weil ein `box-shadow` dort ohnehin nicht ankommt.
**Ins Bild geholt wird nur bei Änderung.** Schlüssel ist der **Label-Text** des
markierten Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als
neue Marke gelten. Ohne diese Bedingung zöge jeder Neubau des Baums den Blick
zurück — man könnte nicht wegscrollen, und bei einem Pad-Dokument wird oft neu
gebaut.
**Beim Hinsehen gefunden: die Pfad-Inversion verschluckte den Zeigefinger.**
`.cheap-on .node:not(.cheap)` setzt `opacity:.32; filter:saturate(.4)` — und die
erste markierte Alternative im Test war genau ein nicht gewählter any-of-Zweig
(„Headless CMS"). Gemessen: Deckkraft 0,32, der Ring praktisch unsichtbar. Der
Zeiger zeigte auf nichts. Dieselbe Ausnahme wie für „neu in Produktion" (D28)
behebt es, und hier ist sie noch zwingender: „Sollten wir diese verworfene
Alternative noch einmal ansehen?" ist einer der wahrscheinlichsten Gründe,
überhaupt zu zeigen. Anders als beim optionalen Knoten (D29), wo das
Zurücktreten die Aussage **ist** — dort bleibt die Schwäche bewusst stehen.
**Nicht im Druck, nicht im Grafikexport** — wie die Cursor-Zeile. Die Marke sagt
„schau jetzt hierhin", nicht „so ist der Plan"; ein Export wäre für jeden
Betrachter etwas anderes. Der Export erbt das ohnehin, weil `diagramToSvg()` nie
`box-shadow` ausliest (D25).
**Dritte, unabhängige Achse.** Die Marke sagt nichts über Fortschritt (§4) oder
Notwendigkeit (§3) — test-abgedeckt, damit niemand später Status oder `optional`
daran koppelt. **Nicht** in das kanonische Beispiel (SPEC §10) aufgenommen: Das
ist zugleich Test-Fixture, und ein dauerhafter Zeigefinger darin wäre eine
Aussage, die niemand gemacht hat.
+41 -2
View File
@@ -7,7 +7,7 @@ Syntaxänderungen werden zuerst hier dokumentiert, dann implementiert.
## 1. Zeilenformat ## 1. Zeilenformat
``` ```
[Einrückung][Zeichen] [Statusbox] Label (Größe) URL @tag … %% Kommentar [Einrückung][Zeichen] [Statusbox] Label (Größe) URL @tag … !!! %% Kommentar
``` ```
Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in
@@ -18,7 +18,25 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit):
3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht). 3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht).
4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal.
5. Tags: alle `@name`-Vorkommen. 5. Tags: alle `@name`-Vorkommen.
6. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren. 6. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten).
7. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren.
**Fokusmarke `!!!`** — „schau hier hin": Der Knoten wird im Diagramm
hervorgehoben und ins Bild geholt (§9). Gedacht für das gemeinsame Arbeiten an
einem Pad (§9, `?etherpad=`): Weil dort niemand den Cursor der anderen sieht,
ist eine Marke **im Text** der einzige Weg, auf eine Stelle zu zeigen — und sie
hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle.
- Erkannt nur **alleinstehend**, also am Zeilenanfang/-ende oder von Leerraum
umgeben. `Achtung!!!` bleibt damit ein gewöhnliches Label; auch `!!!`
innerhalb einer URL bleibt Teil der URL.
- Die Marke gehört **nicht** zum Label und ist an jeder Position der Zeile
zulässig. Mehrere Marken sind erlaubt: alle markierten Knoten werden
hervorgehoben, ins Bild geholt wird der **erste**.
- Sie sagt nichts über Fortschritt (§4) oder Notwendigkeit (§3) — eine dritte,
unabhängige Achse.
- Sie bleibt im Text stehen, bis jemand sie löscht; ein Werkzeug entfernt sie
nicht von selbst.
Referenz-Regex der Implementierung: Referenz-Regex der Implementierung:
@@ -26,6 +44,12 @@ Referenz-Regex der Implementierung:
^([ \t]*)([-|+])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ ^([ \t]*)([-|+])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$
``` ```
Für die Fokusmarke (Schritt 6):
```
(^|\s)!!!(?=\s|$)
```
## 2. Hierarchie ## 2. Hierarchie
- Die Einrückung bestimmt die Ebene. Es gibt keine feste Schrittweite: - Die Einrückung bestimmt die Ebene. Es gibt keine feste Schrittweite:
@@ -372,6 +396,21 @@ verknüpft (siehe D25):
- Die Hervorhebung ist eine reine Editierhilfe: nicht im Grafikexport, nicht im - Die Hervorhebung ist eine reine Editierhilfe: nicht im Grafikexport, nicht im
Druck. Druck.
### Fokusmarke im Diagramm (`!!!`, §1)
Ein mit `!!!` markierter Knoten trägt **dieselbe Hervorhebung wie die
Cursor-Zeile** (weißer Halo + Ring in Tinte) und wird ins Bild geholt — bewusst
kein eigenes Aussehen: Es gibt nur einen Begriff „hier schauen", und ein
dritter Ring neben Fokusrahmen, Strahlenkranz (§9) und Cursor-Ring wäre ein
Zeichen zu viel.
- Ins Bild geholt wird nur, wenn sich die Marke **ändert** — sonst zöge das
Diagramm bei jedem Neubau den Blick zurück und man könnte nicht wegscrollen.
- Weil `!!!` im Text steht, sehen **alle** dieselbe Stelle. Der Unterschied zur
eigenen Cursor-Zeile ist im Bild nicht zu erkennen; für Screenreader benennt
das `aria-label` die Marke ausdrücklich.
- Wie die Cursor-Zeile erscheint sie **nicht** im Druck und **nicht** im
Grafikexport: Sie sagt „schau jetzt hierhin", nicht „so ist der Plan".
### Grafikexport des Diagramms ### Grafikexport des Diagramms
Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen
+ Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen: + Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen:
+1
View File
@@ -68,6 +68,7 @@
- [x] Embed the pad, keep the text mirror beside it (M) - [x] Embed the pad, keep the text mirror beside it (M)
- [x] Three views: pad and text, pad only, text only (S) - [x] Three views: pad and text, pad only, text only (S)
- [x] Splitter between pad and mirror (XS) - [x] Splitter between pad and mirror (XS)
- [x] A shared pointer: !!! focuses a node for everyone (XS)
- [ ] Git as the shared store (L) - [ ] Git as the shared store (L)
| [ ] A file in a repository, changed by pull request (S) %% works today, no code | [ ] A file in a repository, changed by pull request (S) %% works today, no code
| [?] The backend commits every change (L) | [?] The backend commits every change (L)
+13
View File
@@ -170,6 +170,19 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Nichts. Das Markup: `#srcArea` umschließt Rahmen + Splitter + `#src`, damit die Nichts. Das Markup: `#srcArea` umschließt Rahmen + Splitter + `#src`, damit die
Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein
Nachfahren-Selektor und greift weiter. Nachfahren-Selektor und greift weiter.
- Fokusmarke `!!!` (D32, SPEC §1): Parser setzt `focus`, Renderer die Klasse
`focusmark`, CSS teilt die Regel mit `.node.current` (ein Begriff „hier
schauen"). Erkannt **nur alleinstehend**`(^|\s)!!!(?=\s|$)`, bewusst ohne
Lookbehind (Safari erst ab 16.4), sonst verlöre `Achtung!!!` seine
Ausrufezeichen. Extraktion **nach** den Tags; der Kommentar fällt vorher weg,
eine Marke hinter `%%` wirkt also nicht. `revealFocusMark()` scrollt **nur bei
Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren im Pad soll
nicht neu scrollen), sonst zöge jeder Neubau den Blick zurück. Die Regel braucht
den `#out`-Präfix wie `.current` (`ul.or .node{box-shadow:none}` ist
spezifischer) **und** die Ausnahme
`.cheap-on .node:not(.cheap).focusmark{opacity:1;filter:none}` — sonst blasst die
Pfad-Inversion genau den Knoten aus, auf den gezeigt wird (bei einer nicht
gewählten Alternative gemessen: Deckkraft 0,32).
- Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als - Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als
`data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in `data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
`app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die `app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
+1
View File
@@ -96,6 +96,7 @@
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.<br> Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.<br>
<code>-</code>&nbsp; Teilpaket, alle erforderlich <em>(all of)</em><br> <code>-</code>&nbsp; Teilpaket, alle erforderlich <em>(all of)</em><br>
<code>+</code>&nbsp; Zugabe, nicht erforderlich <em>(optional)</em><br> <code>+</code>&nbsp; Zugabe, nicht erforderlich <em>(optional)</em><br>
<code>!!!</code>&nbsp; hierhin schauen (gemeinsamer Zeigefinger)<br>
<code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br> <code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br>
Zeile ohne Zeichen = Wurzelknoten. <code class="or-code">|</code> nicht mit <code>-</code>/<code>+</code> mischen.<br> Zeile ohne Zeichen = Wurzelknoten. <code class="or-code">|</code> nicht mit <code>-</code>/<code>+</code> mischen.<br>
Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>: Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>:
+36 -9
View File
@@ -96,9 +96,26 @@ function render(){
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25). /* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */ Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */
highlightCurrentNode(false); highlightCurrentNode(false);
revealFocusMark(); /* `!!!` ins Bild holen, wenn die Marke neu ist (SPEC §1) */
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */ updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
} }
/* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt —
aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des
Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des
Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als neue Marke
gelten. Die Hervorhebung selbst macht die Klasse `focusmark` aus dem Renderer,
hier geht es nur ums Scrollen. */
let lastFocusMark = null;
function revealFocusMark(){
const el = out.querySelector('.node.focusmark');
const key = el ? el.textContent : null;
if(key && key !== lastFocusMark){
el.scrollIntoView({block:'center', inline:'center', behavior:'smooth'});
}
lastFocusMark = key;
}
/* ---------- Günstigster-Pfad-Linie ---------- /* ---------- Günstigster-Pfad-Linie ----------
Eine gestrichelte, geschwungene Petrol-Linie fädelt durch die Endknoten Eine gestrichelte, geschwungene Petrol-Linie fädelt durch die Endknoten
(Blätter) des günstigen Pfads. Das Overlay-SVG liegt in #out und erbt damit (Blätter) des günstigen Pfads. Das Overlay-SVG liegt in #out und erbt damit
@@ -948,10 +965,11 @@ const I18N = {
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).", sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.", sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yLink:"verlinkt", a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
hint_opt:"Zugabe, nicht erforderlich", hint_opt:"Zugabe, nicht erforderlich",
hint_focus:"hierhin schauen (gemeinsamer Zeigefinger)",
hint_root:"Zeile ohne Zeichen = Wurzelknoten. | nicht mit - / + mischen.", hint_root:"Zeile ohne Zeichen = Wurzelknoten. | nicht mit - / + mischen.",
hint_status:"Status als Kästchen nach dem Zeichen, z. B.", hint_status:"Status als Kästchen nach dem Zeichen, z. B.",
hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:", hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:",
@@ -1011,10 +1029,11 @@ const I18N = {
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.", sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yLink:"has link", a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_all:"sub-task, all required", hint_any:"alternative, choose one",
hint_opt:"extra, not required", hint_opt:"extra, not required",
hint_focus:"look here (a shared pointer)",
hint_root:"Line without a marker = root node. Do not mix | with - / +.", hint_root:"Line without a marker = root node. Do not mix | with - / +.",
hint_status:"Status as a checkbox after the marker, e.g.", hint_status:"Status as a checkbox after the marker, e.g.",
hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:", hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:",
@@ -1074,10 +1093,11 @@ const I18N = {
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.", sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yLink:"con enlace", a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
hint_opt:"extra, no obligatorio", hint_opt:"extra, no obligatorio",
hint_focus:"mirar aquí (un puntero compartido)",
hint_root:"Línea sin marcador = nodo raíz. No mezcles | con - / +.", hint_root:"Línea sin marcador = nodo raíz. No mezcles | con - / +.",
hint_status:"Estado como casilla tras el marcador, p. ej.", hint_status:"Estado como casilla tras el marcador, p. ej.",
hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:", hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:",
@@ -1137,10 +1157,11 @@ const I18N = {
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"« {url} » na pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de lexport (10 fois par 90 s par défaut) ; attends un instant, puis recharge.", sourceTimeoutWarn:"« {url} » na pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de lexport (10 fois par 90 s par défaut) ; attends un instant, puis recharge.",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yLink:"avec lien", a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
hint_opt:"supplément, non requis", hint_opt:"supplément, non requis",
hint_focus:"regarder ici (un pointeur partagé)",
hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas | avec - / +.", hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas | avec - / +.",
hint_status:"Statut sous forme de case après le marqueur, p. ex.", hint_status:"Statut sous forme de case après le marqueur, p. ex.",
hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :", hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :",
@@ -1200,10 +1221,11 @@ const I18N = {
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.", sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yLink:"z linkiem", a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_opt:"dodatek, niewymagany", hint_opt:"dodatek, niewymagany",
hint_focus:"spójrz tutaj (wspólny wskaźnik)",
hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj | z - / +.", hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj | z - / +.",
hint_status:"Status jako pole wyboru po znaczniku, np.", hint_status:"Status jako pole wyboru po znaczniku, np.",
hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:", hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:",
@@ -1263,10 +1285,11 @@ const I18N = {
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.", sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yLink:"со ссылкой", a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_opt:"дополнение, не обязательно", hint_opt:"дополнение, не обязательно",
hint_focus:"смотрите здесь (общая указка)",
hint_root:"Строка без маркера = корневой узел. Не смешивайте | с - / +.", hint_root:"Строка без маркера = корневой узел. Не смешивайте | с - / +.",
hint_status:"Статус в виде флажка после маркера, напр.", hint_status:"Статус в виде флажка после маркера, напр.",
hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:", hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:",
@@ -1326,10 +1349,11 @@ const I18N = {
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।", sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yLink:"लिंक सहित", a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_opt:"अतिरिक्त, आवश्यक नहीं", hint_opt:"अतिरिक्त, आवश्यक नहीं",
hint_focus:"यहाँ देखें (साझा संकेतक)",
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। | को - / + के साथ न मिलाएँ।", hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। | को - / + के साथ न मिलाएँ।",
hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे", hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे",
hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:", hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:",
@@ -1389,10 +1413,11 @@ const I18N = {
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。",
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。", sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yLink:"含链接", a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_opt:"附加项,非必需", hint_opt:"附加项,非必需",
hint_focus:"看这里(共享的指针)",
hint_root:"无标记的行 = 根节点。请勿将 | 与 - / + 混用。", hint_root:"无标记的行 = 根节点。请勿将 | 与 - / + 混用。",
hint_status:"在标记后用方框表示状态,例如", hint_status:"在标记后用方框表示状态,例如",
hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:", hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:",
@@ -1452,10 +1477,11 @@ const I18N = {
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。",
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。", sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yLink:"リンクあり", a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_opt:"追加、必須ではない", hint_opt:"追加、必須ではない",
hint_focus:"ここを見る(共有の指さし)",
hint_root:"マーカーのない行 = ルートノード。| を - / + と混在させないでください。", hint_root:"マーカーのない行 = ルートノード。| を - / + と混在させないでください。",
hint_status:"マーカーの後にチェックボックスで状態、例:", hint_status:"マーカーの後にチェックボックスで状態、例:",
hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:", hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:",
@@ -1493,6 +1519,7 @@ function buildHint(){
${esc(t('hint_break'))}<br> ${esc(t('hint_break'))}<br>
${esc(t('hint_comment'))} ${esc(t('hint_comment'))}
${esc(t('hint_people'))} ${esc(t('hint_people'))}
<code>!!!</code>&nbsp; ${esc(t('hint_focus'))}
<div class="hint-op">${esc(t('hint_jump'))}</div>`; <div class="hint-op">${esc(t('hint_jump'))}</div>`;
} }
function applyLang(l){ function applyLang(l){
+8 -3
View File
@@ -21,14 +21,14 @@ export const STATUS_BY_CODE = {
/* Parst den Notationstext zu { roots, warnings }. /* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or', optional, status, url, size, tags, Jeder Knoten: {label, type:'and'|'or', optional, status, url, size, tags,
children, line}. focus, children, line}.
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3) `type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die (`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
Gemischt-Warnung unverändert richtig — sie schlägt nur an, wenn `|` mit Gemischt-Warnung unverändert richtig — sie schlägt nur an, wenn `|` mit
`-`/`+` gemischt wird. `-`/`+` gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen); -> Tags -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */ Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
export function parse(text){ export function parse(text){
const virtualRoot = {label:'', type:'and', children:[]}; const virtualRoot = {label:'', type:'and', children:[]};
@@ -52,6 +52,11 @@ export function parse(text){
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; }); rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; }); rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; }); rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
/* Fokusmarke `!!!` (SPEC §1) — nur ALLEINSTEHEND, damit „Achtung!!!" ein
gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4):
der führende Leerraum wird mitgefangen und wieder eingesetzt. */
let focus = false;
rest = rest.replace(/(^|\s)!!!(?=\s|$)/g, (s, pre) => { focus = true; return pre; });
const label = rest.replace(/\s+/g, ' ').trim(); const label = rest.replace(/\s+/g, ' ').trim();
if(!label) return; if(!label) return;
@@ -64,7 +69,7 @@ export function parse(text){
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node; const parent = stack[stack.length-1].node;
const node = {label, type, optional, status, url, size, tags, children:[], line:i+1}; const node = {label, type, optional, status, url, size, tags, focus, children:[], line:i+1};
parent.children.push(node); parent.children.push(node);
stack.push({node, width}); stack.push({node, width});
}); });
+5 -1
View File
@@ -20,7 +20,7 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
function extraCls(n, opts){ function extraCls(n, opts){
const cheap = cheapCls(n, opts.cheapSet); const cheap = cheapCls(n, opts.cheapSet);
const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : ''; const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : '';
return [cheap, fresh, n.optional ? 'opt' : ''].filter(Boolean).join(' '); return [cheap, fresh, n.optional ? 'opt' : '', n.focus ? 'focusmark' : ''].filter(Boolean).join(' ');
} }
/* Klassen des <li>: Gate der eigenen Kinder (steuert die Anordnung) plus /* Klassen des <li>: Gate der eigenen Kinder (steuert die Anordnung) plus
@@ -52,6 +52,10 @@ function nodeAria(n, opts){
else if(cheapPath) parts.push(t('a11ySizeImplicit')); else if(cheapPath) parts.push(t('a11ySizeImplicit'));
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
if(n.optional) parts.push(t('a11yOptional')); if(n.optional) parts.push(t('a11yOptional'));
/* Die Fokusmarke ist rein als box-shadow sichtbar — ohne diese Ansage wüsste
ein Screenreader nichts davon. Zugleich der einzige Ort, an dem sie sich von
der eigenen Cursor-Zeile unterscheidet (SPEC §9). */
if(n.focus) parts.push(t('a11yFocusMark'));
if(n.url) parts.push(t('a11yLink')); if(n.url) parts.push(t('a11yLink'));
return parts.join(', '); return parts.join(', ');
} }
+14 -2
View File
@@ -532,7 +532,12 @@
im Druck. Der `#out`-Präfix ist nötig: `ul.or .node{box-shadow:none}` 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 (weiter unten) ist spezifischer als `.node.current` und schluckte den Ring
sonst überall unterhalb einer any-of-Gruppe. */ sonst überall unterhalb einer any-of-Gruppe. */
#out .node.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink)} /* 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,
#out .node.focusmark{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink)}
/* Neu in Produktion seit dem letzten Besuch (D28): gelber Strahlenkranz um den /* 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 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 gehört dem Status (SPEC §4) und bleibt lesbar. Kein Blinken: WCAG 2.2.2/2.3.1
@@ -609,6 +614,12 @@
unsichtbar), und gerade bei einer nicht gewählten Alternative ist „das ist unsichtbar), und gerade bei einer nicht gewählten Alternative ist „das ist
jetzt live" die interessantere Nachricht (D28). */ jetzt live" die interessantere Nachricht (D28). */
.cheap-on .node:not(.cheap).fresh{opacity:1;filter:none} .cheap-on .node:not(.cheap).fresh{opacity:1;filter:none}
/* Dasselbe für die Fokusmarke `!!!` (SPEC §1), und hier noch zwingender: Ein
Zeigefinger, der auf einen ausgeblassten Knoten zeigt, zeigt auf nichts —
und „sollten wir diese verworfene Alternative noch einmal ansehen?" ist einer
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}
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */ /* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)} .size.implicit{background:var(--card);color:var(--or);border-color:var(--or)}
/* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch /* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch
@@ -964,7 +975,8 @@
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important} *{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
/* Knoten nicht über den Seitenrand zerschneiden */ /* Knoten nicht über den Seitenrand zerschneiden */
#out li{break-inside:avoid} #out li{break-inside:avoid}
.node.current{box-shadow:none!important} /* Editierhilfe, nicht drucken (D25) */ /* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1) */
.node.current,.node.focusmark{box-shadow: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) */
@page{margin:12mm} @page{margin:12mm}
} }
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { renderTreeHtml } from '../src/render.js';
const t = (key, vars) => {
const dict = {
a11yStatus: 'Status: ' + (vars && vars.status),
a11yFocusMark: 'hierhin schauen',
a11yOptional: 'optional',
st_geplant: 'geplant'
};
return dict[key] !== undefined ? dict[key] : key;
};
/* renderTreeHtml liefert {html, warnings} — hier interessiert nur der HTML-String. */
const html = text => renderTreeHtml(parse(text).roots,
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set()}).html;
describe('Fokusmarke `!!!` — Parser (SPEC §1)', () => {
it('erkennt die Marke am Zeilenende und hält sie aus dem Label', () => {
const [n] = parse('Backend !!!').roots;
expect(n.focus).toBe(true);
expect(n.label).toBe('Backend');
});
it('erkennt sie am Zeilenanfang und in der Mitte', () => {
expect(parse('!!! Backend').roots[0]).toMatchObject({focus: true, label: 'Backend'});
expect(parse('Backend !!! fertig').roots[0]).toMatchObject({focus: true, label: 'Backend fertig'});
});
/* Der Kern der Regel: nur ALLEINSTEHEND. Sonst verlöre jedes „Achtung!!!"
seine Ausrufezeichen und niemand fände den Grund. */
it('lässt angehängte Ausrufezeichen in Ruhe', () => {
expect(parse('Achtung!!!').roots[0]).toMatchObject({focus: false, label: 'Achtung!!!'});
expect(parse('!!!wichtig').roots[0]).toMatchObject({focus: false, label: '!!!wichtig'});
expect(parse('a!!!b').roots[0]).toMatchObject({focus: false, label: 'a!!!b'});
});
it('zählt zwei oder vier Ausrufezeichen nicht als Marke', () => {
expect(parse('Backend !!').roots[0]).toMatchObject({focus: false, label: 'Backend !!'});
expect(parse('Backend !!!!').roots[0]).toMatchObject({focus: false, label: 'Backend !!!!'});
});
it('lässt `!!!` innerhalb einer URL unberührt', () => {
const [n] = parse('Doku https://example.org/a!!!b').roots;
expect(n.focus).toBe(false);
expect(n.url).toBe('https://example.org/a!!!b');
});
/* Reihenfolge der Extraktion (SPEC §1): Kommentar, Zeichen/Status, URL, Größe,
Tags, Marke, Label — die Marke darf keinen der anderen Teile verschlucken. */
it('verträgt sich mit Status, Größe, URL, Tags und Kommentar', () => {
const [n] = parse(' - [~] Backend (L) https://git.example/x @ben !!! %% Kommentar').roots;
expect(n).toMatchObject({focus: true, label: 'Backend', size: 'L', tags: ['ben']});
expect(n.status.key).toBe('arbeit');
expect(n.url).toBe('https://git.example/x');
});
it('eine Marke im Kommentar wirkt nicht — der Kommentar fällt zuerst weg', () => {
expect(parse('Backend %% später !!!').roots[0].focus).toBe(false);
});
it('mehrere Marken sind erlaubt und markieren jeden betroffenen Knoten', () => {
const roots = parse('A !!!\nB\nC !!!').roots;
expect(roots.map(n => n.focus)).toEqual([true, false, true]);
});
it('eine Zeile aus nur einer Marke hat kein Label und fällt weg', () => {
expect(parse('!!!').roots).toHaveLength(0);
});
it('ohne Marke ist focus false, nicht undefined', () => {
expect(parse('Backend').roots[0].focus).toBe(false);
});
/* Die Marke ist eine dritte, unabhängige Achse (SPEC §1): weder Fortschritt
(§4) noch Notwendigkeit (§3) dürfen sich daran ändern. */
it('lässt Status und Optionalität unberührt', () => {
const [root] = parse('Wurzel\n + [^] Zugabe !!!').roots;
const kid = root.children[0];
expect(kid).toMatchObject({focus: true, optional: true, type: 'and'});
expect(kid.status.key).toBe('prod');
});
});
describe('Fokusmarke — Renderer', () => {
it('setzt die Klasse `focusmark` am Knoten', () => {
expect(html('Backend !!!')).toMatch(/class="node root-node focusmark"/);
/* auch an einem Kind, nicht nur an der Wurzel */
expect(html('Wurzel\n - Kind !!!')).toMatch(/class="node focusmark"[^>]*>Kind/);
});
it('setzt sie nicht ohne Marke', () => {
expect(html('Backend')).not.toMatch(/focusmark/);
});
/* Sichtbar ist die Marke nur als box-shadow — ohne diese Ansage wüsste ein
Screenreader nichts davon (SPEC §9). */
it('nennt die Marke im aria-label', () => {
expect(html('[ ] Backend !!!')).toContain('hierhin schauen');
});
it('das Ausrufezeichen steht nicht im sichtbaren Label', () => {
const out = html('Backend !!!');
expect(out).toContain('>Backend<');
expect(out).not.toContain('!!!');
});
});