diff --git a/CHANGELOG.md b/CHANGELOG.md
index 308fd91..0f35080 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 9005d55..8e26083 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ layers and **40 GPU effects**, an **OBS overlay**, **Spout / Syphon**, **project
[](https://github.com/CaYatur/SoundVisualizer/releases)
[](LICENSE)
[](#download)
-[](#tests)
+[](#tests)
[](https://www.electronjs.org/)
@@ -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 579544a..445add4 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -18,7 +18,7 @@ destesi**, ve **MCP üzerinden yapay zekâ kontrolü**.
[](https://github.com/CaYatur/SoundVisualizer/releases)
[](LICENSE)
[](#hızlı-başlangıç)
-[](#testler)
+[](#testler)
[](https://www.electronjs.org/)
@@ -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 b6d3228..3b37814 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 91e0489..ff3ef62 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 f2689a8..e20bdcb 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 6593bb2..79a51b6 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 65a8f52..f245906 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 6d38f27..e3f6270 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 50ddf42..dd38f0f 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 bb3a5ff..d70645e 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 27feb95..f05e4de 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 0000000..c8528e1
--- /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/);
+});