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:
@@ -766,3 +766,347 @@ file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_browser.py::test_withdraw_buttons_are_big_enough_to_hit"
|
||||
old = ''' .mark-x{min-width:24px;min-height:24px}'''
|
||||
new = ''' .mark-x{min-height:24px}'''
|
||||
|
||||
# ---- S5b: the in-place client (focus, the status line, the unsent-draft guard)
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no focus restore after a swap"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_returns_to_the_pressed_control"
|
||||
old = ''' focusRestore(rec, placed);
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the first same-key match, not the same one"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_picks_the_same_one_of_two"
|
||||
old = ''' var el = rec.key && rec.n >= 0 && keyed(region, rec.key)[rec.n];'''
|
||||
new = ''' var el = rec.key && rec.n >= 0 && keyed(region, rec.key)[0];'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no region fallback (focus falls to body)"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_lands_on_the_region_when_the_control_is_gone"
|
||||
old = ''' region.setAttribute('tabindex', '-1');
|
||||
region.focus({preventScroll: true});'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a closed details' summary is not tried"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_returns_to_the_pressed_control"
|
||||
old = ''' if (sum) { sum.focus({preventScroll: true}); if (document.activeElement === sum) return; }'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the pressed control is restored, not the focused one"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_elsewhere_is_left_alone"
|
||||
old = ''' var ae = document.activeElement;'''
|
||||
new = ''' var ae = document.querySelector('form[aria-busy="true"] button') || document.activeElement;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b restoring focus scrolls the page"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_restore_does_not_scroll"
|
||||
old = ''' if (el) el.focus({preventScroll: true});'''
|
||||
new = ''' if (el) el.focus();'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the covered footer stays in the tab order"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_covered_letterhead_leaves_the_tab_order"
|
||||
old = ''' @media (min-width:901px){body.page-stage .topbar,body.page-stage .foot{visibility:hidden}}'''
|
||||
new = ''' @media (min-width:901px){body.page-stage .topbar{visibility:hidden}}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the covered letterhead stays in the tab order"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_covered_letterhead_leaves_the_tab_order"
|
||||
old = ''' @media (min-width:901px){body.page-stage .topbar,body.page-stage .foot{visibility:hidden}}'''
|
||||
new = ''' @media (min-width:901px){body.page-stage .foot{visibility:hidden}}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the line is hidden again"
|
||||
file = "booth/templates/_status.html"
|
||||
test = "tests/test_antislop.py::test_one_status_line_per_page"
|
||||
old = '''aria-live="polite"></p>'''
|
||||
new = '''aria-live="polite" hidden></p>'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an empty line keeps its padding"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_an_empty_status_line_takes_no_space"
|
||||
old = ''' .status:empty{padding:0;margin:0;border:0;box-shadow:none}'''
|
||||
new = ''' .status:empty{}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an empty line is display:none (out of the accessibility tree)"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_status_line_is_always_displayed"
|
||||
old = ''' .status:empty{padding:0;margin:0;border:0;box-shadow:none}'''
|
||||
new = ''' .status:empty{display:none}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no Saving… at the press"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_says_saving_then_saved"
|
||||
old = ''' say('Saving…');
|
||||
markBusy();'''
|
||||
new = ''' markBusy();'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b Saved. never clears"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_says_saving_then_saved"
|
||||
old = ''' setTimeout(function () { if (said === n) say(''); }, 2000);'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the old timer clears a newer message"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_new_save_is_not_cleared_by_the_last_ones_timer"
|
||||
old = '''if (said === n) say('');'''
|
||||
new = '''say('');'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a queued save's Saving… is replaced by Saved."
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_queued_save_keeps_saying_saving"
|
||||
old = ''' if (others) { say('Saving…'); return; }'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the tone outlives its words"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failure_then_an_edit_then_a_save"
|
||||
old = '''if (tone) st.setAttribute('data-tone', tone); else st.removeAttribute('data-tone');'''
|
||||
new = '''if (tone) st.setAttribute('data-tone', tone);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a repeat press says nothing"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_repeat_press_says_still_saving"
|
||||
old = ''' if (pending[flightKey(form)]) say('Still saving…');
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a clean press during a batch says nothing"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
||||
old = '''if (!batch.length) { say('Still saving…'); return; }'''
|
||||
new = '''if (!batch.length) return;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a batch that lands says nothing"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
||||
old = '''
|
||||
|
||||
else landed(forms.map(flightKey));'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a batch never says Saving…"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
||||
old = ''' say('Saving…');
|
||||
var keys = batch.map(flightKey);'''
|
||||
new = ''' var keys = batch.map(flightKey);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the form in flight is not busy"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_form_in_flight_is_busy"
|
||||
old = ''' say('Saving…');
|
||||
markBusy();'''
|
||||
new = ''' say('Saving…');'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b busy never ends on a failure that stays"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_form_in_flight_is_busy"
|
||||
old = ''' var settle = function () { delete pending[key]; markBusy(); };'''
|
||||
new = ''' var settle = function () { delete pending[key]; };'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a failure is not a warning"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failed_save_keeps_the_other_drafts"
|
||||
old = ''' function warn(text) { return say(text, 'warn'); }'''
|
||||
new = ''' function warn(text) { return say(text); }'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a failure reloads over other drafts"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failed_save_keeps_the_other_drafts"
|
||||
old = ''' return !dirty(f) || (flightKey(f) === key && serial(f) === snap);'''
|
||||
new = ''' return true;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b text typed while the save flew does not block the reload"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_failed_save_keeps_text_typed_while_it_flew"
|
||||
old = '''(flightKey(f) === key && serial(f) === snap)'''
|
||||
new = '''(flightKey(f) === key)'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a changed page reloads over other drafts"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_changed_page_keeps_the_other_drafts"
|
||||
old = ''' if (mayReload(key, snap)) { reload(); return; }'''
|
||||
new = ''' { reload(); return; }'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the reload is not asked again at the beat"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_draft_typed_during_the_beat_stays"
|
||||
old = ''' beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900);'''
|
||||
new = ''' beat = setTimeout(reload, 900);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b no unload guard"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_leaving_with_a_draft_asks"
|
||||
old = ''' ev.preventDefault();
|
||||
ev.returnValue = '';'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the client's own reload asks"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_its_own_reload_does_not_ask"
|
||||
old = ''' if (leaving) return;
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a clean page asks"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_leaving_a_clean_page_does_not_ask"
|
||||
old = ''' if ([].some.call(document.querySelectorAll('form[data-inplace]'), dirty)) {'''
|
||||
new = ''' if (true) {'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a saved draft still asks"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_saved_draft_no_longer_asks"
|
||||
old = ''' if ([].some.call(document.querySelectorAll('form[data-inplace]'), dirty)) {'''
|
||||
new = ''' if (true) {'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the embed has no unload guard"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_leaving_with_a_draft_asks"
|
||||
old = ''' if (forms[i] !== skip && dirty(forms[i])) { ev.preventDefault(); ev.returnValue = ""; return; }'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the embed's own submit asks"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_submit_does_not_ask"
|
||||
old = ''' nativeSent = form;
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the line sits in the page flow (a save moves the page)"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_does_not_move_the_page"
|
||||
old = ''' .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:70;'''
|
||||
new = ''' .status{'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the fixed stage covers the line"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_the_status_line_is_visible_on_the_review"
|
||||
old = ''' .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:70;'''
|
||||
new = ''' .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:10;'''
|
||||
|
||||
# ---- S5b, folded from the heid bug-hunt (HNITTA, thread 01M3MRTNTWEPJHTN4APRR81KH4)
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a settled save leaves its stale tile busy"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_stale_tile_is_not_left_busy"
|
||||
old = ''' var settle = function () { delete pending[key]; markBusy(); };'''
|
||||
new = ''' var settle = function () { delete pending[key]; };'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a queued form's live copy is not re-marked busy when a save settles"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_queued_form_is_busy_on_the_live_page"
|
||||
old = ''' var settle = function () { delete pending[key]; markBusy(); };'''
|
||||
new = ''' var settle = function () { delete pending[key]; };'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a press inside the beat does not cancel the reload"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_resubmit_in_the_beat_is_not_reloaded_away"
|
||||
old = ''' if (beat) { clearTimeout(beat); beat = null; }
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an unrelated save buries a failure"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_an_unrelated_save_does_not_bury_a_failure"
|
||||
old = ''' if (failed.length) { warn(STAY); return; }
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a refresh failure after a 204 says it could not save"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_whose_page_would_not_refresh_says_saved"
|
||||
old = ''' else warn('Saved. Could not refresh the page; reload to see it.');'''
|
||||
new = ''' else fail(form, key, snap);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a focused summary has no key"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_focus_on_a_summary_survives_the_next_swap"
|
||||
old = ''' if (t === 'SUMMARY') {'''
|
||||
new = ''' if (false) {'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an edit made while saving is called Saved."
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_an_edit_made_while_saving_is_not_called_saved"
|
||||
old = ''' if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; }
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a cancelled native submit stays exempt"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_a_cancelled_submit_is_guarded_again"
|
||||
old = ''' setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
|
||||
'''
|
||||
new = ''''''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b the skip covers every leave, not one"
|
||||
file = "booth/static/embed.js"
|
||||
test = "tests/test_antislop_s5b_browser.py::test_embed_the_skip_covers_one_leave"
|
||||
old = ''' var skip = nativeSent;
|
||||
nativeSent = null;
|
||||
'''
|
||||
new = ''' var skip = nativeSent;
|
||||
'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b an in-place form holds a control dirty() cannot read"
|
||||
file = "booth/templates/_marks.html"
|
||||
test = "tests/test_antislop.py::test_the_in_place_client_can_read_every_page"
|
||||
old = ''' <textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it"'''
|
||||
new = ''' <select name="x"></select><textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it"'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S5b a region nests inside another"
|
||||
file = "booth/templates/_marks.html"
|
||||
test = "tests/test_antislop.py::test_the_in_place_client_can_read_every_page"
|
||||
old = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>'''
|
||||
new = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace data-region="nested">'''
|
||||
|
||||
@@ -78,8 +78,9 @@ label = "a new save does not clear the last one's words"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_flow_browser.py::test_a_later_save_clears_a_stale_not_saved_line"
|
||||
old = '''
|
||||
if (st) { st.textContent = ''; st.hidden = true; }'''
|
||||
new = ''''''
|
||||
st.textContent = text;'''
|
||||
new = '''
|
||||
if (!st.textContent) st.textContent = text;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "a batch whose refresh fails reloads (every unsent draft with it)"
|
||||
@@ -88,7 +89,7 @@ test = "tests/test_flow_browser.py::test_a_batch_whose_refresh_fails_keeps_the_p
|
||||
old = '''
|
||||
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
|
||||
new = '''
|
||||
var shown = saved.length ? refresh(saved, true).catch(function (e) { fail(); throw e; }) : Promise.resolve(true);'''
|
||||
var shown = saved.length ? refresh(saved, true).catch(function (e) { reload(); throw e; }) : Promise.resolve(true);'''
|
||||
|
||||
[[mutation]]
|
||||
label = "a sent form counts as sent even when it changed after the press"
|
||||
|
||||
Reference in New Issue
Block a user