feat(ui): polish accessibility and German interface for v2.0.2

Improve the account empty state and modal ergonomics, add complete keyboard focus handling and accessible labels, localize the core upload UI, and align the product version with the v2.0.2 release.
This commit is contained in:
Administrator 2026-08-07 19:25:58 +02:00
parent ae8d82e42b
commit 3709b18930
6 changed files with 251 additions and 108 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.1", "version": "2.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.1", "version": "2.0.2",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.0.1", "version": "2.0.2",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -353,10 +353,14 @@ function _isHistoryTabActive() {
if (!tab || tab === activeTab) return; if (!tab || tab === activeTab) return;
if (activeTab) { if (activeTab) {
activeTab.classList.remove('active'); activeTab.classList.remove('active');
activeTab.setAttribute('aria-selected', 'false');
activeTab.tabIndex = -1;
const prevView = viewsById[`${activeTab.dataset.view}-view`]; const prevView = viewsById[`${activeTab.dataset.view}-view`];
if (prevView) prevView.classList.remove('active'); if (prevView) prevView.classList.remove('active');
} }
tab.classList.add('active'); tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
const nextView = viewsById[`${tab.dataset.view}-view`]; const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active'); if (nextView) nextView.classList.add('active');
activeTab = tab; activeTab = tab;
@ -368,6 +372,18 @@ function _isHistoryTabActive() {
const tabBar = tabs[0] && tabs[0].parentElement; const tabBar = tabs[0] && tabs[0].parentElement;
if (tabBar) { if (tabBar) {
tabBar.addEventListener('click', (e) => handle(e.target)); tabBar.addEventListener('click', (e) => handle(e.target));
tabBar.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
const current = Math.max(0, tabs.indexOf(activeTab));
const next = e.key === 'Home'
? 0
: e.key === 'End'
? tabs.length - 1
: (current + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
handle(tabs[next]);
});
} else { } else {
// Fallback: bind per-tab if somehow no common parent // Fallback: bind per-tab if somehow no common parent
tabs.forEach(t => t.addEventListener('click', () => handle(t))); tabs.forEach(t => t.addEventListener('click', () => handle(t)));
@ -1920,9 +1936,20 @@ document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) hideContextMenu(); if (!e.target.closest('.context-menu')) hideContextMenu();
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
const accountModal = document.getElementById('accountModal');
if (e.key === 'Tab' && accountModal && accountModal.style.display !== 'none') {
const focusable = Array.from(accountModal.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (first && last && ((!e.shiftKey && document.activeElement === last) || (e.shiftKey && document.activeElement === first))) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
hideContextMenu(); hideContextMenu();
cancelHosterModal(); cancelHosterModal();
if (accountModal && accountModal.style.display !== 'none') closeAccountModal();
} }
if (e.target.closest('input, textarea, select')) return; if (e.target.closest('input, textarea, select')) return;
const activeView = document.querySelector('.view.active'); const activeView = document.querySelector('.view.active');
@ -2951,12 +2978,12 @@ function updateStatusBar() {
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`; document.getElementById('sbConnections').textContent = `Verbindungen ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`; document.getElementById('sbQueueCount').textContent = `Gesamt ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`; document.getElementById('sbRemainingCount').textContent = `Verbleibend ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`; document.getElementById('sbInProgressCount').textContent = `Läuft ${stats.inProgress}`;
document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`; document.getElementById('sbDoneCount').textContent = `Fertig ${_sessionDoneCount}`;
document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`; document.getElementById('sbErrorCount').textContent = `Fehler ${_sessionErrorCount}`;
} }
// --- Health Check --- // --- Health Check ---
@ -3864,8 +3891,10 @@ function renderAccounts() {
if (allAccounts.length === 0) { if (allAccounts.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="accounts-empty"> <div class="accounts-empty">
<p>Keine Accounts vorhanden</p> <div class="accounts-empty-icon" aria-hidden="true">+</div>
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span> <h3>Noch keine Accounts</h3>
<p>Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.</p>
<button class="btn btn-primary" type="button" data-account-empty-add>Ersten Account hinzufügen</button>
</div>`; </div>`;
if (footer) footer.style.display = 'none'; if (footer) footer.style.display = 'none';
if (!_accountListenersBound) bindAccountListeners(container); if (!_accountListenersBound) bindAccountListeners(container);
@ -4106,6 +4135,7 @@ function bindAccountListeners(container) {
} }
const btn = e.target.closest('button'); const btn = e.target.closest('button');
if (!btn) return; if (!btn) return;
if (btn.hasAttribute('data-account-empty-add')) return openAccountModal(null);
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
@ -4240,25 +4270,42 @@ function getCredsFieldsHtml(authType, account, hoster) {
}; };
return ` return `
<div class="settings-row"> <div class="settings-row">
<label>${escapeHtml(fld.label)}</label> <label for="accField_username">${escapeHtml(fld.label)}</label>
<input type="${fld.inputType}" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}"> <input type="${fld.inputType}" class="key-input" id="accField_username" name="username" autocomplete="username" spellcheck="false" value="${escapeAttr(account.username || '')}" placeholder="${escapeAttr(fld.placeholder)}">
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label>Passwort</label> <label for="accField_password">Passwort</label>
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort"> <input type="password" class="key-input" id="accField_password" name="password" autocomplete="current-password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button> <button class="toggle-vis" type="button" title="Passwort anzeigen" aria-label="Passwort anzeigen" aria-pressed="false">&#128065;</button>
</div>`; </div>`;
} }
// API key // API key
return ` return `
<div class="settings-row"> <div class="settings-row">
<label>API Key</label> <label for="accField_apiKey">API-Key</label>
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key"> <input type="password" class="key-input" id="accField_apiKey" name="apiKey" autocomplete="off" spellcheck="false" value="${escapeAttr(account.apiKey || '')}" placeholder="API-Key">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button> <button class="toggle-vis" type="button" title="API-Key anzeigen" aria-label="API-Key anzeigen" aria-pressed="false">&#128065;</button>
</div>`; </div>`;
} }
function wireCredentialVisibilityButtons(container) {
container.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
const visible = input.type === 'password';
const fieldName = input.id === 'accField_apiKey' ? 'API-Key' : 'Passwort';
input.type = visible ? 'text' : 'password';
btn.setAttribute('aria-pressed', String(visible));
btn.setAttribute('aria-label', `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`);
btn.title = `${fieldName} ${visible ? 'verbergen' : 'anzeigen'}`;
});
});
}
let _accountModalReturnFocus = null;
function openAccountModal(editAccountId) { function openAccountModal(editAccountId) {
_accountModalReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
editingAccountId = editAccountId || null; editingAccountId = editAccountId || null;
_resetAccountModalState(); _resetAccountModalState();
const modal = document.getElementById('accountModal'); const modal = document.getElementById('accountModal');
@ -4298,17 +4345,17 @@ function openAccountModal(editAccountId) {
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value); credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
} }
// Toggle visibility buttons wireCredentialVisibilityButtons(credsContainer);
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
_wireCredFieldInvalidation(); _wireCredFieldInvalidation();
modal.style.display = 'flex'; modal.style.display = 'flex';
requestAnimationFrame(() => {
const firstControl = editingAccountId
? document.getElementById('accField_label')
: hosterSelect;
if (firstControl) firstControl.focus();
});
} }
function closeAccountModal() { function closeAccountModal() {
@ -4316,6 +4363,12 @@ function closeAccountModal() {
_hideOtpField(); _hideOtpField();
editingAccountId = null; editingAccountId = null;
_resetAccountModalState(); _resetAccountModalState();
const returnFocus = _accountModalReturnFocus;
_accountModalReturnFocus = null;
const focusTarget = returnFocus && returnFocus.isConnected
? returnFocus
: document.getElementById('addAccountBtn');
if (focusTarget) focusTarget.focus();
} }
function openDeleteAccountModal(accountId) { function openDeleteAccountModal(accountId) {
@ -4879,7 +4932,7 @@ function renderHistoryTable(container) {
}; };
container.innerHTML = `<table class="results-table history-table"><thead><tr> container.innerHTML = `<table class="results-table history-table"><thead><tr>
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} ${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Link')}
</tr></thead><tbody id="historyBody"></tbody></table>`; </tr></thead><tbody id="historyBody"></tbody></table>`;
if (!_historyListenersBound) { if (!_historyListenersBound) {
@ -5136,12 +5189,7 @@ function setupListeners() {
const authType = opt ? opt.authType : 'login'; const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields'); const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value); credsContainer.innerHTML = getCredsFieldsHtml(authType, {}, e.target.value);
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { wireCredentialVisibilityButtons(credsContainer);
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
_wireCredFieldInvalidation(); _wireCredFieldInvalidation();
}); });
@ -5185,7 +5233,7 @@ function showUpdateBanner(info) {
function handleUpdateProgress(data) { function handleUpdateProgress(data) {
const msg = document.getElementById('updateMessage'); const msg = document.getElementById('updateMessage');
if (!msg) return; if (!msg) return;
if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`; if (data.stage === 'downloading') msg.textContent = `Wird heruntergeladen… ${data.percent || 0}%`;
else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...';
else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...';
else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...';

View File

@ -66,11 +66,11 @@
</div> </div>
</nav> </nav>
<nav class="tab-bar"> <nav class="tab-bar" role="tablist" aria-label="Hauptbereiche">
<button class="tab active" data-view="upload">Upload</button> <button class="tab active" id="upload-tab" role="tab" aria-selected="true" aria-controls="upload-view" tabindex="0" data-view="upload">Upload</button>
<button class="tab" data-view="accounts">Accounts</button> <button class="tab" id="accounts-tab" role="tab" aria-selected="false" aria-controls="accounts-view" tabindex="-1" data-view="accounts">Accounts</button>
<button class="tab" data-view="settings">Einstellungen</button> <button class="tab" id="settings-tab" role="tab" aria-selected="false" aria-controls="settings-view" tabindex="-1" data-view="settings">Einstellungen</button>
<button class="tab" data-view="history">Verlauf</button> <button class="tab" id="history-tab" role="tab" aria-selected="false" aria-controls="history-view" tabindex="-1" data-view="history">Verlauf</button>
<span class="version-label" id="versionLabel"></span> <span class="version-label" id="versionLabel"></span>
</nav> </nav>
@ -80,7 +80,7 @@
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">&times;</button> <button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">&times;</button>
</div> </div>
<div id="upload-view" class="view active"> <div id="upload-view" class="view active" role="tabpanel" aria-labelledby="upload-tab">
<div class="upload-toolbar"> <div class="upload-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<span class="hoster-summary" id="hosterSummary" style="display:none"></span> <span class="hoster-summary" id="hosterSummary" style="display:none"></span>
@ -99,35 +99,35 @@
<div class="queue-shell" id="queueShell" style="display:none"> <div class="queue-shell" id="queueShell" style="display:none">
<div class="queue-command-bar" id="queueCommandBar"> <div class="queue-command-bar" id="queueCommandBar">
<button class="toolbar-btn" id="startUploadBtn" title="Start all" disabled> <button class="toolbar-btn" id="startUploadBtn" title="Alle Uploads starten" aria-label="Alle Uploads starten" disabled>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="startSelectedBtn" title="Start selected" disabled> <button class="toolbar-btn" id="startSelectedBtn" title="Ausgewählte Uploads starten" aria-label="Ausgewählte Uploads starten" disabled>
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="reuploadSelectedBtn" title="Reupload selected file"> <button class="toolbar-btn" id="reuploadSelectedBtn" title="Ausgewählte Datei erneut hochladen" aria-label="Ausgewählte Datei erneut hochladen">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="abortSelectedBtn" title="Abort selected file"> <button class="toolbar-btn" id="abortSelectedBtn" title="Ausgewählten Upload abbrechen" aria-label="Ausgewählten Upload abbrechen">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="#e53935"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="#e53935"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>
</button> </button>
<button class="toolbar-btn" id="finishStopBtn" title="Finish Uploads in Progress and Stop"> <button class="toolbar-btn" id="finishStopBtn" title="Aktive Uploads beenden und stoppen" aria-label="Aktive Uploads beenden und stoppen">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l4 4 8-8" stroke="#4caf50" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="11" y="9" width="5" height="5" rx="1" fill="#e53935"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l4 4 8-8" stroke="#4caf50" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="11" y="9" width="5" height="5" rx="1" fill="#e53935"/></svg>
</button> </button>
<button class="toolbar-btn toolbar-btn-danger" id="abortAllBtn" title="Abort all Downloads"> <button class="toolbar-btn toolbar-btn-danger" id="abortAllBtn" title="Alle Uploads abbrechen" aria-label="Alle Uploads abbrechen">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="#e53935"/><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg> <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="#e53935"/><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
</button> </button>
<span class="toolbar-sep"></span> <span class="toolbar-sep"></span>
<button class="toolbar-btn" id="moveTopBtn" title="Move to the top"> <button class="toolbar-btn" id="moveTopBtn" title="Ganz nach oben" aria-label="Ganz nach oben">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="1" width="8" height="2" rx="0.5" fill="#4caf50"/><path d="M8 5l-4 5h8z" fill="#4caf50"/><path d="M8 9l-4 5h8z" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="1" width="8" height="2" rx="0.5" fill="#4caf50"/><path d="M8 5l-4 5h8z" fill="#4caf50"/><path d="M8 9l-4 5h8z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="moveUpBtn" title="Move up"> <button class="toolbar-btn" id="moveUpBtn" title="Nach oben" aria-label="Nach oben">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 2l-5 6h10z" fill="#4caf50"/><rect x="6" y="8" width="4" height="6" rx="0.5" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 2l-5 6h10z" fill="#4caf50"/><rect x="6" y="8" width="4" height="6" rx="0.5" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="moveDownBtn" title="Move down"> <button class="toolbar-btn" id="moveDownBtn" title="Nach unten" aria-label="Nach unten">
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="6" y="2" width="4" height="6" rx="0.5" fill="#4caf50"/><path d="M8 14l-5-6h10z" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><rect x="6" y="2" width="4" height="6" rx="0.5" fill="#4caf50"/><path d="M8 14l-5-6h10z" fill="#4caf50"/></svg>
</button> </button>
<button class="toolbar-btn" id="moveBottomBtn" title="Move to the bottom"> <button class="toolbar-btn" id="moveBottomBtn" title="Ganz nach unten" aria-label="Ganz nach unten">
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 7l-4-5h8z" fill="#4caf50"/><path d="M8 11l-4-5h8z" fill="#4caf50"/><rect x="4" y="13" width="8" height="2" rx="0.5" fill="#4caf50"/></svg> <svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 7l-4-5h8z" fill="#4caf50"/><path d="M8 11l-4-5h8z" fill="#4caf50"/><rect x="4" y="13" width="8" height="2" rx="0.5" fill="#4caf50"/></svg>
</button> </button>
</div> </div>
@ -136,14 +136,14 @@
<table class="queue-table" id="queueTable"> <table class="queue-table" id="queueTable">
<thead> <thead>
<tr> <tr>
<th class="col-filename sortable" data-col="filename" data-sort="filename">Filename<span class="col-resizer"></span></th> <th class="col-filename sortable" data-col="filename" data-sort="filename">Dateiname<span class="col-resizer"></span></th>
<th class="col-size sortable" data-col="size" data-sort="size">Uploaded / Size<span class="col-resizer"></span></th> <th class="col-size sortable" data-col="size" data-sort="size">Hochgeladen / Größe<span class="col-resizer"></span></th>
<th class="col-host sortable" data-col="host" data-sort="host">Host<span class="col-resizer"></span></th> <th class="col-host sortable" data-col="host" data-sort="host">Hoster<span class="col-resizer"></span></th>
<th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th> <th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th>
<th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th> <th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th>
<th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th> <th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th>
<th class="col-speed sortable" data-col="speed" data-sort="speed">Speed<span class="col-resizer"></span></th> <th class="col-speed sortable" data-col="speed" data-sort="speed">Geschwindigkeit<span class="col-resizer"></span></th>
<th class="col-progress sortable" data-col="progress" data-sort="progress">Progress</th> <th class="col-progress sortable" data-col="progress" data-sort="progress">Fortschritt</th>
</tr> </tr>
</thead> </thead>
<tbody id="queueBody"></tbody> <tbody id="queueBody"></tbody>
@ -168,8 +168,8 @@
<div class="recent-files-panel" id="recentFilesPanel"> <div class="recent-files-panel" id="recentFilesPanel">
<div class="recent-files-header"> <div class="recent-files-header">
<div class="recent-tabs"> <div class="recent-tabs">
<button class="recent-tab active" data-panel="filesTab">Files</button> <button class="recent-tab active" data-panel="filesTab">Dateien</button>
<button class="recent-tab" data-panel="statsTab">Stats</button> <button class="recent-tab" data-panel="statsTab">Statistik</button>
</div> </div>
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span> <span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
<button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button> <button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button>
@ -181,8 +181,8 @@
<thead id="recentFilesHead"> <thead id="recentFilesHead">
<tr> <tr>
<th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th> <th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator"></span></th>
<th class="col-filename sortable" data-recent-sort="filename">Filename<span class="sort-indicator"></span></th> <th class="col-filename sortable" data-recent-sort="filename">Dateiname<span class="sort-indicator"></span></th>
<th class="col-host sortable" data-recent-sort="host">Host<span class="sort-indicator"></span></th> <th class="col-host sortable" data-recent-sort="host">Hoster<span class="sort-indicator"></span></th>
<th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th> <th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator"></span></th>
</tr> </tr>
</thead> </thead>
@ -193,24 +193,24 @@
<div class="recent-tab-body" id="statsTab"> <div class="recent-tab-body" id="statsTab">
<div class="stats-grid"> <div class="stats-grid">
<div class="stats-col"> <div class="stats-col">
<h4>Files in queue (count)</h4> <h4>Dateien in der Warteschlange</h4>
<div class="stats-row"><span>total:</span><span id="statQueueTotal">0</span></div> <div class="stats-row"><span>Gesamt:</span><span id="statQueueTotal">0</span></div>
<div class="stats-row"><span>done:</span><span id="statQueueDone">0</span></div> <div class="stats-row"><span>Fertig:</span><span id="statQueueDone">0</span></div>
<div class="stats-row"><span>remaining:</span><span id="statQueueRemaining">0</span></div> <div class="stats-row"><span>Verbleibend:</span><span id="statQueueRemaining">0</span></div>
<div class="stats-row"><span>in progress:</span><span id="statQueueInProgress">0</span></div> <div class="stats-row"><span>Läuft:</span><span id="statQueueInProgress">0</span></div>
<div class="stats-row"><span>error:</span><span id="statQueueError">0</span></div> <div class="stats-row"><span>Fehler:</span><span id="statQueueError">0</span></div>
</div> </div>
<div class="stats-col"> <div class="stats-col">
<h4>File size in queue</h4> <h4>Dateigröße in der Warteschlange</h4>
<div class="stats-row"><span>total:</span><span id="statSizeTotal">0 B</span></div> <div class="stats-row"><span>Gesamt:</span><span id="statSizeTotal">0 B</span></div>
<div class="stats-row"><span>remaining:</span><span id="statSizeRemaining">0 B</span></div> <div class="stats-row"><span>Verbleibend:</span><span id="statSizeRemaining">0 B</span></div>
</div> </div>
<div class="stats-col"> <div class="stats-col">
<h4>Session</h4> <h4>Sitzung</h4>
<div class="stats-row"><span>Upload speed:</span><span id="statSpeed">0 B/s</span></div> <div class="stats-row"><span>Upload-Geschwindigkeit:</span><span id="statSpeed">0 B/s</span></div>
<div class="stats-row"><span>Remaining time:</span><span id="statEta">--:--</span></div> <div class="stats-row"><span>Restzeit:</span><span id="statEta">--:--</span></div>
<div class="stats-row"><span>Run time:</span><span id="statRunTime">00:00:00</span></div> <div class="stats-row"><span>Laufzeit:</span><span id="statRunTime">00:00:00</span></div>
<div class="stats-row"><span>Uploaded (this run):</span><span id="statSessionBytes">0 B</span></div> <div class="stats-row"><span>In diesem Lauf hochgeladen:</span><span id="statSessionBytes">0 B</span></div>
</div> </div>
</div> </div>
</div> </div>
@ -219,7 +219,7 @@
</div> </div>
</div> </div>
<div id="accounts-view" class="view"> <div id="accounts-view" class="view" role="tabpanel" aria-labelledby="accounts-tab">
<div class="accounts-container"> <div class="accounts-container">
<div class="accounts-header"> <div class="accounts-header">
<div> <div>
@ -232,7 +232,7 @@
<input type="checkbox" id="autoHealthCheckToggle" checked> <input type="checkbox" id="autoHealthCheckToggle" checked>
<span>Auto-Check vor Upload</span> <span>Auto-Check vor Upload</span>
</label> </label>
<button class="btn btn-primary" id="addAccountBtn">+ Account hinzufügen</button> <button class="btn btn-primary" id="addAccountBtn">Account hinzufügen</button>
</div> </div>
</div> </div>
<div class="health-check-results account-health-results" id="healthCheckResults"></div> <div class="health-check-results account-health-results" id="healthCheckResults"></div>
@ -244,7 +244,7 @@
</div> </div>
<div class="modal-overlay" id="accountModal" style="display:none"> <div class="modal-overlay" id="accountModal" style="display:none">
<div class="modal-card"> <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle">
<div class="modal-header"> <div class="modal-header">
<div> <div>
<h3 id="accountModalTitle">Account hinzufügen</h3> <h3 id="accountModalTitle">Account hinzufügen</h3>
@ -254,15 +254,15 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="settings-row" id="accountHosterRow"> <div class="settings-row" id="accountHosterRow">
<label>Hoster</label> <label for="accountHosterSelect">Hoster</label>
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select> <select class="key-input" id="accountHosterSelect" name="hoster" autocomplete="off" style="max-width:300px"></select>
</div> </div>
<div class="settings-row"> <div class="settings-row">
<label>Label (optional)</label> <label for="accField_label">Label (optional)</label>
<input type="text" class="key-input" id="accField_label" placeholder="z.B. Hauptaccount, Premium, Kunde XY" maxlength="60"> <input type="text" class="key-input" id="accField_label" name="accountLabel" autocomplete="off" placeholder="z. B. Hauptaccount, Premium, Kunde XY" maxlength="60">
</div> </div>
<div id="accountCredsFields"></div> <div id="accountCredsFields"></div>
<div class="account-modal-status" id="accountModalStatus"></div> <div class="account-modal-status" id="accountModalStatus" role="status" aria-live="polite"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button> <button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
@ -303,7 +303,7 @@
</div> </div>
</div> </div>
<div id="settings-view" class="view"> <div id="settings-view" class="view" role="tabpanel" aria-labelledby="settings-tab">
<div class="settings-container"> <div class="settings-container">
<h2>Upload-Einstellungen</h2> <h2>Upload-Einstellungen</h2>
<p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p> <p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p>
@ -315,7 +315,7 @@
</div> </div>
</div> </div>
<div id="history-view" class="view"> <div id="history-view" class="view" role="tabpanel" aria-labelledby="history-tab">
<div class="history-container"> <div class="history-container">
<div class="history-header"> <div class="history-header">
<h2>Upload-Verlauf</h2> <h2>Upload-Verlauf</h2>
@ -368,17 +368,17 @@
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-eta" id="sbEta">ETA --:--</span> <span class="sb-eta" id="sbEta">ETA --:--</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-connections" id="sbConnections">Aktive Verbindungen 0</span> <span class="sb-connections" id="sbConnections">Verbindungen 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span> <span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-remaining-count" id="sbRemainingCount">Remaining 0</span> <span class="sb-remaining-count" id="sbRemainingCount">Verbleibend 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span> <span class="sb-progress-count" id="sbInProgressCount">Läuft 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-done-count" id="sbDoneCount">Done 0</span> <span class="sb-done-count" id="sbDoneCount">Fertig 0</span>
<span class="sb-separator">|</span> <span class="sb-separator">|</span>
<span class="sb-error-count" id="sbErrorCount">Error 0</span> <span class="sb-error-count" id="sbErrorCount">Fehler 0</span>
</div> </div>
<div class="copy-toast" id="copyToast"></div> <div class="copy-toast" id="copyToast"></div>

View File

@ -1,4 +1,5 @@
:root { :root {
color-scheme: dark;
--bg-primary: #16181c; --bg-primary: #16181c;
--bg-secondary: #20242b; --bg-secondary: #20242b;
--bg-card: #262b33; --bg-card: #262b33;
@ -8,7 +9,7 @@
--border-hover: rgba(255, 255, 255, 0.18); --border-hover: rgba(255, 255, 255, 0.18);
--text: #edf1f7; --text: #edf1f7;
--text-muted: #9ea7b3; --text-muted: #9ea7b3;
--text-dim: #727b88; --text-dim: #8490a0;
--accent: #3ea7ff; --accent: #3ea7ff;
--accent-end: #65d8ff; --accent-end: #65d8ff;
--success: #43c788; --success: #43c788;
@ -216,7 +217,7 @@ body {
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: all 0.2s; transition: color 0.2s, background-color 0.2s, border-color 0.2s;
} }
.tab:hover { color: var(--text); } .tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); background: rgba(255, 255, 255, 0.03); } .tab.active { color: var(--text); border-bottom-color: var(--accent); background: rgba(255, 255, 255, 0.03); }
@ -256,7 +257,7 @@ body {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
transition: all 0.2s; transition: color 0.2s, background-color 0.2s, border-color 0.2s, filter 0.2s, transform 0.2s;
} }
.btn-xs { padding: 4px 10px; font-size: 11px; border-radius: 4px; } .btn-xs { padding: 4px 10px; font-size: 11px; border-radius: 4px; }
.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 5px; } .btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 5px; }
@ -319,7 +320,7 @@ body {
border: 1px dashed rgba(126, 220, 255, 0.28); border: 1px dashed rgba(126, 220, 255, 0.28);
border-radius: 18px; border-radius: 18px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: color 0.3s, background-color 0.3s, border-color 0.3s;
color: var(--text-muted); color: var(--text-muted);
min-height: 200px; min-height: 200px;
background: background:
@ -359,7 +360,7 @@ body {
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
color: var(--text-muted); color: var(--text-muted);
transition: all 0.15s; transition: color 0.15s, background-color 0.15s, border-color 0.15s, transform 0.15s;
} }
.toolbar-btn:hover { .toolbar-btn:hover {
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
@ -571,7 +572,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
border-bottom: none; border-bottom: none;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: color 0.15s, background-color 0.15s, border-color 0.15s;
} }
.recent-tab:first-child { border-radius: 4px 0 0 0; } .recent-tab:first-child { border-radius: 4px 0 0 0; }
.recent-tab:last-child { border-radius: 0 4px 0 0; } .recent-tab:last-child { border-radius: 0 4px 0 0; }
@ -677,6 +678,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
background: rgba(5, 7, 16, 0.72); background: rgba(5, 7, 16, 0.72);
z-index: 2500; z-index: 2500;
padding: 24px; padding: 24px;
overscroll-behavior: contain;
} }
.modal-card { .modal-card {
width: min(560px, 100%); width: min(560px, 100%);
@ -713,6 +715,8 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
padding: 14px 16px 10px; padding: 14px 16px 10px;
overflow: auto; overflow: auto;
} }
.modal-body .settings-row { gap: 10px; }
.modal-body .key-input { min-height: 36px; }
.modal-actions-inline { .modal-actions-inline {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -1190,12 +1194,29 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
} }
.accounts-empty { .accounts-empty {
width: min(520px, 100%);
margin: clamp(56px, 12vh, 120px) auto 0;
padding: 32px;
border: 1px solid var(--border);
border-radius: 16px;
background: linear-gradient(180deg, rgba(62, 167, 255, 0.06), rgba(255, 255, 255, 0.025));
box-shadow: var(--panel-shadow);
text-align: center; text-align: center;
padding: 48px 16px;
color: var(--text-dim);
} }
.accounts-empty p { font-size: 14px; margin-bottom: 4px; } .accounts-empty-icon {
.accounts-empty .hint { font-size: 12px; } display: grid;
place-items: center;
width: 48px;
height: 48px;
margin: 0 auto 14px;
border-radius: 14px;
background: rgba(62, 167, 255, 0.14);
color: var(--accent-end);
font-size: 30px;
font-weight: 300;
}
.accounts-empty h3 { font-size: 17px; margin-bottom: 6px; }
.accounts-empty p { max-width: 390px; margin: 0 auto 18px; color: var(--text-muted); font-size: 13px; line-height: 1.55; }
/* History View */ /* History View */
.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } .history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); }
@ -1292,7 +1313,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
font-size: 12px; font-size: 12px;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: all 0.3s; transition: opacity 0.3s, transform 0.3s;
z-index: 2000; z-index: 2000;
} }
.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@ -1339,3 +1360,29 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
:where(button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--accent-end);
outline-offset: 2px;
}
.key-input:focus-visible,
.hs-input:focus-visible {
border-color: var(--accent);
outline: 2px solid rgba(101, 216, 255, 0.45);
outline-offset: 1px;
}
.menu-spinner input[type="text"]:focus-visible {
outline: 2px solid var(--accent-end);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@ -53,9 +53,15 @@ setTimeout(async () => {
const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")'); const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")');
check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf'); check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
const tabSemantics = await wc.executeJavaScript('document.querySelector(".tab-bar")?.getAttribute("role") + "|" + document.querySelector(".tab.active")?.getAttribute("aria-selected")');
check('Tab navigation exposes active state', tabSemantics === 'tablist|true');
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()'); const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
check('Upload tab active by default', activeTab === 'Upload'); check('Upload tab active by default', activeTab === 'Upload');
const tabStops = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.tabIndex).join("|")');
check('Tab navigation exposes one keyboard stop', tabStops === '0|-1|-1|-1');
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"'); const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
check('Drop zone visible (no files)', dropVisible); check('Drop zone visible (no files)', dropVisible);
@ -71,6 +77,21 @@ setTimeout(async () => {
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent'); const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Version label present', version && version.startsWith('v')); check('Version label present', version && version.startsWith('v'));
const localizedQueueHeaders = await wc.executeJavaScript('[...document.querySelectorAll("#queueTable thead th")].map(el => el.childNodes[0]?.textContent.trim()).join("|")');
check('Upload table labels are consistently German', localizedQueueHeaders === 'Dateiname|Hochgeladen / Größe|Hoster|Status|Zeit|Rest|Geschwindigkeit|Fortschritt');
const localizedRecentTabs = await wc.executeJavaScript('[...document.querySelectorAll(".recent-tab")].map(el => el.textContent.trim()).join("|")');
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
const localizedStatusbar = await wc.executeJavaScript('["sbConnections", "sbQueueCount", "sbRemainingCount", "sbInProgressCount", "sbDoneCount", "sbErrorCount"].map(id => document.getElementById(id)?.textContent).join("|")');
check('Statusbar labels are consistently German', localizedStatusbar === 'Verbindungen 0|Gesamt 0|Verbleibend 0|Läuft 0|Fertig 0|Fehler 0');
const toolbarLabels = await wc.executeJavaScript('[...document.querySelectorAll("#queueCommandBar .toolbar-btn")].map(el => el.getAttribute("aria-label")).join("|")');
check('Upload toolbar actions have German accessible names', toolbarLabels === 'Alle Uploads starten|Ausgewählte Uploads starten|Ausgewählte Datei erneut hochladen|Ausgewählten Upload abbrechen|Aktive Uploads beenden und stoppen|Alle Uploads abbrechen|Ganz nach oben|Nach oben|Nach unten|Ganz nach unten');
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display'); const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
check('Context menu hidden', ctxHidden === 'none'); check('Context menu hidden', ctxHidden === 'none');
@ -88,7 +109,10 @@ setTimeout(async () => {
const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false'); const addAccountEnabled = await wc.executeJavaScript('document.getElementById("addAccountBtn")?.disabled === false');
check('Add account button enabled', addAccountEnabled); check('Add account button enabled', addAccountEnabled);
await wc.executeJavaScript('document.getElementById("addAccountBtn").click()'); const emptyAccountAction = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]")?.textContent?.trim()');
check('Empty account state offers direct action', emptyAccountAction === 'Ersten Account hinzufügen');
await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click()');
await new Promise(r => setTimeout(r, 200)); await new Promise(r => setTimeout(r, 200));
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display'); const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
@ -97,6 +121,21 @@ setTimeout(async () => {
const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent'); const accountModalTitle = await wc.executeJavaScript('document.getElementById("accountModalTitle")?.textContent');
check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen'); check('Account modal is in add mode', accountModalTitle === 'Account hinzufügen');
const accountModalSemantics = await wc.executeJavaScript('document.querySelector("#accountModal .modal-card")?.getAttribute("role") + "|" + document.querySelector("#accountModal .modal-card")?.getAttribute("aria-modal")');
check('Account modal exposes dialog semantics', accountModalSemantics === 'dialog|true');
const accountFormLabels = await wc.executeJavaScript('["accountHosterSelect", "accField_label", "accField_username", "accField_password"].every(id => document.getElementById(id)?.labels?.length === 1)');
check('Account form controls have linked labels', accountFormLabels);
const accountStatusLive = await wc.executeJavaScript('document.getElementById("accountModalStatus")?.getAttribute("aria-live")');
check('Account validation status is announced', accountStatusLive === 'polite');
const initialAccountFocus = await wc.executeJavaScript('document.activeElement?.id');
check('Account modal focuses first control', initialAccountFocus === 'accountHosterSelect');
const trappedAccountFocus = await wc.executeJavaScript('document.getElementById("saveAccountBtn").focus(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); document.activeElement?.id');
check('Account modal keeps keyboard focus inside', trappedAccountFocus === 'closeAccountModalBtn');
const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length'); const authOptionCount = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
check('7 hoster authentication options exist', authOptionCount === 7); check('7 hoster authentication options exist', authOptionCount === 7);
@ -109,9 +148,18 @@ setTimeout(async () => {
const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length'); const credentialInputs = await wc.executeJavaScript('document.querySelectorAll("#accountCredsFields .key-input").length');
check('Credential inputs rendered', credentialInputs === 2); check('Credential inputs rendered', credentialInputs === 2);
await wc.executeJavaScript('document.getElementById("cancelAccountModalBtn").click()'); const passwordToggleState = await wc.executeJavaScript('document.querySelector("#accountCredsFields .toggle-vis").click(); document.querySelector("#accountCredsFields .toggle-vis").getAttribute("aria-label") + "|" + document.querySelector("#accountCredsFields .toggle-vis").getAttribute("aria-pressed")');
check('Password visibility action exposes its state', passwordToggleState === 'Passwort verbergen|true');
await wc.executeJavaScript('document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))');
const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display'); const accountModalHidden = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
check('Account modal closes', accountModalHidden === 'none'); check('Escape closes account modal', accountModalHidden === 'none');
const restoredAccountFocus = await wc.executeJavaScript('document.activeElement?.hasAttribute("data-account-empty-add")');
check('Account modal restores trigger focus', restoredAccountFocus === true);
const fallbackAccountFocus = await wc.executeJavaScript('document.querySelector("[data-account-empty-add]").focus(); document.querySelector("[data-account-empty-add]").click(); document.querySelector("[data-account-empty-add]").remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); document.activeElement?.id');
check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn');
console.log('\\n=== Settings View ==='); console.log('\\n=== Settings View ===');