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)