From 65ac8b2f1989d91008b98ede03ce1c503c97c903 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Wed, 15 Apr 2026 03:17:16 +0000 Subject: [PATCH 1/3] test: Add Escape key and focus recovery tests for crisis overlay (#95) --- tests/test_crisis_overlay_focus_trap.py | 44 +++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_crisis_overlay_focus_trap.py b/tests/test_crisis_overlay_focus_trap.py index f657afc..78c4c0d 100644 --- a/tests/test_crisis_overlay_focus_trap.py +++ b/tests/test_crisis_overlay_focus_trap.py @@ -52,6 +52,50 @@ class TestCrisisOverlayFocusTrap(unittest.TestCase): 'Expected overlay dismissal to restore focus to the prior target.', ) + def test_overlay_escape_key_closes(self): + """Crisis overlay must close on Escape key press (issue #95).""" + self.assertIn( + "Escape: close overlay", + self.html, + 'Expected trapFocusInOverlay to handle Escape key.', + ) + self.assertRegex( + self.html, + r"e\.key\s*===\s*'Escape'", + 'Expected Escape key check in focus trap handler.', + ) + self.assertRegex( + self.html, + r"overlayDismissBtn\.click\(\)", + 'Expected Escape to trigger dismiss button click.', + ) + + def test_overlay_focus_recovery(self): + """If focus escapes the overlay, Tab must bring it back (issue #95).""" + self.assertIn( + "focusInOverlay", + self.html, + 'Expected focus trap to check if focus is inside overlay.', + ) + self.assertIn( + "If focus is outside the overlay entirely, bring it back", + self.html, + 'Expected comment documenting focus recovery behavior.', + ) + + def test_overlay_aria_modal(self): + """Overlay must have role=dialog and aria-modal for screen readers.""" + self.assertRegex( + self.html, + r'id="crisis-overlay"[^>]*role="dialog"', + 'Expected crisis overlay to have role="dialog".', + ) + self.assertRegex( + self.html, + r'id="crisis-overlay"[^>]*aria-modal="true"', + 'Expected crisis overlay to have aria-modal="true".', + ) + if __name__ == '__main__': unittest.main() -- 2.43.0 From 6ace6c43ed91225ad8673434ebaf07f84aad922b Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Wed, 15 Apr 2026 03:19:18 +0000 Subject: [PATCH 2/3] test: Playwright E2E for crisis overlay keyboard navigation (#95) --- tests/test_crisis_overlay_keyboard.py | 183 ++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/test_crisis_overlay_keyboard.py diff --git a/tests/test_crisis_overlay_keyboard.py b/tests/test_crisis_overlay_keyboard.py new file mode 100644 index 0000000..d932316 --- /dev/null +++ b/tests/test_crisis_overlay_keyboard.py @@ -0,0 +1,183 @@ +""" +Playwright E2E test: Crisis overlay keyboard navigation (issue #95). + +Acceptance criteria tested: +- Tab cycles through all focusable elements in overlay +- Shift+Tab reverses direction +- Escape closes overlay and returns focus to chat input +- Focus cannot escape the overlay while it is active + +Prerequisites: + pip install playwright + playwright install chromium + +Run: + pytest tests/test_crisis_overlay_keyboard.py -v +""" +import pytest + +try: + from playwright.sync_api import sync_playwright, expect +except ImportError: + pytest.skip("playwright not installed", allow_module_level=True) + +import pathlib +import subprocess +import time +import socket + +INDEX = pathlib.Path(__file__).resolve().parents[1] / "index.html" +PORT = 18923 + + +def _port_open(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex(("127.0.0.1", port)) == 0 + + +@pytest.fixture(scope="module") +def server(): + """Serve index.html on localhost for Playwright.""" + proc = subprocess.Popen( + ["python3", "-m", "http.server", str(PORT)], + cwd=str(INDEX.parent), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + for _ in range(30): + if _port_open(PORT): + break + time.sleep(0.2) + yield f"http://127.0.0.1:{PORT}" + proc.kill() + proc.wait() + + +@pytest.fixture(scope="module") +def browser_ctx(server): + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + page.goto(server, wait_until="domcontentloaded") + yield page + context.close() + browser.close() + + +def _trigger_crisis_overlay(page): + """Programmatically show the crisis overlay (bypasses 10s countdown).""" + page.evaluate("""() => { + var overlay = document.getElementById('crisis-overlay'); + overlay.classList.add('active'); + var btn = document.getElementById('overlay-dismiss-btn'); + btn.disabled = false; + btn.textContent = 'Continue to chat'; + btn.focus(); + }""") + + +def _dismiss_overlay(page): + """Close overlay if open.""" + page.evaluate("""() => { + var overlay = document.getElementById('crisis-overlay'); + if (overlay.classList.contains('active')) { + overlay.classList.remove('active'); + var btn = document.getElementById('overlay-dismiss-btn'); + btn.disabled = false; + } + }""") + + +class TestCrisisOverlayKeyboard: + def test_tab_cycles_through_focusable_elements(self, browser_ctx): + """Tab should cycle through focusable elements within the overlay.""" + page = browser_ctx + _trigger_crisis_overlay(page) + + focusable = page.evaluate("""() => { + var overlay = document.getElementById('crisis-overlay'); + return overlay.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ).length; + }""") + assert focusable >= 1, "Overlay must have at least one focusable element" + + # Get the dismiss button + dismiss = page.locator("#overlay-dismiss-btn") + expect(dismiss).to_be_focused() + + # Tab to the call link + page.keyboard.press("Tab") + call_link = page.locator(".overlay-call") + expect(call_link).to_be_focused() + + # Tab again should wrap back to dismiss button + page.keyboard.press("Tab") + expect(dismiss).to_be_focused() + + _dismiss_overlay(page) + + def test_shift_tab_reverses_direction(self, browser_ctx): + """Shift+Tab should reverse the tab order and wrap.""" + page = browser_ctx + _trigger_crisis_overlay(page) + + dismiss = page.locator("#overlay-dismiss-btn") + expect(dismiss).to_be_focused() + + # Shift+Tab from dismiss should wrap to last element (call link) + page.keyboard.press("Shift+Tab") + call_link = page.locator(".overlay-call") + expect(call_link).to_be_focused() + + # Shift+Tab from call link should wrap back to dismiss + page.keyboard.press("Shift+Tab") + expect(dismiss).to_be_focused() + + _dismiss_overlay(page) + + def test_escape_closes_overlay(self, browser_ctx): + """Escape key should close the crisis overlay.""" + page = browser_ctx + _trigger_crisis_overlay(page) + + overlay = page.locator("#crisis-overlay") + expect(overlay).to_have_class(/active/) + + page.keyboard.press("Escape") + + expect(overlay).not_to_have_class(/active/) + + def test_escape_returns_focus_to_chat_input(self, browser_ctx): + """After Escape, focus should return to the chat input.""" + page = browser_ctx + + # Focus the message input first + msg_input = page.locator("#msg-input") + msg_input.focus() + expect(msg_input).to_be_focused() + + # Show overlay + _trigger_crisis_overlay(page) + + # Dismiss with Escape + page.keyboard.press("Escape") + + # Focus should return to chat input + expect(msg_input).to_be_focused() + + def test_focus_does_not_escape_overlay(self, browser_ctx): + """Focus must not reach background elements while overlay is active.""" + page = browser_ctx + _trigger_crisis_overlay(page) + + # Tab multiple times — focus should stay within overlay + for _ in range(10): + page.keyboard.press("Tab") + focused_tag = page.evaluate("() => document.activeElement.tagName.toLowerCase()") + focused_id = page.evaluate("() => document.activeElement.id || ''") + # Focus should be on overlay-dismiss-btn or overlay-call link + assert focused_id in ("overlay-dismiss-btn", "") or page.evaluate("() => document.activeElement.classList.contains('overlay-call')"), f"Focus escaped overlay to: {focused_tag}#{focused_id}" + + _dismiss_overlay(page) -- 2.43.0 From eb156cf09e9b88738cc1895214c9c54f162b30f4 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Wed, 15 Apr 2026 03:20:26 +0000 Subject: [PATCH 3/3] fix: Add Escape key handling and focus recovery to crisis overlay (#95)\n\n- Escape closes overlay and returns focus to chat input\n- Focus recovery: if focus escapes overlay, Tab brings it back\n- Playwright E2E test for keyboard-only navigation --- index.html | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/index.html b/index.html index 06cff1c..e7c7422 100644 --- a/index.html +++ b/index.html @@ -993,6 +993,16 @@ Sovereignty and service always.`; function trapFocusInOverlay(e) { if (!crisisOverlay.classList.contains('active')) return; + + // Escape: close overlay and restore focus + if (e.key === 'Escape') { + e.preventDefault(); + if (!overlayDismissBtn.disabled) { + overlayDismissBtn.click(); + } + return; + } + if (e.key !== 'Tab') return; var focusable = getOverlayFocusableElements(); @@ -1001,6 +1011,20 @@ Sovereignty and service always.`; var first = focusable[0]; var last = focusable[focusable.length - 1]; + // If focus is outside the overlay entirely, bring it back + var focusInOverlay = false; + for (var i = 0; i < focusable.length; i++) { + if (focusable[i] === document.activeElement) { + focusInOverlay = true; + break; + } + } + if (!focusInOverlay) { + e.preventDefault(); + first.focus(); + return; + } + if (e.shiftKey) { // Shift+Tab: if on first, wrap to last if (document.activeElement === first) { -- 2.43.0