Confirm modals
Release rolling / release (push) Successful in 13m15s

This commit is contained in:
Raven Scott
2026-07-30 17:36:00 -04:00
parent e3d0f70b29
commit 215f52ff53
7 changed files with 491 additions and 18 deletions
+21 -2
View File
@@ -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 })
+1
View File
@@ -21,6 +21,7 @@
<link rel="stylesheet" href="./ui/styles.css" />
</head>
<body data-accent="teal" data-density="comfortable" data-reduce-motion="0">
<!-- Confirm modal is injected by ui/confirm-modal.js -->
<div id="titlebar" role="banner">
<div class="titlebar-left">
<pear-ctrl></pear-ctrl>
+178
View File
@@ -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<typeof createConfirmModal>|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<boolean>}
*/
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 = `
<div class="app-confirm-backdrop" data-confirm-dismiss></div>
<div class="app-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="app-confirm-title" aria-describedby="app-confirm-msg">
<div class="app-confirm-glow" aria-hidden="true"></div>
<div class="app-confirm-icon" id="app-confirm-icon" aria-hidden="true">?</div>
<div class="app-confirm-body">
<h2 class="app-confirm-title" id="app-confirm-title">Confirm</h2>
<p class="app-confirm-message" id="app-confirm-msg"></p>
<p class="app-confirm-detail muted hidden" id="app-confirm-detail"></p>
</div>
<div class="app-confirm-actions">
<button type="button" class="btn btn-ghost app-confirm-cancel" id="app-confirm-cancel">Cancel</button>
<button type="button" class="btn btn-primary app-confirm-ok" id="app-confirm-ok">Confirm</button>
</div>
</div>
`
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<boolean>}
*/
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) }
}
+11 -2
View File
@@ -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()
+11 -8
View File
@@ -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', () => {
+17 -6
View File
@@ -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 ? '↗' : '?',
})
},
})
+252
View File
@@ -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;
}
}