diff --git a/frontend-modern/tests/browser/mobile-alert-history.html b/frontend-modern/tests/browser/mobile-alert-history.html
new file mode 100644
index 000000000..97f99189e
--- /dev/null
+++ b/frontend-modern/tests/browser/mobile-alert-history.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Mobile alert history qualification
+
+
+
+
+
+
diff --git a/frontend-modern/tests/browser/mobile-alert-history.tsx b/frontend-modern/tests/browser/mobile-alert-history.tsx
new file mode 100644
index 000000000..094478840
--- /dev/null
+++ b/frontend-modern/tests/browser/mobile-alert-history.tsx
@@ -0,0 +1,115 @@
+// Synthetic state: this qualifies browser interaction, not backend delivery.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { AlertHistoryMobileList } from '@/features/alerts/AlertHistoryMobileList';
+import type { AlertHistoryState } from '@/features/alerts/useAlertHistoryState';
+import '@/index.css';
+const stub = (callback = (..._args: any[]) => {}) => callback;
+function createState() {
+ const alert = {
+ id: 'alert-1',
+ source: 'alert',
+ resourceId: 'node-1',
+ resourceName: 'pve-production-01',
+ resourceType: 'node',
+ title: 'Backup',
+ description: 'Backup failed after the target became unavailable.',
+ severity: 'warning',
+ status: 'resolved',
+ duration: '14m',
+ startTime: '2026-08-04T14:30:00.000Z',
+ node: 'pve-production-01',
+ nodeDisplayName: 'Production cluster',
+ rawAlertType: 'backup_failed',
+ };
+ const [expandedIncidents, setExpandedIncidents] = createSignal(new Set());
+ const [resourceIncidentPanel, setResourceIncidentPanel] = createSignal<{
+ resourceId: string;
+ resourceName: string;
+ rowKey: string;
+ } | null>(null);
+ const toggleIncidentTimeline = stub((rowKey: string) => {
+ setExpandedIncidents((current) => {
+ const next = new Set(current);
+ if (next.has(rowKey)) next.delete(rowKey);
+ else next.add(rowKey);
+ return next;
+ });
+ });
+ const openResourceIncidentPanel = stub(
+ (resourceId: string, resourceName: string, rowKey: string) => {
+ setResourceIncidentPanel({ resourceId, resourceName, rowKey });
+ },
+ );
+ const closeResourceIncidentPanel = stub(() => setResourceIncidentPanel(null));
+
+ const [groupedAlerts, setGroupedAlerts] = createSignal([
+ {
+ label: 'Today (August 4th)',
+ fullLabel: 'Today, August 4th 2026',
+ alerts: [alert],
+ },
+ ]);
+
+ const incident = {
+ id: 'incident-1',
+ alertIdentifier: alert.id,
+ alertType: 'backup_failed',
+ level: 'warning',
+ resourceId: 'node-1',
+ resourceName: alert.resourceName,
+ status: 'resolved',
+ openedAt: alert.startTime,
+ acknowledged: false,
+ message: 'Backup destination unavailable.',
+ events: [
+ {
+ id: 'event-1',
+ type: 'alert_fired',
+ timestamp: alert.startTime,
+ summary: 'Backup destination unavailable.',
+ },
+ ],
+ };
+ const state = {
+ groupedAlerts,
+ getIncidentRowKey: () => 'alert-1-row',
+ expandedIncidents,
+ incidentLoading: () => ({}),
+ incidentErrors: () => ({}),
+ incidentTimelines: () => ({ 'alert-1-row': incident }),
+ historyIncidentEventFilters: () => new Set(['alert_fired']),
+ setHistoryIncidentEventFilters: stub(),
+ incidentNoteDrafts: () => ({}),
+ setIncidentNoteDraft: stub(),
+ incidentNoteSaving: () => new Set(),
+ saveIncidentNote: stub(),
+ loadIncidentTimeline: stub(),
+ toggleIncidentTimeline,
+ openResourceIncidentPanel,
+ resourceIncidentPanel,
+ resourceIncidents: () => ({ 'node-1': [incident] }),
+ expandedResourceIncidentIds: () => new Set(['incident-1']),
+ toggleResourceIncidentExpanded: stub(),
+ resourceIncidentLoading: () => ({}),
+ resourceIncidentEventFilters: () => new Set(['alert_fired']),
+ setResourceIncidentEventFilters: stub(),
+ refreshResourceIncidentPanel: stub(),
+ setResourceIncidentPanel: closeResourceIncidentPanel,
+ getResource: () => undefined,
+ formatAlertRowTime: () => '14:30',
+ formatAlertRowTimestamp: () => 'Tuesday, 4 August 2026 at 14:30:00',
+ } as unknown as AlertHistoryState;
+
+ return {
+ closeResourceIncidentPanel,
+ openResourceIncidentPanel,
+ state,
+ setGroupedAlerts,
+ toggleIncidentTimeline,
+ };
+}
+
+const fixture = createState();
+Object.assign(window, { removeHistoryRows: () => fixture.setGroupedAlerts([]) });
+render(() => , document.getElementById('root')!);
diff --git a/tests/qualification/mobile-alert-history/README.md b/tests/qualification/mobile-alert-history/README.md
new file mode 100644
index 000000000..1dd9ab802
--- /dev/null
+++ b/tests/qualification/mobile-alert-history/README.md
@@ -0,0 +1,23 @@
+# Mobile alert history browser qualification
+
+From the repository root, after `npm ci` and `npm --prefix frontend-modern ci`:
+
+```sh
+pulse-heavy-run -- node tests/qualification/mobile-alert-history/run.mjs
+```
+
+Requires Playwright's Chromium and WebKit browsers and their system libraries.
+The runner starts a loopback-only Vite server on an ephemeral port and closes
+it and both browsers on completion. No backend, credentials or live data are used.
+
+The fixture renders the production mobile history list and dialog with synthetic
+history and incident events. At 390×844 it checks both Timeline and Resource
+investigations, Tab containment, Escape dismissal, focus return to the surviving
+trigger or list when the row disappears, and page overflow. A computed-style
+assertion guards against accidentally testing without production Tailwind CSS.
+
+This is component-browser evidence, not whole-application, physical-device,
+screen-reader, persistence or notification-delivery qualification. Fixture
+callbacks do not implement note saving, filtering or network operations.
+The fallback case should fail if `return action ?? list` in
+`AlertHistoryMobileList` is changed to `return action ?? null`.
diff --git a/tests/qualification/mobile-alert-history/run.mjs b/tests/qualification/mobile-alert-history/run.mjs
new file mode 100644
index 000000000..e9d457062
--- /dev/null
+++ b/tests/qualification/mobile-alert-history/run.mjs
@@ -0,0 +1,105 @@
+// Run from the repository root with pulse-heavy-run -- node .
+import assert from "node:assert/strict";
+import { chromium, webkit } from "@playwright/test";
+import { createServer } from "../../../frontend-modern/node_modules/vite/dist/node/index.js";
+import { fileURLToPath } from "node:url";
+const root = fileURLToPath(
+ new URL("../../../frontend-modern/", import.meta.url),
+);
+process.chdir(root); // Tailwind resolves its configuration from the frontend directory.
+const server = await createServer({
+ root,
+ configFile: `${root}/vite.config.ts`,
+ server: { host: "127.0.0.1", port: 0, strictPort: false },
+});
+try {
+ await server.listen();
+ const address = server.httpServer.address();
+ for (const [name, engine] of Object.entries({ chromium, webkit })) {
+ const browser = await engine.launch();
+ try {
+ for (const action of ["Timeline", "Resource"]) {
+ for (const removeRow of [false, true]) {
+ const page = await browser.newPage({
+ viewport: { width: 390, height: 844 },
+ isMobile: true,
+ hasTouch: true,
+ });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(
+ `http://127.0.0.1:${address.port}/tests/browser/mobile-alert-history.html`,
+ );
+ const trigger = page.getByRole("button", {
+ name: action,
+ exact: true,
+ });
+ await trigger.waitFor();
+ await trigger.focus();
+ await page.keyboard.press("Enter");
+ const dialog = page.getByRole("dialog");
+ await dialog.waitFor();
+ await dialog
+ .getByText("Backup destination unavailable.", { exact: true })
+ .first()
+ .waitFor();
+ assert.equal(
+ await dialog.evaluate(
+ (el) =>
+ getComputedStyle(el.closest("[data-dialog-layer]")).position,
+ ),
+ "fixed",
+ "Production Tailwind styles must be loaded",
+ );
+ assert.match(
+ await dialog.getAttribute("aria-label"),
+ /pve-production-01/,
+ );
+ for (let i = 0; i < 8; i++) {
+ await page.keyboard.press("Tab");
+ assert.ok(
+ await dialog.evaluate((el) =>
+ el.contains(document.activeElement),
+ ),
+ "Tab must stay in dialog",
+ );
+ }
+ if (removeRow) {
+ await page.evaluate(() => window.removeHistoryRows());
+ assert.equal(await page.locator("article").count(), 0);
+ }
+ await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "detached" });
+ await page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ const target = removeRow
+ ? page.getByTestId("alert-history-mobile-list")
+ : trigger;
+ assert.ok(
+ await target.evaluate((el) => el === document.activeElement),
+ "Focus must return to surviving trigger or list",
+ );
+ assert.ok(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth + 1,
+ ),
+ "No horizontal overflow",
+ );
+ assert.deepEqual(errors, []);
+ console.log(
+ `PASS ${name}: ${action}, row ${removeRow ? "removed" : "retained"}, focus trap/Escape/return/overflow`,
+ );
+ await page.close();
+ }
+ }
+ } finally {
+ await browser.close();
+ }
+ }
+} finally {
+ await server.close();
+}