From 99a4ad7b33fa8fd383e13c4b111a66e8d75b89b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 20 Oct 2026 06:56:06 +0300 Subject: [PATCH] Panel accessibility: every control has a name, keyboard focus is visible; self-test guards both (#777) Chromium's accessibility tree showed 139 unnamed controls (54 sliders, 53 switches, 21 lists, number and text boxes): each .ctrl had a label.lbl that was not tied to its control. A MutationObserver (direct, not rAF - rAF never runs in a background window) moves the label's text into span.lbl-t and points unnamed controls in the row at it with aria-labelledby, so the reset button stays out of the name and the translated text node is the name. Rows without a label use the preceding .group-label. Own names for the crossfader, Smart Silence Filter, MCP port and the timeline BPM/beats/snapping controls (EN strings added). Tabbing through the panel reached 606 elements; 68 showed no focus: range inputs (outline none, thumb changed only on hover), the export/update selects (an inline border overrode select:focus; now class sel-ctrl) and the lighting picker. Focus-visible styles added. Self-test: reads the accessibility tree per category with folds open and Tabs through every category; fails on unnamed controls or unchanged focus. Isolated profile: 872 named controls, 595 focusable elements, PASS. Tests 3238. Co-authored-by: caganturgut <271973834+caganturgut@users.noreply.github.com> --- CHANGELOG.md | 20 ++++++++++ README.md | 4 +- README.tr.md | 4 +- ROADMAP.md | 2 +- src/admin/admin.css | 8 ++++ src/admin/admin.js | 63 ++++++++++++++++++++++++++++- src/admin/analysis-panel.js | 2 + src/admin/group-panel.js | 2 + src/admin/mcp-panel.js | 2 +- src/admin/timeline-panel.js | 19 +++++---- src/main/main.js | 80 +++++++++++++++++++++++++++++++++++++ src/shared/i18n.js | 3 ++ tests/a11y-panel.test.js | 64 +++++++++++++++++++++++++++++ 13 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 tests/a11y-panel.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 308fd915..0f350803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,26 @@ updater sees v3.1.5 as the latest release and does not offer it to the beta as a - **The selected scene's info line** was 3.9:1 in Light and 4.1–4.4:1 in Midnight and CaYaDev. On the selected row's accent tint it now uses the secondary text colour, and the contrast rules cover body and secondary text on selected rows. +- **Screen readers and keyboards [777].** + - Chromium's accessibility tree showed 139 controls without a name: 54 sliders, 53 switches, + 21 lists and some number and text boxes. A screen reader said only "slider". The setting's + label sat beside the control but was not tied to it. + - Each row's label text is now linked to its controls. The "reset to default" button inside the + label stays out of the name. The name follows the language, because the linked text is the + same node the translation updates. + - A few controls with their own layout got a name of their own: the A/B crossfader, the Smart + Silence Filter switch, the MCP port, and the timeline's BPM, beats-per-bar and snapping + controls. + - Pressing Tab through the panel reached 606 elements. On 68 of them focus could not be seen: + - sliders, which removed the outline and changed only on mouse hover; + - the export and update lists, where an inline border overrode the focus style; + - the lighting picker. + + All of them now show focus. + - The self-test reads the accessibility tree in every category with folds open and walks + every category with real Tab presses. It fails on any control without a name or any + focusable element whose look does not change on focus. In the self-test's panel that is + 872 named controls and 595 focusable elements. - **The self-test scans a populated panel [777].** - The self-test's settings had no saved scenes, no clip cells with a preview and no slot without a source. So the untranslated-text scan and the contrast audit never drew those screens. The diff --git a/README.md b/README.md index 9005d55d..8e26083f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ layers and **40 GPU effects**, an **OBS overlay**, **Spout / Syphon**, **project [![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-e11d2a.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#download) -[![Tests](https://img.shields.io/badge/tests-3234%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3238%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -1746,7 +1746,7 @@ npm test npm start -- --smoke ``` -**3234 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3238 unit tests, all passing.** They are written to check answers, not to exercise lines: - **Formulas** are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the torus tube radius, Chladni's m↔n antisymmetry, every attractor staying diff --git a/README.tr.md b/README.tr.md index 579544a4..445add4c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -18,7 +18,7 @@ destesi**, ve **MCP üzerinden yapay zekâ kontrolü**. [![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases) [![Lisans: MIT](https://img.shields.io/badge/Lisans-MIT-e11d2a.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#hızlı-başlangıç) -[![Test](https://img.shields.io/badge/test-3234%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3238%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -1749,7 +1749,7 @@ npm test npm start -- --smoke ``` -**3234 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3238 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: - **Formüller**, tanımlarından elle türetilmiş değerlerle sınanıyor — Viviani eğrisinin küre üzerinde kalması, simidin boru yarıçapı, Chladni'nin m↔n antisimetrisi, her çekicinin sınırlı kalması ve görüş diff --git a/ROADMAP.md b/ROADMAP.md index b6d32289..3b378140 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,7 +133,7 @@ npm test npm start -- --smoke ``` -- **3234 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3238 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; 105 came with v3.1.1; 163 came with v3.1.2; 157 came with v3.1.3 — 1128 at that tag — 469 more with v3.1.4, most of them from the MilkDrop work — 1597 at that tag — 1402 more with v3.1.5 — 2999 at that tag — and 27 diff --git a/src/admin/admin.css b/src/admin/admin.css index 91e0489e..ff3ef62a 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -508,6 +508,8 @@ body { position: relative; font-size: 12px; color: var(--text); display: flex; align-items: center; gap: 6px; padding-right: 20px; } +/* Erişilebilir adın metni (#777): etiketin iç düzeni değişmesin */ +.ctrl label.lbl > .lbl-t { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } .ctrl .val { font-size: 11px; color: var(--muted); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; @@ -543,6 +545,10 @@ input[type=range]::-webkit-slider-thumb { transition: box-shadow .15s; } input[type=range]::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgb(var(--accent-rgb) / .24); } +/* Klavye odağı (#777): `outline: none` vardı ve başparmak yalnız fareyle + değişiyordu; Tab ile gelen kaydırıcı hiç belli olmuyordu. */ +input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; } +input[type=range]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px rgb(var(--accent-rgb) / .35); } /* toggle */ .switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; } @@ -596,6 +602,7 @@ select { } select:focus { border-color: var(--accent); } .ctrl .row + select { margin-top: 7px; } +select.sel-ctrl { font-size: 13px; margin-top: 6px; } /* Etiketle AYNI satırdaki liste (#622). Genel `select` kuralı genişliği %100 ve üst boşluğu 7 px veriyordu: liste satırı ezip etiketi iki satıra kırıyor ("Resim / Kaynağı") ve etiketten 7 px aşağıda duruyordu. Artık @@ -1050,6 +1057,7 @@ kbd { background: var(--card2); border: 1px solid var(--line); border-radius: 5p background: rgb(var(--fg-rgb) / .03); color: var(--muted2); font-size: 10.5px; line-height: 1.5; } .lighting-select-wrap { position: relative; margin-top: 7px; outline: none; } +.lighting-select-wrap:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); } .lighting-select-button { width: 100%; min-height: 40px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm); diff --git a/src/admin/admin.js b/src/admin/admin.js index f2689a88..e20bdcb5 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -280,6 +280,64 @@ hidden: 1, required: 1, open: 1, autofocus: 1, novalidate: 1, }; + /* ERİŞİLEBİLİR ADLAR (#777). Ayar satırı `.ctrl` içinde bir `label.lbl` + ve bir denetim, ama etiket denetime bağlı değildi: Chromium'un + erişilebilirlik ağacında 139 denetimin (54 kaydırıcı, 53 anahtar, 21 + liste…) adı boştu ve ekran okuyucu yalnız "kaydırıcı" diyordu. Etiketin + YALNIZ METNİ bir span'e alınıyor (içindeki "varsayılana döndür" düğmesi + adın parçası olmasın) ve aynı satırdaki adsız denetimler ona + `aria-labelledby` ile bağlanıyor. Metin düğümü taşınıyor, değişmiyor: + çeviri onu yine buluyor ve ad dil değişince kendiliğinden güncel. + Etiketi olmayan satırda grubun başlığı (`.group-label`) kullanılıyor. + Paneller sonradan da çizildiği için bir gözlemci çalıştırıyor. */ + let labelSeq = 0; + function hasName(c) { + if (c.getAttribute('aria-label') || c.getAttribute('aria-labelledby') || c.getAttribute('title')) return true; + const ls = c.labels; + if (ls) for (let i = 0; i < ls.length; i++) if (ls[i].textContent.trim()) return true; + return false; + } + function labelTextOf(lbl) { + let t = lbl.querySelector(':scope > .lbl-t'); + if (t) return t; + const kids = Array.from(lbl.childNodes).filter((n) => !(n.nodeType === 1 && /^(BUTTON|INPUT|SELECT|TEXTAREA)$/.test(n.tagName))); + if (!kids.some((n) => (n.textContent || '').trim())) return null; + t = document.createElement('span'); + t.className = 'lbl-t'; + lbl.insertBefore(t, kids[0]); + kids.forEach((n) => t.appendChild(n)); + return t; + } + function linkLabels(root) { + root.querySelectorAll('.ctrl').forEach((c) => { + const ctls = Array.from(c.querySelectorAll('input:not([type=hidden]), select, textarea')) + .filter((x) => x.closest('.ctrl') === c && !hasName(x)); + if (!ctls.length) return; + const lbl = c.querySelector(':scope > label.lbl, :scope > .row > label.lbl'); + let t = lbl ? labelTextOf(lbl) : null; + if (!t) { + const prev = c.previousElementSibling; + if (prev && prev.classList.contains('group-label') && prev.textContent.trim()) t = prev; + } + if (!t) return; + if (!t.id) t.id = 'svlbl-' + (++labelSeq); + ctls.forEach((x) => x.setAttribute('aria-labelledby', t.id)); + }); + } + /* Gözlemci geri çağrısı zaten toplu (mikro görev). requestAnimationFrame + KULLANILMIYOR: arka plandaki ya da küçültülmüş pencerede hiç çalışmıyor + ve adlar pencere görünene kadar boş kalıyordu. Bağlayıcının kendi + değişiklikleri gözlemciyi bir kez daha tetikler; ikinci tur bir şey + bulamaz ve durur. */ + if (typeof MutationObserver === 'function' && typeof document !== 'undefined' && document.body) { + let busy = false; + new MutationObserver(() => { + if (busy) return; + busy = true; + try { linkLabels(document); } finally { busy = false; } + }).observe(document.body, { childList: true, subtree: true }); + } + function el(tag, props, kids) { const e = document.createElement(tag); if (props) { @@ -889,8 +947,9 @@ opt.selected = true; sel.appendChild(opt); } - sel.style.width = '100%'; - sel.style.cssText += 'background:var(--card2);color:var(--text);border:1px solid var(--line);border-radius:9px;padding:8px 10px;font-size:13px;margin-top:6px;outline:none;'; + /* Görünüş sınıfta: satır içi kenarlık `select:focus`u eziyor ve + klavye odağı görünmüyordu (#777). */ + sel.classList.add('sel-ctrl'); return el('div', { class: 'ctrl' }, [el('label', { class: 'lbl', text: def.label }), sel]); } diff --git a/src/admin/analysis-panel.js b/src/admin/analysis-panel.js index 6593bb2f..79a51b6e 100644 --- a/src/admin/analysis-panel.js +++ b/src/admin/analysis-panel.js @@ -63,6 +63,8 @@ const guardSwitch = el('input', { type: 'checkbox', 'data-path': 'audio.humGuard', + // Başlığı anahtarın yanında ayrı bir öğe (#777) + 'aria-label': 'Akıllı Sessizlik Filtresi', onchange: (e) => { const val = e.target.checked; if (P().set) P().set('audio.humGuard', val); diff --git a/src/admin/group-panel.js b/src/admin/group-panel.js index 65a8f527..f2459065 100644 --- a/src/admin/group-panel.js +++ b/src/admin/group-panel.js @@ -48,6 +48,8 @@ const slider = el('input', { type: 'range', min: 0, max: 1, step: 0.005, value: x.value == null ? 0 : x.value, class: 'xf-slider', + // Etiketi yok, iki ucunda A ve B yazıyor (#777) + 'aria-label': 'A/B Çapraz Geçiş', oninput: (e) => { x.value = parseFloat(e.target.value); val.textContent = label(x.value); diff --git a/src/admin/mcp-panel.js b/src/admin/mcp-panel.js index 6d38f27d..e3f62702 100644 --- a/src/admin/mcp-panel.js +++ b/src/admin/mcp-panel.js @@ -210,7 +210,7 @@ const busy = status.requestedPort || status.configuredPort || model.port; nodes.push(el('div', { class: 'studio-note', text: tx('mcp.port.busy').split('{port}').join(String(busy)) })); } - const portInput = el('input', { type: 'number', min: '1', max: '65535', value: String(model.port || 38471) }); + const portInput = el('input', { type: 'number', min: '1', max: '65535', value: String(model.port || 38471), 'aria-label': tx('mcp.port.label') }); nodes.push(el('div', { class: 'mcp-port' }, [ el('label', { class: 'lbl', text: tx('mcp.port.label') }), portInput, diff --git a/src/admin/timeline-panel.js b/src/admin/timeline-panel.js index 50ddf421..dd38f0fe 100644 --- a/src/admin/timeline-panel.js +++ b/src/admin/timeline-panel.js @@ -1799,18 +1799,23 @@ tempo değişimleri sessizce siliniyordu. Değişimler cetvelde (♩). */ group([ el('span', { class: 'tl-lbl', text: 'BPM', title: 'Oynatma kafasındaki tempo. Tempo değişimleri cetvelde (♩)' }), - bpmIn, + // Araç çubuğunda etiketler ayrı öğe: denetimlerin kendi adı (#777) + (bpmIn.setAttribute('aria-label', 'Oynatma kafasındaki tempo (BPM)'), bpmIn), el('span', { class: 'tl-lbl', text: '/' }), - bpbIn, + (bpbIn.setAttribute('aria-label', 'Ölçüdeki vuruş sayısı'), bpbIn), btn(['plus', '♩'], 'Oynatma kafasına tempo değişimi', () => addTempoAtHead()), ]), group([ el('span', { class: 'tl-lbl', icon: 'magnet', title: 'Yakalama. Sürüklerken Alt tuşu yakalamayı geçici olarak kapatır' }), - select( - [['off', 'Kapalı'], ['bar', 'Ölçü'], ['beat', 'Vuruş'], ['half', 'Yarım Vuruş'], ['quarter', 'Çeyrek Vuruş'], ['frame', 'Kare']], - cfg.snap, - (v) => { cfg.snap = v; p.push(true); } - ), + (() => { + const s = select( + [['off', 'Kapalı'], ['bar', 'Ölçü'], ['beat', 'Vuruş'], ['half', 'Yarım Vuruş'], ['quarter', 'Çeyrek Vuruş'], ['frame', 'Kare']], + cfg.snap, + (v) => { cfg.snap = v; p.push(true); } + ); + s.setAttribute('aria-label', 'Yakalama'); + return s; + })(), ]), group([loopBtn, followBtn]), group([ diff --git a/src/main/main.js b/src/main/main.js index bb3a5ffe..d70645ef 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -7274,6 +7274,86 @@ async function runSmoke() { } catch (e) { errors.push('ui themes: ' + (e && e.message ? e.message : e)); } + /* --- Erişilebilirlik (#777): adsız denetim ve görünmeyen klavye odağı --- + Ad Chromium'un kendi erişilebilirlik ağacından okunur (ekran okuyucunun + duyduğu ad). Odak gerçek Tab tuşlarıyla dolaşılır; odaklanan öğenin + görünüşü odaksız anlık görüntüsüyle aynıysa odak görünmüyordur. + İlk ölçümde 139 adsız denetim ve 68 görünmeyen odak vardı. */ + try { + const dbg = awc4.debugger; + let attached = false; + try { dbg.attach('1.3'); attached = true; } catch (e) { console.log('[SMOKE] erişilebilirlik: hata ayıklayıcı bağlanamadı, atlandı'); } + if (attached) { + await dbg.sendCommand('Accessibility.enable'); + await dbg.sendCommand('DOM.enable'); + const ROLES = new Set(['button', 'link', 'checkbox', 'radio', 'switch', 'tab', 'combobox', 'textbox', 'slider', + 'spinbutton', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'listbox', 'searchbox', 'treeitem']); + const cats = await awc4.executeJavaScript("Array.from(document.querySelectorAll('[data-cat]')).map(function (b) { return b.dataset.cat; })"); + const unnamed = new Map(); + const hidden = new Map(); + let named = 0; + let tabbed = 0; + const tab = async () => { + await dbg.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }); + await dbg.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }); + }; + for (const c of cats) { + await awc4.executeJavaScript(`(async function () { + var b = document.querySelector('[data-cat="${c}"]'); if (b) b.click(); + await new Promise(function (r) { setTimeout(r, 400); }); + var h = document.querySelectorAll('#sections .fold-head:not(.open)'); + for (var i = 0; i < h.length; i++) h[i].click(); + await new Promise(function (r) { setTimeout(r, 300); }); + var P = ['outline-style','outline-width','outline-color','box-shadow','border-top-color','border-bottom-color','background-color','color']; + window.__a11ySnap = function (e) { var s = getComputedStyle(e); return P.map(function (p) { return s.getPropertyValue(p); }).join('|'); }; + window.__a11yBase = new Map(); + document.querySelectorAll('#sections button, #sections a[href], #sections input, #sections select, #sections textarea, #sections [tabindex]').forEach(function (e) { window.__a11yBase.set(e, window.__a11ySnap(e)); }); + if (document.activeElement) document.activeElement.blur(); + return 1; + })()`); + const tree = (await dbg.sendCommand('Accessibility.getFullAXTree')).nodes || []; + for (const n of tree) { + if (n.ignored || !n.role || !ROLES.has(n.role.value)) continue; + if (String((n.name && n.name.value) || '').trim()) { named++; continue; } + let d = n.role.value; + try { + const nd = (await dbg.sendCommand('DOM.describeNode', { backendNodeId: n.backendDOMNodeId })).node; + const at = nd.attributes || []; + const cls = at[at.indexOf('class') + 1]; + d += ' ' + nd.nodeName.toLowerCase() + (at.indexOf('class') >= 0 && cls ? '.' + cls.split(/\s+/).slice(0, 2).join('.') : ''); + } catch (e) { /* düğüm gitti */ } + unnamed.set(d, (unnamed.get(d) || 0) + 1); + } + const seen = new Set(); + for (let k = 0; k < 400; k++) { + await tab(); + const r = await awc4.executeJavaScript(`(function () { + var a = document.activeElement; if (!a || a === document.body || !a.closest('#sections')) return null; + if (a.getAnimations) a.getAnimations().forEach(function (x) { try { x.finish(); } catch (e) { /* sonsuz */ } }); + var base = window.__a11yBase.get(a); + if (!a.dataset.a11yk) a.dataset.a11yk = String(Math.random()).slice(2); + var d = a.tagName.toLowerCase() + (typeof a.className === 'string' && a.className ? '.' + a.className.trim().split(/\\s+/).slice(0, 2).join('.') : ''); + return { k: a.dataset.a11yk, d: d, same: base !== undefined && base === window.__a11ySnap(a) }; + })()`); + if (!r) { if (seen.size > 5) break; continue; } + if (seen.has(r.k)) break; + seen.add(r.k); + tabbed++; + if (r.same) hidden.set(r.d, (hidden.get(r.d) || 0) + 1); + } + } + try { dbg.detach(); } catch (e) { /* zaten ayrıldı */ } + console.log('[SMOKE] erişilebilirlik: ' + named + ' adlı denetim, ' + tabbed + ' odaklanan öğe'); + unnamed.forEach((n, d) => console.log('[SMOKE] ~ adsız ' + n + ' × ' + d)); + hidden.forEach((n, d) => console.log('[SMOKE] ~ odak görünmüyor ' + n + ' × ' + d)); + if (unnamed.size) errors.push('a11y: ' + [...unnamed.values()].reduce((a, b) => a + b, 0) + ' control(s) without an accessible name'); + if (hidden.size) errors.push('a11y: ' + [...hidden.values()].reduce((a, b) => a + b, 0) + ' focusable element(s) without a visible focus change'); + if (named < 500) errors.push('a11y: only ' + named + ' named controls seen'); + if (tabbed < 300) errors.push('a11y: only ' + tabbed + ' elements reached with Tab'); + } + } catch (e) { + errors.push('a11y: ' + (e && e.message ? e.message : e)); + } // Dolu panel eski hâline (sahneler, yuvalar, seçili sahne) try { await awc4.executeJavaScript(`(function(){ diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 27feb95f..f05e4ded 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -335,6 +335,8 @@ 'Sonraki işaret': 'Next marker', 'Tempo (BPM)': 'Tempo (BPM)', 'Yakalama': 'Snapping', + 'Oynatma kafasındaki tempo (BPM)': 'Tempo at the playhead (BPM)', + 'Ölçüdeki vuruş sayısı': 'Beats per bar', 'Cetvel': 'Ruler', 'Süre ve Ölçü': 'Time and Bars', 'Yalnızca Süre': 'Time Only', @@ -1506,6 +1508,7 @@ // ---- Katman grupları, maskeler, bayraklar ---- 'A/B Çapraz Geçiş Etkin': 'A/B Crossfade Enabled', + 'A/B Çapraz Geçiş': 'A/B Crossfade', 'Fader': 'Fader', 'Fader Eğrisi': 'Fader Curve', 'Grup': 'Group', diff --git a/tests/a11y-panel.test.js b/tests/a11y-panel.test.js new file mode 100644 index 00000000..c8528e10 --- /dev/null +++ b/tests/a11y-panel.test.js @@ -0,0 +1,64 @@ +'use strict'; +/* Panel erişilebilirliği (#777). Chromium'un erişilebilirlik ağacında 139 + denetimin adı boştu (etiket denetime bağlı değildi) ve Tab ile gelen 68 + öğede odak görünmüyordu (kaydırıcılar, satır içi kenarlıklı listeler, + ışık seçimi). Asıl ölçüm öz testte; burada yapı korunuyor. */ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); + +test('ayar satırlarının etiketi denetime bağlanıyor', () => { + const a = read('src/admin/admin.js'); + assert.match(a, /function linkLabels\(root\)/); + assert.match(a, /x\.setAttribute\('aria-labelledby', t\.id\)/); + // Ad etiketin yalnız metni: "varsayılana döndür" düğmesi adın parçası değil + assert.match(a, /\/\^\(BUTTON\|INPUT\|SELECT\|TEXTAREA\)\$\//); + // Gözlemci rAF'a bağlı değil (arka plandaki pencerede rAF çalışmıyor) + const i = a.indexOf('function linkLabels(root)'); + const block = a.slice(i, i + 2500); + assert.match(block, /new MutationObserver/); + assert.doesNotMatch(block, /requestAnimationFrame\(/); + const css = read('src/admin/admin.css'); + assert.match(css, /\.ctrl label\.lbl > \.lbl-t \{/); +}); + +test('klavye odağı görünüyor: kaydırıcı, liste, ışık seçimi', () => { + const css = read('src/admin/admin.css'); + assert.match(css, /input\[type=range\]:focus-visible \{ outline: 2px solid var\(--accent\)/); + assert.match(css, /\.lighting-select-wrap:focus-visible \{ outline: 2px solid var\(--accent\)/); + assert.match(css, /\nselect:focus \{ border-color: var\(--accent\); \}/); + // Satır içi stil odak kuralını eziyordu: panel betikleri kenarlık/outline yazmıyor + const dir = path.join(root, 'src', 'admin'); + fs.readdirSync(dir).filter((f) => f.endsWith('.js')).forEach((f) => { + const s = fs.readFileSync(path.join(dir, f), 'utf8'); + assert.doesNotMatch(s, /cssText[^;\n]*outline\s*:\s*none/, f + ': satır içi outline:none'); + }); +}); + +test('araç çubuğundaki ve özel yerleşimli denetimlerin kendi adı var', () => { + assert.match(read('src/admin/group-panel.js'), /class: 'xf-slider',[\s\S]{0,80}'aria-label': 'A\/B Çapraz Geçiş'/); + assert.match(read('src/admin/analysis-panel.js'), /'data-path': 'audio\.humGuard',[\s\S]{0,80}'aria-label': 'Akıllı Sessizlik Filtresi'/); + assert.match(read('src/admin/mcp-panel.js'), /'aria-label': tx\('mcp\.port\.label'\)/); + const tl = read('src/admin/timeline-panel.js'); + assert.match(tl, /bpmIn\.setAttribute\('aria-label'/); + assert.match(tl, /bpbIn\.setAttribute\('aria-label'/); + const i18n = read('src/shared/i18n.js'); + ["'A/B Çapraz Geçiş': 'A/B Crossfade'", "'Oynatma kafasındaki tempo (BPM)':", "'Ölçüdeki vuruş sayısı':"].forEach((k) => assert.ok(i18n.includes(k), k)); +}); + +test('öz test adsız denetimi ve görünmeyen odağı FAIL sayıyor', () => { + const m = read('src/main/main.js'); + const i = m.indexOf('Erişilebilirlik (#777)'); + assert.ok(i > 0, 'öz test adımı'); + const step = m.slice(i, i + 7000); + assert.match(step, /Accessibility\.getFullAXTree/); + assert.match(step, /Input\.dispatchKeyEvent', \{ type: 'keyDown', key: 'Tab'/); + assert.match(step, /control\(s\) without an accessible name/); + assert.match(step, /without a visible focus change/); + assert.match(step, /named < 500/); + assert.match(step, /tabbed < 300/); +});