release: 5.0.13 — VOD-Card Button-Alignment + UI-Polish-Pass

Hauptfix (User-Report): in der VOD-Grid sass der Trim/Queue-Button bei
Cards mit 1-zeiligem Titel hoeher als bei 2-zeiligen Nachbarn. Ursache:
.vod-card war ein Block, Buttons flossen mit dem Content. Grid streckt
zwar alle Cards einer Reihe gleich hoch, aber der Leerraum landete unten.

Fix: .vod-card = flex column, .vod-actions = margin-top:auto -> Buttons
docken am Boden an. Verifiziert per Playwright ueber xqc (16 Reihen),
papaplatte (6), xrohat (12): maxButtonTopSpread = 0px in allen.

Weitere Funde aus dem Screenshot-Pass (scripts/ui-screenshot.js):
- Globale Basis-Dark-Theme-Regel fuer alle text-Inputs + textarea, damit
  bare Inputs ohne .form-group/.form-stack Wrapper nie OS-weiss durchkommen
  (#cutterFilePath war weiss im Dark-Theme).
- Cutter-Preview-Placeholder 'Video auswahlen um Vorschau zu sehen' war
  hardcoded Deutsch ohne id -> id + Locale-Key + Wiring (zeigt jetzt
  'Select a video to see a preview' im EN-Mode).
- Clips-Button '#btnClip' wurde nie lokalisiert (zeigte immer 'Clip
  herunterladen') -> setText-Wiring ergaenzt, nutzt existierenden
  clips.downloadButton key.

scripts/ui-screenshot.js: neues Harness das die App startet, durch
Tabs/Streamer/Themes navigiert, Screenshots macht + Button-Alignment
programmatisch misst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-23 17:00:08 +02:00
parent 7bc7ef84a2
commit 951158fe5a
9 changed files with 163 additions and 4 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ release/
tmp_e2e_full/ tmp_e2e_full/
tmp_bugtest/ tmp_bugtest/
tmp_dl/ tmp_dl/
tmp_ui_shots/
# Dev-Scripts ohne Token (Stub, nicht produktiv) # Dev-Scripts ohne Token (Stub, nicht produktiv)
codeberg_api_upload.sh codeberg_api_upload.sh

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.12", "version": "5.0.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.12", "version": "5.0.13",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.16.1", "axios": "^1.16.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "5.0.12", "version": "5.0.13",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

119
scripts/ui-screenshot.js Normal file
View File

@ -0,0 +1,119 @@
// UI-Screenshot-Harness — laedt die App, navigiert durch Tabs + Streamer
// + Themes und schreibt PNGs nach tmp_ui_shots/. Kein Test-Assert, nur
// visuelle Forensik fuer UI-Polishing.
//
// Run: node scripts/ui-screenshot.js
const { _electron: electron } = require('playwright');
const fs = require('fs');
const path = require('path');
const OUT_DIR = path.join(process.cwd(), 'tmp_ui_shots');
async function run() {
fs.rmSync(OUT_DIR, { recursive: true, force: true });
fs.mkdirSync(OUT_DIR, { recursive: true });
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);
const shot = async (name) => {
const file = path.join(OUT_DIR, `${name}.png`);
try {
await win.screenshot({ path: file, animations: 'disabled', timeout: 8000 });
process.stdout.write(`shot: ${name}\n`);
} catch (e) {
process.stdout.write(`shot FAILED: ${name}${String(e).split('\n')[0]}\n`);
}
};
// Misst die Y-Position aller .vod-actions relativ zu ihrer Grid-Reihe.
// Buttons in derselben Reihe muessen die gleiche bottom-Y haben.
const checkButtonAlignment = async (label) => {
const rows = await win.evaluate(() => {
const cards = Array.from(document.querySelectorAll('.vod-card'));
const byRow = {};
for (const card of cards) {
const actions = card.querySelector('.vod-actions');
if (!actions) continue;
const cardRect = card.getBoundingClientRect();
const actRect = actions.getBoundingClientRect();
const rowKey = Math.round(cardRect.top / 10) * 10; // Reihe nach top gruppieren
(byRow[rowKey] = byRow[rowKey] || []).push(Math.round(actRect.top));
}
// Pro Reihe: max-min der button-tops (sollte ~0 sein wenn aligned)
const result = [];
for (const [row, tops] of Object.entries(byRow)) {
if (tops.length < 2) continue;
result.push({ row: Number(row), spread: Math.max(...tops) - Math.min(...tops), count: tops.length });
}
return result;
});
const maxSpread = rows.reduce((m, r) => Math.max(m, r.spread), 0);
process.stdout.write(` [align ${label}] rows=${rows.length} maxButtonTopSpread=${maxSpread}px ${maxSpread <= 2 ? 'OK' : 'MISALIGNED'}\n`);
return maxSpread;
};
// 1. Settings tab (alle Inputs/Selects/Checkboxes sichtbar)
await win.evaluate(() => window.showTab('settings'));
await win.waitForTimeout(600);
await shot('01-settings');
// 2. Streamer mit vielen VODs + variablen Titel-Laengen
const streamers = ['xqc', 'papaplatte', 'xrohat'];
for (const s of streamers) {
await win.evaluate(() => window.showTab('vods'));
await win.evaluate(async (name) => { await window.selectStreamer(name); }, s);
await win.waitForTimeout(4500);
const count = await win.locator('.vod-card').count();
process.stdout.write(` ${s}: ${count} vod-cards\n`);
await checkButtonAlignment(s);
await shot(`02-vods-${s}`);
}
// 3. Queue (mit einem item drin)
await win.evaluate(() => window.showTab('vods'));
const vodCount = await win.locator('.vod-card').count();
if (vodCount > 0) {
await win.locator('.vod-card .vod-btn.primary').first().click().catch(() => {});
await win.waitForTimeout(800);
}
await win.evaluate(() => window.showTab('queue'));
await win.waitForTimeout(500);
await shot('03-queue');
// 4. Clips / Cutter / Merge / Stats / Archive tabs
for (const tab of ['clips', 'cutter', 'merge', 'stats', 'archive']) {
await win.evaluate((t) => window.showTab(t), tab);
await win.waitForTimeout(600);
await shot(`04-tab-${tab}`);
}
// 5. Themes durchschalten — auf VODs-Tab mit xqc geladen
await win.evaluate(() => window.showTab('vods'));
await win.evaluate(async () => { await window.selectStreamer('xqc'); });
await win.waitForTimeout(3500);
for (const theme of ['discord', 'youtube', 'apple', 'light', 'twitch']) {
await win.evaluate((t) => window.changeTheme(t), theme);
await win.waitForTimeout(500);
await shot(`05-theme-${theme}`);
}
// 6. Settings im Light-Theme (Input-Backgrounds pruefen)
await win.evaluate(() => window.changeTheme('light'));
await win.evaluate(() => window.showTab('settings'));
await win.waitForTimeout(600);
await shot('06-settings-light');
await win.evaluate(() => window.changeTheme('twitch'));
await app.close();
process.stdout.write(`\nDone. PNGs in ${OUT_DIR}\n`);
}
run().catch((e) => { process.stderr.write(String(e) + '\n'); process.exit(1); });

View File

@ -330,7 +330,7 @@
<div class="video-preview" id="cutterPreview"> <div class="video-preview" id="cutterPreview">
<div class="placeholder"> <div class="placeholder">
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg> <svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
<p>Video auswahlen um Vorschau zu sehen</p> <p id="cutterPreviewPlaceholder">Video auswaehlen um Vorschau zu sehen</p>
</div> </div>
</div> </div>

View File

@ -20,6 +20,7 @@ const UI_TEXT_DE = {
clipsInfoTitle: 'Info', clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswahlen', cutterSelectTitle: 'Video auswahlen',
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen',
cutterBrowse: 'Durchsuchen', cutterBrowse: 'Durchsuchen',
mergeTitle: 'Videos zusammenfugen', mergeTitle: 'Videos zusammenfugen',
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.', mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',

View File

@ -20,6 +20,7 @@ const UI_TEXT_EN = {
clipsInfoTitle: 'Info', clipsInfoTitle: 'Info',
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".', clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
cutterSelectTitle: 'Select video', cutterSelectTitle: 'Select video',
cutterPreviewPlaceholder: 'Select a video to see a preview',
cutterBrowse: 'Browse', cutterBrowse: 'Browse',
mergeTitle: 'Merge videos', mergeTitle: 'Merge videos',
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.', mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',

View File

@ -114,9 +114,11 @@ function applyLanguageToStaticUI(): void {
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel); setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm); setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder); setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
setText('btnClip', UI_TEXT.clips.downloadButton);
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder); setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder); setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration); setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution); setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);

View File

@ -693,6 +693,32 @@ body {
/* ============================================ /* ============================================
GLOBAL TEXT-INPUT POLISH focus ring + smooth transitions GLOBAL TEXT-INPUT POLISH focus ring + smooth transitions
============================================ */ ============================================ */
/* Basis-Dark-Theme fuer ALLE text-artigen Inputs + Selects, damit nie ein
OS-default-weisses Feld durchkommt wenn ein Input mal nicht in einem
.form-group / .form-stack / .header-search etc. Wrapper sitzt (z.B.
#cutterFilePath in .settings-card > .form-row). Per-Container-Regeln
ueberschreiben Groesse/Padding spaeter Farbe/Border kommen von hier. */
input[type="text"],
input[type="search"],
input[type="number"],
input[type="password"],
input[type="email"],
textarea {
background-color: var(--bg-main);
color: var(--text);
border: 1px solid var(--border-soft);
border-radius: 4px;
}
input[type="text"]::placeholder,
input[type="search"]::placeholder,
input[type="number"]::placeholder,
input[type="password"]::placeholder,
input[type="email"]::placeholder,
textarea::placeholder {
color: var(--text-secondary);
}
input[type="text"], input[type="text"],
input[type="search"], input[type="search"],
input[type="number"], input[type="number"],
@ -1443,6 +1469,12 @@ select option {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
border: 1px solid transparent; border: 1px solid transparent;
/* Flex-Column + stretch (grid default) macht alle Cards einer Reihe
gleich hoch. Die Actions unten kriegen margin-top:auto und docken
damit am Boden an egal ob der Titel 1 oder 2 Zeilen hat. Vorher
sass der Button bei 1-Zeilen-Titeln hoeher als bei 2-Zeilen-Nachbarn. */
display: flex;
flex-direction: column;
} }
.vod-card:hover { .vod-card:hover {
@ -1665,6 +1697,9 @@ select option {
padding: 10px 15px 15px; padding: 10px 15px 15px;
display: flex; display: flex;
gap: 8px; gap: 8px;
/* Dockt am Card-Boden an, sodass Trim/Queue-Buttons ueber alle Cards
einer Reihe auf gleicher Hoehe liegen unabhaengig von Titel-Zeilen. */
margin-top: auto;
} }
.vod-btn { .vod-btn {