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())