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) {
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()
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)