diff --git a/README.de.md b/README.de.md index a261b99..38e5b2f 100644 --- a/README.de.md +++ b/README.de.md @@ -121,8 +121,25 @@ Getestet gegen Etherpad: Der Klartext-Export sendet zurück — führende Leerzeichen, `-`/`+`/`|`, Statusboxen und `%%` überleben 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 -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 diff --git a/README.md b/README.md index e636d86..30c7294 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,23 @@ Verified against Etherpad: the plain-text export sends leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage 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 -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f6c9fe4..df02f78 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 bleiben Pad 317 px und Spiegel 180 px, per Splitter verschiebbar — knapp, aber 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. diff --git a/docs/SPEC.md b/docs/SPEC.md index 09cb52a..31b0bbc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -7,7 +7,7 @@ Syntaxänderungen werden zuerst hier dokumentiert, dann implementiert. ## 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 @@ -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). 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 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: @@ -26,6 +44,12 @@ Referenz-Regex der Implementierung: ^([ \t]*)([-|+])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ ``` +Für die Fokusmarke (Schritt 6): + +``` +(^|\s)!!!(?=\s|$) +``` + ## 2. Hierarchie - 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 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 Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen: diff --git a/docs/examples/example-werkbaum.werkbaum b/docs/examples/example-werkbaum.werkbaum index 1c2dcfa..d481a5e 100644 --- a/docs/examples/example-werkbaum.werkbaum +++ b/docs/examples/example-werkbaum.werkbaum @@ -68,6 +68,7 @@ - [x] Embed the pad, keep the text mirror beside it (M) - [x] Three views: pad and text, pad only, text only (S) - [x] Splitter between pad and mirror (XS) + - [x] A shared pointer: !!! focuses a node for everyone (XS) - [ ] Git as the shared store (L) | [ ] A file in a repository, changed by pull request (S) %% works today, no code | [?] The backend commits every change (L) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 13caacd..bd4f5b9 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein 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 `data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in `app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die diff --git a/frontend/index.html b/frontend/index.html index ed5a7b5..8c92511 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -96,6 +96,7 @@ Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.
-  Teilpaket, alle erforderlich (all of)
+  Zugabe, nicht erforderlich (optional)
+ !!!  hierhin schauen (gemeinsamer Zeigefinger)
|  Alternative, eine wählen (any of)
Zeile ohne Zeichen = Wurzelknoten. | nicht mit -/+ mischen.
Status als Kästchen nach dem Zeichen, z. B. - [~] Frontend: diff --git a/frontend/src/app.js b/frontend/src/app.js index 7b93113..1e20e21 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -96,9 +96,26 @@ function render(){ /* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25). Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */ highlightCurrentNode(false); + revealFocusMark(); /* `!!!` ins Bild holen, wenn die Marke neu ist (SPEC §1) */ 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 ---------- 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 @@ -948,10 +965,11 @@ const I18N = { 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).", 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_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_opt:"Zugabe, nicht erforderlich", + hint_focus:"hierhin schauen (gemeinsamer Zeigefinger)", hint_root:"Zeile ohne Zeichen = Wurzelknoten. | nicht mit - / + mischen.", 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:", @@ -1011,10 +1029,11 @@ const I18N = { 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).", 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_all:"sub-task, all required", hint_any:"alternative, choose one", 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_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:", @@ -1074,10 +1093,11 @@ const I18N = { 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).", 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_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", 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_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:", @@ -1137,10 +1157,11 @@ const I18N = { 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).", sourceTimeoutWarn:"« {url} » n’a pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de l’export (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_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", 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_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 :", @@ -1200,10 +1221,11 @@ const I18N = { 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).", 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_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", 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_status:"Status jako pole wyboru po znaczniku, np.", hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:", @@ -1263,10 +1285,11 @@ const I18N = { unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", 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_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_opt:"дополнение, не обязательно", + hint_focus:"смотрите здесь (общая указка)", hint_root:"Строка без маркера = корневой узел. Не смешивайте | с - / +.", hint_status:"Статус в виде флажка после маркера, напр.", hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:", @@ -1326,10 +1349,11 @@ const I18N = { unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", 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_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_opt:"अतिरिक्त, आवश्यक नहीं", + hint_focus:"यहाँ देखें (साझा संकेतक)", hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। | को - / + के साथ न मिलाएँ।", hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे", hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:", @@ -1389,10 +1413,11 @@ const I18N = { unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。", 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_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_opt:"附加项,非必需", + hint_focus:"看这里(共享的指针)", hint_root:"无标记的行 = 根节点。请勿将 | 与 - / + 混用。", hint_status:"在标记后用方框表示状态,例如", hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:", @@ -1452,10 +1477,11 @@ const I18N = { unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。", 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_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_opt:"追加、必須ではない", + hint_focus:"ここを見る(共有の指さし)", hint_root:"マーカーのない行 = ルートノード。| を - / + と混在させないでください。", hint_status:"マーカーの後にチェックボックスで状態、例:", hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:", @@ -1493,6 +1519,7 @@ function buildHint(){ ${esc(t('hint_break'))}
${esc(t('hint_comment'))} ${esc(t('hint_people'))} + !!!  ${esc(t('hint_focus'))}
${esc(t('hint_jump'))}
`; } function applyLang(l){ diff --git a/frontend/src/parser.js b/frontend/src/parser.js index e322b9c..59e35f0 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -21,14 +21,14 @@ export const STATUS_BY_CODE = { /* Parst den Notationstext zu { roots, warnings }. 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) eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung (`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die Gemischt-Warnung unverändert richtig — sie schlägt nur an, wenn `|` mit `-`/`+` gemischt wird. 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. */ export function parse(text){ 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(/\((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 ''; }); + /* 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(); if(!label) return; @@ -64,7 +69,7 @@ export function parse(text){ while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); 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); stack.push({node, width}); }); diff --git a/frontend/src/render.js b/frontend/src/render.js index 059f2be..eef31a6 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -20,7 +20,7 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; function extraCls(n, opts){ const cheap = cheapCls(n, opts.cheapSet); 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
  • : Gate der eigenen Kinder (steuert die Anordnung) plus @@ -52,6 +52,10 @@ function nodeAria(n, opts){ else if(cheapPath) parts.push(t('a11ySizeImplicit')); if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); 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')); return parts.join(', '); } diff --git a/frontend/src/style.css b/frontend/src/style.css index c3fbc3b..48a807b 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -532,7 +532,12 @@ 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. */ - #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 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 @@ -609,6 +614,12 @@ unsichtbar), und gerade bei einer nicht gewählten Alternative ist „das ist jetzt live" die interessantere Nachricht (D28). */ .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) */ .size.implicit{background:var(--card);color:var(--or);border-color:var(--or)} /* 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} /* Knoten nicht über den Seitenrand zerschneiden */ #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) */ @page{margin:12mm} } diff --git a/frontend/tests/focusmark.test.js b/frontend/tests/focusmark.test.js new file mode 100644 index 0000000..7a3010c --- /dev/null +++ b/frontend/tests/focusmark.test.js @@ -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('!!!'); + }); +});