diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md
index 96d53e1..edb6f5c 100644
--- a/docs/reference/file-map.md
+++ b/docs/reference/file-map.md
@@ -239,7 +239,8 @@ python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard opera
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks
-python tests/sections_check.py # CR-006 — off means hidden, never deleted 53 checks
+python tests/sections_check.py # CR-006/CR-002/CR-016 — hidden, not deleted 88 checks
+python tests/validation_check.py # S1 wizard — errors at the field, no dialogs 81 checks
```
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its
@@ -283,7 +284,8 @@ one.
| — | `outline: none` in stylesheets | 6 | **1**, with its replacement one rule above | `T3.4`, `T4.7` |
| — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` |
| 3 | `
` | 12 | **2** | `T5.1` |
-| 1 | native dialogs app-wide | 79 | **77** | `T5.1` (2), `T5.2` (1), wave 4 (+1) |
+| 1 | native dialogs app-wide | 79 | **64** | `T5.1` (2), `T5.2` (1), `T5.8` (13) |
+| 2 | …of those, in the creator | 43 | **43** | unchanged — wave 7/9 |
Metrics 2, 4 and 6 (creator dialogs, ``, `.help-tip` badges) are wave 9's to
move and are unchanged.
@@ -293,6 +295,10 @@ move and are unchanged.
are wave 7 / wave 9 work. Nothing in the SOP wizard's markup carries a click handler on a
non-interactive element any more.
+**Metric 1 after `T5.8`:** the SOP wizard has **none**, measured both raw and with
+comments stripped. What is left is `wp-creation-app.js` 43, `users.js` 10, `admin.js` 6 and
+`index.html` 5 — the creator's are wave 7's, the rest wave 9's.
+
**Metric 1 is noisier than it looks** — the command matches `alert(` inside a *comment* as
readily as inside code, and waves 3 and 4 left several comments explaining dialogs they
were removing. That is why the figure was 80 before `T5.1` rather than the 79 wave 0
diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js
index c71de16..99bd546 100644
--- a/html/work-package-suite-app.js
+++ b/html/work-package-suite-app.js
@@ -388,7 +388,8 @@ function loadSampleData(){
if(currentTool && currentTool !== 'sop'){
const f = document.getElementById('wp-frame');
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
- else { alert('Open the Work Package Creation tab first, then load the sample.'); }
+ else { wizardToast('Open the Work Package Creation tab first, then load the sample.',
+ {role: 'alert'}); }
return;
}
// Populate Step 1
@@ -454,7 +455,7 @@ function loadSampleData(){
collectStepData();
track('sample_loaded');
- alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.');
+ wizardToast('Sample data loaded. Every step now holds example values — edit or replace any of them.');
// Switch to step 1. Every step now holds sample content, so the rail marks them
// all as visited — otherwise a fully populated wizard shows ten unstarted steps.
@@ -1108,7 +1109,10 @@ function addCustomConstraintText(){
const inp = document.getElementById('custom-constraint-input');
const name = (inp && inp.value || '').trim();
if(!name){ if(inp) inp.focus(); return; }
- if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
+ if(state.constraints.find(c=>c.name===name)){
+ wizardToast('“' + name + '” is already in the list.', {role: 'alert'});
+ return;
+ }
state.constraints.push({name, description:''});
if(inp) inp.value='';
renderStandardConstraints();
@@ -1528,6 +1532,57 @@ document.addEventListener('DOMContentLoaded', function(){
}
});
+// ── ANNOUNCEMENTS (S1 / T5.8) ────────────────────────────────────────────────
+// Everything the wizard used to say in a native dialog says it here.
+//
+// A dialog is not merely ugly. It blocks the page until dismissed, it cannot be
+// styled or placed, a screen reader can only present it as a modal interruption,
+// and it is one OK button whatever it is telling you — so "sample data loaded"
+// and "you cannot do that" arrive identically. This is the same split T4.5
+// established everywhere else: an error interrupts, a confirmation does not.
+let _toastTimer = null;
+
+function wizardToast(message, opts){
+ opts = opts || {};
+ const el = document.getElementById('wp-toast');
+ if(!el) return;
+ clearTimeout(_toastTimer);
+ el.hidden = false;
+ el.setAttribute('role', opts.role === 'alert' ? 'alert' : 'status');
+ el.className = 'wp-toast' + (opts.role === 'alert' ? ' is-alert' : '');
+ el.textContent = '';
+ const text = document.createElement('span');
+ text.className = 'wp-toast-text';
+ text.textContent = message;
+ el.appendChild(text);
+ if(opts.action && opts.action.label){
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'wp-toast-action';
+ b.textContent = opts.action.label;
+ b.addEventListener('click', function(){ hideWizardToast(); opts.action.fn(); });
+ el.appendChild(b);
+ }
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'wp-toast-close';
+ close.setAttribute('aria-label', 'Dismiss this message');
+ close.textContent = '\u2715';
+ close.addEventListener('click', hideWizardToast);
+ el.appendChild(close);
+ // An error stays until dismissed. A confirmation does not need to be read
+ // twice, and one that lingers becomes furniture.
+ if(opts.role !== 'alert' && !opts.action){
+ _toastTimer = setTimeout(hideWizardToast, 6000);
+ }
+}
+
+function hideWizardToast(){
+ clearTimeout(_toastTimer);
+ const el = document.getElementById('wp-toast');
+ if(el){ el.hidden = true; el.textContent = ''; }
+}
+
// ── STEP 12: WORK PACKAGE SECTIONS (CR-006) ───────────────────────────────────
// Which sections of a work package this project uses. The list itself lives in
// wp-sections.js, shared with the creator so the two cannot disagree about what
@@ -1651,13 +1706,45 @@ function pushSectionsToCreator(){
// T5.8 widens this to every step whose markup marks a field required, and replaces
// the native dialog with inline errors. The shape is chosen so that is an edit to this
// table rather than to the four functions below it.
+// T5.8 widened this from three steps to every step whose markup marks a field
+// required — which was the S1 defect: the markup marked fields throughout and
+// validateStep() guarded 1, 5 and 6. Steps 3 and 7 marked required fields with an
+// asterisk that meant nothing.
+//
+// The message is per FIELD now, not per step. "Subject and WP Type are required"
+// named no field, highlighted nothing and scrolled nowhere; each of these names
+// the one field it is about, renders at that field, and says what to do.
const STEP_GATES = {
- 1: {fields: ['proj_name', 'proj_number', 'proj_client', 'proj_division', 'proj_site'],
- msg: 'Please complete all required fields: Project Name, Number, Client, Division, and Site Location.'},
- 5: {fields: ['gov_woformat'], msg: 'Please enter a Work Package Number Format.'},
- 6: {fields: ['qual_qcreq'], msg: 'Please select a QC requirement.'}
+ 1: {fields: [
+ ['proj_name', 'Enter the project name.'],
+ ['proj_number', 'Enter the project number.'],
+ ['proj_client', 'Enter the client name.'],
+ ['proj_division', 'Enter the division or sector.'],
+ ['proj_site', 'Enter the site location.'],
+ ]},
+ 3: {fields: [
+ ['role_super_title', 'Name the first required sign-off role — rename it if “Superintendent” is not what this project calls it.'],
+ ['role_foreman_title', 'Name the second required sign-off role — rename it if “Foreman” is not what this project calls it.'],
+ ]},
+ 5: {fields: [
+ ['gov_woformat', 'Enter a work package number format, e.g. WP##-[Sector]-[TYPE].'],
+ ['gov_discmode', 'Choose how work packages use disciplines.'],
+ ]},
+ 6: {fields: [
+ ['qual_qcreq', 'Choose whether QC is required on this project.'],
+ ]},
+ 7: {fields: [
+ ['plat_tracking', 'Choose the construction tracking platform.'],
+ ['plat_commissioning', 'Choose the commissioning tool.'],
+ ]},
};
+// Just the ids, for the places that only care which fields a step gates on.
+function stepGateFields(n){
+ const gate = STEP_GATES[n];
+ return gate ? gate.fields.map(f => f[0]) : [];
+}
+
const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance',
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources',
11:'Locations', 12:'Sections'};
@@ -1672,13 +1759,13 @@ const LAST_STEP = 12;
// unsaved change and fire T4.3's unsaved-work guard on the way out.
const _visitedSteps = new Set([1]);
+function fieldFilled(id){
+ const el = document.getElementById(id);
+ return !!(el && String(el.value || '').trim());
+}
+
function stepGateMet(n){
- const gate = STEP_GATES[n];
- if(!gate) return true;
- return gate.fields.every(id => {
- const el = document.getElementById(id);
- return !!(el && String(el.value || '').trim());
- });
+ return stepGateFields(n).every(fieldFilled);
}
// What the rail may offer, stated as the guard already behaved rather than as
@@ -1700,9 +1787,10 @@ function stepReachable(n){
function goToStep(n, opts){
const fromUrl = !!(opts && opts.fromUrl);
// Restoring a step from the URL is not a forward navigation, so it must not run
- // the forward-navigation guard. validateStep() ends in alert() when a required
- // field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared
- // link to step 3 opened a modal dialog before the page had finished booting.
+ // the forward-navigation guard. validateStep marks every empty required field
+ // and focuses the first, which on a freshly-loaded deep link would mean landing
+ // on a step already painted red before you had typed anything. (Before T5.8 it
+ // was worse: a native dialog opened before the page had finished booting.)
//
// Neither is going BACKWARDS. previousStep() has never validated, so a rail that
// did would trap you on an incomplete step with no way out but the Back button —
@@ -1815,7 +1903,7 @@ function railBlockedClick(n){
const gate = STEP_GATES[currentStep];
railMessage(`Step ${n}, ${STEP_LABELS[n]}, is not available yet — finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`);
if(!gate) return;
- const missing = gate.fields
+ const missing = stepGateFields(currentStep)
.map(id => document.getElementById(id))
.find(el => el && !String(el.value || '').trim());
if(missing){
@@ -1842,13 +1930,16 @@ function collapseRailIfNarrow(){
// which is worse than the strip it replaced, because that one at least lied
// consistently.
const _GATE_FIELD_IDS = new Set(
- Object.keys(STEP_GATES).reduce((all, n) => all.concat(STEP_GATES[n].fields), []));
+ Object.keys(STEP_GATES).reduce((all, n) => all.concat(stepGateFields(n)), []));
function railWatchField(e){
const t = e.target;
if(!t || !t.id || !_GATE_FIELD_IDS.has(t.id)) return;
- // The refusal message is about a state that no longer holds once the field it
- // named has been filled.
+ // An error message is about a state that no longer holds once the field it
+ // named has been filled. Cleared as you type rather than on the next submit —
+ // an error still showing over a field you have just corrected teaches people
+ // to ignore errors.
+ if(fieldFilled(t.id)) setFieldError(t.id, '');
if(stepGateMet(currentStep)) railMessage('');
renderStepRail();
}
@@ -1856,6 +1947,7 @@ function railWatchField(e){
document.addEventListener('DOMContentLoaded', function(){
const list = document.getElementById('step-rail-list');
const toggle = document.getElementById('step-rail-toggle');
+ ensureErrorBoxes();
document.addEventListener('input', railWatchField);
document.addEventListener('change', railWatchField);
if(toggle){
@@ -1964,16 +2056,88 @@ function collectStepData(){
}
}
-// Same three gates, same three messages, read from STEP_GATES so the rail and the
-// guard can never disagree about which steps are open. T5.8 replaces the dialog
-// here with inline field errors; the table above is what it will validate against.
+// ── INLINE VALIDATION (S1 wizard half / T5.8) ────────────────────────────────
+// Was one native dialog per step, naming no field. Now: an error at each field
+// that needs one, associated with it through aria-describedby, announced through
+// its own live region, and the first one focused and scrolled to.
+//
+// The error elements are BUILT from STEP_GATES rather than written into the
+// markup twelve times. That is not laziness — it is what makes the table the
+// single source: adding a required field is one row here, and its error element,
+// its aria-describedby and its announcement all follow. A markup-side error box
+// that somebody forgets to add is an error nobody ever sees.
+function errBoxId(fieldId){ return fieldId + '_err'; }
+
+function ensureErrorBoxes(){
+ Object.keys(STEP_GATES).forEach(n => {
+ STEP_GATES[n].fields.forEach(([id]) => {
+ const el = document.getElementById(id);
+ if(!el || document.getElementById(errBoxId(id))) return;
+ const box = document.createElement('div');
+ box.className = 'field-error';
+ box.id = errBoxId(id);
+ // role="alert" so a validation failure interrupts. T4.5's rule: an error is
+ // the case where waiting for a pause is too late, because the person is
+ // already trying to leave the step.
+ box.setAttribute('role', 'alert');
+ (el.parentNode || document.body).insertBefore(box, el.nextSibling);
+ // Appended rather than assigned: several of these fields already point at a
+ // hint, and clobbering it would trade one message for another.
+ const prior = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
+ if(prior.indexOf(box.id) < 0) prior.push(box.id);
+ el.setAttribute('aria-describedby', prior.join(' '));
+ });
+ });
+}
+
+function setFieldError(fieldId, message){
+ const el = document.getElementById(fieldId);
+ const box = document.getElementById(errBoxId(fieldId));
+ if(box) box.textContent = message || '';
+ if(el){
+ if(message) el.setAttribute('aria-invalid', 'true');
+ else el.removeAttribute('aria-invalid');
+ }
+}
+
+// For a field that is not part of a step gate but still needs to say something
+// at itself — the feedback textarea. Its error box is in the markup because it is
+// one field rather than a table of them.
+function setSimpleFieldError(fieldId, message){
+ const el = document.getElementById(fieldId);
+ const box = document.getElementById(fieldId + '_err');
+ if(box) box.textContent = message || '';
+ if(el){
+ if(message) el.setAttribute('aria-invalid', 'true');
+ else el.removeAttribute('aria-invalid');
+ }
+}
+
+function clearStepErrors(n){
+ stepGateFields(n).forEach(id => setFieldError(id, ''));
+}
+
+/* Validate one step, marking every field that needs marking and focusing the
+ first. Nothing here paints a step you have not reached: the rail asks
+ stepGateMet(), which reads the same fields and marks nothing. Painting nine
+ steps red on arrival is not validation, it is noise. */
function validateStep(n){
collectStepData();
- if(!stepGateMet(n)){
- alert(STEP_GATES[n].msg);
- return false;
+ const gate = STEP_GATES[n];
+ if(!gate) return true;
+ let first = null;
+ gate.fields.forEach(([id, msg]) => {
+ const bad = !fieldFilled(id);
+ if(bad && !first) first = id;
+ setFieldError(id, bad ? msg : '');
+ });
+ if(!first) return true;
+ const el = document.getElementById(first);
+ if(el){
+ try { el.scrollIntoView({block: 'center', behavior: 'smooth'}); } catch(e) { }
+ el.focus();
}
- return true;
+ return false;
}
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
@@ -1986,8 +2150,9 @@ function canEditCompletedSOP(){
function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
- alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
- 'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
+ wizardToast('This project\'s SOP is already complete, and changing it needs the Project Admin '
+ + 'role. Ask a project admin — the SOP is the baseline every work package inherits.',
+ {role: 'alert'});
return;
}
if(!validateStep(LAST_STEP)) return;
@@ -2096,7 +2261,7 @@ function completeSOP(){
// the user stays), then return to the project home page per the requested flow.
if(typeof onSOPReady === 'function') onSOPReady(sop);
- alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
+ wizardToast('SOP configuration complete. Returning to the project home page.');
window.location.href = 'index.html';
}
@@ -2120,7 +2285,13 @@ function submitComment(){
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
const text = document.getElementById('comment-text').value.trim();
- if(!text){ alert('Please enter a comment.'); return; }
+ if(!text){
+ // At the field, like every other validation message on this page now.
+ setSimpleFieldError('comment-text', 'Enter some feedback before submitting.');
+ document.getElementById('comment-text')?.focus();
+ return;
+ }
+ setSimpleFieldError('comment-text', '');
const comment = {
step: currentStep,
@@ -2140,7 +2311,7 @@ function submitComment(){
function exportComments(){
const saved = localStorage.getItem('wp_suite_comments');
const data = saved ? JSON.parse(saved) : [];
- if(!data.length){ alert('No comments to export yet.'); return; }
+ if(!data.length){ wizardToast('There is no feedback to export yet.', {role: 'alert'}); return; }
const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data};
const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
const a = document.createElement('a');
@@ -2158,7 +2329,7 @@ function importComments(ev){
try{
const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
- if(!incoming.length){ alert('No comments found in that file.'); return; }
+ if(!incoming.length){ wizardToast('No feedback found in that file.', {role: 'alert'}); return; }
const saved = localStorage.getItem('wp_suite_comments');
allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text));
@@ -2166,8 +2337,8 @@ function importComments(ev){
incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }});
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
loadStepComments();
- alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.');
- }catch(e){ alert('Could not read that file.'); }
+ wizardToast('Imported ' + added + ' feedback item' + (added===1?'':'s') + '.');
+ }catch(e){ wizardToast('Could not read that file.', {role: 'alert'}); }
ev.target.value = '';
};
r.readAsText(f);
@@ -2240,8 +2411,17 @@ function showAnalytics(){
txt += `\nActions:\n`;
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
- txt += `\nDownload full event log as JSON?`;
- if(confirm(txt)) downloadAnalytics();
+ // Was a native confirmation carrying the whole summary as its body — a wall of
+ // text in a dialog with one OK button. The summary is the useful part, so it is
+ // shown, and the download is offered as an action beside it rather than as the
+ // only way to dismiss the message.
+ //
+ // Note this function has no caller in the wizard's markup: the "Usage data"
+ // button lives on the creator, which has its own showAnalytics(). Converted
+ // rather than deleted because deleting a feature is not what T5.8 was asked to
+ // do, and its dialog counts toward the number this task has to drive to zero.
+ wizardToast(txt.replace(/\n+/g, ' · ').trim(),
+ {action: {label: 'Download the full event log', fn: downloadAnalytics}});
}
function downloadAnalytics(){
const data = analyticsLoad();
diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css
index 15047a4..b95a42e 100644
--- a/html/work-package-suite-styles.css
+++ b/html/work-package-suite-styles.css
@@ -754,6 +754,51 @@ body.embed-full { overflow: hidden; }
.field-toggle.is-off { border-left-color: var(--border-strong); }
.field-toggle.is-off .section-toggle-name { color: var(--text-light); }
+/* ══ ANNOUNCEMENTS — S1 / T5.8 ════════════════════════════════════════════════
+ Where the wizard's thirteen native dialogs went. Fixed to the bottom of the
+ viewport so it is visible from any scroll position on a step that is three
+ screens tall, and never over the step navigation, which is where somebody's
+ attention is when a validation message fires. */
+.wp-toast {
+ position: fixed;
+ left: 50%;
+ bottom: var(--wp-s5);
+ transform: translateX(-50%);
+ z-index: 1400; /* over the drawer scrim, under a modal */
+ display: flex;
+ align-items: flex-start;
+ gap: var(--wp-s3);
+ max-width: min(640px, calc(100vw - 2 * var(--wp-s4)));
+ padding: var(--wp-s3) var(--wp-s4);
+ background: var(--cds-ui-05);
+ color: var(--cds-text-on-color);
+ box-shadow: var(--wp-shadow-toast);
+ font-size: 13px;
+ line-height: 1.5;
+}
+.wp-toast[hidden] { display: none; }
+.wp-toast-text { flex: 1 1 auto; min-width: 0; }
+/* An error looks different, not just louder: a red rule down its edge, and it
+ stays until dismissed where a confirmation times out (C1 — never colour
+ alone, and the persistence is the second channel). */
+.wp-toast.is-alert { border-left: 4px solid var(--danger); }
+.wp-toast-action,
+.wp-toast-close {
+ flex: none;
+ background: none;
+ border: 1px solid var(--wp-appbar-border);
+ border-radius: 0;
+ color: var(--cds-text-on-color);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ padding: var(--wp-s1) var(--wp-s2);
+ cursor: pointer;
+}
+.wp-toast-close { border-color: transparent; padding: var(--wp-s1); }
+.wp-toast-action:hover,
+.wp-toast-close:hover { background: var(--wp-appbar-hover); }
+
/* .field-error is declared once, in theme-light.css — the launcher's create form
and T5.8's step validation use the same component. */
diff --git a/html/work-package-suite.html b/html/work-package-suite.html
index bd0283f..904776c 100644
--- a/html/work-package-suite.html
+++ b/html/work-package-suite.html
@@ -513,8 +513,9 @@
-
-
+
+
+
@@ -542,6 +543,13 @@
+
+
+
diff --git a/tests/stepper_check.py b/tests/stepper_check.py
index 1f0cca8..a2660b1 100644
--- a/tests/stepper_check.py
+++ b/tests/stepper_check.py
@@ -343,13 +343,14 @@ def run(page, base, tok):
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
chk("...and the guard refuses the same move",
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
- # That call went through validateStep() directly rather than through the rail,
- # so it hit the wizard's surviving alert. Recorded here so the fact is on the
- # page rather than hidden, and cleared so check 8 below still means something.
+ # Until T5.8 this call raised the wizard's surviving alert, and this check
+ # recorded that rather than hiding it. T5.8 replaced it with an error at the
+ # field, so the same call now refuses silently and marks the field instead.
guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
- chk("...via the wizard's alert, which T5.8 still owns",
- len(guard_dialogs) == 1 and guard_dialogs[0][0] == "alert", guard_dialogs)
- page.eval("window.__dialogs = []")
+ chk("...with no dialog at all, since T5.8", not guard_dialogs, guard_dialogs)
+ chk("...and an error at the field that is in the way",
+ bool((page.eval("(document.getElementById('proj_site_err')||{}).textContent||''")).strip()),
+ page.eval("(document.getElementById('proj_site_err')||{}).textContent||''"))
chk("...while going BACKWARDS is still allowed, so nobody is trapped",
page.eval("(() => { currentStep = 4; updateStepUI(); goToStep(2); return currentStep; })()") == 2,
page.eval("currentStep"))
diff --git a/tests/validation_check.py b/tests/validation_check.py
new file mode 100644
index 0000000..1a33e94
--- /dev/null
+++ b/tests/validation_check.py
@@ -0,0 +1,419 @@
+#!/usr/bin/env python3
+"""Inline validation in the SOP wizard — S1, wizard half (T5.8).
+
+The defect S1 records has two halves and they are easy to conflate. One is that
+validation was a native dialog. The other is that it guarded three steps while
+the markup marked required fields on five — so two steps' asterisks meant
+nothing at all, which is worse than no asterisk.
+
+ 1. every step with required fields validates them (1, 3, 5, 6, 7 — counted
+ from the markup, not from the old guard)
+ 2. each error renders at its field and is associated via aria-describedby
+ 3. submitting an invalid step focuses and scrolls to the first error
+ 4. errors announce to screen readers
+ 5. the wizard's native dialog count is 0
+
+Check 5 is measured two ways, because the wave 0 metric counts the word
+`alert(` inside a comment as readily as inside code (BL-017). Both figures are
+recorded: raw, and with comments stripped.
+
+Every dialog is stubbed before anything is driven, so a survivor is caught and
+reported rather than hanging the session.
+
+Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+import os
+import re
+import subprocess
+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, _c # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+STUB = """
+window.__dialogs = [];
+window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
+window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
+window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
+true
+"""
+
+# Read out of the markup, not out of the app's gate table: the S1 defect is
+# precisely that the two disagreed, so a probe that read the table would agree
+# with whatever the table says and prove nothing.
+STEP_FIELDS = {
+ 1: ["proj_name", "proj_number", "proj_client", "proj_division", "proj_site"],
+ 3: ["role_super_title", "role_foreman_title"],
+ 5: ["gov_woformat", "gov_discmode"],
+ 6: ["qual_qcreq"],
+ 7: ["plat_tracking", "plat_commissioning"],
+}
+NO_REQUIRED = [2, 4, 8, 9, 10, 11, 12]
+
+
+def settle(seconds=1.4):
+ time.sleep(seconds)
+
+
+def ascii_(v):
+ """Windows consoles are cp1252, and a failure message carrying the toast's
+ close glyph crashes the reporter instead of reporting the failure. Losing the
+ glyph costs nothing; losing the diagnosis costs the run."""
+ return str(v).encode("ascii", "replace").decode("ascii")
+
+
+def open_wizard(page, base, tok, query="?project=projA"):
+ page.clear_cookies()
+ page.set_cookie("wp_session", tok["root"])
+ page.goto(base + "/work-package-suite.html" + query)
+ settle(2.0)
+ page.eval(STUB)
+
+
+def required_fields_in_markup():
+ """Which steps mark a field required, straight from work-package-suite.html.
+
+ Two notations: an asterisk in the