From 15699c321ce104f8b95446acae9a72b8ef16b368 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Tue, 21 Jul 2026 21:08:41 +0200 Subject: [PATCH] =?UTF-8?q?frontend:=20Barrierefreiheit=20=E2=80=94=20aria?= =?UTF-8?q?-Labels,=20Fokusreihenfolge,=20Live-Region?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASKS Phase 2. Jeder Diagramm-Knoten bekommt in render.js (nodeAria) einen sprechenden, lokalisierten aria-label: Label + Status + Aufwand (inkl. „(angenommen)" beim impliziten M) + Zuständige + Link. Die rein visuellen Badges (Größe, Tags, ↗) sind aria-hidden, damit der Screenreader nicht „M", „anna", „↗" kryptisch doppelt vorliest. Neue a11y*-i18n-Keys in allen 9 Sprachen. Alle Knoten sind fokussierbar (tabindex=0 bzw. der Link) in Dokument-/Lese- reihenfolge; sichtbarer :focus-visible-Rahmen (Petrol). #warn ist eine Live-Region (role=status, aria-live=polite) — neue Warnungen werden angesagt. Verifiziert: aria-label folgt dem Sprachwechsel (DE/EN), Badges aria-hidden, keine visuelle Regression, keine Konsolenfehler. Renderer-Snapshots aktualisiert (29 Tests grün). SPEC §9 (Barrierefreiheit) + CLAUDE ergänzt. Co-Authored-By: Claude Opus 4.8 --- docs/SPEC.md | 13 +++++++ docs/TASKS.md | 7 +++- frontend/CLAUDE.md | 6 +++ frontend/index.html | 2 +- frontend/src/app.js | 9 +++++ frontend/src/render.js | 38 ++++++++++++++----- frontend/src/style.css | 3 ++ .../tests/__snapshots__/render.test.js.snap | 6 +-- frontend/tests/render.test.js | 2 +- 9 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 0472514..1e8b757 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -222,6 +222,19 @@ Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen - Es wird genau die **sichtbare** Struktur exportiert (der „verworfene einblenden“-Filter wirkt auch hier). +### Barrierefreiheit +Die visuell codierten Knoten-Eigenschaften werden für Screenreader in einem +sprechenden **`aria-label`** je Knoten zusammengefasst — Label, Status, Aufwand +(inkl. „(angenommen)“ beim impliziten M), Zuständige und ob der Knoten verlinkt +ist —, alles in der aktuellen UI-Sprache. Die rein visuellen Beiwerke +(Größen-Badge, Tags, ↗-Pfeil) sind `aria-hidden`, damit sie nicht kryptisch +doppelt vorgelesen werden. **Alle** Knoten sind fokussierbar (`tabindex="0"` +bzw. der Link selbst); die **Fokusreihenfolge entspricht der Dokument-/ +Lesereihenfolge** (Wurzeln, dann Kinder in Tiefe). Ein sichtbarer +`:focus-visible`-Rahmen (Petrol) zeigt den Tastaturfokus. Der Warnungsbereich +ist eine **Live-Region** (`role="status"`, `aria-live="polite"`), sodass neue +Warnungen (z. B. gemischte Gates) angesagt werden. + ## 10. Beispiel (kanonisch) ``` diff --git a/docs/TASKS.md b/docs/TASKS.md index d58e269..e8a6263 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -31,7 +31,12 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen. Phase 2 (`unknownStatus`). `tests/warnings.test.js`. ## Phase 2 — Qualität -- [ ] Barrierefreiheit: Fokusreihenfolge, aria-Labels für Status/Größe/Tags. +- [x] Barrierefreiheit: Fokusreihenfolge, aria-Labels für Status/Größe/Tags. + → je Knoten ein sprechender `aria-label` (Label+Status+Aufwand+Zuständige+ + Link, lokalisiert, neue `a11y*`-Keys in allen 9 Sprachen); visuelle Badges + `aria-hidden`; Knoten `tabindex="0"` (Fokus = Lesereihenfolge) mit + `:focus-visible`-Rahmen; `#warn` als Live-Region (`role=status`, + `aria-live=polite`). Snapshots aktualisiert. - [ ] Druck-Stylesheet (Diagramm ohne Editor-Panel). - [ ] Fehlertolerantes Parsen weiter ausbauen (unbekannte Statuszeichen melden). diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 90b57fe..c1a91ac 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -58,6 +58,12 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Umbauten dieses Nesting mitprüfen. - „verworfen" ist per Default ausgeblendet; Filterlogik steckt in `visibleChildren()` und muss bei Renderer-Umbauten erhalten bleiben. +- Barrierefreiheit (SPEC §9): `render.js` baut je Knoten einen sprechenden + `aria-label` (Label + Status + Aufwand + Zuständige + Link, lokalisiert via + `t`); die visuellen Badges (Größe, Tags, ↗) sind `aria-hidden`. Neue + Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys + (`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"` + (Fokus = Lesereihenfolge), `#warn` ist eine Live-Region. - Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang` (Sprache), `werkbaum-src` (Editortext), `werkbaum-ui` (JSON: Modus, verworfene, günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, diff --git a/frontend/index.html b/frontend/index.html index 952296f..6c81e5d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -139,7 +139,7 @@
    -
    +
    diff --git a/frontend/src/app.js b/frontend/src/app.js index 44c8f67..045a0bd 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -561,6 +561,7 @@ const I18N = { mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.", st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_fertig:"fertig", st_prod:"in Produktion", st_verworfen:"verworfen", + a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", 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_root:"Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.", @@ -598,6 +599,7 @@ const I18N = { mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.", st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_fertig:"done", st_prod:"in production", st_verworfen:"discarded", + a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", 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_root:"Line without a marker = root node. Siblings should share the same marker.", @@ -635,6 +637,7 @@ const I18N = { mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.", st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_fertig:"terminado", st_prod:"en producción", st_verworfen:"descartado", + a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", 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_root:"Línea sin marcador = nodo raíz. Los hermanos deberían llevar el mismo marcador.", @@ -672,6 +675,7 @@ const I18N = { mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.", st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_fertig:"terminé", st_prod:"en production", st_verworfen:"abandonné", + a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", 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_root:"Ligne sans marqueur = nœud racine. Les frères devraient porter le même marqueur.", @@ -709,6 +713,7 @@ const I18N = { mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.", st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_fertig:"gotowe", st_prod:"w produkcji", st_verworfen:"odrzucone", + a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_root:"Wiersz bez znacznika = węzeł główny. Rodzeństwo powinno mieć ten sam znacznik.", @@ -746,6 +751,7 @@ const I18N = { mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_fertig:"готово", st_prod:"в эксплуатации", st_verworfen:"отклонено", + a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_root:"Строка без маркера = корневой узел. У соседних узлов должен быть одинаковый маркер.", @@ -783,6 +789,7 @@ const I18N = { mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_verworfen:"अस्वीकृत", + a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। सहोदर नोड्स का मार्कर समान होना चाहिए।", @@ -820,6 +827,7 @@ const I18N = { mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_fertig:"已完成", st_prod:"已上线", st_verworfen:"已放弃", + a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_root:"无标记的行 = 根节点。同级应使用相同的标记。", @@ -857,6 +865,7 @@ const I18N = { mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_fertig:"完了", st_prod:"本番稼働", st_verworfen:"破棄", + a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_root:"マーカーのない行 = ルートノード。兄弟は同じマーカーを使うべきです。", diff --git a/frontend/src/render.js b/frontend/src/render.js index b4f357f..d551f54 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -16,29 +16,47 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; export function esc(s){ return s.replace(/&/g,'&').replace(//g,'>'); } +/* Escaping für Attributwerte (zusätzlich " -> "). */ +function attr(s){ return esc(String(s)).replace(/"/g,'"'); } + +/* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige + + 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){ + const { t, cheapPath } = opts; + const parts = [n.label]; + 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')); + if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); + if(n.url) parts.push(t('a11yLink')); + return parts.join(', '); +} function nodeHtml(n, extra, opts){ const { t, cheapPath } = opts; const need = needsBreakdown(n); const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : ''] .filter(Boolean).join(' '); - const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : ''; + const title = n.status ? ` title="${attr(t('st_' + n.status.key))}"` : ''; const tagsHtml = n.tags && n.tags.length - ? `${n.tags.map(tag => `${esc(tag)}`).join('')}` + ? `` : ''; - const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"'); + const implicitTip = attr(t('implicitSizeTooltip')); const sizeBadge = n.size - ? `${n.size}` - : (cheapPath ? `M` : ''); + ? `` + : (cheapPath ? `` : ''); const inner = esc(n.label) + - (n.url ? '' : '') + + (n.url ? '' : '') + sizeBadge + tagsHtml; + const aria = ` aria-label="${attr(nodeAria(n, opts))}"`; const html = n.url - ? `${inner}` - : `
    ${inner}
    `; - const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"'); - const ghost = `
    ${esc(t('ghost'))}
    `; + ? `${inner}` + : `
    ${inner}
    `; + const ghostTip = attr(t('ghostTooltip')); + const ghost = `
    ${esc(t('ghost'))}
    `; return html + (need ? ghost : ''); } diff --git a/frontend/src/style.css b/frontend/src/style.css index 74f1fef..b51a112 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -324,6 +324,9 @@ } a.node{text-decoration:none;cursor:pointer} a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)} + /* 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)} .ext{font-size:.72em;margin-left:5px;opacity:.55} .size{ position:absolute;top:-9px;right:-9px; diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap index dd55ed4..9e57305 100644 --- a/frontend/tests/__snapshots__/render.test.js.snap +++ b/frontend/tests/__snapshots__/render.test.js.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-RelaunchXL
    • KonzeptionM
      • ZielgruppenanalyseS
      • SitemapXS
    • UmsetzungXL
    • HostingM
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
    • Hosting
      • Cloud
      • On-Premise
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-RelaunchXL
    • KonzeptionM
      • ZielgruppenanalyseS
      • SitemapXS
    • UmsetzungXL
    • HostingM
      • CloudM
      • On-PremiseM
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
    • Hosting
      • Cloud
      • On-Premise
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-RelaunchXL
    • KonzeptionM
      • ZielgruppenanalyseS
      • SitemapXS
    • UmsetzungXL
      • FrontendSanna
      • BackendLbencarla
        ghost
      • CMS-AnbindungM
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • HostingM
      • Cloud
      • On-Premise
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
    • Konzeption
      • Zielgruppenanalyse
      • Sitemap
    • Umsetzung
      • Frontend
      • Backend
        ghost
      • CMS-Anbindung
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • Hosting
      • Cloud
      • On-Premise
  • "`; diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js index dcf67dc..576e3e5 100644 --- a/frontend/tests/render.test.js +++ b/frontend/tests/render.test.js @@ -69,7 +69,7 @@ describe('renderTreeHtml — „Untergliederung fehlt" (Geister-Knoten, SPEC §5 const {html} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()}); expect(count(html, 'ghost-node')).toBe(1); expect(html).toContain('title="ghostTooltip"'); - expect(html).toMatchInlineSnapshot(`"
  • Großes PaketL
    ghost
  • "`); + expect(html).toMatchInlineSnapshot(`"
  • Großes Paket
    ghost
  • "`); }); it('verworfenes M+ löst die Regel nicht aus', () => {