From c0d45ab268cbbaee1411d65e02cd3bcbf37463e2 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:41:42 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 8 + README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/dev.mjs | 2 +- scripts/smoke-test-public-release-config.js | 8 +- scripts/smoke-test-workspace-ui.js | 263 +++++++++++++++++--- src/index.html | 8 +- src/styles.css | 18 +- src/workspace.css | 54 +++- 10 files changed, 311 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d58595..ffd1ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f50a5cb..c87af4c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 6df6621..727c3ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5844640..48f15cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 8cb6cb6..323499f 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -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', }); } diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index d0ebf3c..e00149e 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -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'); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index 86d542a..7d7f77d 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -352,36 +352,51 @@ async function run() { check(updateModalButtons.skipLineHeight >= 14, `Update skip-version line height is too small: ${updateModalButtons.skipLineHeight}`); await win.emulateMedia({ reducedMotion: 'reduce' }); - await win.evaluate(() => { - updateReady = false; - openUpdateModal({ - version: '9.9.9', - releaseNotes: '- Stable Windows shell registration\n- Smoother update details' - }); - }); - const captureUpdateChangelog = () => win.evaluate(() => { - const panel = document.getElementById('updateChangelogPanel'); - const toggle = document.getElementById('updateChangelogToggle'); - const style = panel ? getComputedStyle(panel) : null; - return { + await win.evaluate(() => { + updateReady = false; + openUpdateModal({ + version: '9.9.9', + 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 || '' - }; - }); + ariaExpanded: toggle?.getAttribute('aria-expanded') || '', + height: panel?.getBoundingClientRect().height || 0, + 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(changelogExpanded.expanded && changelogExpanded.ariaHidden === 'false' && changelogExpanded.ariaExpanded === 'true' && changelogExpanded.height > 0, `Expanded changelog state is incorrect: ${JSON.stringify(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`), diff --git a/src/index.html b/src/index.html index d385d8c..545d1a0 100644 --- a/src/index.html +++ b/src/index.html @@ -23,8 +23,10 @@ @@ -940,7 +942,7 @@ diff --git a/src/styles.css b/src/styles.css index bb87d5d..09faaf2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/workspace.css b/src/workspace.css index 813af75..f27d268 100644 --- a/src/workspace.css +++ b/src/workspace.css @@ -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"] {