T5.8 - S1 (wizard): errors at the field, and the last dialog is gone

S1 has two halves and they are easy to conflate.

One is that validation was a native dialog: "Please complete all required
fields: Project Name, Number, Client, Division, and Site Location." names five
fields at once, highlights none of them, and scrolls nowhere. The other is that
validateStep guarded steps 1, 5 and 6 while the MARKUP marks required fields on
1, 3, 5, 6 and 7 - so two steps' asterisks meant nothing at all, which is worse
than no asterisk.

Both fixed, and the second is the one worth measuring: the probe reads the
required-field list out of work-package-suite.html rather than out of STEP_GATES,
because a probe that read the table would agree with whatever the table says and
prove nothing. Both notations count - an asterisk in a <label>, and the red span
beside step 3's role titles.

Now: an error per FIELD, rendered at it, associated by aria-describedby, marked
aria-invalid, announced through role="alert", and the first one focused and
scrolled into view. The error boxes are BUILT from the gate table rather than
written into the markup twelve times - adding a required field is one row, and
its error element, its association and its announcement all follow. A
markup-side error box somebody forgets to add is an error nobody ever sees.

An error clears 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.

And nothing paints a step you have not tried to leave: the rail asks
stepGateMet(), which reads the same fields and marks none of them.

The thirteen dialogs

  Every one is now the thing it should have been - an error at the field it is
  about, or an announcement in a live region with the role T4.5 established:
  errors interrupt, confirmations do not.

  A dialog is not merely ugly. It blocks the page, cannot be placed or styled, a
  screen reader can present it only as a modal interruption, and it is one OK
  button whatever it says - so "sample data loaded" and "you cannot do that"
  arrived identically.

  Two deserve naming. The empty-comment alert became an inline error on the
  feedback textarea. And showAnalytics() was a confirm() carrying the entire
  usage summary as its body - a wall of text in a dialog whose only dismissal
  was also the download button. The summary is the useful part, so it is shown,
  with the download offered as an action beside it. That function has no caller
  in the wizard's markup (the "Usage data" button is the creator's, calling the
  creator's own showAnalytics), and it was converted rather than deleted:
  deleting a feature is not what this task was asked to do, and its dialog
  counted toward the number this task has to drive to zero.

  html/work-package-suite.html       #wp-toast, an error box on the textarea
  html/work-package-suite-app.js     STEP_GATES widened; per-field messages;
                                     ensureErrorBoxes; wizardToast; 13 removals
  html/work-package-suite-styles.css .wp-toast
  tests/validation_check.py          new - 81 checks
  tests/stepper_check.py             its "the alert T5.8 still owns" check now
                                     asserts the opposite, by name

Done when
  [x] every step with required fields validates them - 5 steps, from the markup
  [x] each error renders at its field and is associated via aria-describedby
  [x] submitting an invalid step focuses AND scrolls to the first error
      (scroll checked by bounding box, not by trusting scrollIntoView)
  [x] errors announce to screen readers
  [x] the wizard's native dialog count is 0

The count, recorded both ways because BL-017 says the metric counts prose

  work-package-suite-app.js   0 raw, 0 with comments stripped
  app-wide                   64 raw, 64 stripped, against wave 0's 79
                             wp-creation-app.js 43 (wave 7), users.js 10 and
                             admin.js 6 and index.html 5 (wave 9)

The wizard contributes none of what is left, which the probe asserts rather
than leaving to the total.

Verified one at a time
  validation_check 81/81  new
  stepper_check    71/71
  sections_check   88/88
  browser_check    71/71
  a11y             22/22
  url_state        23/23
  autosave         34/34
  locations        58/58
  aggregates       16/16
  pipeline         43/43
  launcher         58/58
  f_items          F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added. The toast says "error" by a red rule AND by staying
until dismissed where a confirmation times out - two channels, not one (C1).

Question for the PR, per CLAUDE.md: step 3's two role TITLES are validated
because the markup marks them required, but the two role NAME pickers beside
them are not marked and so are not gated. A sign-off role with nobody in it is
arguably the more useful thing to catch. The markup is what was built to; if the
intent was the names, that is two rows in STEP_GATES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:30:32 -05:00
parent 3bc3abe209
commit 55caefb099
6 changed files with 706 additions and 47 deletions

View File

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

View File

@@ -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. */

View File

@@ -513,8 +513,9 @@
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
<label style="font-weight: 600; font-size: 13px;" for="comment-text">Feedback</label>
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." aria-describedby="comment-text_err" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
<div class="field-error" id="comment-text_err" role="alert"></div>
</div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button onclick="submitComment()" style="background: var(--primary); color: var(--cds-text-on-color); padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
@@ -542,6 +543,13 @@
</div>
</div>
<!-- S1 / T5.8. Every message the wizard used to put in a native dialog lands
here instead. role is set per message: an error interrupts, a confirmation
does not (T4.5). A dialog cannot be styled, cannot be read by a screen
reader as anything but a modal interruption, and — the reason this matters
beyond taste — blocks the page until it is dismissed. -->
<div id="wp-toast" class="wp-toast" role="status" hidden></div>
<script src="feedback-config.js"></script>
<script src="project-data.js"></script>
<script src="help.js"></script>