diff --git a/app.js b/app.js
index 5ddc802..ac39536 100644
--- a/app.js
+++ b/app.js
@@ -41,6 +41,9 @@ import { createProcessesView } from './ui/processes.js'
import { createDataManager } from './ui/data-manager.js'
import { formatMib, formatKilobitsPerSec } from './shared/format.js'
import { chartOptionLabel } from './shared/container-names.js'
+import { initConfirmModal, appConfirm } from './ui/confirm-modal.js'
+
+initConfirmModal()
const $ = (id) => document.getElementById(id)
@@ -924,7 +927,15 @@ function loadFleetView() {
},
onForget: async (peer) => {
const label = peer.alias || peer.id.slice(0, 12)
- if (!confirm(`Forget ${label}?`)) return
+ const ok = await appConfirm({
+ title: 'Forget agent',
+ message: `Remove “${label}” from saved agents?`,
+ detail: 'You can reconnect later with an invite or public key.',
+ confirmLabel: 'Forget',
+ danger: true,
+ icon: '⊘',
+ })
+ if (!ok) return
removeBookmark(peer.publicKeyHex)
await manager.disconnect(peer.publicKeyHex).catch(() => {})
log(`Forgot ${label}`)
@@ -1601,7 +1612,15 @@ els.settingQvacIdle?.addEventListener('change', () => {
els.btnQvacOpen?.addEventListener('click', () => showView('qvac'))
els.btnResetPeers?.addEventListener('click', async () => {
- if (!confirm('Forget all saved agents and disconnect?')) return
+ const ok = await appConfirm({
+ title: 'Forget all agents',
+ message: 'Disconnect and clear every saved agent from this device?',
+ detail: 'Bookmarks, peer cache, and active sessions will be removed. Invites will be required to reconnect.',
+ confirmLabel: 'Forget all',
+ danger: true,
+ icon: '⊘',
+ })
+ if (!ok) return
await manager.disconnectAll({ forget: true })
const { savePeers } = await import('./client/peerCache.js')
savePeers({}, { activePeerId: null, force: true })
diff --git a/index.html b/index.html
index 75eb815..c0d0262 100644
--- a/index.html
+++ b/index.html
@@ -21,6 +21,7 @@
+
diff --git a/ui/confirm-modal.js b/ui/confirm-modal.js
new file mode 100644
index 0000000..fec29a0
--- /dev/null
+++ b/ui/confirm-modal.js
@@ -0,0 +1,178 @@
+/**
+ * App confirmation modal — replaces window.confirm with a polished UI.
+ *
+ * Usage:
+ * import { initConfirmModal, appConfirm } from './confirm-modal.js'
+ * initConfirmModal()
+ * const ok = await appConfirm({ title: 'Delete?', message: '…', danger: true })
+ */
+
+/**
+ * @typedef {{
+ * title?: string,
+ * message?: string,
+ * detail?: string,
+ * confirmLabel?: string,
+ * cancelLabel?: string,
+ * danger?: boolean,
+ * icon?: string,
+ * }} ConfirmOptions
+ */
+
+/** @type {ReturnType
|null} */
+let singleton = null
+
+/**
+ * @param {HTMLElement} [mount]
+ */
+export function initConfirmModal(mount = document.body) {
+ if (singleton) return singleton
+ singleton = createConfirmModal(mount)
+ return singleton
+}
+
+/**
+ * @param {string|ConfirmOptions} opts
+ * @returns {Promise}
+ */
+export function appConfirm(opts) {
+ if (!singleton) initConfirmModal()
+ return singleton.confirm(opts)
+}
+
+/**
+ * @param {HTMLElement} mount
+ */
+export function createConfirmModal(mount) {
+ const root = document.createElement('div')
+ root.id = 'app-confirm-root'
+ root.className = 'app-confirm-root'
+ root.setAttribute('aria-hidden', 'true')
+ root.innerHTML = `
+
+
+
+
?
+
+
+
+
+
+
+ `
+ mount.appendChild(root)
+
+ const dialog = root.querySelector('.app-confirm-dialog')
+ const titleEl = root.querySelector('#app-confirm-title')
+ const msgEl = root.querySelector('#app-confirm-msg')
+ const detailEl = root.querySelector('#app-confirm-detail')
+ const iconEl = root.querySelector('#app-confirm-icon')
+ const okBtn = root.querySelector('#app-confirm-ok')
+ const cancelBtn = root.querySelector('#app-confirm-cancel')
+
+ /** @type {((v: boolean) => void)|null} */
+ let resolveFn = null
+ /** @type {HTMLElement|null} */
+ let lastFocus = null
+
+ function close(result) {
+ if (!resolveFn) return
+ const r = resolveFn
+ resolveFn = null
+ root.classList.remove('is-open')
+ root.setAttribute('aria-hidden', 'true')
+ document.body.classList.remove('app-confirm-open')
+ window.setTimeout(() => {
+ root.classList.remove('is-visible')
+ }, 220)
+ try {
+ lastFocus?.focus?.()
+ } catch {
+ // ignore
+ }
+ lastFocus = null
+ r(result)
+ }
+
+ /**
+ * @param {string|ConfirmOptions} raw
+ * @returns {Promise}
+ */
+ function confirm(raw) {
+ const opts = typeof raw === 'string' ? { message: raw } : raw || {}
+ if (resolveFn) {
+ // Stack: resolve previous as false
+ resolveFn(false)
+ resolveFn = null
+ }
+ return new Promise((resolve) => {
+ resolveFn = resolve
+ lastFocus = /** @type {HTMLElement|null} */ (document.activeElement)
+
+ const danger = Boolean(opts.danger)
+ titleEl.textContent = opts.title || (danger ? 'Please confirm' : 'Confirm')
+ msgEl.textContent = opts.message || 'Are you sure?'
+ if (opts.detail) {
+ detailEl.textContent = opts.detail
+ detailEl.classList.remove('hidden')
+ } else {
+ detailEl.textContent = ''
+ detailEl.classList.add('hidden')
+ }
+ iconEl.textContent = opts.icon || (danger ? '!' : '?')
+ root.dataset.danger = danger ? '1' : '0'
+ okBtn.textContent = opts.confirmLabel || (danger ? 'Confirm' : 'Continue')
+ cancelBtn.textContent = opts.cancelLabel || 'Cancel'
+ okBtn.className = danger
+ ? 'btn app-confirm-ok app-confirm-ok-danger'
+ : 'btn btn-primary app-confirm-ok'
+
+ root.classList.add('is-visible')
+ // next frame for enter animation
+ requestAnimationFrame(() => {
+ root.classList.add('is-open')
+ document.body.classList.add('app-confirm-open')
+ // Safer default: focus Cancel for destructive actions
+ ;(danger ? cancelBtn : okBtn).focus()
+ })
+ root.setAttribute('aria-hidden', 'false')
+ })
+ }
+
+ okBtn.addEventListener('click', () => close(true))
+ cancelBtn.addEventListener('click', () => close(false))
+ root.querySelector('[data-confirm-dismiss]')?.addEventListener('click', () => close(false))
+
+ root.addEventListener('keydown', (ev) => {
+ if (!resolveFn) return
+ if (ev.key === 'Escape') {
+ ev.preventDefault()
+ close(false)
+ } else if (ev.key === 'Enter' && document.activeElement !== cancelBtn) {
+ // Enter confirms unless Cancel is focused
+ if (document.activeElement === okBtn || document.activeElement === dialog) {
+ ev.preventDefault()
+ close(true)
+ }
+ } else if (ev.key === 'Tab') {
+ // Simple focus trap
+ const focusables = [cancelBtn, okBtn]
+ const i = focusables.indexOf(/** @type {HTMLElement} */ (document.activeElement))
+ if (ev.shiftKey) {
+ if (i <= 0) {
+ ev.preventDefault()
+ okBtn.focus()
+ }
+ } else if (i === focusables.length - 1 || i < 0) {
+ ev.preventDefault()
+ cancelBtn.focus()
+ }
+ }
+ })
+
+ return { confirm, close: () => close(false) }
+}
diff --git a/ui/custom-dashboard.js b/ui/custom-dashboard.js
index 0818b9c..012f93b 100644
--- a/ui/custom-dashboard.js
+++ b/ui/custom-dashboard.js
@@ -10,6 +10,7 @@
*/
import { drawChart, seriesColor } from './charts.js'
import { normalizeChartMode, CHART_MODE_LABEL } from '../shared/chart-types.js'
+import { appConfirm } from './confirm-modal.js'
/**
* @typedef {{
@@ -486,10 +487,18 @@ export function createCustomDashboardView(opts) {
syncEditChrome()
render()
})
- opts.els.deleteBtn?.addEventListener('click', () => {
+ opts.els.deleteBtn?.addEventListener('click', async () => {
const d = store().getActive()
if (!d) return
- if (!confirm(`Delete dashboard “${d.name}”?`)) return
+ const ok = await appConfirm({
+ title: 'Delete dashboard',
+ message: `Delete “${d.name}”?`,
+ detail: 'Tiles and layout for this board will be removed. Other dashboards are unaffected.',
+ confirmLabel: 'Delete',
+ danger: true,
+ icon: '⌫',
+ })
+ if (!ok) return
store().remove(d.id)
persist()
render()
diff --git a/ui/data-manager.js b/ui/data-manager.js
index bb8debc..b246a35 100644
--- a/ui/data-manager.js
+++ b/ui/data-manager.js
@@ -11,6 +11,7 @@ import {
matchPointsPreset,
matchBytesPreset,
} from '../shared/retention.js'
+import { appConfirm } from './confirm-modal.js'
/**
* @param {{
@@ -343,14 +344,16 @@ export function createDataManager(deps) {
els.btnRefresh?.addEventListener('click', () => refresh())
els.btnSave?.addEventListener('click', () => save())
els.btnPruneDry?.addEventListener('click', () => runPrune(true))
- els.btnPruneNow?.addEventListener('click', () => {
- if (
- !confirm(
- 'Prune warm history on the connected agent now? Points older than the retention window will be deleted.'
- )
- ) {
- return
- }
+ els.btnPruneNow?.addEventListener('click', async () => {
+ const ok = await appConfirm({
+ title: 'Prune warm history',
+ message: 'Delete points older than the retention window on the connected agent?',
+ detail: 'This cannot be undone. A dry-run is available if you want to preview first.',
+ confirmLabel: 'Prune now',
+ danger: true,
+ icon: '⌫',
+ })
+ if (!ok) return
runPrune(false)
})
els.hotPreset?.addEventListener('change', () => {
diff --git a/ui/qvac/index.js b/ui/qvac/index.js
index 3f98776..ca36f11 100644
--- a/ui/qvac/index.js
+++ b/ui/qvac/index.js
@@ -13,6 +13,7 @@ import {
} from './agents.js'
import { chartIdsFromToolLog, mountChartEmbeds } from './chart-embed.js'
import { createSwarmPanel } from './swarm-ui.js'
+import { appConfirm } from '../confirm-modal.js'
/**
* @param {{
@@ -68,12 +69,22 @@ export function createQvacView(opts) {
dashboards: opts.dashboards,
getAutoNavigate: () => settings().qvacAutoNavigate || 'ask',
confirmAction: (msg) => {
- try {
- // Must be strict true — Cancel returns false
- return typeof confirm === 'function' ? confirm(msg) : false
- } catch {
- return false
- }
+ const text = String(msg || 'Continue?')
+ const danger =
+ /silence|acknowledge|ack |run job|delete|forget|prune|remove|kill/i.test(text)
+ const isNav = /open|switch|view|navigate/i.test(text)
+ return appConfirm({
+ title: danger
+ ? 'Confirm agent action'
+ : isNav
+ ? 'Switch view'
+ : 'Confirm',
+ message: text,
+ confirmLabel: danger ? 'Allow' : isNav ? 'Open' : 'Continue',
+ cancelLabel: 'Not now',
+ danger,
+ icon: danger ? '!' : isNav ? '↗' : '?',
+ })
},
})
diff --git a/ui/styles.css b/ui/styles.css
index dfe054c..b6e3d79 100644
--- a/ui/styles.css
+++ b/ui/styles.css
@@ -4902,3 +4902,255 @@ html[data-theme='light'] .proc-detail-cmd {
max-width: 40vw;
}
}
+
+/* ── App confirmation modal ─────────────────────────────────────────────── */
+
+body.app-confirm-open {
+ overflow: hidden;
+}
+
+.app-confirm-root {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+ transition:
+ opacity 0.22s ease,
+ visibility 0.22s ease;
+}
+
+.app-confirm-root.is-visible {
+ visibility: visible;
+}
+
+.app-confirm-root.is-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.app-confirm-backdrop {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(ellipse 70% 50% at 50% 40%, rgba(52, 211, 153, 0.07), transparent 60%),
+ rgba(4, 6, 10, 0.68);
+ backdrop-filter: blur(16px) saturate(1.2);
+ -webkit-backdrop-filter: blur(16px) saturate(1.2);
+ cursor: pointer;
+}
+
+html[data-theme='light'] .app-confirm-backdrop {
+ background:
+ radial-gradient(ellipse 70% 50% at 50% 40%, rgba(52, 211, 153, 0.1), transparent 60%),
+ rgba(15, 23, 42, 0.38);
+}
+
+.app-confirm-root[data-danger='1'] .app-confirm-backdrop {
+ background:
+ radial-gradient(ellipse 70% 50% at 50% 40%, rgba(248, 113, 113, 0.1), transparent 60%),
+ rgba(4, 6, 10, 0.72);
+}
+
+html[data-theme='light'] .app-confirm-root[data-danger='1'] .app-confirm-backdrop {
+ background:
+ radial-gradient(ellipse 70% 50% at 50% 40%, rgba(248, 113, 113, 0.12), transparent 60%),
+ rgba(15, 23, 42, 0.42);
+}
+
+.app-confirm-dialog {
+ position: relative;
+ z-index: 1;
+ width: min(420px, 100%);
+ padding: 28px 28px 22px;
+ border-radius: 20px;
+ border: 1px solid var(--border-strong);
+ background:
+ linear-gradient(165deg, rgba(255, 255, 255, 0.055) 0%, transparent 42%),
+ var(--bg-secondary);
+ box-shadow:
+ var(--shadow-md),
+ 0 0 0 1px rgba(52, 211, 153, 0.08),
+ 0 24px 64px rgba(0, 0, 0, 0.45);
+ transform: translateY(14px) scale(0.96);
+ opacity: 0;
+ transition:
+ transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
+ opacity 0.22s ease;
+ overflow: hidden;
+}
+
+.app-confirm-root.is-open .app-confirm-dialog {
+ transform: translateY(0) scale(1);
+ opacity: 1;
+}
+
+.app-confirm-root[data-danger='1'] .app-confirm-dialog {
+ box-shadow:
+ var(--shadow-md),
+ 0 0 0 1px rgba(248, 113, 113, 0.14),
+ 0 24px 64px rgba(0, 0, 0, 0.45);
+}
+
+.app-confirm-glow {
+ position: absolute;
+ top: -40%;
+ left: 50%;
+ width: 140%;
+ height: 70%;
+ transform: translateX(-50%);
+ background: radial-gradient(
+ ellipse at center,
+ color-mix(in srgb, var(--accent-primary) 22%, transparent),
+ transparent 68%
+ );
+ pointer-events: none;
+ opacity: 0.85;
+}
+
+.app-confirm-root[data-danger='1'] .app-confirm-glow {
+ background: radial-gradient(
+ ellipse at center,
+ rgba(248, 113, 113, 0.22),
+ transparent 68%
+ );
+}
+
+.app-confirm-icon {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ margin: 0 auto 16px;
+ border-radius: 16px;
+ font-size: 22px;
+ font-weight: 700;
+ font-family: var(--font-sans);
+ letter-spacing: -0.02em;
+ color: var(--accent-primary);
+ background: color-mix(in srgb, var(--accent-primary) 14%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent);
+ box-shadow:
+ 0 0 0 4px color-mix(in srgb, var(--accent-primary) 8%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ animation: appConfirmIconIn 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+.app-confirm-root[data-danger='1'] .app-confirm-icon {
+ color: #f87171;
+ background: rgba(248, 113, 113, 0.12);
+ border-color: rgba(248, 113, 113, 0.38);
+ box-shadow:
+ 0 0 0 4px rgba(248, 113, 113, 0.08),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+}
+
+@keyframes appConfirmIconIn {
+ from {
+ opacity: 0;
+ transform: scale(0.7) rotate(-8deg);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) rotate(0);
+ }
+}
+
+.app-confirm-body {
+ position: relative;
+ text-align: center;
+ margin-bottom: 22px;
+}
+
+.app-confirm-title {
+ margin: 0 0 8px;
+ font-size: 1.2rem;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+ color: var(--text-primary);
+ line-height: 1.25;
+}
+
+.app-confirm-message {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+}
+
+.app-confirm-detail {
+ margin: 10px 0 0;
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--text-muted);
+}
+
+.app-confirm-detail.hidden {
+ display: none;
+}
+
+.app-confirm-actions {
+ position: relative;
+ display: flex;
+ gap: 10px;
+ justify-content: stretch;
+}
+
+.app-confirm-actions .btn {
+ flex: 1;
+ min-height: 40px;
+ border-radius: 11px;
+ font-weight: 560;
+ font-size: 13.5px;
+}
+
+.app-confirm-cancel:focus-visible,
+.app-confirm-ok:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--accent-primary) 55%, transparent);
+ outline-offset: 2px;
+}
+
+.app-confirm-ok-danger {
+ background: rgba(248, 113, 113, 0.16) !important;
+ border-color: rgba(248, 113, 113, 0.45) !important;
+ color: #fca5a5 !important;
+}
+
+.app-confirm-ok-danger:hover:not(:disabled) {
+ background: rgba(248, 113, 113, 0.26) !important;
+ color: #fecaca !important;
+ border-color: rgba(248, 113, 113, 0.6) !important;
+}
+
+.app-confirm-ok-danger:focus-visible {
+ outline-color: rgba(248, 113, 113, 0.55);
+}
+
+html[data-theme='light'] .app-confirm-dialog {
+ box-shadow:
+ 0 20px 50px rgba(15, 23, 42, 0.18),
+ 0 0 0 1px rgba(52, 211, 153, 0.1);
+}
+
+html[data-theme='light'] .app-confirm-ok-danger {
+ color: #dc2626 !important;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-confirm-root,
+ .app-confirm-dialog,
+ .app-confirm-icon {
+ transition: none !important;
+ animation: none !important;
+ }
+ .app-confirm-dialog {
+ transform: none;
+ }
+}