T4.4 - B5: two save indicators, two sentences, neither readable as the other
The app already showed "✓ All changes saved". That badge belongs to the OUTBOX -
it reports whether saved records have reached the project - and it went green
when the queue emptied, whether or not anything in the form had been saved at
all. So the promise B5 says the app does not keep was being made by a component
that could not know whether it was true.
Two indicators now, each speaking for one thing:
DRAFT .wp-draft-status, mounted in the creator's sticky save bar and the
wizard's step navigation. Driven by WPAutosave's status: "No unsaved
changes" / "Unsaved changes" / "Saving draft…" / "Draft saved at HH:MM"
/ "Draft not saved on this device — <reason>" with a Retry.
OUTBOX the existing badge, reworded so every state names the project:
"Sending N changes to the project…", "Everything sent to the project",
"N changes not yet sent to the project — retrying", "rejected by the
project".
"No unsaved changes" rather than "Saved" for an untouched form: those are
different statements and only the first is true before anything is typed. The
component was getting that wrong in the same way the outbox badge was.
Announced per S10 (T4.5's pattern, arriving one task early because this indicator
needs it to exist): role="status" while things are going well, swapping to
role="alert" on failure. A failed autosave means the safety net is not there, and
waiting for a pause in the screen reader's queue to mention that is too late.
The retry button is only rendered in the failed state - a retry offered when
nothing has failed is a button that does nothing.
Styles live 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.
VERIFICATION. tests/autosave_check.py grew to 34 checks, all passing. The B5 ones:
- the indicator reports "No unsaved changes" untouched, then a real save with a
timestamp, and is visually distinct in each state
- a simulated storage failure is visually distinct, names the reason, offers a
retry, and switches to role=alert
- the sync badge no longer RENDERS "All changes saved", and every state it does
render names the project
That last check is deliberately scoped to what the badge renders rather than to
the file text: the old 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.
Note for wave 9: the outbox badge is styled with inline hexes, including #8a6d00
- the ninth amber from BL-009, independently confirming that entry. It is
BL-005's territory, not this task's.
browser_check 71/71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
|
||||
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — not saved</span>' +
|
||||
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(){
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user