fix(as-S5b): focus survives a swap, a status line you can see, drafts that ask before they go

The in-place client half of the anti-slop interaction work (guidelines
G1, G2, G4, G13). It still never re-POSTs, still serializes saves, and a
batch still never reloads.
- Focus: the focused element is recorded by identity (its region, its
  key, which same-key element it was) and the fresh one is focused
  without scrolling. If an answered pick's form folds into a closed
  <details>, focus goes to its summary; if nothing is left, to the region
  (tabindex=-1, set by the script). Focus outside the swapped regions
  is not touched.
- One status line per page (_status.html). It floats at the bottom
  centre, above the fixed review stage, so it moves nothing and is in view
  wherever the reader is. Wider than 900px, the letterhead and footer the
  review covers leave the Tab order (visibility:hidden, CSS only).
- The line is never hidden: empty, it takes no space and stays displayed.
  "Saving…" at the press, "Still saving…" on a repeat press, "Saved." when
  the swap lands (cleared after 2s if still the same write), and warnings
  with data-tone="warn". Every write sets or clears the tone. The form in
  flight carries aria-busy until its save settles.
- The client never reloads over a draft: both of its reloads run only
  when every in-place form is clean except the one just sent, unchanged
  since its press, asked again at the reload beat; otherwise it says so
  and stays. A beforeunload guard asks when an in-place form is dirty (its
  own reload does not ask). The embed asks when one of our answers is
  unsent, and skips the pressed form on its own one-form submit.
- Six booth-dev browser tests read the line's hidden state; they read
  its words and tone instead. Two r2_submit_all.toml rows are re-anchored
  to the same failure in the moved code.
Folded from the heid bug-hunt (panel 4/4, thread 01M3MRTNTWEPJHTN4APRR81KH4):
- aria-busy mirrors which forms are in flight on the LIVE page. It is set
  at the press and re-synced whenever a save settles, so it ends on every
  path (a stale tile the swap never replaced included), and a queued form
  replaced by an earlier swap is marked busy again.
- A press inside the reload beat cancels the reload.
- A failure that stayed is said again after an unrelated save, rather
  than buried under "Saved.".
- A 204 followed by a failed page GET is "Saved.", never "could not save".
- An edit made while its save flew is said to be unsaved.
- A focused <summary> has a key.
- The queue settles on rejection.
- The embed's skip covers the one navigation its submit starts; a
  cancelled submit, or one that leaves the page in place, is guarded
  again.
- Pinned: no in-place form holds a control dirty() cannot read, and no
  region nests in another.
Folded from this slice's gate: the status line floats (fixed, bottom
centre, above the review stage) instead of sitting at the top of <main>
or under the viewer's bar. In the flow, every save's "Saving…" moved the
page; booth-dev's test_a_flag_lands_in_place_and_every_region_catches_up
caught a 50px jump. The viewers' grids are back as they were.

Contract: as_antislop S5b (heid contract review and bug-hunt folded).
Falsifiers: antislop.toml S5b sections.
This commit is contained in:
vh
2026-09-28 15:42:37 -07:00
parent 9a97cbdf0c
commit 0233ca64fb
9 changed files with 1620 additions and 50 deletions
+22 -9
View File
@@ -144,7 +144,9 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
(b / ".marks.json").write_text("{damaged")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
arg="Could not save in place", timeout=5000)
said = page.locator('[data-region="status"]').inner_text()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # past the reload
@@ -173,7 +175,9 @@ def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live):
page.route("**/b/g/note", drop_after_write)
page.locator(".verdict .mark-add textarea").fill("exactly once")
page.locator(".verdict .mark-add button").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
arg="Could not save in place", timeout=5000)
page.wait_for_timeout(1500)
page.close()
notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"]
@@ -1588,7 +1592,9 @@ def test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing(browser,
page.check(f'#mark-{mid} input[type=radio][value="yes"]')
page.locator(".mark-add textarea").fill("half a thought")
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
arg="Not saved", timeout=10000)
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
said = page.locator('[data-region="status"]').inner_text()
@@ -1644,11 +1650,13 @@ def test_pressing_a_clean_pick_during_a_batch_sends_nothing(browser, live):
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
page.wait_for_timeout(1200)
status_hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
# as S5b: the line is never hidden and says "Saved."; what this held is that
# it carries no failure (no warn tone)
status_quiet = page.evaluate("!document.querySelector('[data-region=\"status\"]').hasAttribute('data-tone')")
same = page.evaluate("window.__same === 1")
page.close()
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a3"], posts
assert status_hidden and same, (status_hidden, same)
assert status_quiet and same, (status_quiet, same)
# The first page refresh after a save held 1s, so a batch queues behind a flag
@@ -1698,16 +1706,19 @@ def test_a_later_save_clears_a_stale_not_saved_line(browser, live):
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a2 input[type=radio][value="no"]')
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
arg="Not saved", timeout=10000)
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
drop["a2"] = False
page.locator("#mark-a2 .mark-submit").click()
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
# as S5b: the line is never hidden; what this held is that the stale words go
gone = page.evaluate("!document.querySelector('[data-region=\"status\"]').textContent.includes('Not saved')")
page.close()
assert hidden, "the stale 'not saved' line outlived the save that fixed it"
assert gone, "the stale 'not saved' line outlived the save that fixed it"
assert _chosen(b) == {"a1": "yes", "a2": "no"}
@@ -1797,7 +1808,9 @@ def test_a_batch_whose_refresh_fails_keeps_the_page(browser, live):
page.check('#mark-a2 input[type=radio][value="no"]')
page.locator(".mark-add textarea").fill("not sent")
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
page.wait_for_function( # as S5b: the line is never hidden; wait for its words
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)",
arg="Could not refresh", timeout=10000)
page.wait_for_timeout(1500)
said = page.locator('[data-region="status"]').inner_text()
same = page.evaluate("window.__same === 1")