diff --git a/html/project-data.js b/html/project-data.js index dd56abe..010684d 100644 --- a/html/project-data.js +++ b/html/project-data.js @@ -362,18 +362,27 @@ el.style.flexDirection = c.dead ? 'column' : 'row'; el.style.alignItems = c.dead ? 'flex-start' : 'center'; el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none'; + // B5. This badge reports the OUTBOX — whether saved records have reached the + // project — and it used to say "✓ All changes saved", which is what a draft + // autosave says. So the app was already making the promise B5 says it does not + // keep: the badge went green when the queue emptied, whether or not anything in + // the form had been saved at all. + // + // Every string here now names the project explicitly. The draft indicator + // (WPAutosave.mountIndicator) is the one that speaks for the form, and the two + // can no longer be read as each other. if (c.dead) { - el.innerHTML = '✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved' + + el.innerHTML = '✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — not saved' + (c.reason ? '' + esc(c.reason) + '' : ''); el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex'; } else if (c.failed) { - el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying'; + el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying'; el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex'; } else if (c.pending) { - el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…'; + el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…'; el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex'; } else { - el.textContent = '✓ All changes saved'; + el.textContent = '✓ Everything sent to the project'; el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex'; _badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800); } diff --git a/html/theme-light.css b/html/theme-light.css index 725621b..aa774a9 100644 --- a/html/theme-light.css +++ b/html/theme-light.css @@ -463,3 +463,47 @@ input, textarea, select { .wp-appbar-actions { flex-wrap: wrap; } .wp-appbar-meta { width: 100%; order: 5; } } + +/* ============================================================================ + DRAFT STATE INDICATOR (B5 / T4.4) + ---------------------------------------------------------------------------- + Reports the DRAFT — what wp-autosave.js is holding for you on this device. + The sync badge in project-data.js reports the OUTBOX, which is a different + question ("has the saved record reached the project"), and its wording now + says so. Two indicators, two sentences, neither readable as the other. + + Lives in theme-light.css because both form pages mount the same component, + and a second copy in a page sheet is what wave 3 spent itself removing. + ============================================================================ */ +.wp-draft-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 400; + line-height: 1.3; + color: var(--cds-text-secondary); + white-space: nowrap; +} +.wp-draft-status.is-saved { color: var(--cds-support-success); font-weight: 600; } +.wp-draft-status.is-saving { color: var(--cds-text-secondary); } +/* A failed autosave is not a quieter version of a successful one — it means the + safety net is not there. Red, bold, and it keeps its retry. */ +.wp-draft-status.is-failed { + color: var(--cds-support-error); + font-weight: 600; + white-space: normal; +} +.wp-draft-retry { + font: inherit; + font-size: 11px; + font-weight: 600; + padding: 2px 9px; + cursor: pointer; + background: var(--wp-btn-secondary-bg); + color: var(--wp-btn-danger-fg); + border: 1px solid var(--wp-btn-danger-border); + border-radius: var(--wp-radius-0); +} +.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); } +.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; } diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index daa4d6d..a4e951e 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -364,6 +364,9 @@ document.addEventListener('DOMContentLoaded', function(){ collect: function(){ collectStepData(); return state; }, isDirty: sopIsDirty, }); + // B5: in the step navigation, where Save/Next already are. + const host = document.querySelector('.step-navigation'); + if(host) WPAutosave.mountIndicator(host, {id:'wp-draft-status-sop'}); }); function initializeWPTypes(){ diff --git a/html/wp-autosave.js b/html/wp-autosave.js index c03d4ef..508170f 100644 --- a/html/wp-autosave.js +++ b/html/wp-autosave.js @@ -167,6 +167,64 @@ method exists to prevent. */ settled: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); }, + /* A persistent draft-state indicator — B5 / T4.4. + + The suite already showed "✓ All changes saved", but that badge belonged to + the OUTBOX: it reported whether saved records had reached the project, and + went green when the queue emptied whether or not anything in the form had + been saved at all. This indicator speaks only for the draft, and the outbox + badge's wording now names the project explicitly, so neither can be read as + the other. + + role="status" so the state is announced politely (S10 / T4.5); a failure + swaps in role="alert" so it interrupts, because a failed autosave means the + safety net is not there and waiting for a pause to say so is too late. */ + mountIndicator: function (host, opts) { + if (!host) return function () {}; + opts = opts || {}; + var el = document.createElement('div'); + el.className = 'wp-draft-status'; + el.id = opts.id || 'wp-draft-status'; + host.appendChild(el); + + function fmt(ts) { + try { return new Date(ts).toLocaleTimeString(); } catch (e) { return ''; } + } + + var un = window.WPAutosave.onStatus(function (st, dirty) { + var role = 'status', cls = '', text = ''; + if (st.state === 'failed') { + role = 'alert'; + cls = 'is-failed'; + text = '⚠ Draft not saved on this device — ' + (st.error || 'storage unavailable'); + } else if (st.state === 'saving') { + cls = 'is-saving'; + text = '↻ Saving draft…'; + } else if (st.state === 'saved') { + cls = 'is-saved'; + text = '✓ Draft saved at ' + fmt(st.at); + } else { + cls = 'is-idle'; + // "No unsaved changes" is a different statement from "saved", and it is + // the true one when nothing has been typed. + text = dirty ? 'Unsaved changes' : 'No unsaved changes'; + } + el.className = 'wp-draft-status ' + cls; + el.setAttribute('role', role); + el.textContent = text; + // Retry is only offered where it can do something. + if (st.state === 'failed') { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'wp-draft-retry'; + b.textContent = 'Retry'; + b.onclick = function () { window.WPAutosave.flush('retry'); }; + el.appendChild(b); + } + }); + return function () { un(); if (el.parentNode) el.parentNode.removeChild(el); }; + }, + _key: draftKey, }; })(window, document); diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index bcda1cf..954249d 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -2183,6 +2183,10 @@ function initAutosave(){ collect: collectPackage, isDirty: wpFormIsDirty, }); + // B5: the indicator lives in the sticky save bar, beside the button it is telling + // the truth about. The bar is rendered by the app, so mount when it exists. + const mountHost = document.querySelector('#sticky-save .sticky-status') || document.querySelector('#sticky-save'); + if(mountHost) WPAutosave.mountIndicator(mountHost); // Section changes are a "you have visibly moved on" moment, so flush rather than // wait out the debounce. document.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click', ()=>WPAutosave.flush('section'))); diff --git a/tests/autosave_check.py b/tests/autosave_check.py index 1f620e0..000cbfe 100644 --- a/tests/autosave_check.py +++ b/tests/autosave_check.py @@ -188,6 +188,72 @@ def main(): page.eval("WPAutosave.status().state") == "failed", st) chk("...and carries the reason", "simulated" in (st or ""), st) + print("\n8. B5 — the save indicator tells the truth about the DRAFT") + boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1") + chk("a draft indicator is mounted", + page.eval("!!document.getElementById('wp-draft-status')")) + ind = "(document.getElementById('wp-draft-status')||{})" + chk("it says 'no unsaved changes' on an untouched form", + "No unsaved changes" in page.eval(ind + ".textContent||''"), + page.eval(ind + ".textContent||''")) + chk("...announced politely", + page.eval(ind + ".getAttribute && document.getElementById('wp-draft-status').getAttribute('role')") == "status") + page.eval("""(() => { + const el = document.getElementById('wp_subject'); + el.value = 'B5 PROBE'; el.dispatchEvent(new Event('input', {bubbles:true})); + return true; + })()""") + for _ in range(25): + if "Draft saved" in page.eval(ind + ".textContent||''"): + break + time.sleep(0.3) + txt = page.eval(ind + ".textContent||''") + chk("it reports a real save, with a time", "Draft saved at" in txt, txt) + chk("...and is visually distinct when saved", + "is-saved" in page.eval(ind + ".className||''")) + + print("\n8b. a failed save looks different and offers a retry") + page.eval("""(() => { + const real = localStorage.setItem.bind(localStorage); + localStorage.setItem = function(k, v){ + if (String(k).indexOf('wp_draft::') === 0) throw new Error('disk on fire (simulated)'); + return real(k, v); + }; + const el = document.getElementById('wp_subject'); + el.value = 'B5 PROBE 2'; el.dispatchEvent(new Event('input', {bubbles:true})); + return true; + })()""") + for _ in range(25): + if "is-failed" in page.eval(ind + ".className||''"): + break + time.sleep(0.3) + cls = page.eval(ind + ".className||''") + txt = page.eval(ind + ".textContent||''") + chk("a failed draft save is visually distinct", "is-failed" in cls, cls) + chk("...names the failure", "disk on fire" in txt, txt[:120]) + chk("...offers a retry", + page.eval("!!document.querySelector('#wp-draft-status .wp-draft-retry')")) + chk("...and interrupts rather than waiting for a pause", + page.eval("document.getElementById('wp-draft-status').getAttribute('role')") == "alert") + + print("\n8c. the outbox message no longer reads as a draft-save confirmation") + src = page.eval("(() => fetch('/project-data.js').then(r=>r.text()))()") or "" + # Only what the badge RENDERS counts. The phrase still appears in the + # comment explaining why it was changed, and asserting on that would be + # asserting that the reason cannot be written down. + rendered = [ln for ln in src.split("\n") + if ("el.textContent" in ln or "el.innerHTML" in ln) + and "All changes saved" in ln] + chk("the badge no longer renders 'All changes saved'", not rendered, rendered[:1]) + chk("...and every state it renders names the project", + all("the project" in ln + for ln in src.split("\n") + if ("el.textContent = '" in ln or "el.innerHTML = '" in ln) + and "wp-sync-badge" not in ln and ("✓" in ln or "⚠" in ln or "↻" in ln or "✕" in ln)), + [ln.strip()[:70] for ln in src.split("\n") + if ("el.textContent = '" in ln or "el.innerHTML = '" in ln) + and "the project" not in ln and ("✓" in ln or "⚠" in ln or "↻" in ln or "✕" in ln)]) + print("\n7. the analytics dwell listener still fires") boot(page, base, tok, "/work-package-suite.html?project=projA&tab=sop") time.sleep(0.8)