From dacd66fe1c98402227b6a87d672f1a0fb17082b6 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:20:53 +0200 Subject: [PATCH] feat: redesign the desktop workspace Add a compact top navigation, contextual sidebars, responsive workspaces, accessible theme cards, and a persistent updater control. Preserve all existing renderer IDs and actions while adding an isolated Electron UI contract across the supported desktop sizes. --- package.json | 2 + scripts/public-release-files.json | 1 - scripts/smoke-test-public-release-config.js | 2 +- scripts/smoke-test-workspace-ui.js | 251 ++ src/index.html | 279 +- src/renderer-settings.ts | 15 + src/renderer-texts.ts | 8 +- src/renderer-updates.ts | 61 +- src/renderer.ts | 59 + src/workspace.css | 2861 +++++++++++++++++++ 10 files changed, 3457 insertions(+), 82 deletions(-) create mode 100644 scripts/smoke-test-workspace-ui.js create mode 100644 src/workspace.css diff --git a/package.json b/package.json index 13590c9..254a324 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:e2e": "node scripts/smoke-test.js", "test:e2e:guide": "node scripts/smoke-test-template-guide.js", "test:e2e:full": "node scripts/smoke-test-full.js", + "test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js", "test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e:public-release && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "pack": "npm run build && electron-builder --dir", @@ -47,6 +48,7 @@ "dist/**/*", "src/index.html", "src/styles.css", + "src/workspace.css", "package.json" ], "directories": { diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index be73c40..2634af6 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -4,7 +4,6 @@ "CHANGELOG.md", "LICENSE", "README.md", - "assets", "build", "eslint.config.mjs", "package-lock.json", diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index 85f6d3b..14a2fd9 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -19,7 +19,7 @@ check(packageLock.packages?.['']?.version === '1.0.1', `lockfile root package ve 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}`); -check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'package.json']), 'packaged file list is not restricted'); +check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'package.json']), 'packaged file list is not restricted'); check(mainSource.includes('GITHUB_RELEASES_API_LATEST_URL'), 'GitHub releases API constant is missing'); check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases download constant is missing'); check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js new file mode 100644 index 0000000..5261fa7 --- /dev/null +++ b/scripts/smoke-test-workspace-ui.js @@ -0,0 +1,251 @@ +const { _electron: electron } = require('playwright'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const TARGETS = [ + { width: 2048, height: 1094 }, + { width: 1600, height: 900 }, + { width: 1280, height: 800 } +]; + +const TABS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings']; + +async function run() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-ui-contract-')); + const tempProgramData = path.join(tempRoot, 'programdata'); + const tempUserData = path.join(tempRoot, 'userdata'); + const tempDownloadPath = path.join(tempRoot, 'downloads'); + const tempAppData = path.join(tempProgramData, 'Twitch_VOD_Manager'); + fs.mkdirSync(tempProgramData, { recursive: true }); + fs.mkdirSync(tempUserData, { recursive: true }); + fs.mkdirSync(tempDownloadPath, { recursive: true }); + fs.mkdirSync(tempAppData, { recursive: true }); + fs.writeFileSync(path.join(tempAppData, 'config.json'), JSON.stringify({ + download_path: tempDownloadPath, + streamers: [], + language: 'en', + theme: 'twitch' + })); + const artifactDir = path.join(process.cwd(), 'artifacts', 'ui-overhaul', 'workspace-ui'); + fs.mkdirSync(artifactDir, { recursive: true }); + + const failures = []; + const runtimeIssues = []; + const checks = {}; + let app; + + const check = (condition, message) => { + if (!condition) failures.push(message); + }; + + try { + app = await electron.launch({ + executablePath: require('electron'), + args: [`--user-data-dir=${tempUserData}`, '.'], + cwd: process.cwd(), + env: { ...process.env, PROGRAMDATA: tempProgramData } + }); + + const win = await app.firstWindow(); + const actualUserData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')); + const runtimeConfig = await win.evaluate(() => window.api.getConfig()); + checks.dataIsolation = { + expectedUserData: tempUserData, + actualUserData, + expectedDownloadPath: tempDownloadPath, + actualDownloadPath: runtimeConfig.download_path + }; + check(path.resolve(actualUserData) === path.resolve(tempUserData), 'Electron userData is not isolated from the regular application profile'); + check(path.resolve(runtimeConfig.download_path) === path.resolve(tempDownloadPath), 'Workspace content is not isolated from the regular download folder'); + win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`)); + win.on('console', (message) => { + if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`); + }); + + await win.waitForFunction(() => typeof window.showTab === 'function'); + + const shell = await win.evaluate(() => ({ + topbar: Boolean(document.querySelector('.app-topbar')), + topNavigation: Boolean(document.querySelector('.top-nav')), + workspace: Boolean(document.querySelector('.workspace-shell')), + contextSidebar: Boolean(document.querySelector('.context-sidebar')), + workspaceMain: Boolean(document.querySelector('.workspace-main')), + toolbar: Boolean(document.querySelector('.workspace-toolbar')), + updateButton: Boolean(document.getElementById('workspaceUpdateButton')), + topNavigationItems: document.querySelectorAll('.top-nav [data-tab]').length + })); + checks.shell = shell; + + check(shell.topbar, 'The persistent application topbar is missing'); + check(shell.topNavigation, 'The primary icon navigation is missing'); + check(shell.workspace, 'The workspace shell is missing'); + check(shell.contextSidebar, 'The contextual sidebar is missing'); + check(shell.workspaceMain, 'The workspace main region is missing'); + check(shell.toolbar, 'The workspace toolbar is missing'); + check(shell.updateButton, 'The persistent update action is missing'); + check(shell.topNavigationItems === 7, `Expected 7 primary navigation items, found ${shell.topNavigationItems}`); + + const updateContract = await win.evaluate(() => ({ + action: typeof window.handleWorkspaceUpdateAction, + available: typeof window.setUpdateBannerAvailableUi, + downloading: typeof window.setDownloadPendingUi, + ready: typeof window.setDownloadReadyUi, + idle: typeof window.hideUpdateBanner + })); + checks.updateContract = updateContract; + check(Object.values(updateContract).every((value) => value === 'function'), 'The workspace updater state contract is incomplete'); + + if (Object.values(updateContract).every((value) => value === 'function')) { + const updaterStates = await win.evaluate(() => { + const capture = () => ({ + state: document.getElementById('updateBanner')?.dataset.updateState || '', + label: document.getElementById('workspaceUpdateLabel')?.textContent?.trim() || '', + description: document.getElementById('updateText')?.textContent?.trim() || '', + checkLabel: document.getElementById('checkUpdateBtn')?.textContent?.trim() || '', + disabled: document.getElementById('workspaceUpdateButton')?.disabled || false + }); + + window.setUpdateBannerAvailableUi({ version: '9.9.9' }); + const available = capture(); + window.setDownloadPendingUi(); + const downloading = capture(); + window.setDownloadReadyUi({ version: '9.9.9' }); + const ready = capture(); + window.hideUpdateBanner(); + const idle = capture(); + return { available, downloading, ready, idle }; + }); + + checks.updaterStates = updaterStates; + check(updaterStates.available.state === 'available', 'Available update state is not reflected in the topbar'); + check(updaterStates.available.description.includes('9.9.9'), 'Available update version is not announced'); + check(updaterStates.downloading.state === 'downloading', 'Download state is not reflected in the topbar'); + check(updaterStates.downloading.disabled, 'Update action remains enabled while downloading'); + check(updaterStates.ready.state === 'ready', 'Ready-to-install state is not reflected in the topbar'); + check(updaterStates.ready.description.includes('9.9.9'), 'Ready update version is not announced'); + check(updaterStates.idle.state === 'idle', 'Idle update state is not restored in the topbar'); + check(!updaterStates.idle.description.includes('9.9.9'), 'Idle update state keeps stale release information'); + check(updaterStates.idle.description === updaterStates.idle.checkLabel, 'Idle update tooltip does not describe the available action'); + } + + if (shell.topNavigation && shell.workspace) { + await win.setViewportSize(TARGETS[0]); + const tabChecks = {}; + for (const tab of TABS) { + const button = win.locator(`.top-nav [data-tab="${tab}"]`); + await button.focus(); + await button.press('Enter'); + await win.waitForTimeout(50); + + const state = await win.evaluate((tabId) => { + const navItem = document.querySelector(`.top-nav [data-tab="${tabId}"]`); + const content = document.getElementById(`${tabId}Tab`); + const context = document.querySelector(`[data-context-for="${tabId}"]`); + const title = document.getElementById('pageTitle'); + return { + current: navItem?.getAttribute('aria-current') === 'page', + contentVisible: Boolean(content?.classList.contains('active')), + contextVisible: Boolean(context && !context.hidden), + title: title?.textContent?.trim() || '', + focused: document.activeElement === navItem + }; + }, tab); + + tabChecks[tab] = state; + check(state.current, `Primary navigation does not mark ${tab} as current`); + check(state.contentVisible, `The ${tab} workspace is not visible after activation`); + check(state.contextVisible, `The ${tab} contextual sidebar is not visible after activation`); + check(Boolean(state.title), `The ${tab} workspace title is empty`); + check(state.focused, `Keyboard focus was lost while activating ${tab}`); + + await win.screenshot({ + path: path.join(artifactDir, `workspace-${tab}-${TARGETS[0].width}x${TARGETS[0].height}.png`), + fullPage: true + }); + } + checks.tabs = tabChecks; + + const themePicker = win.locator('#workspaceThemePicker [data-theme]'); + const themeCount = await themePicker.count(); + check(themeCount === 3, `Expected 3 workspace theme choices, found ${themeCount}`); + if (themeCount === 3) { + await win.locator('#workspaceThemePicker [data-theme="light"]').click(); + const lightTheme = await win.evaluate(() => ({ + bodyClass: document.body.className, + selected: document.getElementById('themeSelect')?.value || '', + pressed: document.querySelector('#workspaceThemePicker [data-theme="light"]')?.getAttribute('aria-pressed') + })); + check(lightTheme.bodyClass === 'theme-light', 'Light theme choice does not update the application theme'); + check(lightTheme.selected === 'light', 'Light theme choice does not update the settings value'); + check(lightTheme.pressed === 'true', 'Light theme choice is not exposed as selected'); + + await win.locator('#workspaceThemePicker [data-theme="system"]').click(); + const systemTheme = await win.evaluate(() => ({ + bodyClass: document.body.className, + selected: document.getElementById('themeSelect')?.value || '', + pressed: document.querySelector('#workspaceThemePicker [data-theme="system"]')?.getAttribute('aria-pressed') + })); + check(systemTheme.bodyClass === 'theme-system', 'System theme choice does not update the application theme'); + check(systemTheme.selected === 'system', 'System theme choice does not update the settings value'); + check(systemTheme.pressed === 'true', 'System theme choice is not exposed as selected'); + + await win.locator('#workspaceThemePicker [data-theme="twitch"]').click(); + } + + await win.evaluate(() => window.setUpdateBannerAvailableUi({ version: '9.9.9' })); + await win.locator('#workspaceUpdateButton').hover(); + await win.waitForTimeout(100); + await win.screenshot({ + path: path.join(artifactDir, `workspace-update-${TARGETS[0].width}x${TARGETS[0].height}.png`), + fullPage: true + }); + await win.evaluate(() => window.hideUpdateBanner()); + } + + const targetChecks = []; + for (const target of TARGETS) { + await win.setViewportSize(target); + await win.waitForTimeout(100); + const geometry = await win.evaluate(() => { + const topbar = document.querySelector('.app-topbar')?.getBoundingClientRect(); + const sidebar = document.querySelector('.context-sidebar')?.getBoundingClientRect(); + const toolbar = document.querySelector('.workspace-toolbar')?.getBoundingClientRect(); + const updateButton = document.getElementById('workspaceUpdateButton')?.getBoundingClientRect(); + return { + viewportWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + topbarHeight: topbar?.height || 0, + sidebarWidth: sidebar?.width || 0, + toolbarHeight: toolbar?.height || 0, + updateVisible: Boolean(updateButton && updateButton.width > 0 && updateButton.height > 0) + }; + }); + + targetChecks.push({ ...target, ...geometry }); + check(geometry.scrollWidth <= geometry.viewportWidth + 1, `Horizontal overflow at ${target.width}x${target.height}`); + check(geometry.topbarHeight >= 39 && geometry.topbarHeight <= 41, `Topbar height is ${geometry.topbarHeight}px at ${target.width}x${target.height}`); + check(geometry.sidebarWidth >= 260 && geometry.sidebarWidth <= 272, `Context sidebar width is ${geometry.sidebarWidth}px at ${target.width}x${target.height}`); + check(geometry.toolbarHeight >= 59 && geometry.toolbarHeight <= 61, `Workspace toolbar height is ${geometry.toolbarHeight}px at ${target.width}x${target.height}`); + check(geometry.updateVisible, `Update action is not visible at ${target.width}x${target.height}`); + + await win.screenshot({ + path: path.join(artifactDir, `workspace-${target.width}x${target.height}.png`), + fullPage: true + }); + } + checks.targets = targetChecks; + } finally { + if (app) await app.close(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + const result = { checks, failures, runtimeIssues }; + console.log(JSON.stringify(result, null, 2)); + process.exitCode = failures.length || runtimeIssues.length ? 1 : 0; +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/index.html b/src/index.html index aaff500..2abe784 100644 --- a/src/index.html +++ b/src/index.html @@ -6,18 +6,9 @@ Twitch VOD Manager + -
- Neue Version verfügbar! - - -
- -
-