diff --git a/scripts/ui-audit-modals.js b/scripts/ui-audit-modals.js new file mode 100644 index 0000000..727bbff --- /dev/null +++ b/scripts/ui-audit-modals.js @@ -0,0 +1,98 @@ +// Modal-fokussiertes Audit: oeffnet jedes Modal, prueft DE/EN-Localization +// + Layout-Overflow + ob das Modal in den Viewport passt (nicht abgeschnitten). +const { _electron: electron } = require('playwright'); + +const NEUTRAL = /^[\s\d.,:%/x+()|-]*$|VOD|FPS|Twitch|Ctrl|HH|MM|SS|@|http|\.mp4|\.json|MB|GB|KB|TB|TAB|^x$/i; +const GERMAN = /(auswahlen|herunterladen|loeschen|schliessen|ausfuehren|hinzufugen|zusammenfugen|aufloesung|datei|einstellung|speichern|abbrechen|durchsuchen|fehlgeschlagen|zuerst|neueste|alteste|vorschau|qualitat|sprache|\bund\b|\boder\b|\bfur\b|\bnicht\b|\bzum\b|\bzur\b|\bdie\b|\bder\b|\bdas\b|wahle|reihenfolge|geandert|unterstutzte)/i; + +async function modalText(win) { + return win.evaluate(() => { + const open = document.querySelector('.modal-overlay.show, .modal-overlay[style*="flex"], #commandPaletteModal.show'); + const root = open || document.querySelector('.modal-overlay.show'); + if (!root) return null; + const txts = []; + for (const el of root.querySelectorAll('button, label, span, h1, h2, h3, h4, p, option')) { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + const t = Array.from(el.childNodes).filter(n => n.nodeType === 3).map(n => n.textContent.trim()).join(' ').trim(); + if (t) txts.push(t); + } + const rect = root.querySelector('.modal') ? root.querySelector('.modal').getBoundingClientRect() : root.getBoundingClientRect(); + return { texts: txts, modalRect: { top: Math.round(rect.top), bottom: Math.round(rect.bottom), left: Math.round(rect.left), right: Math.round(rect.right) }, vh: window.innerHeight, vw: window.innerWidth }; + }); +} + +async function auditModal(win, name, openFn) { + await win.evaluate(() => { ['de'].forEach(() => window.changeLanguage && window.changeLanguage('de')); }); + await win.waitForTimeout(300); + const opened = await win.evaluate(openFn); + if (!opened) { process.stdout.write(` ${name}: could not open\n`); return; } + await win.waitForTimeout(700); + const de = await modalText(win); + // close, switch en, reopen + await win.keyboard.press('Escape'); await win.waitForTimeout(300); + await win.evaluate(() => window.changeLanguage && window.changeLanguage('en')); + await win.waitForTimeout(300); + await win.evaluate(openFn); + await win.waitForTimeout(700); + const en = await modalText(win); + await win.keyboard.press('Escape'); await win.waitForTimeout(300); + + if (!de || !en) { process.stdout.write(` ${name}: no modal captured\n`); return; } + + // Localization: deutsche Strings die in EN identisch blieben + const enSet = new Set(en.texts); + const leaks = de.texts.filter(t => enSet.has(t) && GERMAN.test(t) && !NEUTRAL.test(t) && t.length >= 3); + // Modal off-screen check (EN) + const m = en.modalRect; + const clipped = m.top < 0 || m.left < 0 || m.bottom > en.vh || m.right > en.vw; + process.stdout.write(` ${name}: ${leaks.length ? 'LOCALIZATION LEAK -> ' + JSON.stringify(leaks) : 'loc OK'} | ${clipped ? `CLIPPED ${JSON.stringify(m)} vh=${en.vh} vw=${en.vw}` : 'fits viewport'}\n`); +} + +async function run() { + const app = await electron.launch({ executablePath: require('electron'), args: ['.'], cwd: process.cwd() }); + const win = await app.firstWindow(); + await win.setViewportSize({ width: 1280, height: 800 }); + await win.waitForTimeout(2500); + await win.evaluate(async () => { await window.selectStreamer('xqc'); }).catch(() => {}); + await win.waitForTimeout(4000); + + process.stdout.write('\n===== MODAL AUDIT (1280x800) =====\n'); + + // Trim/Clip dialog — braucht eine VOD-Card + await auditModal(win, 'trim-dialog', () => { + const btn = document.querySelector('.vod-card .vod-btn.secondary'); + if (btn) { btn.click(); return true; } + return false; + }); + + await auditModal(win, 'template-guide', () => { + if (typeof window.openTemplateGuide === 'function') { window.openTemplateGuide(); return true; } + return false; + }); + + await auditModal(win, 'command-palette', () => { + // Ctrl+K simulieren via direkter open falls exposed, sonst keydown + const ev = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }); + document.dispatchEvent(ev); + return document.getElementById('commandPaletteModal')?.classList.contains('show') || false; + }); + + // Kleines Fenster — Template Guide ist das hoechste Modal, bei 700px Hoehe testen + await win.setViewportSize({ width: 1280, height: 700 }); + await win.waitForTimeout(500); + process.stdout.write('\n===== MODAL AUDIT (1280x700, min-height) =====\n'); + await auditModal(win, 'template-guide@700', () => { + if (typeof window.openTemplateGuide === 'function') { window.openTemplateGuide(); return true; } + return false; + }); + await auditModal(win, 'trim-dialog@700', () => { + const btn = document.querySelector('.vod-card .vod-btn.secondary'); + if (btn) { btn.click(); return true; } + return false; + }); + + await app.close(); + process.stdout.write('\nDone modal audit.\n'); +} +run().catch(e => { process.stderr.write(String(e) + '\n'); process.exit(1); }); diff --git a/scripts/ui-audit.js b/scripts/ui-audit.js new file mode 100644 index 0000000..901f6e4 --- /dev/null +++ b/scripts/ui-audit.js @@ -0,0 +1,129 @@ +// Intensives UI-Audit. Findet Bugs die man visuell uebersieht: +// 1. Localization-Luecken: Text der bei DE->EN Wechsel identisch bleibt +// (potentiell hardcoded/untranslated), pro Tab + Modal. +// 2. Horizontal-Overflow: Elemente deren scrollWidth > clientWidth. +// 3. Zero-size sichtbare interaktive Elemente (Buttons/Inputs mit 0 Hoehe/Breite). +// 4. Off-screen positionierte sichtbare Elemente. +// 5. Text-Overflow (Inhalt groesser als Box, abgeschnitten ohne ellipsis). +// +// Run: node scripts/ui-audit.js +const { _electron: electron } = require('playwright'); + +const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings']; + +// Bekannte sprach-neutrale Texte (Namen, Zahlen, Marken) — keine Localization-Luecke. +const NEUTRAL = /^[\s\d.,:%/x+()-]*$|VOD|FPS|Twitch|Discord|YouTube|Apple|xQc|Ctrl|HH|MM|SS|@|http|\.mp4|\.json|MB|GB|KB|TB|B$/i; + +async function collectVisibleText(win) { + return await win.evaluate(() => { + const out = {}; + const els = document.querySelectorAll('button, label, span, h1, h2, h3, h4, p, option, .vod-btn, .nav-item, td, th, li'); + let idx = 0; + for (const el of els) { + // Nur direkter Text, keine verschachtelten Element-Kinder einsammeln + const rect = el.getBoundingClientRect(); + const visible = rect.width > 0 && rect.height > 0 && window.getComputedStyle(el).display !== 'none' && window.getComputedStyle(el).visibility !== 'hidden'; + const directText = Array.from(el.childNodes) + .filter(n => n.nodeType === 3) + .map(n => n.textContent.trim()) + .join(' ') + .trim(); + if (!directText) continue; + const key = (el.id || el.className || el.tagName) + '#' + (idx++); + out[key] = { text: directText, visible }; + } + return out; + }); +} + +async function checkLayout(win, label) { + return await win.evaluate((lbl) => { + const issues = []; + const all = document.querySelectorAll('*'); + const vw = window.innerWidth, vh = window.innerHeight; + for (const el of all) { + const cs = window.getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + + // Horizontal overflow innerhalb des Elements (Content breiter als Box, kein scroll/hidden) + if (el.scrollWidth > el.clientWidth + 2 && cs.overflowX === 'visible' && el.clientWidth > 0) { + const tag = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + String(el.className).split(' ')[0] : ''); + issues.push(`overflow-x: ${tag} scrollW=${el.scrollWidth} clientW=${el.clientWidth}`); + } + // Interaktives Element mit 0-Groesse aber im Layout (display nicht none) + if ((el.tagName === 'BUTTON' || el.tagName === 'INPUT' || el.tagName === 'SELECT') && rect.width > 0 && rect.height === 0) { + issues.push(`zero-height interactive: ${el.tagName}#${el.id}`); + } + // Off-screen (komplett ausserhalb viewport, aber sichtbar gestylt) — nur grobe Faelle + if (rect.right < -5 || rect.left > vw + 5) { + const tag = el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''); + if (el.id && rect.width > 20) issues.push(`offscreen-x: ${tag} left=${Math.round(rect.left)} right=${Math.round(rect.right)}`); + } + } + return [...new Set(issues)]; + }, label); +} + +async function run() { + const electronPath = require('electron'); + const app = await electron.launch({ executablePath: electronPath, args: ['.'], cwd: process.cwd() }); + const win = await app.firstWindow(); + await win.setViewportSize({ width: 1280, height: 900 }); + await win.waitForTimeout(2500); + + // Streamer laden fuer VODs-Kontext + await win.evaluate(async () => { await window.selectStreamer('xqc'); }).catch(() => {}); + await win.waitForTimeout(4000); + + const findings = { localization: [], layout: [] }; + + for (const tab of TABS) { + await win.evaluate((t) => window.showTab(t), tab); + await win.waitForTimeout(700); + + // DE snapshot + await win.evaluate(() => window.changeLanguage && window.changeLanguage('de')); + await win.waitForTimeout(400); + const de = await collectVisibleText(win); + // EN snapshot + await win.evaluate(() => window.changeLanguage && window.changeLanguage('en')); + await win.waitForTimeout(400); + const en = await collectVisibleText(win); + + // Diff: gleiche keys, identischer Text, sichtbar, nicht neutral, enthaelt Buchstaben + for (const key of Object.keys(de)) { + if (!en[key]) continue; + const dt = de[key].text, et = en[key].text; + if (!de[key].visible) continue; + if (dt !== et) continue; // hat sich geaendert -> uebersetzt, ok + if (!/[a-zA-ZäöüÄÖÜ]/.test(dt)) continue; // keine Buchstaben + if (NEUTRAL.test(dt)) continue; + if (dt.length < 3) continue; + // Heuristik: deutsche Marker ODER generell verdaechtig (mehr als 1 Wort + Buchstaben) + findings.localization.push(`[${tab}] "${dt}"`); + } + + const layout = await checkLayout(win, tab); + for (const l of layout) findings.layout.push(`[${tab}] ${l}`); + } + + // Dedup + nur potenziell-deutsche Strings (haben deutsche Muster) + const GERMAN = /(auswahlen|herunterladen|loeschen|schliessen|ausfuehren|hinzufugen|zusammenfugen|aufloesung|datei|einstellung|speichern|abbrechen|durchsuchen|fehlgeschlagen|wird|werden|zuerst|neueste|alteste|groesste|kleinste|alle|aktivieren|verschieben|aufnahme|laden|warteschlange|herunter|vorschau|qualitat|sprache|streamer hinzu|\bund\b|\boder\b|\bfur\b|\bmit\b|\bnicht\b|\bzum\b|\bzur\b)/i; + const locUnique = [...new Set(findings.localization)]; + const locGerman = locUnique.filter(s => GERMAN.test(s)); + const locOther = locUnique.filter(s => !GERMAN.test(s)); + + process.stdout.write('\n===== LOCALIZATION (German-pattern, likely untranslated) =====\n'); + locGerman.forEach(s => process.stdout.write(' ' + s + '\n')); + process.stdout.write(`\n===== LOCALIZATION (other identical DE==EN, review) ===== (${locOther.length})\n`); + locOther.slice(0, 40).forEach(s => process.stdout.write(' ' + s + '\n')); + process.stdout.write('\n===== LAYOUT issues =====\n'); + [...new Set(findings.layout)].forEach(s => process.stdout.write(' ' + s + '\n')); + process.stdout.write('\nDone audit.\n'); + + await app.close(); +} + +run().catch((e) => { process.stderr.write(String(e) + '\n'); process.exit(1); });