feat: Faltmarken (>/<) — Teilbäume ein-/ausklappbar (SPEC §1/§9, D38)
Parser liest die Marke zwischen Zeichen und Statusbox (Leerraum-Regel, `- >Achtung` bleibt Label); den Anfangszustand rechnet initialCollapsed() in model.js: `>` klappt ein, `<` (und die Fokusmarke !!!) holt seinen Teilbaum hervor, indem die Faltung die Pfad-Ebenen hinunterwandert — Geschwister stehen als einzelne eingeklappte Knoten da, jede Kante bleibt eine echte. Im Diagramm: Falt-Zeichen ▾/„▸ n" vor dem Label (n = alle verborgenen Knoten), Klick oder ←/→ am fokussierten Knoten klappt um; Eingriffe je Label-Pfad, nur für die Sitzung, Dokumentwechsel setzt zurück. Eingeklappte Kinder werden nicht gerendert (Export, Messungen, Pfadlinie automatisch konsistent), ihre Warnungen aber weiter gemeldet. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg. Legende + a11y in allen 9 Sprachen; SPEC-§11-Abschnitt in §1/§9 überführt; 15 neue Tests (tests/fold.test.js), Snapshots aktualisiert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c778e3e267
commit
cbe798d746
+86
-12
@@ -1,6 +1,6 @@
|
||||
import './style.css';
|
||||
import { parse } from './parser.js';
|
||||
import { computeCheapSet, freshProdSet } from './model.js';
|
||||
import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys } from './model.js';
|
||||
import { esc, renderTreeHtml } from './render.js';
|
||||
import { formatWarning } from './warnings.js';
|
||||
import { padUrls } from './remote.js';
|
||||
@@ -61,6 +61,12 @@ let sourceWarning = null;
|
||||
Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde;
|
||||
er wird erst beim Bestätigen fortgeschrieben. */
|
||||
let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = null;
|
||||
/* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des
|
||||
Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei
|
||||
jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den
|
||||
Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg.
|
||||
`foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */
|
||||
let foldOverrides = new Map(), foldByLine = new Map();
|
||||
|
||||
/* ---------- Renderer (Anbindung an den DOM) ----------
|
||||
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
|
||||
@@ -82,6 +88,7 @@ function render(){
|
||||
if(!roots.length){
|
||||
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
|
||||
freshSet = new Set();
|
||||
foldByLine = new Map();
|
||||
} else {
|
||||
const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
|
||||
out.classList.toggle('cheap-on', cheapPathOn);
|
||||
@@ -91,8 +98,21 @@ function render(){
|
||||
Objektidentität nie zu (D28). */
|
||||
freshSet = (freshDocId === activeId && freshPrevRoots)
|
||||
? freshProdSet(freshPrevRoots, roots) : new Set();
|
||||
/* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit
|
||||
hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei
|
||||
`freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */
|
||||
const initFold = initialCollapsed(roots, true);
|
||||
const keys = nodeKeys(roots);
|
||||
const collapsedSet = new Set();
|
||||
foldByLine = new Map();
|
||||
keys.forEach((key, n) => {
|
||||
const ov = foldOverrides.get(key);
|
||||
const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0;
|
||||
if(collapsed) collapsedSet.add(n);
|
||||
foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
|
||||
});
|
||||
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet,
|
||||
freshSet});
|
||||
freshSet, collapsedSet});
|
||||
out.innerHTML = r.html;
|
||||
warnings = warnings.concat(r.warnings);
|
||||
}
|
||||
@@ -378,7 +398,11 @@ function diagramToSvg(){
|
||||
const dashed = cs.borderTopStyle === 'dashed';
|
||||
parts.push(`<rect x="${b.x.toFixed(1)}" y="${b.y.toFixed(1)}" width="${b.w.toFixed(1)}" height="${b.h.toFixed(1)}" rx="8" fill="${cs.backgroundColor}" stroke="${cs.borderTopColor}" stroke-width="${parseFloat(cs.borderTopWidth)||1.5}"${dashed?' stroke-dasharray="4 3"':''}/>`);
|
||||
const clone = node.cloneNode(true);
|
||||
clone.querySelectorAll('.size,.tags,.ext,.risk').forEach(e => e.remove());
|
||||
/* Das „▸ n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC
|
||||
§9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener
|
||||
Knoten ist Bedienelement und fällt weg. */
|
||||
const stripFold = node.classList.contains('folded') ? '' : ',.fold';
|
||||
clone.querySelectorAll('.size,.tags,.ext,.risk' + stripFold).forEach(e => e.remove());
|
||||
const label = clone.textContent.replace(/\s+/g,' ').trim();
|
||||
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
|
||||
parts.push(`<text x="${b.cx.toFixed(1)}" y="${(b.cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(label)}</text>`);
|
||||
@@ -685,6 +709,29 @@ function nodeFromEvent(e){
|
||||
return el && out.contains(el) ? el : null;
|
||||
}
|
||||
|
||||
/* Faltung umklappen (SPEC §9, D38). Der Eingriff überlagert den Anfangszustand
|
||||
aus dem Text; nach dem Neubau bekommt derselbe Knoten den Fokus zurück,
|
||||
sonst risse die Tastaturbedienung ab (das alte Element ist weg). */
|
||||
function toggleFold(el){
|
||||
const st = foldByLine.get(+el.dataset.line);
|
||||
if(!st || !st.canFold) return;
|
||||
foldOverrides.set(st.key, !st.collapsed);
|
||||
render();
|
||||
const again = out.querySelector('.node[data-line="' + el.dataset.line + '"]');
|
||||
if(again) again.focus({preventScroll: true});
|
||||
}
|
||||
|
||||
/* Klick auf das Falt-Zeichen ▾/▸ klappt um. preventDefault, weil das Zeichen
|
||||
bei Link-Knoten IM <a> sitzt — sonst öffnete der Klick zusätzlich die URL. */
|
||||
out.addEventListener('click', e => {
|
||||
const f = e.target && e.target.closest ? e.target.closest('.fold') : null;
|
||||
if(!f || !out.contains(f) || e.altKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const el = f.closest('.node[data-line]');
|
||||
if(el) toggleFold(el);
|
||||
});
|
||||
|
||||
out.addEventListener('click', e => {
|
||||
if(!e.altKey) return;
|
||||
const el = nodeFromEvent(e);
|
||||
@@ -705,6 +752,20 @@ out.addEventListener('keydown', e => {
|
||||
jumpToLine(+el.dataset.line);
|
||||
});
|
||||
|
||||
/* Tastatur-Faltung (SPEC §9, D38): ← klappt zu, → klappt auf — das
|
||||
WAI-ARIA-Baum-Idiom. Nur ohne Modifier, damit nichts anderes kollidiert. */
|
||||
out.addEventListener('keydown', e => {
|
||||
if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
if(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
const el = nodeFromEvent(e);
|
||||
if(!el) return;
|
||||
const st = foldByLine.get(+el.dataset.line);
|
||||
if(!st || !st.canFold) return;
|
||||
e.preventDefault();
|
||||
const want = e.key === 'ArrowLeft';
|
||||
if(st.collapsed !== want) toggleFold(el);
|
||||
});
|
||||
|
||||
/* Mobil gibt es kein Alt: langer Druck (500 ms) auf einen Knoten springt.
|
||||
Der Sprung passiert erst beim LOSLASSEN: `focus()` aus einem Timer heraus
|
||||
gilt in mobilen Browsern nicht als Nutzergeste — der Fokus fiel sofort wieder
|
||||
@@ -1109,7 +1170,7 @@ 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}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", 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_xor:"Alternative, genau eine",
|
||||
@@ -1123,6 +1184,7 @@ const I18N = {
|
||||
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.",
|
||||
hint_id:"Knoten-ID mit #name — erscheint im Tooltip des Knotens.",
|
||||
hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.",
|
||||
hint_fold:"Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).",
|
||||
hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild."
|
||||
},
|
||||
en: {
|
||||
@@ -1179,7 +1241,7 @@ 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}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", 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_xor:"alternative, exactly one",
|
||||
@@ -1193,6 +1255,7 @@ const I18N = {
|
||||
hint_people:"People with @name — shown at the bottom-right of the node.",
|
||||
hint_id:"Node ID with #name — shown in the node's tooltip.",
|
||||
hint_deps:"Dependencies with :#name,#name — shown in the tooltip.",
|
||||
hint_fold:"Folding: - > [x] … starts collapsed, < brings it back; ▾/▸ on a node toggles (keyboard: ←/→).",
|
||||
hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view."
|
||||
},
|
||||
es: {
|
||||
@@ -1249,7 +1312,7 @@ 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}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
|
||||
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", 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_xor:"alternativa, exactamente una",
|
||||
@@ -1263,6 +1326,7 @@ const I18N = {
|
||||
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.",
|
||||
hint_id:"ID de nodo con #nombre — visible en el tooltip del nodo.",
|
||||
hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.",
|
||||
hint_fold:"Plegado: - > [x] … empieza plegado, < lo recupera; ▾/▸ en el nodo alterna (teclado: ←/→).",
|
||||
hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista."
|
||||
},
|
||||
fr: {
|
||||
@@ -1319,7 +1383,7 @@ 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}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
|
||||
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", 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_xor:"alternative, exactement une",
|
||||
@@ -1333,6 +1397,7 @@ const I18N = {
|
||||
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.",
|
||||
hint_id:"ID de nœud avec #nom — visible dans l’infobulle du nœud.",
|
||||
hint_deps:"Dépendances avec :#nom,#nom — visibles dans l’infobulle.",
|
||||
hint_fold:"Pliage : - > [x] … démarre replié, < le fait ressortir ; ▾/▸ sur le nœud bascule (clavier : ←/→).",
|
||||
hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran."
|
||||
},
|
||||
pl: {
|
||||
@@ -1389,7 +1454,7 @@ 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}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", 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_xor:"alternatywa, dokładnie jedna",
|
||||
@@ -1403,6 +1468,7 @@ const I18N = {
|
||||
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.",
|
||||
hint_id:"ID węzła przez #nazwa — widoczne w podpowiedzi węzła.",
|
||||
hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.",
|
||||
hint_fold:"Zwijanie: - > [x] … zaczyna zwinięte, < przywraca; ▾/▸ na węźle przełącza (klawiatura: ←/→).",
|
||||
hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie."
|
||||
},
|
||||
ru: {
|
||||
@@ -1459,7 +1525,7 @@ 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}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
|
||||
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
|
||||
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
|
||||
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
|
||||
hint_xor:"альтернатива, ровно одна",
|
||||
@@ -1473,6 +1539,7 @@ const I18N = {
|
||||
hint_people:"Люди через @имя — показываются справа внизу узла.",
|
||||
hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.",
|
||||
hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.",
|
||||
hint_fold:"Сворачивание: - > [x] … открывается свёрнутым, < возвращает; ▾/▸ на узле переключает (клавиши: ←/→).",
|
||||
hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме."
|
||||
},
|
||||
hi: {
|
||||
@@ -1529,7 +1596,7 @@ 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}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
|
||||
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
|
||||
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
|
||||
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
|
||||
hint_xor:"विकल्प, ठीक एक",
|
||||
@@ -1543,6 +1610,7 @@ const I18N = {
|
||||
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
|
||||
hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।",
|
||||
hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।",
|
||||
hint_fold:"फ़ोल्डिंग: - > [x] … समेटा हुआ खुलता है, < वापस लाता है; नोड पर ▾/▸ टॉगल करता है (कीबोर्ड: ←/→)।",
|
||||
hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।"
|
||||
},
|
||||
zh: {
|
||||
@@ -1599,7 +1667,7 @@ 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}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
|
||||
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
|
||||
hint_indent:"缩进(2 个空格或制表符)定义层级。",
|
||||
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
|
||||
hint_xor:"备选项,恰好一个",
|
||||
@@ -1613,6 +1681,7 @@ const I18N = {
|
||||
hint_people:"用 @姓名 表示人员——显示在节点右下角。",
|
||||
hint_id:"用 #名称 指定节点 ID——显示在节点提示中。",
|
||||
hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。",
|
||||
hint_fold:"折叠:- > [x] … 打开时即折叠,< 将其展开;节点上的 ▾/▸ 切换(键盘:←/→)。",
|
||||
hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。"
|
||||
},
|
||||
ja: {
|
||||
@@ -1669,7 +1738,7 @@ 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}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
|
||||
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
|
||||
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
|
||||
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
|
||||
hint_xor:"選択肢、ちょうど1つ",
|
||||
@@ -1683,6 +1752,7 @@ const I18N = {
|
||||
hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
|
||||
hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。",
|
||||
hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。",
|
||||
hint_fold:"折りたたみ:- > [x] … は折りたたんだ状態で開き、< は呼び戻します。ノードの ▾/▸ で切替(キー:←/→)。",
|
||||
hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。"
|
||||
}
|
||||
};
|
||||
@@ -1717,6 +1787,7 @@ function buildHint(){
|
||||
${esc(t('hint_people'))}
|
||||
${esc(t('hint_id'))}
|
||||
${esc(t('hint_deps'))}
|
||||
${esc(t('hint_fold'))}
|
||||
<code>!!!</code> ${esc(t('hint_focus'))}
|
||||
<div class="hint-op">${esc(t('hint_jump'))}</div>`;
|
||||
}
|
||||
@@ -2076,6 +2147,7 @@ function switchDoc(id){
|
||||
if(id === activeId) return;
|
||||
flushActive();
|
||||
activeId = id;
|
||||
foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */
|
||||
loadActiveIntoEditor();
|
||||
persistDocs();
|
||||
}
|
||||
@@ -2084,6 +2156,7 @@ function newDoc(){
|
||||
const d = { id: uid(), name: uniqueName(t('docNewName')), text: '' };
|
||||
docs.push(d);
|
||||
activeId = d.id;
|
||||
foldOverrides.clear();
|
||||
loadActiveIntoEditor();
|
||||
persistDocs();
|
||||
keyboardOnJump(false); /* neues, leeres Dokument = tippen ist gemeint */
|
||||
@@ -2112,6 +2185,7 @@ function deleteDoc(){
|
||||
docs = docs.filter(x => x.id !== d.id);
|
||||
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
|
||||
activeId = docs[0].id;
|
||||
foldOverrides.clear();
|
||||
loadActiveIntoEditor();
|
||||
persistDocs();
|
||||
closeDocMenu();
|
||||
|
||||
@@ -100,6 +100,46 @@ function walkKeys(nodes, parentKey, fn){
|
||||
walkKeys(n.children, key, fn);
|
||||
}
|
||||
}
|
||||
/* Knoten -> stabiler Schlüssel (Label-Pfad) über den ganzen Baum. Dieselbe
|
||||
Identität wie bei „Was ist neu?" — sie überlebt Umsortieren und Neu-Parsen;
|
||||
genutzt für die interaktiven Falt-Eingriffe (D38). */
|
||||
export function nodeKeys(roots){
|
||||
const map = new Map();
|
||||
walkKeys(roots, '', (key, n) => map.set(n, key));
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ---------- Faltmarken (SPEC §1/§9, D38) ----------
|
||||
Anfangszustand der Faltung aus den Textmarken: `>` klappt den Knoten ein.
|
||||
`<` (und mit `rescueFocus` auch die Fokusmarke `!!!`) holt den eigenen
|
||||
Teilbaum hervor, indem die Faltung die Pfad-Ebenen HINUNTERWANDERT: Jeder
|
||||
eingeklappte Vorfahr wird geöffnet, seine Nicht-Pfad-Kinder werden
|
||||
stattdessen eingeklappt. Sichtbar ist genau der Pfad samt Teilbaum, die
|
||||
Geschwister stehen als einzelne eingeklappte Knoten da — und jede
|
||||
gezeichnete Kante bleibt eine echte. Ein `>` innerhalb des hervorgeholten
|
||||
Teilbaums bleibt respektiert. */
|
||||
export function initialCollapsed(roots, rescueFocus){
|
||||
const set = new Set();
|
||||
const paths = [];
|
||||
const walk = (n, path) => {
|
||||
const p = path.concat(n);
|
||||
if(n.fold === '>') set.add(n);
|
||||
if(n.fold === '<' || (rescueFocus && n.focus)) paths.push(p);
|
||||
n.children.forEach(c => walk(c, p));
|
||||
};
|
||||
roots.forEach(r => walk(r, []));
|
||||
for(const p of paths){
|
||||
for(let i = 0; i < p.length - 1; i++){
|
||||
if(!set.has(p[i])) continue;
|
||||
set.delete(p[i]);
|
||||
for(const c of p[i].children)
|
||||
if(c !== p[i+1] && c.children.length) set.add(c);
|
||||
}
|
||||
set.delete(p[p.length - 1]); /* der geholte Knoten selbst ist offen */
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/* key -> Status-Schlüssel ('' für neutrale Knoten) über den ganzen Baum. */
|
||||
export function statusByKey(roots){
|
||||
const map = new Map();
|
||||
|
||||
+13
-7
@@ -25,8 +25,10 @@ export const STATUS_BY_CODE = {
|
||||
const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
|
||||
|
||||
/* Parst den Notationstext zu { roots, warnings }.
|
||||
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size,
|
||||
tags, id, deps, focus, children, line}.
|
||||
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, fold, status, url,
|
||||
size, tags, id, deps, focus, children, line}.
|
||||
`fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung —
|
||||
den wirksamen Zustand rechnet `initialCollapsed()` in model.js.
|
||||
`deps` sind ID-Strings, keine Knoten-Referenzen — aufgelöst wird erst beim
|
||||
Konsumenten (D37); der Parser prüft nur die Existenz (`unknownDep`).
|
||||
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
|
||||
@@ -51,14 +53,18 @@ export function parse(text){
|
||||
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
|
||||
(fehlertolerant: die Zeile geht nicht verloren). */
|
||||
/* `=` (XOR, SPEC §3) nur mit folgendem Leerraum — die Leerraum-Regel hält
|
||||
Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher. */
|
||||
const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([^\]])\]\s*)?(.*)$/);
|
||||
Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher.
|
||||
Die Faltmarke `>`/`<` (SPEC §1, D38) steht zwischen Zeichen und
|
||||
Statusbox (bei Wurzeln am Zeilenanfang), ebenfalls nur mit folgendem
|
||||
Leerraum — `- >Achtung` bleibt ein Label. */
|
||||
const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(.*)$/);
|
||||
const width = m[1].replace(/\t/g,' ').length;
|
||||
const type = m[2] === '|' ? 'or' : m[2] === '=' ? 'xor' : 'and';
|
||||
const optional = m[2] === '+';
|
||||
const boxChar = m[3]; // undefined, wenn keine Statusbox
|
||||
const fold = m[3] || null;
|
||||
const boxChar = m[4]; // undefined, wenn keine Statusbox
|
||||
|
||||
let rest = m[4], url = null, size = null;
|
||||
let rest = m[5], url = null, size = null;
|
||||
const tags = [];
|
||||
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 ''; });
|
||||
@@ -102,7 +108,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, id, deps, focus, children:[], line:i+1};
|
||||
const node = {label, type, optional, fold, status, url, size, tags, id, deps, focus, children:[], line:i+1};
|
||||
parent.children.push(node);
|
||||
stack.push({node, width});
|
||||
});
|
||||
|
||||
+58
-25
@@ -10,6 +10,7 @@
|
||||
cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge)
|
||||
cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus)
|
||||
freshSet, // optional: Knoten, die neu in Produktion sind (D28)
|
||||
collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38)
|
||||
} */
|
||||
|
||||
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
|
||||
@@ -44,9 +45,12 @@ function attr(s){ return esc(String(s)).replace(/"/g,'"'); }
|
||||
Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre
|
||||
Information steckt hier, sonst würde der Screenreader Kryptisches („M",
|
||||
„anna", „↗") vorlesen. */
|
||||
function nodeAria(n, opts){
|
||||
function nodeAria(n, opts, fold){
|
||||
const { t, cheapPath } = opts;
|
||||
const parts = [n.label];
|
||||
/* Eingeklappt (SPEC §9/D38): das ▾/▸-Zeichen ist aria-hidden — ohne diese
|
||||
Ansage wüsste ein Screenreader nicht, dass hier etwas verborgen ist. */
|
||||
if(fold && fold.collapsed) parts.push(t('a11yFolded', {n: fold.count}));
|
||||
if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)}));
|
||||
if(n.size) parts.push(t('a11ySize', {size: n.size}));
|
||||
else if(cheapPath) parts.push(t('a11ySizeImplicit'));
|
||||
@@ -65,10 +69,11 @@ function nodeAria(n, opts){
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function nodeHtml(n, extra, opts){
|
||||
function nodeHtml(n, extra, opts, fold){
|
||||
const { t, cheapPath } = opts;
|
||||
const need = needsBreakdown(n);
|
||||
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
|
||||
const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '',
|
||||
n.status ? 'st-' + n.status.key : '']
|
||||
.filter(Boolean).join(' ');
|
||||
/* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und
|
||||
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
|
||||
@@ -92,20 +97,62 @@ function nodeHtml(n, extra, opts){
|
||||
const riskMark = n.status && n.status.key === 'highrisk'
|
||||
? `<span class="risk" aria-hidden="true" title="${attr(t('riskTooltip'))}">⚠︎</span>`
|
||||
: '';
|
||||
const inner = esc(n.label) +
|
||||
/* Falt-Zeichen (SPEC §9/D38): ▾ offen, „▸ n" eingeklappt — das Klickziel
|
||||
fürs Umklappen (der einfache Klick auf den Knoten bleibt der Link, §6).
|
||||
aria-hidden: die Information steht im aria-label (a11yFolded). */
|
||||
const foldHtml = fold
|
||||
? `<span class="fold" aria-hidden="true">${fold.collapsed ? '▸ ' + fold.count : '▾'}</span>`
|
||||
: '';
|
||||
const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : '';
|
||||
const inner = foldHtml +
|
||||
esc(n.label) +
|
||||
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
|
||||
riskMark +
|
||||
sizeBadge +
|
||||
tagsHtml;
|
||||
const aria = ` aria-label="${attr(nodeAria(n, opts))}"`;
|
||||
const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`;
|
||||
const html = n.url
|
||||
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${aria}${title}>${inner}</a>`
|
||||
: `<div class="${cls}" tabindex="0"${lineAttr}${aria}${title}>${inner}</div>`;
|
||||
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${aria}${expanded}${title}>${inner}</a>`
|
||||
: `<div class="${cls}" tabindex="0"${lineAttr}${aria}${expanded}${title}>${inner}</div>`;
|
||||
const ghostTip = attr(t('ghostTooltip'));
|
||||
const ghost = `<div class="ghost-node" aria-label="${ghostTip}" title="${ghostTip}">${esc(t('ghost'))}</div>`;
|
||||
return html + (need ? ghost : '');
|
||||
}
|
||||
|
||||
/* Eingeklappter Teilbaum (SPEC §9/D38): Das HTML entfällt, aber die
|
||||
Warnungen des verborgenen Teils werden trotzdem gemeldet — sie sind eine
|
||||
Aussage über den TEXT, nicht über die Ansicht. Derselbe Lauf zählt die
|
||||
verborgenen Knoten für das „▸ n"-Kennzeichen. */
|
||||
function walkFolded(node, warnings, opts){
|
||||
const kids = visibleChildren(node, opts.showDiscarded);
|
||||
if(!kids.length) return 0;
|
||||
const types = new Set(kids.map(k => k.type));
|
||||
if(types.size > 1){
|
||||
warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label});
|
||||
}
|
||||
let count = kids.length;
|
||||
for(const k of kids) count += walkFolded(k, warnings, opts);
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Ein Knoten samt <li> und (sofern nicht eingeklappt) seiner Kinder. */
|
||||
function itemHtml(n, extra, warnings, opts){
|
||||
const vk = visibleChildren(n, opts.showDiscarded);
|
||||
const canFold = vk.length > 0;
|
||||
const collapsed = canFold && !!(opts.collapsedSet && opts.collapsedSet.has(n));
|
||||
const fold = canFold
|
||||
? {collapsed, count: collapsed ? walkFolded(n, warnings, opts) : 0}
|
||||
: null;
|
||||
/* `opt` auch am <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird
|
||||
für optionale Knoten gestrichelt (D29). Eingeklappt ist der Knoten ein
|
||||
Blatt — kein has-*-Layout, keine Kinderliste. */
|
||||
const liCls = liClass(collapsed ? [] : vk, opts, n.optional);
|
||||
return `<li${liCls}>` +
|
||||
nodeHtml(n, extra, opts, fold) +
|
||||
(collapsed ? '' : renderChildren(n, warnings, opts)) +
|
||||
`</li>`;
|
||||
}
|
||||
|
||||
function renderChildren(node, warnings, opts){
|
||||
const kids = visibleChildren(node, opts.showDiscarded);
|
||||
if(!kids.length) return '';
|
||||
@@ -122,16 +169,7 @@ function renderChildren(node, warnings, opts){
|
||||
die Klasse `or` (alle Modi, Export-Routing); `xor` ergänzt nur die
|
||||
„1"-Plakette an der Sammelleiste (D35). */
|
||||
const ulCls = gate === 'xor' ? 'or xor' : gate;
|
||||
const items = kids.map(k => {
|
||||
const vk = visibleChildren(k, opts.showDiscarded);
|
||||
/* `opt` auch am <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird
|
||||
für optionale Knoten gestrichelt (D29). */
|
||||
const liCls = liClass(vk, opts, k.optional);
|
||||
return `<li${liCls}>` +
|
||||
nodeHtml(k, extraCls(k, opts), opts) +
|
||||
renderChildren(k, warnings, opts) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
const items = kids.map(k => itemHtml(k, extraCls(k, opts), warnings, opts)).join('');
|
||||
return `<ul class="${ulCls}">${items}</ul>`;
|
||||
}
|
||||
|
||||
@@ -140,13 +178,8 @@ function renderChildren(node, warnings, opts){
|
||||
Leere Wurzelliste ⇒ leerer String. */
|
||||
export function renderTreeHtml(roots, opts){
|
||||
const warnings = [];
|
||||
const html = roots.map(root => {
|
||||
const vk = visibleChildren(root, opts.showDiscarded);
|
||||
const liCls = liClass(vk, opts, root.optional);
|
||||
return `<li${liCls}>` +
|
||||
nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) +
|
||||
renderChildren(root, warnings, opts) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
const html = roots.map(root =>
|
||||
itemHtml(root, ('root-node ' + extraCls(root, opts)).trim(), warnings, opts)
|
||||
).join('');
|
||||
return { html, warnings };
|
||||
}
|
||||
|
||||
@@ -549,6 +549,18 @@
|
||||
}
|
||||
a.node{text-decoration:none;cursor:pointer}
|
||||
a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)}
|
||||
/* Falt-Zeichen (SPEC §9, D38): ▾ offen / „▸ n" eingeklappt, Klickziel fürs
|
||||
Umklappen. Gedeckt in Grau — es ist Bedienelement, keine Statusaussage;
|
||||
beim Zeigen färbt es sich zur Tinte. */
|
||||
.node .fold{
|
||||
cursor:pointer;color:var(--muted);
|
||||
font-size:.72em;margin-right:6px;
|
||||
user-select:none;-webkit-user-select:none;
|
||||
}
|
||||
.node .fold:hover{color:var(--line)}
|
||||
/* Der dunkle Wurzelknoten braucht ein helleres Zeichen als das Grau. */
|
||||
.root-node .fold{color:rgba(255,255,255,.75)}
|
||||
.root-node .fold:hover{color:#fff}
|
||||
/* Sichtbarer Tastatur-Fokus (Knoten sind fokussierbar: Fokusreihenfolge =
|
||||
Lese-/Dokumentreihenfolge; Screenreader liest den aria-label). */
|
||||
.node:focus-visible{outline:2px solid var(--or);outline-offset:2px;box-shadow:0 2px 8px rgba(36,52,71,.22)}
|
||||
@@ -1040,5 +1052,8 @@
|
||||
/* 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) */
|
||||
/* Faltung (D38): das ▾ offener Knoten ist Bedienelement — nicht drucken;
|
||||
das „▸ n" eingeklappter Knoten ist Kennzeichnung und bleibt. */
|
||||
.node:not(.folded) .fold{display:none!important}
|
||||
@page{margin:12mm}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user