Polish update and settings workspace UI

Keep download progress inside the updater popover, slow changelog transitions, and improve dark-theme checkbox contrast. Expand diagnostics, prevent cleanup option clipping, improve navigation readability, and cover the affected states in Electron regression tests. Prepare version 1.0.15 release metadata.
This commit is contained in:
Sucukdeluxe
2026-08-13 04:41:42 +02:00
parent f4df47c89f
commit c0d45ab268
10 changed files with 311 additions and 58 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 1.0.15 - 2026-08-13
- Keep update download progress within its popover and make changelog expansion and collapse easier to follow.
- Expand Live Debug Log and Runtime Metrics to use the available Settings workspace.
- Improve dark-theme checkbox contrast with a green selection and dark checkmark.
- Prevent Auto-Cleanup options from being clipped.
- Increase primary navigation label size and improve inactive-label contrast.
## 1.0.14 - 2026-08-12
- Provision the Electron binary with a bounded retry before Windows CI smoke tests.
+1 -1
View File
@@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch
## Installation
1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest).
2. Download `Twitch-VOD-Manager-Setup-1.0.14.exe`.
2. Download `Twitch-VOD-Manager-Setup-1.0.15.exe`.
3. Run the installer and choose the installation directory.
4. Start Twitch VOD Manager and add a streamer.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "1.0.14",
"version": "1.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "1.0.14",
"version": "1.0.15",
"license": "MIT",
"dependencies": {
"axios": "^1.16.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "1.0.14",
"version": "1.0.15",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "Sucukdeluxe",
+1 -1
View File
@@ -93,7 +93,7 @@ if (process.platform === 'win32') {
sourcePath: electronSourceExecutable,
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
version: '1.0.14',
version: '1.0.15',
});
}
+4 -4
View File
@@ -15,9 +15,9 @@ function check(condition, message) {
if (!condition) failures.push(message);
}
check(packageJson.version === '1.0.14', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.14', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.14', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
check(packageJson.version === '1.0.15', `package version is ${packageJson.version}`);
check(packageLock.version === '1.0.15', `lockfile version is ${packageLock.version}`);
check(packageLock.packages?.['']?.version === '1.0.15', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
@@ -65,7 +65,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
check(indexSource.includes('Version: v1.0.14'), 'initial version label is not 1.0.14');
check(indexSource.includes('Version: v1.0.15'), 'initial version label is not 1.0.15');
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
check(fs.existsSync(manifestPath), 'public release manifest is missing');
+208 -23
View File
@@ -356,32 +356,47 @@ async function run() {
updateReady = false;
openUpdateModal({
version: '9.9.9',
releaseNotes: '- Stable Windows shell registration\n- Smoother update details'
releaseNotes: Array.from({ length: 24 }, (_, index) => `- Update detail ${index + 1}`).join('\n')
});
});
const captureUpdateChangelog = () => win.evaluate(() => {
const panel = document.getElementById('updateChangelogPanel');
const inner = panel?.querySelector('.update-changelog-panel-inner');
const toggle = document.getElementById('updateChangelogToggle');
const style = panel ? getComputedStyle(panel) : null;
const innerStyle = inner ? getComputedStyle(inner) : null;
return {
hidden: panel?.hidden || false,
expanded: panel?.classList.contains('is-expanded') || false,
ariaHidden: panel?.getAttribute('aria-hidden') || '',
ariaExpanded: toggle?.getAttribute('aria-expanded') || '',
height: panel?.getBoundingClientRect().height || 0,
transitionDuration: style?.transitionDuration || ''
scrollHeight: panel?.scrollHeight || 0,
innerHeight: inner?.getBoundingClientRect().height || 0,
innerScrollHeight: inner?.scrollHeight || 0,
innerOverflowY: innerStyle?.overflowY || '',
transitionProperty: style?.transitionProperty || '',
transitionDuration: style?.transitionDuration || '',
transitionDelay: style?.transitionDelay || ''
};
});
const changelogCollapsed = await captureUpdateChangelog();
await win.locator('#updateChangelogToggle').click();
await win.waitForTimeout(100);
await win.waitForTimeout(140);
const changelogOpening = await captureUpdateChangelog();
await win.waitForTimeout(260);
await win.waitForTimeout(400);
const changelogExpanded = await captureUpdateChangelog();
const changelogScroll = await win.evaluate(() => {
const inner = document.querySelector('#updateChangelogPanel .update-changelog-panel-inner');
if (!(inner instanceof HTMLElement)) return { exists: false, before: 0, after: 0 };
const before = inner.scrollTop;
inner.scrollTop = 80;
return { exists: true, before, after: inner.scrollTop };
});
await win.locator('#updateChangelogToggle').click();
await win.waitForTimeout(100);
await win.waitForTimeout(140);
const changelogClosing = await captureUpdateChangelog();
await win.waitForTimeout(260);
await win.waitForTimeout(400);
const changelogClosed = await captureUpdateChangelog();
await win.evaluate(() => dismissUpdateModal());
await win.emulateMedia({ reducedMotion: 'no-preference' });
@@ -393,11 +408,20 @@ async function run() {
closed: changelogClosed
};
check(!changelogCollapsed.hidden && !changelogCollapsed.expanded && changelogCollapsed.ariaHidden === 'true' && changelogCollapsed.ariaExpanded === 'false', `Collapsed changelog does not remain animatable: ${JSON.stringify(changelogCollapsed)}`);
check(changelogOpening.expanded && changelogOpening.height > changelogCollapsed.height && changelogOpening.height < changelogExpanded.height, `Changelog does not visibly expand through an intermediate frame: ${JSON.stringify({ changelogCollapsed, changelogOpening, changelogExpanded })}`);
check(changelogOpening.expanded && changelogOpening.innerHeight > changelogCollapsed.innerHeight && changelogOpening.innerHeight < changelogExpanded.innerHeight, `Changelog does not visibly expand through an intermediate frame: ${JSON.stringify({ changelogCollapsed, changelogOpening, changelogExpanded })}`);
check(changelogExpanded.expanded && changelogExpanded.ariaHidden === 'false' && changelogExpanded.ariaExpanded === 'true' && changelogExpanded.height > 0, `Expanded changelog state is incorrect: ${JSON.stringify(changelogExpanded)}`);
check(changelogExpanded.height <= 321 && changelogExpanded.innerScrollHeight > changelogExpanded.innerHeight + 1 && ['auto', 'scroll'].includes(changelogExpanded.innerOverflowY), `Long changelog does not remain bounded and scrollable: ${JSON.stringify(changelogExpanded)}`);
checks.updateChangelogScroll = changelogScroll;
check(changelogScroll.exists && changelogScroll.after > changelogScroll.before, `Expanded changelog cannot actually scroll: ${JSON.stringify(changelogScroll)}`);
check(changelogClosing.height > changelogClosed.height && changelogClosing.height < changelogExpanded.height, `Changelog does not visibly collapse through an intermediate frame: ${JSON.stringify({ changelogExpanded, changelogClosing, changelogClosed })}`);
check(!changelogClosed.expanded && changelogClosed.ariaHidden === 'true' && changelogClosed.ariaExpanded === 'false' && changelogClosed.height === 0, `Collapsed changelog state is incorrect: ${JSON.stringify(changelogClosed)}`);
check(!/^0\.01ms(?:, 0\.01ms)*$/.test(changelogOpening.transitionDuration), `Changelog animation is disabled by reduced-motion fallback: ${changelogOpening.transitionDuration}`);
const changelogDurations = changelogOpening.transitionDuration.split(',').map((duration) => duration.trim().endsWith('ms') ? Number.parseFloat(duration) : Number.parseFloat(duration) * 1000);
const changelogProperties = changelogOpening.transitionProperty.split(',').map((property) => property.trim());
const changelogDelays = changelogOpening.transitionDelay.split(',').map((delay) => delay.trim().endsWith('ms') ? Number.parseFloat(delay) : Number.parseFloat(delay) * 1000);
const gridTransitionIndex = changelogProperties.indexOf('grid-template-rows');
check(gridTransitionIndex >= 0 && changelogDurations[gridTransitionIndex] >= 400 && changelogDelays[gridTransitionIndex] === 0, `Changelog grid expansion is not explicitly slowed: ${JSON.stringify(changelogOpening)}`);
check(changelogOpening.transitionDuration === changelogClosing.transitionDuration, `Changelog expansion and collapse use different timings: ${changelogOpening.transitionDuration} / ${changelogClosing.transitionDuration}`);
await win.evaluate(() => window.setDownloadPendingUi());
await win.locator('#workspaceUpdateButton').focus();
@@ -405,14 +429,26 @@ async function run() {
const downloadingKeyboardState = await win.evaluate(() => {
const button = document.getElementById('workspaceUpdateButton');
const popover = document.querySelector('.workspace-update-popover');
const progress = document.getElementById('updateProgress');
const track = document.getElementById('updateProgressGauge');
const style = popover ? getComputedStyle(popover) : null;
const popoverRect = popover?.getBoundingClientRect();
const progressRect = progress?.getBoundingClientRect();
const trackRect = track?.getBoundingClientRect();
return {
focused: document.activeElement === button,
disabled: button?.disabled || false,
ariaDisabled: button?.getAttribute('aria-disabled') || '',
ariaExpanded: button?.getAttribute('aria-expanded') || '',
visible: Boolean(style && style.visibility === 'visible' && Number(style.opacity) > 0),
state: document.getElementById('updateBanner')?.dataset.updateState || ''
state: document.getElementById('updateBanner')?.dataset.updateState || '',
progressWithinPopover: Boolean(popoverRect && progressRect && progressRect.width > 0 && progressRect.height > 0 && progressRect.left >= popoverRect.left + 7 && progressRect.right <= popoverRect.right - 7 && progressRect.top >= popoverRect.top && progressRect.bottom <= popoverRect.bottom),
trackWithinPopover: Boolean(popoverRect && trackRect && trackRect.width > 0 && trackRect.height > 0 && trackRect.left >= popoverRect.left + 7 && trackRect.right <= popoverRect.right - 7 && trackRect.top >= popoverRect.top && trackRect.bottom <= popoverRect.bottom),
geometry: popoverRect && progressRect && trackRect ? {
popover: { left: popoverRect.left, top: popoverRect.top, right: popoverRect.right, bottom: popoverRect.bottom },
progress: { left: progressRect.left, top: progressRect.top, right: progressRect.right, bottom: progressRect.bottom, width: progressRect.width, height: progressRect.height },
track: { left: trackRect.left, top: trackRect.top, right: trackRect.right, bottom: trackRect.bottom, width: trackRect.width, height: trackRect.height }
} : null
};
});
await win.keyboard.press('Enter');
@@ -422,6 +458,12 @@ async function run() {
check(downloadingKeyboardState.ariaDisabled === 'true', 'Downloading update trigger does not communicate its unavailable action');
check(downloadingKeyboardState.visible && downloadingKeyboardState.ariaExpanded === 'true', 'Downloading progress is hidden from keyboard focus');
check(downloadingKeyboardState.state === 'downloading' && downloadingStateAfterEnter === 'downloading', 'Keyboard activation changes the downloading state');
check(downloadingKeyboardState.progressWithinPopover && downloadingKeyboardState.trackWithinPopover, `Downloading progress exceeds the update popover: ${JSON.stringify(downloadingKeyboardState.geometry)}`);
const updateProgressViewport = await win.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }));
await win.screenshot({
path: path.join(artifactDir, `workspace-update-downloading-${updateProgressViewport.width}x${updateProgressViewport.height}.png`),
fullPage: true
});
await win.evaluate(() => window.hideUpdateBanner());
await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' }));
@@ -657,12 +699,12 @@ async function run() {
updateStatus(UI_TEXT.status.noLogin, false, 'public');
document.getElementById('smartSchedulerToggle').checked = true;
});
await win.waitForTimeout(80);
await win.waitForTimeout(240);
const downloadSettingsWide = await win.evaluate(() => {
const tab = document.getElementById('settingsTab');
const card = tab?.querySelector('.settings-card[data-settings-pane="downloads"]');
const layout = card?.querySelector('.download-settings-layout');
const checkbox = card?.querySelector('.toggle-row input[type="checkbox"]');
const checkbox = card?.querySelector('#smartSchedulerToggle');
const label = checkbox?.closest('.toggle-row')?.querySelector('span');
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
@@ -673,6 +715,7 @@ async function run() {
sections: card?.querySelectorAll('.download-settings-section').length || 0,
checkboxWidth: checkbox ? checkbox.getBoundingClientRect().width : 0,
checkboxBackground: checkbox ? getComputedStyle(checkbox).backgroundImage : '',
checkboxColor: checkbox ? getComputedStyle(checkbox).backgroundColor : '',
labelFontSize: label ? Number.parseFloat(getComputedStyle(label).fontSize) : 0,
statusText: text?.textContent?.trim() || '',
statusTitle: text?.getAttribute('title') || '',
@@ -683,6 +726,8 @@ async function run() {
check(downloadSettingsWide.cardWidth >= downloadSettingsWide.tabWidth * 0.8, `Download Settings wastes the wide workspace: ${downloadSettingsWide.cardWidth}/${downloadSettingsWide.tabWidth}`);
check(downloadSettingsWide.columns === 2 && downloadSettingsWide.sections === 5, `Download Settings is not arranged as five semantic groups in two columns: ${downloadSettingsWide.columns}/${downloadSettingsWide.sections}`);
check(downloadSettingsWide.checkboxWidth >= 18 && downloadSettingsWide.checkboxBackground !== 'none', `Checked Download Settings toggle has no clear checkmark: ${downloadSettingsWide.checkboxWidth}/${downloadSettingsWide.checkboxBackground}`);
check(downloadSettingsWide.checkboxColor === 'rgb(34, 197, 94)', `Checked Dark Settings toggle is not green: ${downloadSettingsWide.checkboxColor}`);
check(/23111111/i.test(downloadSettingsWide.checkboxBackground), `Checked Dark Settings toggle does not use a black checkmark: ${downloadSettingsWide.checkboxBackground}`);
check(downloadSettingsWide.labelFontSize >= 13, `Download Settings toggle labels remain too small: ${downloadSettingsWide.labelFontSize}px`);
check(downloadSettingsWide.statusText === 'Public-Modus · öffentliche VODs verfügbar', `Public status copy is unclear: ${downloadSettingsWide.statusText}`);
check(downloadSettingsWide.statusPublic && downloadSettingsWide.statusTitle.includes('Twitch-API'), `Public status lacks orange state or explanatory API tooltip: ${downloadSettingsWide.statusPublic}/${downloadSettingsWide.statusTitle}`);
@@ -701,6 +746,116 @@ async function run() {
checks.downloadSettingsNarrow = downloadSettingsNarrow;
check(downloadSettingsNarrow.columns === 1, `Narrow Download Settings does not collapse to one column: ${downloadSettingsNarrow.columns}`);
check(downloadSettingsNarrow.documentOverflow <= 1 && downloadSettingsNarrow.tabOverflow <= 1, `Narrow Download Settings causes horizontal overflow: ${JSON.stringify(downloadSettingsNarrow)}`);
const diagnosticLayouts = [];
for (const target of [TARGETS[2], TARGETS[1], TARGETS[0]]) {
await win.setViewportSize(target);
for (const pane of [
{ id: 'debug', card: '[data-settings-pane="debug"]', output: 'debugLogOutput' },
{ id: 'metrics', card: '[data-settings-pane="metrics"]', output: 'runtimeMetricsOutput' }
]) {
await win.evaluate((paneId) => {
window.showTab('settings');
window.setSettingsPane(paneId);
}, pane.id);
await win.waitForTimeout(520);
const layout = await win.evaluate(({ cardSelector, outputId }) => {
const tab = document.getElementById('settingsTab');
const card = tab?.querySelector(`.settings-card${cardSelector}`);
const output = document.getElementById(outputId);
const tabRect = tab?.getBoundingClientRect();
const cardRect = card?.getBoundingClientRect();
const outputRect = output?.getBoundingClientRect();
const rect = (value) => value ? { left: value.left, top: value.top, right: value.right, bottom: value.bottom, width: value.width, height: value.height } : null;
return {
tabWidth: tabRect?.width || 0,
tabHeight: tabRect?.height || 0,
cardWidth: cardRect?.width || 0,
cardHeight: cardRect?.height || 0,
outputWidth: outputRect?.width || 0,
outputHeight: outputRect?.height || 0,
maxHeight: output ? getComputedStyle(output).maxHeight : '',
tabRect: rect(tabRect),
cardRect: rect(cardRect),
outputRect: rect(outputRect),
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
tabOverflow: tab ? tab.scrollWidth - tab.clientWidth : 0
};
}, { cardSelector: pane.card, outputId: pane.output });
diagnosticLayouts.push({ target, pane: pane.id, ...layout });
await win.screenshot({
path: path.join(artifactDir, `workspace-settings-${pane.id}-${target.width}x${target.height}.png`),
fullPage: true
});
}
}
checks.diagnosticLayouts = diagnosticLayouts;
check(diagnosticLayouts.every((layout) => layout.cardWidth >= layout.tabWidth * 0.85 && layout.outputWidth >= layout.tabWidth * 0.85), `Settings diagnostics waste horizontal workspace: ${JSON.stringify(diagnosticLayouts)}`);
check(diagnosticLayouts.every((layout) => layout.outputHeight >= layout.tabHeight * 0.55 && layout.maxHeight === 'none'), `Settings diagnostics waste vertical workspace: ${JSON.stringify(diagnosticLayouts)}`);
check(diagnosticLayouts.every((layout) => layout.tabRect && layout.cardRect && layout.outputRect && layout.cardRect.left >= layout.tabRect.left - 1 && layout.cardRect.right <= layout.tabRect.right + 1 && layout.cardRect.top >= layout.tabRect.top - 1 && layout.cardRect.bottom <= layout.tabRect.bottom + 1 && layout.outputRect.left >= layout.cardRect.left - 1 && layout.outputRect.right <= layout.cardRect.right + 1 && layout.outputRect.top >= layout.cardRect.top - 1 && layout.outputRect.bottom <= layout.cardRect.bottom + 1), `Settings diagnostics exceed their pane bounds: ${JSON.stringify(diagnosticLayouts)}`);
check(diagnosticLayouts.every((layout) => layout.documentOverflow <= 1 && layout.tabOverflow <= 1), `Settings diagnostics cause horizontal overflow: ${JSON.stringify(diagnosticLayouts)}`);
await win.setViewportSize({ width: 1280, height: 800 });
await win.evaluate(() => {
window.showTab('settings');
window.setSettingsPane('storage');
});
await win.waitForTimeout(520);
const cleanupSelects = [];
for (const language of ['de', 'en']) {
await win.evaluate((nextLanguage) => window.changeLanguage(nextLanguage), language);
await win.waitForTimeout(160);
const states = await win.evaluate(() => ['autoCleanupTarget', 'autoCleanupAction'].map((id) => {
const select = document.getElementById(id);
const style = select ? getComputedStyle(select) : null;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context && style) context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const options = [...(select?.options || [])].map((option) => {
const text = option.textContent?.trim() || '';
return { text, textWidth: context?.measureText(text).width || 0 };
});
const availableWidth = select && style
? select.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight)
: 0;
const availableHeight = select && style
? select.clientHeight - Number.parseFloat(style.paddingTop) - Number.parseFloat(style.paddingBottom)
: 0;
const lineHeight = style ? Number.parseFloat(style.lineHeight) : 0;
const rect = select?.getBoundingClientRect();
return {
id,
exists: select instanceof HTMLSelectElement,
visible: Boolean(rect && rect.width > 0 && rect.height > 0 && style && style.display !== 'none' && style.visibility !== 'hidden'),
contextAvailable: Boolean(context),
optionCount: options.length,
options,
availableWidth,
availableHeight,
lineHeight
};
}));
cleanupSelects.push({ language, states });
}
checks.cleanupSelects = cleanupSelects;
check(cleanupSelects.every(({ states }) => states.length === 2 && states.every((select) => select.exists && select.visible && select.contextAvailable && select.optionCount > 0 && Number.isFinite(select.availableWidth) && select.availableWidth > 0 && Number.isFinite(select.availableHeight) && select.availableHeight > 0 && Number.isFinite(select.lineHeight) && select.lineHeight > 0)), `Cleanup select measurement is incomplete: ${JSON.stringify(cleanupSelects)}`);
check(cleanupSelects.every(({ states }) => states.every((select) => select.options.every((option) => select.availableWidth >= option.textWidth + 2))), `Cleanup select text is horizontally clipped: ${JSON.stringify(cleanupSelects)}`);
check(cleanupSelects.every(({ states }) => states.every((select) => select.availableHeight + 1 >= select.lineHeight)), `Cleanup select text is vertically clipped: ${JSON.stringify(cleanupSelects)}`);
const cleanupOverflow = await win.evaluate(() => {
const tab = document.getElementById('settingsTab');
return {
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
tab: tab ? tab.scrollWidth - tab.clientWidth : 0
};
});
checks.cleanupOverflow = cleanupOverflow;
check(cleanupOverflow.document <= 1 && cleanupOverflow.tab <= 1, `Cleanup Settings causes horizontal overflow: ${JSON.stringify(cleanupOverflow)}`);
await win.evaluate(() => window.changeLanguage('de'));
await win.waitForTimeout(160);
await win.screenshot({
path: path.join(artifactDir, 'workspace-settings-storage-1280x800.png'),
fullPage: true
});
await win.setViewportSize(TARGETS[0]);
const dynamicQueue = await win.evaluate(() => {
@@ -932,7 +1087,6 @@ async function run() {
check(profileGrammarAndAlignment.centerDeltas.length === 3 && profileGrammarAndAlignment.centerDeltas.every((delta) => delta <= 1), `Profile metadata icons and values are vertically misaligned: ${JSON.stringify(profileGrammarAndAlignment)}`);
check(new Set(profileGrammarAndAlignment.lineHeights).size === 1 && profileGrammarAndAlignment.lineHeights[0] === '18px', `Profile metadata does not share one 18px line height: ${JSON.stringify(profileGrammarAndAlignment.lineHeights)}`);
await win.setViewportSize({ width: 1600, height: 900 });
const captureTopNavigationLayout = async (language) => {
await win.evaluate((nextLanguage) => window.changeLanguage(nextLanguage), language);
await win.waitForTimeout(40);
@@ -944,16 +1098,26 @@ async function run() {
text: label?.textContent?.trim() || '',
left: rect.left,
width: rect.width,
truncated: Boolean(label && label.scrollWidth > label.clientWidth + 1)
truncated: Boolean(label && label.scrollWidth > label.clientWidth + 1),
active: button.classList.contains('active'),
color: getComputedStyle(button).color,
fontSize: label ? Number.parseFloat(getComputedStyle(label).fontSize) : 0
};
}));
};
const englishTopNavigation = await captureTopNavigationLayout('en');
const germanTopNavigation = await captureTopNavigationLayout('de');
checks.topNavigationLocaleLayout = { english: englishTopNavigation, german: germanTopNavigation };
check(germanTopNavigation.find((item) => item.tab === 'merge')?.text === 'Videos zusammenfügen', `German merge navigation says "${germanTopNavigation.find((item) => item.tab === 'merge')?.text}"`);
check(germanTopNavigation.every((item) => !item.truncated), `German top navigation truncates: ${germanTopNavigation.filter((item) => item.truncated).map((item) => item.text).join(', ')}`);
check(englishTopNavigation.every((item, index) => Math.abs(item.left - germanTopNavigation[index].left) <= 1 && Math.abs(item.width - germanTopNavigation[index].width) <= 1), 'Top navigation geometry shifts when switching language');
const topNavigationLocaleLayout = [];
for (const target of [TARGETS[2], TARGETS[1]]) {
await win.setViewportSize(target);
const english = await captureTopNavigationLayout('en');
const german = await captureTopNavigationLayout('de');
topNavigationLocaleLayout.push({ target, english, german });
}
checks.topNavigationLocaleLayout = topNavigationLocaleLayout;
check(topNavigationLocaleLayout.every(({ german }) => german.length === TABS.length && german.find((item) => item.tab === 'merge')?.text === 'Videos zusammenfügen'), `German merge navigation is missing or incorrect: ${JSON.stringify(topNavigationLocaleLayout)}`);
check(topNavigationLocaleLayout.every(({ german }) => german.every((item) => !item.truncated)), `German top navigation truncates: ${JSON.stringify(topNavigationLocaleLayout)}`);
check(topNavigationLocaleLayout.every(({ german }) => german.every((item) => item.fontSize >= 13)), `Top navigation labels are not one size larger: ${JSON.stringify(topNavigationLocaleLayout)}`);
check(topNavigationLocaleLayout.every(({ german }) => german.filter((item) => !item.active).every((item) => item.color === 'rgb(255, 255, 255)')), `Inactive top navigation labels are not white: ${JSON.stringify(topNavigationLocaleLayout)}`);
check(topNavigationLocaleLayout.every(({ english, german }) => english.length === german.length && english.every((item, index) => Math.abs(item.left - german[index].left) <= 1 && Math.abs(item.width - german[index].width) <= 1)), `Top navigation geometry shifts when switching language: ${JSON.stringify(topNavigationLocaleLayout)}`);
const germanTextAudit = await win.evaluate(() => {
const flatten = (value) => Object.values(value).flatMap((entry) => typeof entry === 'string' ? [entry] : entry && typeof entry === 'object' ? flatten(entry) : []);
@@ -1158,10 +1322,23 @@ async function run() {
const background = effectiveBackground(element);
return { foreground, background, contrast: contrast(parse(foreground), parse(background)) };
};
const checkbox = document.getElementById('sidebarSplitViewToggle');
if (checkbox instanceof HTMLInputElement) checkbox.checked = true;
const checkboxStyle = checkbox ? getComputedStyle(checkbox) : null;
const inactiveNavigation = document.querySelector('.top-nav button[data-tab]:not(.active)');
const primaryProbe = document.createElement('span');
primaryProbe.style.color = 'var(--workspace-primary)';
document.body.appendChild(primaryProbe);
const primaryColor = getComputedStyle(primaryProbe).color;
primaryProbe.remove();
return {
bodyClass: document.body.className,
bodyBackground: getComputedStyle(document.body).backgroundColor,
bodyColor: getComputedStyle(document.body).color,
checkboxColor: checkboxStyle?.backgroundColor || '',
checkboxBackground: checkboxStyle?.backgroundImage || '',
primaryColor,
inactiveNavigationColor: inactiveNavigation ? getComputedStyle(inactiveNavigation).color : '',
title: pair('#pageTitle'),
contextHeading: pair('[data-context-for="settings"] [data-context-heading]'),
settingsSearch: pair('#settingsSearchInput'),
@@ -1172,29 +1349,37 @@ async function run() {
await win.evaluate(() => window.setSettingsPane('design'));
await win.emulateMedia({ colorScheme: 'dark' });
await win.locator('#workspaceThemePicker [data-theme="twitch"]').click();
await win.waitForTimeout(160);
await win.waitForTimeout(260);
const darkTheme = await captureTheme();
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
await win.waitForTimeout(260);
const systemDarkTheme = await captureTheme();
await win.locator('#workspaceThemePicker [data-theme="light"]').click();
await win.waitForTimeout(160);
await win.waitForTimeout(260);
const lightTheme = await captureTheme();
await win.emulateMedia({ colorScheme: 'light' });
await win.locator('#workspaceThemePicker [data-theme="system"]').click();
await win.waitForTimeout(160);
await win.waitForTimeout(260);
const systemLightTheme = await captureTheme();
checks.themes = { darkTheme, lightTheme, systemLightTheme };
checks.themes = { darkTheme, systemDarkTheme, lightTheme, systemLightTheme };
for (const [name, theme] of Object.entries({ dark: darkTheme, light: lightTheme, systemLight: systemLightTheme })) {
for (const [name, theme] of Object.entries({ dark: darkTheme, systemDark: systemDarkTheme, light: lightTheme, systemLight: systemLightTheme })) {
check(Boolean(theme.title && theme.contextHeading && theme.settingsSearch && theme.toolbarAction), `${name} theme is missing a representative computed-style target`);
for (const [pairName, pair] of Object.entries({ title: theme.title, contextHeading: theme.contextHeading, settingsSearch: theme.settingsSearch, toolbarAction: theme.toolbarAction })) {
if (pair) check(pair.contrast >= 4.5, `${name} ${pairName} contrast is ${pair.contrast.toFixed(2)}:1`);
}
check(theme.inactiveNavigationColor === theme.bodyColor, `${name} inactive navigation color ${theme.inactiveNavigationColor} does not match primary text ${theme.bodyColor}`);
}
check(darkTheme.bodyClass === 'theme-twitch', `Dark theme body class is ${darkTheme.bodyClass}`);
check(systemDarkTheme.bodyClass === 'theme-system', `System-Dark theme body class is ${systemDarkTheme.bodyClass}`);
check(lightTheme.bodyClass === 'theme-light', `Light theme body class is ${lightTheme.bodyClass}`);
check(systemLightTheme.bodyClass === 'theme-system', `System theme body class is ${systemLightTheme.bodyClass}`);
check([darkTheme, systemDarkTheme].every((theme) => theme.checkboxColor === 'rgb(34, 197, 94)' && /23111111/i.test(theme.checkboxBackground)), `Dark checked Settings toggles do not use green with a black check: ${JSON.stringify({ darkTheme, systemDarkTheme })}`);
check([lightTheme, systemLightTheme].every((theme) => theme.checkboxColor === theme.primaryColor && /23ffffff/i.test(theme.checkboxBackground)), `Light checked Settings toggles do not use the theme primary color with a white check: ${JSON.stringify({ lightTheme, systemLightTheme })}`);
check(darkTheme.bodyBackground !== lightTheme.bodyBackground, 'Explicit Dark and Light themes compute the same body background');
check(systemLightTheme.bodyBackground === lightTheme.bodyBackground, `System-Light background ${systemLightTheme.bodyBackground} does not match Light ${lightTheme.bodyBackground}`);
check(systemLightTheme.bodyColor === lightTheme.bodyColor, `System-Light text ${systemLightTheme.bodyColor} does not match Light ${lightTheme.bodyColor}`);
check(systemLightTheme.checkboxColor === lightTheme.checkboxColor && systemLightTheme.checkboxBackground === lightTheme.checkboxBackground, `System-Light checkbox does not match explicit Light: ${JSON.stringify({ lightTheme, systemLightTheme })}`);
await win.screenshot({
path: path.join(artifactDir, `workspace-settings-system-light-${TARGETS[0].width}x${TARGETS[0].height}.png`),
+3 -1
View File
@@ -23,10 +23,12 @@
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()" aria-controls="updateChangelogPanel" aria-expanded="false">Changelog anzeigen</button>
</div>
<div class="update-changelog-panel" id="updateChangelogPanel" aria-hidden="true">
<div class="update-changelog-panel-inner">
<div class="update-changelog-content" id="updateChangelogContent"></div>
<p class="update-changelog-empty" id="updateChangelogEmpty" hidden>Kein Changelog verfügbar.</p>
</div>
</div>
</div>
<div class="modal-actions update-modal-actions">
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
@@ -940,7 +942,7 @@
<div class="settings-card" data-settings-pane="updates" hidden>
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.14</p>
<p id="versionInfo" class="card-intro">Version: v1.0.15</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
+14 -4
View File
@@ -3000,22 +3000,32 @@ input[type="checkbox"].vod-select-checkbox {
}
.update-changelog-panel {
max-height: 0;
display: grid;
grid-template-rows: 0fr;
max-height: 320px;
overflow: hidden;
padding: 0 14px;
opacity: 0;
transform: translateY(-4px);
transition: max-height 260ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 260ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 180ms ease, transform 260ms cubic-bezier(0.22, 0.76, 0.22, 1);
transition: grid-template-rows 440ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 440ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 320ms ease, transform 440ms cubic-bezier(0.22, 0.76, 0.22, 1);
}
.update-changelog-panel.is-expanded {
max-height: 320px;
overflow: auto;
grid-template-rows: 1fr;
padding: 14px;
opacity: 1;
transform: translateY(0);
}
.update-changelog-panel-inner {
min-height: 0;
overflow: hidden;
}
.update-changelog-panel.is-expanded .update-changelog-panel-inner {
overflow: auto;
}
.update-changelog-content {
display: grid;
gap: 12px;
+51 -3
View File
@@ -323,7 +323,7 @@ textarea:disabled,
margin: 0;
padding: 0;
overflow: hidden;
color: var(--workspace-text-muted);
color: var(--workspace-text);
background: transparent;
border: 1px solid transparent;
border-radius: var(--workspace-radius-small);
@@ -1456,6 +1456,16 @@ input[type="checkbox"]:checked {
border-color: var(--workspace-primary);
}
body.theme-twitch #settingsTab input[type="checkbox"]:checked,
body.theme-dark #settingsTab input[type="checkbox"]:checked,
body.theme-discord #settingsTab input[type="checkbox"]:checked,
body.theme-youtube #settingsTab input[type="checkbox"]:checked,
body.theme-apple #settingsTab input[type="checkbox"]:checked {
background-color: #22c55e;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23111111' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3.5 8.5 6.5 11.5 12.5 5'/%3E%3C/svg%3E");
border-color: #22c55e;
}
input[type="radio"] {
accent-color: var(--workspace-primary);
}
@@ -2643,6 +2653,14 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
}
}
@media (prefers-color-scheme: dark) {
body.theme-system #settingsTab input[type="checkbox"]:checked {
background-color: #22c55e;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23111111' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3.5 8.5 6.5 11.5 12.5 5'/%3E%3C/svg%3E");
border-color: #22c55e;
}
}
@media (prefers-reduced-motion: reduce) {
.vod-bulk-bar {
transition: transform 280ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 220ms ease, visibility 0s linear 280ms !important;
@@ -2662,7 +2680,7 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
}
.update-changelog-panel {
transition: max-height 260ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 260ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 180ms ease, transform 260ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
transition: grid-template-rows 440ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 440ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 320ms ease, transform 440ms cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
.top-nav::before {
@@ -2934,6 +2952,7 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
.workspace-update-popover .update-banner-progress-wrap {
width: 100%;
margin: 0;
}
.workspace-update-popover .update-banner-progress-track {
@@ -3519,6 +3538,35 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
width: 100%;
}
#settingsTab .settings-card[data-settings-pane="storage"] {
width: 720px;
}
#settingsTab.active[data-settings-pane="debug"],
#settingsTab.active[data-settings-pane="metrics"] {
display: flex;
flex-direction: column;
overflow: hidden;
}
#settingsTab[data-settings-pane="debug"] .settings-card[data-settings-pane="debug"],
#settingsTab[data-settings-pane="metrics"] .settings-card[data-settings-pane="metrics"] {
display: flex;
width: 100%;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
padding-bottom: 0;
}
#debugLogOutput,
#runtimeMetricsOutput {
width: 100%;
min-height: 0;
max-height: none;
flex: 1 1 auto;
}
#settingsTab .form-row:has(#downloadPath) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -3908,7 +3956,7 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
font-size: 12px;
font-size: 13px;
}
.topbar-navigation-cluster .top-nav-item[data-tab="vods"] {