diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e21f63..933ebf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,12 +34,34 @@ GitHub. The full reasoning and the measurements behind each change are in [ROADM - The self-test selects every theme from the picker, walks every category, and measures the colour Chromium actually draws for each visible text against its background: about 2,600 texts per theme, none below the threshold. -- **Three CaYaDev colours moved so the default theme passes the same test.** - - The small hint text was 3.4–4.4:1 on cards and is now at least 4.5:1. - - Code comments in the shader editor were 3.3:1. - - White text on the solid red danger button was 3.9:1. - - The end of the red gradient on filled buttons is slightly deeper, so white text reads in the - middle of the gradient too. +- **CaYaDev looks the same, with these visible changes.** + - Four colours moved so the default theme passes the same test: + - the small hint text was 3.4–4.4:1 on cards and is now at least 4.5:1; + - code comments in the shader editor were 3.3:1; + - white text on the solid red danger button was 3.9:1; + - the end of the red gradient on filled buttons is slightly deeper, so white text reads in + the middle of the gradient too. + - Near-identical shades became one per role. Error text had six reds across the panel and + Studio, and now uses one. The status greens and ambers each come down to one fill shade and + one text shade. + - The Performance View had its own neutral greys. It now uses the panel's surfaces. + - The lyric editor and the MCP setup snippets use the code editor's background and text colour. + - Studio's solid danger button is the panel's red. + - The projection-mapping guide line is the panel's info blue instead of cyan. A selected tempo + marker on the timeline is drawn in the strong text colour. +- **Clip Deck cells stay readable in any slot colour.** + - A cell's background is tinted with the slot colour. On a white slot, the small info line fell + to 3.3:1 on hover. The tint is now 10%, and 12% on hover, with a stronger border showing the + hover. + - Countdown numbers use the readable amber. They were 3.5:1 in Light. + - A slot with no source was faded with opacity, so its name fell to 3.3:1. It now has a dashed + border, no tint and secondary text. + - The empty-cell "+" was drawn in the line colour, at 1.5:1. It is now secondary text. + - The Auto VJ conflict note uses the amber meant for text, which is paler in CaYaDev. + - A test checks every text in a cell, in every state, on white, black, grey and the six primary + and secondary colours. The self-test now measures clip cells too and reads `color-mix()` + backgrounds. Before, it skipped them and measured the text against the card underneath. + Only cells that show a scene preview are left out, because their text sits on a picture. - **Two labels were unreadable in every theme.** The empty side of the A/B crossfader and the "built-in" tag in Studio's list were dimmed with opacity to 2.9:1 and 3.7:1. They now use the secondary text colour. The decorative "·" separators in status lines are hidden from screen diff --git a/README.md b/README.md index e86c668..79a54f3 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-3222%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-3223%20passing-2ea043.svg)](#tests) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Download for Windows @@ -1744,7 +1744,7 @@ npm test npm start -- --smoke ``` -**3222 unit tests, all passing.** They are written to check answers, not to exercise lines: +**3223 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 bb13663..20a0ff3 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-3222%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-3223%20geçiyor-2ea043.svg)](#testler) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) Windows için indir @@ -1747,7 +1747,7 @@ npm test npm start -- --smoke ``` -**3222 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**3223 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 da29d21..a1d56ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,7 @@ npm test npm start -- --smoke ``` -- **3222 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **3223 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 @@ -3591,7 +3591,23 @@ The panel had one look. It now has seven themes and Device default, chosen under - `--accent2` is both heading text and the end of the button gradient. It cannot be both: a red that reads at 4.5:1 on a dark card cannot carry white text at 4.5:1. So the gradient end became its own token, `--accent-grad`. - **Measured before and after in CaYaDev.** - Panel screenshots of every category in both languages, taken before any CSS change, were compared pixel by pixel. - - With the four intentional changes set back to their old values, the remaining differences were live content (BPM, the app list, meters), the new picker, and one consolidated green on the lighting status. + - With the four contrast fixes set back to their old values, the remaining differences in those screenshots were live content (BPM, the app list, meters), the new picker, and one consolidated green on the lighting status. + - The screenshots did not cover every surface. Reading the old CSS afterwards found more visible CaYaDev changes: + - near-identical status shades merged: six error reds became `--danger-text`, and greens and ambers became one fill and one text shade each; + - the Performance View moved from its own neutral greys to the panel's surfaces; + - the lyric editor and MCP snippets moved to the code editor's colours; + - Studio's solid danger button became the panel's red; + - the mapping guide line became info blue; + - the selected tempo marker became the strong text colour. + + The CHANGELOG lists them. +- **Clip cells, checked afterwards.** The Performance View, the Auto VJ conflict note, the lyric editor and the MCP setup were opened in all seven themes and measured. The clip cells were not covered by the self-test, which skipped them because the background is the user's slot colour. + - Measured with them included, a white slot dropped the small info line to 3.3:1 on hover in CaYaDev. + - In Light, the countdown was 3.5:1. + - A slot with no source was faded with opacity, which brought its name to 3.3:1. + - The tint is now 10% (12% on hover), the countdowns use `--warn-text`, and the sourceless slot is dimmed with colour instead of opacity. + - A test measures every text in a cell, in every state, against nine extreme slot colours, and forbids opacity on the cell. + - The self-test also read `color-mix()` backgrounds (`color(srgb …)`) as nothing and measured the text against the card underneath. It now parses them and measures clip cells. Only cells with a scene preview are skipped. - **Tests that fail on misuse.** - Every theme defines exactly the token set. - `:root` equals the CaYaDev table. diff --git a/src/admin/admin.css b/src/admin/admin.css index 2e006b9..92049cb 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -1354,8 +1354,11 @@ input[type=color].tl-swatch.big { width: 34px; height: 24px; } .cd-plus { font-size: 18px; line-height: 1; color: var(--muted2); transition: color .12s, transform .12s; } .cd-cell.empty:hover { border-color: var(--line2); background: rgb(var(--fg-rgb) / .02); } .cd-cell.empty:hover .cd-plus { color: var(--text); transform: scale(1.15); } -.cd-cell.filled { border: 1px solid color-mix(in srgb, var(--slot) 45%, var(--line)); background: color-mix(in srgb, var(--slot) 14%, var(--card)); color: var(--text); } -.cd-cell.filled:hover { background: color-mix(in srgb, var(--slot) 22%, var(--card)); } +/* Zemin, kullanıcının yuva rengiyle boyanıyor: beyaz ya da siyah bir yuvada + da ikincil yazı 4.5:1'in altına düşmesin diye karışım en çok %12 (#770). + Üzerine gelmeyi kenar da gösteriyor. */ +.cd-cell.filled { border: 1px solid color-mix(in srgb, var(--slot) 45%, var(--line)); background: color-mix(in srgb, var(--slot) 10%, var(--card)); color: var(--text); } +.cd-cell.filled:hover { background: color-mix(in srgb, var(--slot) 12%, var(--card)); border-color: color-mix(in srgb, var(--slot) 75%, var(--line)); } .cd-color { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; } .cd-name { font-weight: 600; font-size: 11.5px; } .cd-meta { font-size: 10px; color: var(--muted); min-height: 12px; } @@ -1374,7 +1377,7 @@ input[type=color].tl-swatch.big { width: 34px; height: 24px; } .cd-cell.sel { outline: 2px solid var(--accent); outline-offset: 1px; } @media (prefers-reduced-motion: reduce) { .cd-cell.armed { animation: none; } } .cd-name, .cd-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cd-count { position: absolute; right: 5px; top: 3px; font-size: 10px; font-weight: 700; color: var(--warn); +.cd-count { position: absolute; right: 5px; top: 3px; font-size: 10px; font-weight: 700; color: var(--warn-text); font-variant-numeric: tabular-nums; } .cd-editor { border-top: 1px solid var(--line); padding-top: 8px; margin-top: 8px; } .cd-editor-head { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 6px; } @@ -1432,7 +1435,7 @@ input[type=color].tl-swatch.big { width: 34px; height: 24px; } .perf-cell.active { border-color: var(--cue-live); background: color-mix(in srgb, var(--cue-live) 22%, var(--bg2)); color: var(--text-strong); } .perf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .perf-count { - position: absolute; right: 8px; bottom: 4px; font-size: 13px; color: var(--cue-armed); + position: absolute; right: 8px; bottom: 4px; font-size: 13px; color: var(--warn-text); font-variant-numeric: tabular-nums; } .perf-hint { color: var(--muted2); font-size: 12px; text-align: center; } @@ -1449,9 +1452,10 @@ input[type=color].tl-swatch.big { width: 34px; height: 24px; } /* Çakışma uyarısı — engellemiyoruz ama görünür olmalı. */ .settings-io-note.warn { color: var(--warn-text); border-left: 2px solid var(--warn); padding-left: 8px; } -/* Kaynağı seçilmemiş yuva: dolu görünüyor ama ateşlenemez. Kesikli kenar - ve soluk metin bunu ateşlemeden önce belli eder. */ -.cd-cell.filled.noref { border-style: dashed; opacity: .65; } +/* Kaynağı seçilmemiş yuva: dolu görünüyor ama ateşlenemez. Kesikli kenar, + boyasız zemin ve ikincil yazı bunu ateşlemeden önce belli eder. Opaklık + adı 3.3:1'e düşürüyordu; adın okunması gerekiyor ki düzeltilsin (#770). */ +.cd-cell.filled.noref { border-style: dashed; background: var(--card); color: var(--muted); } /* OpenRGB aygıt listesi. Sürülemeyen aygıtlar sönük çizilir: renk kabul diff --git a/src/shared/ui-contrast-audit.js b/src/shared/ui-contrast-audit.js index 5937a4f..a322ba4 100644 --- a/src/shared/ui-contrast-audit.js +++ b/src/shared/ui-contrast-audit.js @@ -22,18 +22,26 @@ Eşik: 4.5:1; büyük yazı (24 px ya da 18.66 px kalın) 3:1. AAA temada 7 ve 4.5. */ (function () { + /* color-mix() sonucu `color(srgb 0.1 0.2 0.3 / 0.5)` olarak hesaplanır + (0–1 arası). Çözülemeyen zemin sessizce atlanıyor ve yazı alttaki + karta göre ölçülüyordu: kullanıcı renkli klip hücreleri böyle geçti. */ const parse = (s) => { - const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/.exec(String(s || '')); + const str = String(s || ''); + const alpha = (v) => { + let a = v === undefined ? 1 : v.endsWith('%') ? parseFloat(v) / 100 : parseFloat(v); + return Number.isFinite(a) ? a : 1; + }; + const c = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)(?:\s*\/\s*([\d.]+%?))?\s*\)/.exec(str); + if (c) return [+c[1] * 255, +c[2] * 255, +c[3] * 255, alpha(c[4])]; + const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/.exec(str); if (!m) return null; - let a = m[4] === undefined ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]); - if (!Number.isFinite(a)) a = 1; - return [+m[1], +m[2], +m[3], a]; + return [+m[1], +m[2], +m[3], alpha(m[4])]; }; - // Geçişin renk durakları (hesaplanan değer rgb()/rgba() biçiminde gelir) + // Geçişin renk durakları (hesaplanan değer rgb()/rgba()/color() biçiminde gelir) function gradientStops(img) { if (!img || img === 'none' || img.indexOf('gradient') < 0 || img.indexOf('url(') >= 0) return null; - const re = /rgba?\([^)]*\)/g; + const re = /(?:rgba?|color)\([^)]*\)/g; const stops = []; let m; while ((m = re.exec(img))) { @@ -91,7 +99,10 @@ const SKIP = [ '.preview-stage', '.studio-preview', '.md-thumb-box', '.logo-lib-thumb', '.img-thumb', '.scene-thumb', '.layer-preview', '.md-cell .md-fav', '.preset', '.up-swatch', - '.tpl-swatch', '.tl-swatch', '.cd-cell', '.perf-cell', '[aria-hidden="true"]', + /* Klip hücreleri ölçülür (zemin kullanıcının rengiyle boyalı ama oran + her renkte yetecek kadar düşük); yalnız sahne önizlemesi taşıyanlar + görüntü üstünde sayılır. */ + '.tpl-swatch', '.tl-swatch', '.cd-cell:has(> .cd-thumb)', '.perf-cell:has(> .perf-thumb)', '[aria-hidden="true"]', '.ui-theme-preview', ].join(','); diff --git a/tests/ui-themes.test.js b/tests/ui-themes.test.js index 364da82..eda45f7 100644 --- a/tests/ui-themes.test.js +++ b/tests/ui-themes.test.js @@ -114,6 +114,23 @@ test('karşıtlık kuralları: yazı, düğme, kod ve odak halkası kapsanıyor' assert.strictEqual(C.AAA, 7); }); +/* Kullanıcının seçebileceği uç yuva renkleri: zemin bunlarla boyandığında + da yazı okunmalı (#770). */ +const SLOT_EXTREMES = ['#ffffff', '#000000', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#808080']; + +function mixVars(value) { + const m = /^color-mix\(in srgb,\s*var\((--[\w-]+)(?:,\s*var\((--[\w-]+)\))?\)\s+([\d.]+)%,\s*var\((--[\w-]+)\)\)$/.exec(String(value).trim()); + return m ? { top: m[1], fallback: m[2] || null, p: parseFloat(m[3]) / 100, base: m[4] } : null; +} + +function mixBases(t, mx) { + const base = T.solid(t, mx.base); + const tops = mx.top === '--slot' + ? SLOT_EXTREMES.map((h) => T.parseColor(h)).concat([T.solid(t, '--slot-default')]) + : [T.solid(t, mx.top)]; + return tops.map((c) => [0, 1, 2].map((i) => c[i] * mx.p + base[i] * (1 - mx.p)).concat(1)); +} + /* Aynı kuralda yazı rengi ve düz zemin değişkenle verilmişse o ikili her temada okunmalı. Yeni bir kart kendi zeminini ve yazısını seçtiğinde bunu ayrıca listeye yazmak gerekmiyor. */ @@ -139,7 +156,9 @@ test('CSS kuralları: aynı kuraldaki yazı ve zemin her temada okunuyor', () => const g = b ? null : gradVars(bg.value); // Örtülü zemin: rgb(var(--x-rgb) / a), kartların ve rayın üstünde const tm = b || g ? null : /^rgb\(var\((--[\w-]+)\) \/ ([\d.]+)\)$/.exec(bg.value.trim()); - if (!b && !g && !tm) return; + // color-mix(in srgb, var(--a) N%, var(--b)); --slot kullanıcının rengi + const mx = b || g || tm ? null : mixVars(bg.value); + if (!b && !g && !tm && !mx) return; pairs++; T.THEMES.forEach((t) => { const min = t.aaa ? C.AAA : C.TEXT; @@ -149,6 +168,8 @@ test('CSS kuralları: aynı kuraldaki yazı ve zemin her temada okunuyor', () => const a = T.solid(t, g[0]); const c = T.solid(t, g[1]); bases.push([0, 1, 2].map((i) => (a[i] + c[i]) / 2).concat(1)); + } else if (mx) { + mixBases(t, mx).forEach((x) => bases.push(x)); } else { ['--bg2', '--card', '--card2'].forEach((s) => bases.push(T.tint(t, tm[1], parseFloat(tm[2]), s))); } @@ -163,6 +184,56 @@ test('CSS kuralları: aynı kuraldaki yazı ve zemin her temada okunuyor', () => assert.deepStrictEqual(fails, []); }); +/* Klip hücresindeki yazılar rengini kendi kuralından, zemini hücrenin + kuralından alıyor; yukarıdaki aynı-kural taraması bunları eşleştiremez. + Zemin kullanıcının yuva rengiyle boyalı: beyaz yuvada üzerine gelince + ikincil yazı 3.3:1'e, Aydınlık'ta geri sayım 3.5:1'e düşüyordu (#770). */ +test('klip hücreleri: içindeki yazılar her yuva renginde ve her durumda okunuyor', () => { + const rules = S.cssRules(read('src/admin/admin.css')); + const prop = (sel, name) => { + const hit = rules.filter((r) => r.sel.split(',').map((x) => x.trim()).includes(sel)) + .map((r) => r.decls.find((d) => d.prop === name)).filter(Boolean).pop(); + assert.ok(hit, sel + ' { ' + name + ' }'); + return hit.value; + }; + const token = (sel) => { + const m = /^var\((--[\w-]+)\)$/.exec(prop(sel, 'color')); + assert.ok(m && T.TOKENS[m[1]] === 'color', sel + ' yazı rengi bir tema değişkeni'); + return m[1]; + }; + const cases = [ + { bg: ['.cd-cell.filled', '.cd-cell.filled:hover'], fg: ['.cd-cell.filled', '.cd-meta', '.cd-count'] }, + { bg: ['.perf-cell.filled'], fg: ['.perf-cell.filled'] }, + { bg: ['.perf-cell.armed'], fg: ['.perf-cell.filled', '.perf-count'] }, + { bg: ['.perf-cell.active'], fg: ['.perf-cell.active', '.perf-count'] }, + { bg: ['.cd-cell.filled.noref'], fg: ['.cd-cell.filled.noref', '.cd-meta', '.cd-count'] }, + ]; + const fails = []; + let checked = 0; + cases.forEach((c) => c.bg.forEach((bgSel) => { + const bgValue = prop(bgSel, 'background'); + const solid = /^var\((--[\w-]+)\)$/.exec(bgValue); + const mx = solid ? { top: solid[1], p: 0, base: solid[1] } : mixVars(bgValue); + assert.ok(mx, bgSel + ' zemini düz değişken ya da color-mix(in srgb, var(--a) N%, var(--b))'); + c.fg.forEach((fgSel) => { + const fg = token(fgSel); + T.THEMES.forEach((t) => { + const min = t.aaa ? C.AAA : C.TEXT; + mixBases(t, mx).forEach((base) => { + checked++; + const ratio = T.contrast(T.solid(t, fg, base), base); + if (ratio < min) fails.push(t.id + ' ' + fgSel + ' (' + fg + ') on ' + bgSel + ' = ' + ratio.toFixed(2)); + }); + }); + }); + })); + assert.ok(checked > 300, 'ölçülen: ' + checked); + // Soluk gösterim opaklıkla değil renkle: opaklık yazıyı da soldurur + rules.filter((r) => r.sel.split(',').some((x) => /\.(cd|perf)-cell[\w.:-]*$/.test(x.trim())) && r.decls.some((d) => d.prop === 'opacity')) + .forEach((r) => fails.push(r.sel + ' { opacity }')); + assert.deepStrictEqual(fails, []); +}); + // --------------------------------------------------------------------------- // CSS koruması // --------------------------------------------------------------------------- @@ -436,6 +507,10 @@ test('ekran taraması: renk çözme, geçiş ortalaması, katman bindirme, eşik const m = A.mean([[200, 100, 50, 1], [0, 0, 0, 0]]); assert.deepStrictEqual(m.slice(0, 3), [200, 100, 50]); assert.strictEqual(m[3], 0.5); + // color-mix() sonucu color(srgb …) olarak gelir; çözülmezse zemin atlanırdı + assert.deepStrictEqual(A.parse('color(srgb 1 0.5 0)'), [255, 127.5, 0, 1]); + assert.deepStrictEqual(A.parse('color(srgb 0 0 1 / 0.25)'), [0, 0, 255, 0.25]); + assert.strictEqual(A.gradientStops('linear-gradient(color(srgb 0 0 0), rgb(1, 2, 3))').length, 2); assert.strictEqual(A.gradientStops('url(x.png)'), null); assert.strictEqual(A.gradientStops('none'), null); assert.strictEqual(A.gradientStops('linear-gradient(rgb(1, 2, 3), rgba(4, 5, 6, 0.5))').length, 2);