diff --git a/booth/static/embed.js b/booth/static/embed.js index 090c098..ccdf0f9 100644 --- a/booth/static/embed.js +++ b/booth/static/embed.js @@ -460,6 +460,28 @@ return out; } + function hostDirty(forms) { + /* Any control NOT owned by one of our forms that differs from its default: + the report's own inputs, which a reload would clear. */ + var els = document.querySelectorAll("input, textarea, select"); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (el.form && forms.indexOf(el.form) >= 0) continue; + var type = (el.type || "").toLowerCase(); + if (/^(hidden|submit|button|reset|image)$/.test(type)) continue; + if (type === "radio" || type === "checkbox") { + if (el.checked !== el.defaultChecked) return true; + } else if (el.tagName === "SELECT") { + for (var j = 0; j < el.options.length; j++) { + if (el.options[j].selected !== el.options[j].defaultSelected) return true; + } + } else if (el.value !== el.defaultValue) { + return true; + } + } + return false; + } + function askOf(form) { var els = form.elements; for (var i = 0; i < els.length; i++) { @@ -517,15 +539,22 @@ alone reloaded over the failure without a word (heid bug-hunt, hulda). Otherwise anything dirty was touched mid-flight. */ var changed = forms.some(dirty); - if (!failed.length && !changed) { location.reload(); return; } + /* ...and the REPORT's own inputs: the reload would take whatever the + operator typed into the author's page too (design-dev's report, + 2026-09-28). ourForms cannot see those, so this looks at the rest. */ + var host = hostDirty(forms); + if (!failed.length && !changed && !host) { location.reload(); return; } var saved = batch.length - failed.length; var st = form.parentNode && form.parentNode.querySelector(".bk-ask-status"); if (!st) return; st.textContent = failed.length ? "Saved " + saved + " of " + batch.length + ". Not saved: " + failed.join("; ") + ". Nothing you entered was cleared; reload to see what was saved." - : "Saved " + saved + " of " + batch.length + ". You changed an answer while " + - "that was saving, and it is not saved yet: press Submit again to save it."; + : changed + ? "Saved " + saved + " of " + batch.length + ". You changed an answer while " + + "that was saving, and it is not saved yet: press Submit again to save it." + : "Saved " + saved + " of " + batch.length + ". The page was not reloaded, because " + + "something else on it holds text you have not saved; reload when you are ready."; st.hidden = false; }); }); diff --git a/booth/templates/base.html b/booth/templates/base.html index 99a8901..df86ea0 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -1295,9 +1295,13 @@ if (el.type === 'hidden' || isSent(el.form)) return; var t = freshFields[fieldKey(el)]; if (!t) return; + /* The baseline: what a sent-then-changed form SENT; else the default. */ + var snap = isSent.snapOf ? isSent.snapOf(el.form) : null; + var was = snap === null ? null : new URLSearchParams(snap); if (el.type === 'radio' || el.type === 'checkbox') { - if (el.checked !== el.defaultChecked) t.checked = el.checked; - } else if (el.value !== el.defaultValue) { + var on = was ? was.getAll(el.name).indexOf(el.value) >= 0 : el.defaultChecked; + if (el.checked !== on) t.checked = el.checked; + } else if (el.value !== (was ? (was.get(el.name) || '') : el.defaultValue)) { t.value = el.value; } }); @@ -1428,14 +1432,24 @@ node an earlier swap replaced is still recognised (hulda: its saved note came back as a draft), and a form edited mid-flight is not. */ function sentSet(recs) { - return function (f) { - if (!f) return false; + function snapOf(f) { + if (!f) return null; var k = flightKey(f); for (var i = 0; i < recs.length; i++) { - if (recs[i].key === k && recs[i].snap === serial(f)) return true; + if (recs[i].key === k) return recs[i].snap; } - return false; + return null; + } + var isSent = function (f) { + var snap = snapOf(f); + return snap !== null && snap === serial(f); }; + /* A sent form that CHANGED after its press is carried — measured against + what it sent, not its old defaults, or an answer set back to the + value the page first showed would read as untouched and the swap + would put the saved one over it (design-dev's report, 2026-09-28). */ + isSent.snapOf = snapOf; + return isSent; } /* Resolves true when the fresh page was placed. It never reloads: the caller decides what an unplaced page means (`keep`, a batch, says so; diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 593d600..e9bb221 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -221,7 +221,11 @@ today's zoom flag form carries no `back`, so it lands on the gallery. is the form's IDENTITY plus its fields as they stood at the press, never the DOM node: a queued save whose node an earlier swap replaced is still recognised, where a node test missed it and carried a saved note's text - back as a draft. + back as a draft. A sent form that changed after its press is carried + against what it SENT, not its old defaults (amended 2026-09-28): an + answer set back mid-flight to the value the page first showed would + otherwise read as untouched, and the swap would put the saved value over + the operator's last word. 3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap before the next begins, so an older snapshot never lands after a newer one (three quick flags show three flags). A form already queued or in flight @@ -285,11 +289,22 @@ today's zoom flag form carries no `back`, so it lands on the gallery. `test_a_batch_whose_refresh_fails_keeps_the_page` fails; count a sent form as sent after it changed and `test_a_change_to_a_sent_pick_during_the_flight_is_kept` fails; count no - form as sent and `test_a_saved_notes_box_comes_back_empty` fails. + form as sent and `test_a_saved_notes_box_comes_back_empty` fails; measure + a sent-then-changed form against its old defaults and + `test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept` fails. 4. **The script never re-POSTs.** A retry after a lost response would re-apply the judgment: a duplicate note, or a re-dated answer. - - On a non-204 HTTP response, or a network failure, it writes a fixed - message into the page's server-rendered status element + - ⚠ **SUPERSEDED IN PART by `as_antislop.contract.md` S5b (merged + 2026-09-28, `0233ca6`)**, which owns the status line and the failure + path from there on. What changed: the status line is never `hidden` (it + floats, and every save says "Saving…" / "Saved." with a warn tone for a + failure); and a failed one-form save reloads ONLY when no in-place form + holds a draft (the sent one excepted while it still equals its press), + re-checked at the beat. Otherwise it says so and keeps the page. Leaving + a page with an unsent draft asks first. What did not change: no re-POST, + ever, and the words are about the script's own requests only. + - As first written: on a non-204 HTTP response, or a network failure, it + writes a fixed message into the page's server-rendered status element (`data-region="status"`, via textContent). After a beat (0.9 s, so the words can be read) it reloads the page with a GET, so what you see is the server's truth. diff --git a/docs/contracts/u3_declared_embed_seam.contract.md b/docs/contracts/u3_declared_embed_seam.contract.md index 8d0e7c9..6d092dc 100644 --- a/docs/contracts/u3_declared_embed_seam.contract.md +++ b/docs/contracts/u3_declared_embed_seam.contract.md @@ -279,6 +279,10 @@ submit on one of our forms F: operator entered stays on the page. A refusal blocks the reload ON ITS OWN: a refused form set back to its first value reads clean, and "nothing dirty" alone reloaded over it. + So does unsaved input in the REPORT'S OWN controls (amended + 2026-09-28): any input, textarea or select not owned by one of our + forms that differs from its default. The reload would clear it, and + ourForms cannot see it. ``` Five consequences, each deliberate: @@ -440,7 +444,9 @@ leave a saved form's baseline where it was and `test_a_retry_after_a_refusal_sends_only_what_did_not_save` fails; drop the class-level `[hidden]` rule and `test_the_empty_status_line_stays_hidden_under_host_css` fails; let "nothing dirty" alone decide the reload and -`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails. +`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails; ignore +the report's own inputs and +`test_a_clean_batch_does_not_reload_over_text_typed_into_the_report` fails. ## Out of scope (deferred or never) diff --git a/persistent-memory.md b/persistent-memory.md index 3ff6961..02694a5 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -26,15 +26,14 @@ _As of 2026-09-27:_ A hulda hunt on S3-S5a found 6 (release unasked, the Wipe-now guard moved to the footer, a `__proto__` trapdoor, id collisions); all folded. The confirm helper for wipe/release now lives in base.html's . - **Still owed by booth-dev, after S5b lands (it rewrites the same code):** - (a) the embed's clean-batch reload can take text typed into the HOST page; - (b) carry() loses an edit set back to its original default mid-flight; - (c) two r2b.toml anchors match twice ("D3 a stored theme…", "D3 forced - light…") and prove only by where the first match falls; (d) the r2_flow - contract's C3 steps 2-4 are stale against S5b (status line never hidden, - one-form failure no longer reloads over a draft) — amend to point at - as_antislop S5b. **S5b MERGED AND PUSHED** (`0233ca6`, operator "merge and - push", 2026-09-28; booth-dev gate 1068 / 428/428 + seam pass clean): focus + **The four items booth-dev owed after S5b are DONE (2026-09-28):** the + embed's clean-batch reload now holds for unsaved text in the report's own + inputs; carry() measures a sent-then-changed form against what it SENT (an + answer set back mid-flight survives); the two double-matching r2b anchors + are re-anchored AND `scripts/mutation_check.py` now refuses any anchor that + matches more than once; the r2_flow C3 prose points at as_antislop S5b. + **S5b MERGED AND PUSHED** (`0233ca6`, operator "merge and push", + 2026-09-28; booth-dev gate 1068 / 428/428 + seam pass clean): focus survives a swap, a floating status line, and leaving with an unsent answer ASKS FIRST (beforeunload, both surfaces). S5c (keys, doc bar, and the refused-batch words that a later "Saved." can bury) is design-dev's next. diff --git a/scripts/mutation_check.py b/scripts/mutation_check.py index e1a22ae..2f3335f 100755 --- a/scripts/mutation_check.py +++ b/scripts/mutation_check.py @@ -80,8 +80,14 @@ def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]: return False, f"BASELINE RED — {test} fails BEFORE the mutation" src = path.read_text() - if mutation["old"] not in src: + hits = src.count(mutation["old"]) + if hits == 0: return False, f"anchor not found in {mutation['file']} — the table has drifted" + if hits > 1: + # The replace below takes the FIRST match, so an anchor that matches + # twice proves by where that match happens to fall, not by the line the + # row names. Two r2b rows proved that way until 2026-09-28. + return False, f"anchor is ambiguous — it matches {hits} places in {mutation['file']}" INFLIGHT.write_text(f"{path}\n") stat = path.stat() # mtime included; see the restore below diff --git a/tests/mutations/r2_flow.toml b/tests/mutations/r2_flow.toml index dff9f24..ad12c76 100644 --- a/tests/mutations/r2_flow.toml +++ b/tests/mutations/r2_flow.toml @@ -126,7 +126,7 @@ new = '''''' label = 'C3 client: an unsent radio is not carried across a swap' file = "booth/templates/base.html" test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once" -old = ''' if (el.checked !== el.defaultChecked) t.checked = el.checked;''' +old = ''' if (el.checked !== on) t.checked = el.checked;''' new = '''''' [[mutation]] diff --git a/tests/mutations/r2_submit_all.toml b/tests/mutations/r2_submit_all.toml index 5b03b1a..c908a6c 100644 --- a/tests/mutations/r2_submit_all.toml +++ b/tests/mutations/r2_submit_all.toml @@ -96,15 +96,24 @@ label = "a sent form counts as sent even when it changed after the press" file = "booth/templates/base.html" test = "tests/test_flow_browser.py::test_a_change_to_a_sent_pick_during_the_flight_is_kept" old = ''' - if (recs[i].key === k && recs[i].snap === serial(f)) return true;''' + return snap !== null && snap === serial(f);''' new = ''' - if (recs[i].key === k) return true;''' + return snap !== null;''' [[mutation]] label = "no form counts as sent (a saved note's text carries back as a draft)" file = "booth/templates/base.html" test = "tests/test_flow_browser.py::test_a_saved_notes_box_comes_back_empty" old = ''' - if (recs[i].key === k && recs[i].snap === serial(f)) return true;''' + if (recs[i].key === k) return recs[i].snap;''' new = ''' - if (false) return true;''' + if (false) return recs[i].snap;''' + +[[mutation]] +label = "a sent-then-changed form is measured against its OLD defaults (a value set back mid-flight is lost)" +file = "booth/templates/base.html" +test = "tests/test_flow_browser.py::test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept" +old = ''' + var snap = isSent.snapOf ? isSent.snapOf(el.form) : null;''' +new = ''' + var snap = null;''' diff --git a/tests/mutations/r2b.toml b/tests/mutations/r2b.toml index 6a0a62e..1d4387a 100644 --- a/tests/mutations/r2b.toml +++ b/tests/mutations/r2b.toml @@ -384,8 +384,10 @@ label = "D3 a stored theme is not applied at load (a reload forgets it)" file = "booth/templates/base.html" test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live" old = ''' + var t = localStorage.getItem('booth.theme'); if (t === 'light' || t === 'dark') d.setAttribute('data-theme', t);''' new = ''' + var t = localStorage.getItem('booth.theme'); if (false) d.setAttribute('data-theme', t);''' [[mutation]] @@ -402,8 +404,10 @@ label = "D3 forced light is not in the sheet" file = "booth/templates/_svos_tokens.css" test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live" old = ''' +/* ... or the viewer forced light. Same declarations as above, by construction. */ :root[data-theme="light"] {''' new = ''' +/* ... or the viewer forced light. Same declarations as above, by construction. */ :root[data-theme="light-OFF"] {''' [[mutation]] diff --git a/tests/mutations/u3_submit_all.toml b/tests/mutations/u3_submit_all.toml index 878a414..bddd237 100644 --- a/tests/mutations/u3_submit_all.toml +++ b/tests/mutations/u3_submit_all.toml @@ -67,7 +67,7 @@ label = "a refusal reloads the page and clears what was entered" file = "booth/static/embed.js" test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing" old = ''' - if (!failed.length && !changed) { location.reload(); return; }''' + if (!failed.length && !changed && !host) { location.reload(); return; }''' new = ''' location.reload(); return;''' @@ -76,9 +76,9 @@ label = "only dirtiness blocks the reload (a refused form set back to its first file = "booth/static/embed.js" test = "tests/test_embed_browser.py::test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty" old = ''' - if (!failed.length && !changed) { location.reload(); return; }''' + if (!failed.length && !changed && !host) { location.reload(); return; }''' new = ''' - if (!changed) { location.reload(); return; }''' + if (!changed && !host) { location.reload(); return; }''' [[mutation]] label = "a refusal is never said" @@ -139,3 +139,12 @@ test = "tests/test_embed_browser.py::test_the_empty_status_line_stays_hidden_und old = ''' ".bk-ask-status[hidden]{display:none}",''' new = '''''' + +[[mutation]] +label = "the reload ignores the report's own inputs (text typed into the host page is lost)" +file = "booth/static/embed.js" +test = "tests/test_embed_browser.py::test_a_clean_batch_does_not_reload_over_text_typed_into_the_report" +old = ''' + if (!failed.length && !changed && !host) { location.reload(); return; }''' +new = ''' + if (!failed.length && !changed) { location.reload(); return; }''' diff --git a/tests/test_embed_browser.py b/tests/test_embed_browser.py index 963e0d2..65a2d39 100644 --- a/tests/test_embed_browser.py +++ b/tests/test_embed_browser.py @@ -1031,3 +1031,30 @@ def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live page.close() assert same, "the page reloaded over a refused POST" assert shown, "the refusal was never said" + + +def test_a_clean_batch_does_not_reload_over_text_typed_into_the_report(browser, live): + """design-dev's report, 2026-09-28: a successful batch reloaded whenever + none of OUR forms was dirty — but a report can carry inputs of its own, and + the reload took whatever the operator had typed into them. Unsaved input + anywhere on the page now holds the reload, and the page says so.""" + base, data = live + b = _asks(data / "b", ("a1", "a2")) + html = ("r" + '' + f"{SEAM}") + page = _open(browser, base, "b", html, b) + page.wait_for_selector("#bk-ask-a2-submit") + page.evaluate("window.__same = 1") + page.fill("#scratch", "my own working") + _choice(page, "a1", "yes") + _choice(page, "a2", "no") + _press(page, "a1") + page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000) + said = page.locator("#bk-ask-a1-submit .bk-ask-status").inner_text() + same = page.evaluate("window.__same === 1") + kept = page.input_value("#scratch") + page.close() + assert same and kept == "my own working", (same, kept) + assert "reload" in said.lower(), said + assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no"} diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py index 2be4e49..dfb63c3 100644 --- a/tests/test_flow_browser.py +++ b/tests/test_flow_browser.py @@ -1819,3 +1819,33 @@ def test_a_batch_whose_refresh_fails_keeps_the_page(browser, live): assert _chosen(b) == {"a1": "yes", "a2": "no"} assert same and draft == "not sent", (same, draft) assert "reload" in said.lower(), said + + +def test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept(browser, live): + """design-dev's report, 2026-09-28: carry() measured a changed-after-press + form against its OLD DEFAULTS, so an answer set back to the value the page + first showed looked untouched — and the swap put the just-saved value + back over the operator's last word, silently. A sent form that changed + since its press is now measured against what it SENT.""" + from booth.marks import answer_pick + base, root = live + b = _set(root, 1) + _picks(b, ("a1",)) + answer_pick(b, "a1", "no") + page = browser.new_page() + page.add_init_script(_HOLD_POSTS) + page.goto(f"{base}/b/g/marks", wait_until="networkidle") + page.locator("#mark-a1 summary.mark-change").click() + page.check('#mark-a1 input[type=radio][value="yes"]') + page.locator("#mark-a1 .mark-submit").click() + page.wait_for_timeout(150) + page.check('#mark-a1 input[type=radio][value="no"]') # back, mid-flight + page.wait_for_function( + "document.querySelector('#mark-a1 .mark-answer-choice') && " + "document.querySelector('#mark-a1 .mark-answer-choice').textContent.trim() === 'yes'", + timeout=10000) + page.wait_for_timeout(300) + showing = page.is_checked('#mark-a1 input[type=radio][value="no"]') + page.close() + assert _chosen(b) == {"a1": "yes"} + assert showing, "the value set back mid-flight was replaced by the saved one" diff --git a/tests/test_mutation_check.py b/tests/test_mutation_check.py index 1fd4230..646642c 100644 --- a/tests/test_mutation_check.py +++ b/tests/test_mutation_check.py @@ -128,3 +128,17 @@ def test_a_reverted_file_keeps_its_mtime(tmp_path): check({"label": "flip", "file": "mod.py", "test": "test_probe.py::test_f", "old": "return 2", "new": "return 3"}, repo=repo) assert mod.stat().st_mtime_ns == before + + +def test_an_ambiguous_anchor_is_reported_not_guessed(tmp_path): + """An `old` that matches twice mutates whichever comes FIRST, so the row + proves or fails by where the first match happens to fall rather than by + the line it names. design-dev found two r2b rows proving that way + (2026-09-28). A row must name exactly one place.""" + repo = _tree(tmp_path, "def f():\n x = 2\n x = 2\n return x\n", + "def test_f():\n assert f() == 2\n") + proved, note = check( + {"label": "twice", "file": "mod.py", "test": "test_probe.py::test_f", + "old": " x = 2\n", "new": " x = 3\n"}, repo=repo) + assert not proved + assert "ambiguous" in note