diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index 02597e9..b513009 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -313,6 +313,7 @@ The August 20 integration adds:
```bash
python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks
python tests/critical_reopen_check.py # BL-021 - on-hold mail reaches PM + CM 11 checks
+python tests/console_dialogs_check.py # BL-024 - consoles/launcher: 21 natives -> 0 17 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md
index ffc7e6f..5e6f480 100644
--- a/docs/waves/backlog.md
+++ b/docs/waves/backlog.md
@@ -525,7 +525,7 @@ deliberately deferred.
a product conversation about where it displays and who reads it.
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
-### BL-024 — 21 native dialogs remain on the operator consoles and the launcher
+### BL-024 — CLOSED 2026-08-20 (wp-dialog.js, the T7.9 kit shared; 21 -> 0; `console_dialogs_check` 17)
- **Found during:** T9.5 (the audit's dialog count)
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
diff --git a/html/admin.html b/html/admin.html
index 7a23417..470e610 100644
--- a/html/admin.html
+++ b/html/admin.html
@@ -217,7 +217,8 @@
-
+
+
diff --git a/html/admin.js b/html/admin.js
index 7805658..f339bd1 100644
--- a/html/admin.js
+++ b/html/admin.js
@@ -146,7 +146,9 @@ async function seedDemo(){
snapshot();
}
async function cleanDemo(){
- if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
+ if(!(await wpConfirmDialog({title:'Delete demo data',
+ message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
+ okLabel:'Delete them'}))) return;
const o=document.getElementById('demo-out'); o.innerHTML='';
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
// this button — the default list hides it and nothing else here can delete it.
@@ -250,10 +252,12 @@ async function archiveProject(id, name, archived){
'• Nothing is deleted. Unarchive here at any time to bring it back.'
: 'Unarchive “'+name+'”?\n\n'+
'It becomes visible in the pickers again and can be edited as normal.';
- if(!confirm(ask)) return;
+ if(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
+ message:ask, okLabel:archived?'Archive':'Unarchive'}))) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects();
- else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
+ else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
+ message:'Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))});
}
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
@@ -261,13 +265,16 @@ async function archiveProject(id, name, archived){
// enough to collide with one of them later. The -Admin suffix also says which of the
// two project deletions this is — the console's, not a project member's.
async function deleteProjectAdmin(id, name){
- if(!confirm('DELETE “'+name+'” permanently?\n\n'+
- 'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
- '(database cascade). This cannot be undone.\n\n'+
- 'If you only want it out of the way, cancel and use Archive instead.')) return;
+ if(!(await wpConfirmDialog({title:'Delete project permanently',
+ message:'DELETE “'+name+'” permanently?\n\n'+
+ 'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
+ '(database cascade). This cannot be undone.\n\n'+
+ 'If you only want it out of the way, cancel and use Archive instead.',
+ okLabel:'Delete permanently'}))) return;
const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects();
- else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
+ else wpAlertDialog({title:'Delete failed',
+ message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
}
// ── default members on new projects ─────────────────────────────────────────────
@@ -373,7 +380,8 @@ async function setAutoAdd(id, username){
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
renderDefaultMembers();
} else {
- alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
+ wpAlertDialog({title:'Change failed',
+ message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
loadDefaultMembers();
}
}
diff --git a/html/index.html b/html/index.html
index 4ee3c3a..6b16c0d 100644
--- a/html/index.html
+++ b/html/index.html
@@ -593,6 +593,7 @@
+
-
+
+
diff --git a/html/users.js b/html/users.js
index fe63e98..2a32c77 100644
--- a/html/users.js
+++ b/html/users.js
@@ -255,37 +255,44 @@ function projAccessCell(u){
// Each one reloads on failure so a control can never sit there showing a value the
// server refused.
async function resetPw(id, username){
- const pw = prompt('New password for "'+username+'" (min 12 characters):');
+ // The min-12 rule was stated in the prompt label and enforced only by the
+ // server round-trip; the kit's validate() answers AT the input instead.
+ const pw = await wpPromptDialog({title:'Reset password',
+ message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
+ label:'New password (min 12 characters)',
+ validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
- if(status === 200) alert('Password reset for '+username+'. Their existing sessions are signed out.');
- else alert('Could not reset the password: '+apiError(status, json));
+ if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
+ else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
}
async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers();
- else { alert('Could not change that account: '+apiError(status, json)); loadUsers(); }
+ else { wpAlertDialog({title:'Change failed', message:'Could not change that account: '+apiError(status, json)}); loadUsers(); }
}
async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
- if(status !== 200) alert('Could not change permissions for '+username+': '+apiError(status, json));
+ if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not change permissions for '+username+': '+apiError(status, json)});
loadUsers();
}
async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
- if(status !== 200) alert('Could not set the project role for '+username+': '+apiError(status, json));
+ if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not set the project role for '+username+': '+apiError(status, json)});
loadUsers();
}
async function deleteUser(id, username){
- if(!confirm('Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
- 'This cannot be undone — disable the account instead if you only want to block sign-in.')) return;
+ if(!(await wpConfirmDialog({title:'Delete user',
+ message:'Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
+ 'This cannot be undone — disable the account instead if you only want to block sign-in.',
+ okLabel:'Delete user'}))) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status === 200) loadUsers();
- else alert('Could not delete '+username+': '+apiError(status, json));
+ else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
}
// ── create ────────────────────────────────────────────────────────────────────
@@ -368,7 +375,7 @@ async function createUser(){
// more the person is on, and a save leaves those others untouched.
async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
- if(status !== 200 || !json){ alert('Could not load projects: '+apiError(status, json)); return; }
+ if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
openProjectModal(id, username, json);
}
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
@@ -453,7 +460,7 @@ function openProjectModal(userId, username, data){
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
{ project_ids: ids, roles: roleMap });
if(status === 200){ closeProjectModal(); loadUsers(); }
- else alert('Save failed: '+apiError(status, json));
+ else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
};
}
diff --git a/html/wp-dialog.js b/html/wp-dialog.js
new file mode 100644
index 0000000..6e99bc5
--- /dev/null
+++ b/html/wp-dialog.js
@@ -0,0 +1,172 @@
+/* Dialog kit + toast, shared (BL-024, 2026-08-20).
+ *
+ * The T7.9 kit, extracted for the pages the S1 tasks never named: the launcher
+ * (index.html), the admin console and the user console carried 21 native
+ * dialogs between them. Same contract as the creator's copy:
+ *
+ * wpConfirmDialog({title, message, okLabel, cancelLabel}) -> Promise
+ * wpPromptDialog({title, message, label, value, validate}) -> Promise
+ * wpAlertDialog({title, message, okLabel}) -> Promise (value not meaningful)
+ * toast(msg, kind) kind 'alert' interrupts (role=alert); default role=status
+ *
+ * Self-contained on purpose: markup and styles are injected on first use, the
+ * styles are theme tokens only (the token rule), and the class names are its
+ * own (wp-dlg-*) so the consoles' existing .modal styles are never touched.
+ * The creator keeps its inline copy - it owns the same-id markup in its HTML -
+ * so everything here is guarded: if the page already has the kit, this file
+ * defines nothing.
+ */
+(function (global) {
+ 'use strict';
+ if (typeof global.wpConfirmDialog === 'function') return; // the creator's copy wins
+
+ var CSS =
+ '#wp-dlg-overlay{position:fixed;inset:0;background:var(--wp-scrim-cool-strong);' +
+ 'display:none;align-items:center;justify-content:center;z-index:10500;padding:20px;}' +
+ '#wp-dlg-overlay.open{display:flex;}' +
+ '.wp-dlg{background:var(--cds-layer);color:var(--cds-text-primary);max-width:480px;width:100%;' +
+ 'border-radius:8px;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;' +
+ 'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;}' +
+ '.wp-dlg-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;' +
+ 'border-bottom:1px solid var(--cds-border-subtle);font-weight:700;}' +
+ '.wp-dlg-x{background:none;border:none;font-size:18px;line-height:1;cursor:pointer;' +
+ 'color:var(--cds-text-secondary);padding:4px 6px;}' +
+ '.wp-dlg-x:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
+ '.wp-dlg-body{padding:16px 18px;}' +
+ '#wp-dlg-msg{white-space:pre-wrap;line-height:1.5;}' +
+ '#wp-dlg-input-wrap{margin-top:10px;}' +
+ '#wp-dlg-input-wrap label{display:block;font-size:12px;margin-bottom:4px;color:var(--cds-text-secondary);}' +
+ '#wp-dlg-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;' +
+ 'border:1px solid var(--cds-border-strong);border-radius:4px;background:var(--cds-field);}' +
+ '#wp-dlg-input:focus{outline:2px solid var(--cds-focus);outline-offset:-1px;}' +
+ '#wp-dlg-err{color:var(--cds-text-error);font-size:12px;font-weight:600;margin-top:4px;}' +
+ '#wp-dlg-err:empty{display:none;}' +
+ '.wp-dlg-foot{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px;' +
+ 'border-top:1px solid var(--cds-border-subtle);}' +
+ '.wp-dlg-btn{font:inherit;font-weight:600;padding:8px 16px;border-radius:6px;cursor:pointer;' +
+ 'border:1px solid var(--cds-border-strong);background:var(--cds-layer);color:var(--cds-text-primary);}' +
+ '.wp-dlg-btn.primary{background:var(--cds-interactive-01);border-color:var(--cds-interactive-01);' +
+ 'color:var(--cds-text-on-color);}' +
+ '.wp-dlg-btn:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
+ '@media(pointer:coarse){.wp-dlg-btn{min-height:44px;}.wp-dlg-x{min-width:44px;min-height:44px;}}' +
+ '#toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);' +
+ 'background:var(--cds-background-inverse);color:var(--cds-text-inverse);padding:9px 16px;' +
+ 'border-radius:6px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;' +
+ 'pointer-events:none;z-index:10600;max-width:min(480px,calc(100vw - 32px));}' +
+ '#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}';
+
+ function ensure() {
+ var ov = document.getElementById('wp-dlg-overlay');
+ if (ov) return ov;
+ var st = document.createElement('style');
+ st.textContent = CSS;
+ document.head.appendChild(st);
+ ov = document.createElement('div');
+ ov.id = 'wp-dlg-overlay';
+ ov.setAttribute('role', 'dialog');
+ ov.setAttribute('aria-modal', 'true');
+ ov.setAttribute('aria-labelledby', 'wp-dlg-title');
+ ov.innerHTML =
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
';
+ document.body.appendChild(ov);
+ document.getElementById('wp-dlg-x').addEventListener('click', cancel);
+ document.getElementById('wp-dlg-cancel').addEventListener('click', cancel);
+ document.getElementById('wp-dlg-ok').addEventListener('click', ok);
+ document.getElementById('wp-dlg-input').addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') ok();
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && ov.classList.contains('open')) cancel();
+ });
+ return ov;
+ }
+
+ var resolveFn = null;
+
+ function open(opts) {
+ return new Promise(function (res) {
+ resolveFn = res;
+ var ov = ensure();
+ ov._opts = opts || {};
+ document.getElementById('wp-dlg-title').textContent = opts.title || 'Confirm';
+ document.getElementById('wp-dlg-msg').textContent = opts.message || '';
+ document.getElementById('wp-dlg-input-wrap').style.display = opts.input ? '' : 'none';
+ document.getElementById('wp-dlg-label').textContent = opts.label || '';
+ var inp = document.getElementById('wp-dlg-input');
+ inp.value = (opts.value != null ? String(opts.value) : '');
+ document.getElementById('wp-dlg-err').textContent = '';
+ document.getElementById('wp-dlg-ok').textContent = opts.okLabel || 'OK';
+ var cb = document.getElementById('wp-dlg-cancel');
+ cb.textContent = opts.cancelLabel || 'Cancel';
+ cb.style.display = opts.okOnly ? 'none' : '';
+ ov.classList.add('open');
+ setTimeout(function () {
+ (opts.input ? inp : document.getElementById('wp-dlg-ok')).focus();
+ }, 0);
+ });
+ }
+
+ function close(val) {
+ var ov = document.getElementById('wp-dlg-overlay');
+ if (ov) ov.classList.remove('open');
+ var r = resolveFn;
+ resolveFn = null;
+ if (r) r(val);
+ }
+
+ function ok() {
+ var ov = document.getElementById('wp-dlg-overlay');
+ var opts = (ov && ov._opts) || {};
+ if (opts.input) {
+ var v = document.getElementById('wp-dlg-input').value;
+ if (opts.validate) {
+ var err = opts.validate(v);
+ if (err) {
+ document.getElementById('wp-dlg-err').textContent = err;
+ document.getElementById('wp-dlg-input').focus();
+ return;
+ }
+ }
+ close(v);
+ } else close(true);
+ }
+
+ function cancel() {
+ var ov = document.getElementById('wp-dlg-overlay');
+ var opts = (ov && ov._opts) || {};
+ close(opts.input ? null : false);
+ }
+
+ global.wpConfirmDialog = function (opts) { return open(Object.assign({}, opts, { input: false })); };
+ global.wpPromptDialog = function (opts) { return open(Object.assign({}, opts, { input: true })); };
+ global.wpAlertDialog = function (opts) { return open(Object.assign({}, opts, { input: false, okOnly: true })); };
+
+ if (typeof global.toast !== 'function') {
+ // S10's rule, same as the creator: role BEFORE text, 'alert' interrupts.
+ global.toast = function (msg, kind) {
+ ensure();
+ var t = document.getElementById('toast');
+ if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
+ t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
+ t.textContent = msg;
+ t.classList.add('show');
+ clearTimeout(global.toast._t);
+ global.toast._t = setTimeout(function () { t.classList.remove('show'); }, 2200);
+ };
+ }
+})(window);
diff --git a/tests/console_dialogs_check.py b/tests/console_dialogs_check.py
new file mode 100644
index 0000000..346db79
--- /dev/null
+++ b/tests/console_dialogs_check.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Are the consoles' and launcher's 21 native dialogs gone? — BL-024, 2026-08-20.
+
+S1 counted 79 native dialogs app-wide; its two tasks (T5.8 wizard, T7.9
+creator) removed 58 and the audit found the remaining 21 on surfaces no S1
+task named: admin.js (6), users.js (10), the launcher's inline script (5).
+They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
+self-injecting component (guarded so the creator's inline copy still wins on
+its own page).
+
+Static half greps the counts; browser half drives the password-reset prompt on
+the users console with natives poisoned, and proves validate() answers AT the
+input while the server round-trip completes end to end.
+
+Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import io
+import os
+import re
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from qa_gate_check import api # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HTML = os.path.join(ROOT, "html")
+
+
+def ascii_(v, n=240):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def strip_js(src):
+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
+ return "\n".join(re.sub(r"(?{throw new Error('native alert reached')};"
+ "window.confirm=()=>{throw new Error('native confirm reached')};"
+ "window.prompt=()=>{throw new Error('native prompt reached')};")
+ chk("the console booted with a user table",
+ page.eval("!!document.querySelector('table')"))
+
+ page.eval("void resetPw('user_pat','pat')")
+ time.sleep(0.4)
+ chk("the reset prompt is the kit's modal, open, focused at the input",
+ page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
+ " return !!o && o.classList.contains('open')"
+ " && document.activeElement.id==='wp-dlg-input'; })()"))
+ page.eval("document.getElementById('wp-dlg-input').value='short';"
+ "document.getElementById('wp-dlg-ok').click()")
+ chk("a short password is refused AT the input - dialog stays, error says why",
+ page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
+ " return o.classList.contains('open')"
+ " && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
+ page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
+ "document.getElementById('wp-dlg-ok').click()")
+ time.sleep(1.2)
+ chk("a good answer closes the dialog and the server accepts it",
+ page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
+ chk("...announced through the kit's toast (role=status)",
+ page.eval("(() => { const t=document.getElementById('toast');"
+ " return !!t && t.getAttribute('role')==='status'"
+ " && /Password reset for pat/.test(t.textContent); })()"))
+ st, _ = api(base, "/api/auth/login", "x", "POST",
+ {"username": "pat", "password": "CorrectHorseBattery10"})
+ chk("...and the new password actually works", st == 200, st)
+
+ print("\n3. destroy needs a real yes")
+ page.eval("void deleteUser('user_bob','bob')")
+ time.sleep(0.4)
+ chk("the delete asks through the kit, spelling out what goes with it",
+ page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
+ " return o.classList.contains('open')"
+ " && /cannot be undone/.test(document.getElementById('wp-dlg-msg').textContent); })()"))
+ page.eval("document.getElementById('wp-dlg-cancel').click()")
+ time.sleep(0.6)
+ st, users = api(base, "/api/auth/users", tok["root"])
+ chk("cancel means no: bob is still an account",
+ st == 200 and any(u.get("username") == "bob" for u in (users or [])),
+ ascii_([u.get("username") for u in (users or [])]))
+ errs = [e for e in page.js_errors() if "beforeunload" not in e]
+ chk("no JavaScript errors, and no path reached a native dialog (they throw here)",
+ not errs, ascii_(errs[:3]))
+ finally:
+ if browser:
+ browser.close()
+ if server:
+ server.terminate()
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())