diff --git a/AGENTS.md b/AGENTS.md index 2067990..1eb0372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,3 +47,10 @@ SQLite databases, WAL, or SHM files as a repair, and never automatically change ownership or permissions. Diagnostic commands must not initialize missing databases or run migrations. Root-run diagnostics must not open SQLite and create root-owned artifacts; verify write access as the actual service user. + +## Confirmation dialogs + +Use the shared themed in-app dialog for accepting or cancelling actions. Do not +introduce browser-native alert, confirm, or prompt dialogs. Preserve keyboard +focus, Escape/Cancel dismissal, form validation, and exactly-once submission. +Run `scripts/test_confirmations.py` with Playwright when changing this behavior. diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index 37df3ae..d8c1783 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -368,3 +368,11 @@ kbd { font-size: 11px; padding: 1px 5px; border-radius: 4px; border: 1px var(--b /* Diagnostic commands must wrap safely on narrow screens. */ .database-diagnostics { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--mono); margin: 0; } + +/* Shared confirmations use the active theme and the browser's modal focus trap. */ +.confirm-dialog { width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); margin: auto; padding: 24px; color: var(--text); background: var(--surface); border: 1px var(--border-style) var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); overflow: auto; } +.confirm-dialog::backdrop { background: var(--overlay); } +.confirm-dialog h2 { margin: 0 0 12px; font-size: 18px; overflow-wrap: anywhere; } +.confirm-dialog p { margin: 0; color: var(--text-2); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } +.confirm-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; } +.confirm-actions .btn { min-height: 44px; white-space: normal; } diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index e2e5884..d73bb82 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -106,15 +106,80 @@ } // ---------- confirm / copy / misc ---------- - // Boosted forms confirm through htmx's hx-confirm; this covers forms htmx - // doesn't handle (hx-boost="false") and the no-htmx fallback. + let confirmation = null; + const approvedForms = new WeakSet(); + + // Use the same accessible dialog for htmx requests and ordinary form posts. + function confirmAction(message, source, submitter, proceed) { + if (confirmation) return; + const trigger = submitter || document.activeElement; + const button = submitter || (source.matches("button, input") ? source : source.querySelector('button[type="submit"], button:not([type]), input[type="submit"]')); + const label = source.dataset.confirmAction || (button && (button.getAttribute("aria-label") || button.textContent.trim() || button.value)) || "Continue"; + const dialog = document.createElement("dialog"); + dialog.className = "confirm-dialog"; + dialog.setAttribute("aria-labelledby", "confirm-title"); + dialog.setAttribute("aria-describedby", "confirm-message"); + dialog.innerHTML = '
'; + dialog.querySelector("h2").textContent = label; + dialog.querySelector("p").textContent = message; + const accept = dialog.querySelector("[data-accept]"); + accept.textContent = label; + if (button && button.classList.contains("danger")) accept.className = "btn danger"; + let accepted = false; + dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.close()); + accept.addEventListener("click", () => { + if (accepted) return; + accepted = true; + accept.disabled = true; + dialog.close(); + }); + // Keep page shortcuts (such as the command palette) out of this modal. + dialog.addEventListener("keydown", (event) => event.stopPropagation()); + dialog.addEventListener("close", () => { + confirmation = null; + dialog.remove(); + if (trigger && trigger.isConnected) trigger.focus(); + if (accepted && source.isConnected) proceed(); + }, { once: true }); + confirmation = dialog; + document.body.appendChild(dialog); + dialog.showModal(); + } + + document.addEventListener("htmx:confirm", (e) => { + if (!e.detail.question) return; + e.preventDefault(); + const source = e.detail.elt; + if (approvedForms.has(source)) { + e.detail.issueRequest(true); + return; + } + const submitter = e.detail.triggeringEvent && e.detail.triggeringEvent.submitter; + confirmAction(e.detail.question, source, submitter, () => e.detail.issueRequest(true)); + }); + document.addEventListener("submit", (e) => { - const f = e.target; - const boosted = window.htmx && f.closest("[hx-boost=true]") && !f.closest("[hx-boost=false]"); - const msg = f.getAttribute("hx-confirm"); - if (msg && !boosted && !window.confirm(msg)) e.preventDefault(); + const form = e.target; + const message = form.getAttribute("hx-confirm"); + if (!message || approvedForms.has(form)) return; + e.preventDefault(); + e.stopImmediatePropagation(); + const submitter = e.submitter; + confirmAction(message, form, submitter, () => { + approvedForms.add(form); + try { + form.requestSubmit(submitter || undefined); + } finally { + approvedForms.delete(form); + } + }); }, true); + // A live refresh or navigation must not leave an obsolete action pending. + document.addEventListener("htmx:beforeSwap", () => { + if (confirmation) confirmation.close(); + }); + document.addEventListener("click", (e) => { const c = e.target.closest("[data-copy]"); if (c) { diff --git a/scripts/test_confirmations.py b/scripts/test_confirmations.py new file mode 100644 index 0000000..c6c35e1 --- /dev/null +++ b/scripts/test_confirmations.py @@ -0,0 +1,122 @@ +"""Exercise shared confirmation dialogs with real htmx and mocked HTTP endpoints. + +Requires Playwright and Chromium: python scripts/test_confirmations.py +Use --browser /path/to/chromium to use an existing browser installation. +""" +import argparse +from pathlib import Path +from playwright.sync_api import sync_playwright, expect + +ROOT = Path(__file__).resolve().parents[1] +ORIGIN = "http://meshpanel.test" + + +def run(browser): + """Verify cancellation, focus, exactly-once submission, and fallback behavior.""" + for use_htmx in (True, False): + context = browser.new_context() + page = context.new_page() + requests, native_dialogs = [], [] + page.on("dialog", lambda dialog: (native_dialogs.append(dialog.type), dialog.dismiss())) + assets = {f"/static/{name}": ROOT / "internal/web/static" / name for name in ( + "js/app.js", "js/htmx.min.js", "css/app.css", "css/tokens.css")} + html = ''' + + + + HTMX + + + + '''.replace("HTMX", '' if use_htmx else "") + + def route(request): + """Serve real assets and record posts without mutating application data.""" + path = request.request.url.removeprefix(ORIGIN).split("?")[0] + if request.request.method == "POST": + requests.append((path, request.request.post_data or "")) + if request.request.is_navigation_request(): + request.fulfill(content_type="text/html", body=html) + else: + request.fulfill(status=204) + elif path in assets: + request.fulfill(path=str(assets[path])) + else: + request.fulfill(content_type="text/html", body=html) + + page.route(ORIGIN + "/**", route) + page.goto(ORIGIN) + page.wait_for_load_state("networkidle") + trigger = page.get_by_role("button", name="Set password", exact=True) + trigger.click() + expect(page.locator("dialog")).to_have_count(0) # required input validation + page.get_by_label("Password").fill("test-password") + trigger.click() + dialog = page.get_by_role("dialog", name="Set password") + expect(dialog).to_be_visible() + expect(dialog.get_by_role("button", name="Cancel")).to_be_focused() + page.keyboard.press("Tab") + expect(dialog.get_by_role("button", name="Set password")).to_be_focused() + page.keyboard.press("Tab") + # Browser may focus the modal itself at the cycle boundary, but never the background. + assert page.evaluate("document.activeElement.closest('dialog') !== null || document.activeElement === document.body") + page.keyboard.press("Escape") + expect(page.locator("dialog")).to_have_count(0) + expect(trigger).to_be_focused() + assert not requests + trigger.click() + page.keyboard.press("Enter") # safe default cancels + expect(page.locator("dialog")).to_have_count(0) + assert not requests + trigger.click() + with page.expect_request(lambda r: r.method == "POST"): + page.get_by_role("dialog").get_by_role("button", name="Set password").click() + expect(page.locator("dialog")).to_have_count(0) + assert requests == [("/save", "password=test-password&operation=password")], requests + for width, height, theme in ((1440, 960, "paper"), (375, 812, "midnight"), (812, 375, "paper")): + page.set_viewport_size({"width": width, "height": height}) + page.evaluate("v => document.documentElement.dataset.theme = v", theme) + page.get_by_role("button", name="Delete account").click() + dialog = page.get_by_role("dialog") + expect(dialog).to_contain_text("Delete