No history yet
+Run your first analysis to see it here
+No favorites saved
+Star an analysis result to save it here
+${getTranslation('empty_history_title')}
+${getTranslation('empty_history_desc')}
+${getTranslation('empty_favorites_title')}
+${getTranslation('empty_favorites_desc')}
+$1');
}
+ /**
+ * Show a result pane and hide its matching empty-state placeholder.
+ *
+ * @param {string} resultId - ID of the result content div (e.g. 'explainResult').
+ * @param {string} emptyId - ID of the empty-state div (e.g. 'emptyExplain').
+ * @param {string} [display] - CSS display value for the result div (default 'block').
+ * @returns {HTMLElement} The result element, ready for innerHTML assignment.
+ */
+ function showPane(resultId, emptyId, display = 'block') {
+ document.getElementById(emptyId).style.display = 'none';
+ const el = document.getElementById(resultId);
+ el.style.display = display;
+ return el;
+ }
+
+ /**
+ * Set a CSS `display` value on a list of elements identified by ID.
+ *
+ * @param {string[]} ids - Array of element IDs.
+ * @param {string} display - The display value to apply.
+ */
+ function setDisplayAll(ids, display) {
+ ids.forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.style.display = display;
+ });
+ }
+
+ /**
+ * Set a property (e.g. `textContent`) to a value on a list of elements by ID.
+ *
+ * @param {string[]} ids - Array of element IDs.
+ * @param {string} prop - Property name to set.
+ * @param {*} value - Value to assign.
+ */
+ function setPropAll(ids, prop, value) {
+ ids.forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el[prop] = value;
+ });
+ }
+
+ /**
+ * Deactivate all buttons/tabs matching `selector`, then activate `activeEl`.
+ * Handles both `aria-pressed` (buttons) and `aria-selected` (tabs).
+ *
+ * @param {string} selector - CSS selector for the full group.
+ * @param {HTMLElement} activeEl - The element to mark as active.
+ * @param {string} ariaAttr - ARIA attribute to toggle ('aria-pressed' | 'aria-selected').
+ */
+ function activateExclusive(selector, activeEl, ariaAttr) {
+ document.querySelectorAll(selector).forEach(el => {
+ el.classList.remove('active');
+ el.setAttribute(ariaAttr, 'false');
+ });
+ activeEl.classList.add('active');
+ activeEl.setAttribute(ariaAttr, 'true');
+ }
+
/* ═══════════════════════════════════════════════════════════════
SAMPLE CODE LOADER
═══════════════════════════════════════════════════════════════ */
diff --git a/frontend/package.json b/frontend/package.json
index 0bba672c..b0b2c992 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,7 +4,7 @@
"version": "0.0.0",
"description": "Playwright end-to-end tests for the QyverixAI frontend.",
"scripts": {
- "test:static": "node tests/sample-comments.test.js && node tests/shortcut-ui.test.js",
+ "test:static": "node tests/sample-comments.test.js && node tests/shortcut-ui.test.js && node tests/empty-states.test.js && node tests/mobile-layout.test.js",
"test:e2e": "playwright test",
"test:e2e:ci": "playwright test --reporter=list",
"test:e2e:headed": "playwright test --headed"
diff --git a/frontend/tests/empty-states.test.js b/frontend/tests/empty-states.test.js
new file mode 100644
index 00000000..dcda6507
--- /dev/null
+++ b/frontend/tests/empty-states.test.js
@@ -0,0 +1,176 @@
+/**
+ * Static assertions that all expected empty-state elements are present
+ * in index.html with correct structure, roles, and i18n attributes.
+ *
+ * Run with: node tests/empty-states.test.js
+ */
+import assert from 'node:assert';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const html = fs.readFileSync(path.resolve(__dirname, '..', 'index.html'), 'utf8');
+
+// ── Result-panel empty states ────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /id="emptyExplain"/,
+ 'explain pane should have an emptyExplain empty-state element',
+);
+assert.match(
+ html,
+ /id="emptyDebug"/,
+ 'debug pane should have an emptyDebug empty-state element',
+);
+assert.match(
+ html,
+ /id="emptySuggest"/,
+ 'suggest pane should have an emptySuggest empty-state element',
+);
+
+// Each result-pane empty state should use the .empty-state class
+assert.match(
+ html,
+ /id="emptyExplain"[^>]*>[\s\S]*?class="empty-state"|class="empty-state"[^>]*>[\s\S]{0,200}id="emptyExplain"|id="emptyExplain"[\s\S]{0,50}empty-state/,
+ 'emptyExplain should use empty-state styling',
+);
+
+// Each result pane empty state should have a human-readable title
+assert.match(
+ html,
+ /data-i18n="empty_explain_title"/,
+ 'explain empty state should have an i18n title',
+);
+assert.match(
+ html,
+ /data-i18n="empty_debug_title"/,
+ 'debug empty state should have an i18n title',
+);
+assert.match(
+ html,
+ /data-i18n="empty_suggest_title"/,
+ 'suggest empty state should have an i18n title',
+);
+
+// Each result pane empty state should have a description
+assert.match(
+ html,
+ /data-i18n="empty_explain_desc"/,
+ 'explain empty state should have an i18n description',
+);
+assert.match(
+ html,
+ /data-i18n="empty_debug_desc"/,
+ 'debug empty state should have an i18n description',
+);
+assert.match(
+ html,
+ /data-i18n="empty_suggest_desc"/,
+ 'suggest empty state should have an i18n description',
+);
+
+// Icons in result pane empty states should be aria-hidden
+assert.match(
+ html,
+ /class="empty-icon"[^>]*aria-hidden="true"|aria-hidden="true"[^>]*class="empty-icon"/,
+ 'result pane empty-state icons should be aria-hidden',
+);
+
+// ── History empty state ──────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /id="emptyHistory"/,
+ 'history panel should have an emptyHistory empty-state element',
+);
+assert.match(
+ html,
+ /class="list-empty-state"[\s\S]{0,400}id="emptyHistory"|id="emptyHistory"[\s\S]{0,20}class="list-empty-state"|id="emptyHistory"/,
+ 'emptyHistory should use list-empty-state styling',
+);
+assert.match(
+ html,
+ /class="list-empty-title"/,
+ 'history and favorites empty states should have a list-empty-title element',
+);
+assert.match(
+ html,
+ /class="list-empty-desc"/,
+ 'history and favorites empty states should have a list-empty-desc element',
+);
+assert.match(
+ html,
+ /class="list-empty-icon"[^>]*aria-hidden="true"|aria-hidden="true"[^>]*class="list-empty-icon"/,
+ 'list empty-state icons should be aria-hidden',
+);
+
+// ── Favorites empty state ────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /id="emptyFavorites"/,
+ 'favorites panel should have an emptyFavorites empty-state element',
+);
+
+// ── i18n keys exist in the translations object ───────────────────────────────
+
+assert.match(
+ html,
+ /empty_history_title:/,
+ 'translations should include empty_history_title key',
+);
+assert.match(
+ html,
+ /empty_history_desc:/,
+ 'translations should include empty_history_desc key',
+);
+assert.match(
+ html,
+ /empty_favorites_title:/,
+ 'translations should include empty_favorites_title key',
+);
+assert.match(
+ html,
+ /empty_favorites_desc:/,
+ 'translations should include empty_favorites_desc key',
+);
+
+// ── CSS classes exist ────────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /\.list-empty-state\s*\{/,
+ 'CSS should define .list-empty-state rule',
+);
+assert.match(
+ html,
+ /\.list-empty-icon\s*\{/,
+ 'CSS should define .list-empty-icon rule',
+);
+assert.match(
+ html,
+ /\.list-empty-title\s*\{/,
+ 'CSS should define .list-empty-title rule',
+);
+assert.match(
+ html,
+ /\.list-empty-desc\s*\{/,
+ 'CSS should define .list-empty-desc rule',
+);
+
+// ── aria-live regions ────────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /id="historyList"[^>]*aria-live="polite"|aria-live="polite"[^>]*id="historyList"/,
+ 'history list should have aria-live="polite" for screen reader announcements',
+);
+assert.match(
+ html,
+ /id="favList"[^>]*aria-live="polite"|aria-live="polite"[^>]*id="favList"/,
+ 'favorites list should have aria-live="polite" for screen reader announcements',
+);
+
+console.log('All empty-state assertions passed.');
diff --git a/frontend/tests/mobile-layout.test.js b/frontend/tests/mobile-layout.test.js
new file mode 100644
index 00000000..ec5ac135
--- /dev/null
+++ b/frontend/tests/mobile-layout.test.js
@@ -0,0 +1,118 @@
+/**
+ * Static assertions that the mobile layout CSS improvements are present
+ * in index.html.
+ *
+ * Run with: node tests/mobile-layout.test.js
+ */
+import assert from 'node:assert';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const html = fs.readFileSync(path.resolve(__dirname, '..', 'index.html'), 'utf8');
+
+// ── Breakpoint existence ─────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /@media\(max-width:600px\)/,
+ '600px breakpoint should be present',
+);
+assert.match(
+ html,
+ /@media\(max-width:400px\)/,
+ '400px breakpoint should be present',
+);
+assert.match(
+ html,
+ /@media\(max-width:900px\)/,
+ '900px (tablet) breakpoint for workspace grid should be present',
+);
+
+// ── 44px touch target enforcement ────────────────────────────────────────────
+
+assert.match(
+ html,
+ /min-height:44px/,
+ 'At least one element should enforce a 44px minimum touch target height',
+);
+
+// ── Mode buttons: min-height touch target ────────────────────────────────────
+
+assert.match(
+ html,
+ /\.mode-btn\{[^}]*min-height:44px|mode-btn[^{]*\{[^}]*min-height:44px/,
+ 'mode-btn should have min-height:44px in mobile breakpoint',
+);
+
+// ── Shortcut hints hidden on small screens ───────────────────────────────────
+
+assert.match(
+ html,
+ /\.shortcut-hints\{display:none\}|\.shortcut-hints\s*\{\s*display\s*:\s*none/,
+ 'shortcut-hints should be hidden (display:none) inside a mobile breakpoint',
+);
+
+// ── Panel header flex-wrap ────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /\.panel-header\{[^}]*flex-wrap:wrap|panel-header[^{]*\{[^}]*flex-wrap\s*:\s*wrap/,
+ 'panel-header should use flex-wrap:wrap in mobile breakpoint',
+);
+
+// ── Horizontal scroll for lang-tabs and result-tabs on 400px ─────────────────
+
+assert.match(
+ html,
+ /\.lang-tabs\{overflow-x:auto|lang-tabs[^{]*\{[^}]*overflow-x\s*:\s*auto/,
+ 'lang-tabs should allow horizontal scroll on 400px breakpoint',
+);
+assert.match(
+ html,
+ /\.result-tabs\{overflow-x:auto|result-tabs[^{]*\{[^}]*overflow-x\s*:\s*auto/,
+ 'result-tabs should allow horizontal scroll on 400px breakpoint',
+);
+
+// ── Editor height reduction on mobile ────────────────────────────────────────
+
+assert.match(
+ html,
+ /#codeEditor\{[^}]*min-height:200px|#codeEditor[^{]*\{[^}]*min-height\s*:\s*200px/,
+ 'codeEditor should have reduced min-height on mobile',
+);
+
+// ── API URL input full-width on mobile ────────────────────────────────────────
+
+assert.match(
+ html,
+ /#apiUrl\{[^}]*width:100%|#apiUrl[^{]*\{[^}]*width\s*:\s*100%/,
+ 'apiUrl input should be full-width on mobile',
+);
+
+// ── Result actions wrapping ───────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /\.result-actions\{[^}]*flex-wrap:wrap|result-actions[^{]*\{[^}]*flex-wrap\s*:\s*wrap/,
+ 'result-actions should wrap on mobile',
+);
+
+// ── Pagination touch targets ──────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /#btnPrevHistory,#btnNextHistory\{[^}]*min-height:44px|btnPrevHistory[^{]*\{[^}]*min-height\s*:\s*44px/,
+ 'history pagination buttons should have 44px touch target',
+);
+
+// ── Viewport meta tag ─────────────────────────────────────────────────────────
+
+assert.match(
+ html,
+ /]+name="viewport"[^>]+content="[^"]*width=device-width/,
+ 'viewport meta tag with width=device-width should be present for proper mobile scaling',
+);
+
+console.log('All mobile layout assertions passed.');
diff --git a/frontend/tests/shortcut-ui.test.js b/frontend/tests/shortcut-ui.test.js
index 6a6dc586..8dcc4606 100644
--- a/frontend/tests/shortcut-ui.test.js
+++ b/frontend/tests/shortcut-ui.test.js
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const indexHtml = fs.readFileSync(path.resolve(__dirname, '..', 'index.html'), 'utf8');
+// ── Original shortcuts ──────────────────────────────────────────────────────
assert.match(indexHtml, /class="shortcut-hints"/, 'shortcut hints should be visible in the frontend');
assert.match(indexHtml, /Ctrl\/⌘ \+ Enter<\/kbd>/, 'analyze shortcut should be displayed');
assert.match(indexHtml, /\/<\/kbd>/, 'editor focus shortcut should be displayed');
@@ -15,3 +16,39 @@ assert.match(
/aria-keyshortcuts="Control\+Enter Meta\+Enter"/,
'analyze shortcut should be exposed to assistive technology',
);
+
+// ── New shortcuts ───────────────────────────────────────────────────────────
+assert.match(indexHtml, /Ctrl\/⌘ \+ K<\/kbd>/, 'clear shortcut should be displayed');
+assert.match(indexHtml, /Ctrl\/⌘ \+ Shift \+ T<\/kbd>/, 'theme toggle shortcut should be displayed');
+assert.match(indexHtml, /Alt \+ 1–4<\/kbd>/, 'mode switch shortcut should be displayed');
+
+assert.match(
+ indexHtml,
+ /aria-keyshortcuts="Control\+K Meta\+K"/,
+ 'clear shortcut should be exposed to assistive technology on clearBtn',
+);
+assert.match(
+ indexHtml,
+ /aria-keyshortcuts="Control\+Shift\+T Meta\+Shift\+T"/,
+ 'theme toggle shortcut should be exposed to assistive technology on themeToggle',
+);
+assert.match(
+ indexHtml,
+ /aria-keyshortcuts="Alt\+1"/,
+ 'analyze mode shortcut should be exposed to assistive technology',
+);
+assert.match(
+ indexHtml,
+ /aria-keyshortcuts="Alt\+2"/,
+ 'explain mode shortcut should be exposed to assistive technology',
+);
+assert.match(
+ indexHtml,
+ /aria-keyshortcuts="Alt\+3"/,
+ 'debug mode shortcut should be exposed to assistive technology',
+);
+assert.match(
+ indexHtml,
+ /aria-keyshortcuts="Alt\+4"/,
+ 'suggest mode shortcut should be exposed to assistive technology',
+);