Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions internal/web/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
77 changes: 71 additions & 6 deletions internal/web/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<h2 id="confirm-title"></h2><p id="confirm-message"></p><div class="confirm-actions"><button type="button" class="btn" data-cancel autofocus>Cancel</button><button type="button" class="btn primary" data-accept></button></div>';
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) {
Expand Down
122 changes: 122 additions & 0 deletions scripts/test_confirmations.py
Original file line number Diff line number Diff line change
@@ -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 = '''<!doctype html><html data-theme="paper"><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/css/tokens.css">
<link rel="stylesheet" href="/static/css/app.css">
HTMX<script src="/static/js/app.js" defer></script></head><body hx-boost="true">
<form id="boosted" method="post" action="/save" hx-confirm="Set a new password and sign them out everywhere?">
<input aria-label="Password" name="password" required>
<button class="btn primary" name="operation" value="password">Set password</button>
</form>
<form id="ordinary" hx-boost="false" method="post" action="/delete" hx-confirm="Delete &lt;img src=x onerror=alert(1)&gt;?">
<button class="btn danger" name="operation" value="delete">Delete account</button>
</form>
<button id="direct" hx-post="/direct" hx-confirm="Install this update?">Install update</button>
</body></html>'''.replace("HTMX", '<script src="/static/js/htmx.min.js" defer></script>' 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 <img src=x onerror=alert(1)>?")
expect(dialog.locator("img")).to_have_count(0)
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
box = dialog.bounding_box()
assert box["x"] >= 0 and box["y"] >= 0 and box["y"] + box["height"] <= height
if use_htmx:
page.screenshot(path=f"/tmp/meshpanel-confirm-{width}.png")
dialog.get_by_role("button", name="Cancel").click()
expect(page.locator("dialog")).to_have_count(0)
assert len(requests) == 1
page.get_by_role("button", name="Delete account").click()
with page.expect_request(lambda r: r.method == "POST"):
page.get_by_role("dialog").get_by_role("button", name="Delete account").click()
expect(page.locator("dialog")).to_have_count(0)
assert requests[-1] == ("/delete", "operation=delete"), requests
if use_htmx:
page.locator("#direct").click()
with page.expect_request(lambda r: r.method == "POST"):
page.get_by_role("dialog").get_by_role("button", name="Install update").click()
expect(page.locator("dialog")).to_have_count(0)
assert requests[-1][0] == "/direct"
assert len(requests) == (3 if use_htmx else 2), requests
assert not native_dialogs, native_dialogs
context.close()
print("Passed: themed confirmations, cancellation, keyboard focus, validation, submitter values, htmx and ordinary posts, no native dialogs.")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--browser", help="Existing Chromium executable")
args = parser.parse_args()
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path=args.browser)
try:
run(browser)
finally:
browser.close()
Loading