dashboard: theme all remaining dialogs (replace native confirm/alert/prompt)

Follow-up to the themed-modal component: route every remaining native
browser dialog through modal.js so nothing falls back to the OS chrome.

- modal.js: add themedPrompt (input dialog) + themedToast (non-blocking
  transient notification, info/error/ok) alongside openDialog/themedConfirm.
- Migrate call sites: bindAsyncForms confirm/prompt/alerts (common.js),
  the answer-validation alert (call.js), the M0V3 reparent confirm + action
  toasts (tabs.js), and the schedule form/cancel/fire confirms + validation
  and error alerts (schedules.js).
- UX: blocking modal for confirms/prompts; non-blocking toast for transient
  errors + validation. Destructive confirms keep the danger styling.
- CSS for the toast stack + prompt input.

common.js <-> modal.js is a safe deferred import cycle (usage is call-time
only); esbuild bundles it clean.
This commit is contained in:
iris 2026-06-19 02:03:46 +02:00 committed by mara
commit 7c2de690ec
6 changed files with 141 additions and 32 deletions

View file

@ -12,6 +12,7 @@
// target-chip pickers. Render + format helpers come from util.js.
import { $, el } from './common.js';
import { themedConfirm, themedToast } from './modal.js';
import { paintAtomic, fmtAgo, fmtDuration } from './util.js';
import { containersState } from './state.js';
@ -530,14 +531,14 @@ async function submitNewScheduleInline(tr, submitBtn) {
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
if (!targets.length) {
alert('schedule must have at least one target — tick at least one agent column');
themedToast('schedule must have at least one target — tick at least one agent column', { type: 'error' });
return;
}
if (!body) { alert('prompt body must be non-empty'); return; }
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
if (!body) { themedToast('prompt body must be non-empty', { type: 'error' }); return; }
if (!firstFireStr) { themedToast('first fire timestamp is required', { type: 'error' }); return; }
const firstFireDate = new Date(firstFireStr);
if (Number.isNaN(firstFireDate.getTime())) {
alert('first fire is not a valid datetime'); return;
themedToast('first fire is not a valid datetime', { type: 'error' }); return;
}
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
@ -554,7 +555,7 @@ async function submitNewScheduleInline(tr, submitBtn) {
};
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
if (Number.isNaN(intervalTotal)) {
alert('interval fields must be non-negative integers (or blank for one-shot)');
themedToast('interval fields must be non-negative integers (or blank for one-shot)', { type: 'error' });
return;
}
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
@ -574,14 +575,14 @@ async function submitNewScheduleInline(tr, submitBtn) {
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
themedToast('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
return;
}
// Reset carry so the next render shows an empty row.
resetNewScheduleCarry();
await refreshSchedules();
} catch (err) {
alert('schedule submit failed: ' + err);
themedToast('schedule submit failed: ' + err, { type: 'error' });
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalLabel;
@ -899,11 +900,11 @@ async function submitEditSchedule(originalSchedule, form_) {
const newDescription = String(fd.get('description') || '').trim();
const newNextFireStr = String(fd.get('next_fire') || '');
if (!newBody) { alert('body must be non-empty'); return; }
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
if (!newBody) { themedToast('body must be non-empty', { type: 'error' }); return; }
if (!newNextFireStr) { themedToast('next-fire timestamp is required', { type: 'error' }); return; }
const newNextFireDate = new Date(newNextFireStr);
if (Number.isNaN(newNextFireDate.getTime())) {
alert('next-fire is not a valid datetime'); return;
themedToast('next-fire is not a valid datetime', { type: 'error' }); return;
}
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
@ -911,7 +912,7 @@ async function submitEditSchedule(originalSchedule, form_) {
// `edit_interval_*` names in this form).
const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_');
if (Number.isNaN(intervalTotal)) {
alert('interval fields must be non-negative integers');
themedToast('interval fields must be non-negative integers', { type: 'error' });
return;
}
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
@ -929,7 +930,7 @@ async function submitEditSchedule(originalSchedule, form_) {
.map((t) => t.target),
);
if (!newTargets.size) {
alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead');
themedToast('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead', { type: 'error' });
return;
}
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
@ -971,14 +972,14 @@ async function submitEditSchedule(originalSchedule, form_) {
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
themedToast('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
return;
}
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
await refreshSchedules();
} catch (err) {
alert('edit failed: ' + err);
themedToast('edit failed: ' + err, { type: 'error' });
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
}
@ -992,7 +993,7 @@ async function fireScheduleNow(id, isOneShot, targets, btn) {
: `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is RECURRING — sends an extra pulse out-of-band. '
+ 'the regular cadence keeps firing on schedule.';
if (!confirm(prompt)) return;
if (!(await themedConfirm({ message: prompt, danger: true }))) return;
// Capture child nodes so we can restore on error, then replace
// with DOM-built content (textContent + element children rather
// than innerHTML — the format string only carries server-side
@ -1017,7 +1018,7 @@ async function fireScheduleNow(id, isOneShot, targets, btn) {
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
themedToast('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
restoreBtn();
return;
}
@ -1041,16 +1042,16 @@ async function fireScheduleNow(id, isOneShot, targets, btn) {
// refresh wipes the row in place.
setTimeout(refreshSchedules, 1500);
} catch (err) {
alert('fire-now failed: ' + err);
themedToast('fire-now failed: ' + err, { type: 'error' });
restoreBtn();
}
}
async function cancelScheduleAll(id) {
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
if (!(await themedConfirm({ message: `cancel schedule #${id}? this stops all future fires for every target.`, danger: true }))) return;
await postScheduleCancel(id, null);
}
async function cancelScheduleTargets(id, targets) {
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
if (!(await themedConfirm({ message: `cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`, danger: true }))) return;
await postScheduleCancel(id, targets);
}
async function postScheduleCancel(id, targets) {
@ -1063,12 +1064,12 @@ async function postScheduleCancel(id, targets) {
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
themedToast('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
return;
}
await refreshSchedules();
} catch (err) {
alert('cancel failed: ' + err);
themedToast('cancel failed: ' + err, { type: 'error' });
}
}