diff --git a/booth/static/embed.js b/booth/static/embed.js
index 4c93c2a..090c098 100644
--- a/booth/static/embed.js
+++ b/booth/static/embed.js
@@ -408,6 +408,24 @@
the browser submits: the plain POST and 303 it always was. */
var sending = false;
+ /* as S5b (G13): LEAVING WITH AN UNSENT ANSWER ASKS FIRST, when any form of
+ ours is dirty (against what the server last took). Our own leaving does
+ not ask: the one-form path is the pressed form going to the server, so
+ that form is skipped for the ONE navigation its submit starts. A host
+ handler that cancels the submit after ours ran, or a navigation that
+ does not replace the page (stopped, or a 204), leaves the answer unsent
+ on screen, and the next leave asks (heid bug-hunt R3). A clean batch's
+ reload finds nothing dirty. */
+ var nativeSent = null;
+ window.addEventListener("beforeunload", function (ev) {
+ var skip = nativeSent;
+ nativeSent = null;
+ var forms = ourForms();
+ for (var i = 0; i < forms.length; i++) {
+ if (forms[i] !== skip && dirty(forms[i])) { ev.preventDefault(); ev.returnValue = ""; return; }
+ }
+ });
+
function serial(form) {
return new URLSearchParams(new FormData(form)).toString();
}
@@ -473,6 +491,10 @@
batch (heid bug-hunt, hulda). */
if (sending) { ev.preventDefault(); return; }
var others = forms.some(function (f) { return f !== form && dirty(f); });
+ if (!others) {
+ nativeSent = form;
+ setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
+ }
if (!others) return; // the browser's own POST and 303
ev.preventDefault();
sending = true;
diff --git a/booth/templates/_status.html b/booth/templates/_status.html
new file mode 100644
index 0000000..8e4770a
--- /dev/null
+++ b/booth/templates/_status.html
@@ -0,0 +1,6 @@
+{# as S5b: THE status line — one per page, server-rendered and empty. Never
+ hidden: an empty live region takes no space (.status:empty) and stays
+ displayed, so words set into it are announced. The script sets its text and
+ its tone (data-tone) only; it builds no markup (INV-6). The CSS floats it
+ (bottom centre, above the fixed review stage), so it never moves the page. #}
+
diff --git a/booth/templates/base.html b/booth/templates/base.html
index dc1a24e..99a8901 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -169,6 +169,9 @@
line (brand + theme); the server marks those pages on ( carries
data-booth, which the reveal scripts and their tests pin exactly) */
@media (max-width:600px){body.page-stage .topbar .tagline{display:none}}
+ /* as S5b: where the fixed viewer covers them, the letterhead and footer leave
+ the Tab order and the accessibility tree (the viewer is fixed above 900px). */
+ @media (min-width:901px){body.page-stage .topbar,body.page-stage .foot{visibility:hidden}}
/* as S6 (operator, 2026-09-28): a sentence in the muted mono voice, not tracked capitals */
.tagline{font-family:var(--font-mono);font-size:var(--size-caption);font-weight:500;color:var(--text-muted)}
/* THE THEME TOGGLE (r2b D3): a quiet three-way segment at the far end of the
@@ -585,8 +588,19 @@
.is-stale{opacity:.45;filter:grayscale(.6)}
/* a region boundary that must not change layout */
.region-wrap{display:contents}
- .status{margin:0 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warning-soft);
- color:var(--warning-text);font-size:var(--size-sm)}
+ /* as S5b: the status line FLOATS, bottom centre. Every save says something
+ now, and a line in the page flow moved the page under the reader
+ (booth-dev's test_a_flag_lands_in_place_and_every_region_catches_up
+ measured a 50px jump). Fixed, it moves nothing, it is in view wherever
+ the reader has scrolled, and it sits above the fixed review stage (the
+ viewer is z-index 50). Neutral words by default; only the warn tone is
+ coloured. Empty, it has no box but stays displayed (a live region must). */
+ .status{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:70;
+ width:max-content;max-width:min(560px,calc(100vw - 32px));margin:0;padding:8px 12px;
+ border-radius:var(--radius-md);background:var(--surface-raised);border:1px solid var(--border-subtle);
+ color:var(--text-body);font-size:var(--size-sm);box-shadow:var(--shadow-sm)}
+ .status[data-tone="warn"]{background:var(--warning-soft);border-color:transparent;color:var(--warning-text)}
+ .status:empty{padding:0;margin:0;border:0;box-shadow:none}
/* ---- the gallery ------------------------------------------------------ */
/* THE RAIL. Sticky, opaque, and counted in mono because the counts are
@@ -1140,10 +1154,10 @@
-{# R2 C3: where the in-place script says it could not save in place. Server
- rendered and empty, so the script only ever sets its text — it builds no
- markup (INV-6). #}
-
+{# R2 C3: where the in-place script says what its saves are doing (as S5b:
+ _status.html, one per page; the CSS floats it, so where it sits in the markup
+ moves nothing). #}
+{% include "_status.html" %}
{% block content %}{% endblock %}
diff --git a/docs/contracts/as_antislop.contract.md b/docs/contracts/as_antislop.contract.md
index 27a67eb..71970df 100644
--- a/docs/contracts/as_antislop.contract.md
+++ b/docs/contracts/as_antislop.contract.md
@@ -16,6 +16,7 @@ touches:
- "booth/templates/base.html, view.html, compare.html; tests/test_antislop_browser.py (S3)"
- "booth/templates/base.html (S4, S6)"
- "booth/app.py (`human_dur`); every page template; booth/static/embed.js (S5a)"
+ - "booth/templates/base.html (the in-place client), view.html, compare.html; booth/static/embed.js (S5b)"
- "tests/test_antislop.py; tests/mutations/antislop.toml"
assumptions:
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
@@ -175,6 +176,147 @@ The markup and CSS half of the interaction work. It changes no script behaviour
- **Existing rows this slice edits** (booth-dev's): two `r2_flow.toml` rows for the confirm helper now name `base.html`, where the helper moved. Their anchors are unchanged.
- **Reported, not changed:** mark ids repeat across a tile and its aside (`mark-note-1`). The CLI prints `#mark-` links to them, so the fix is booth-dev's call.
-## S5b
+## S5b — the in-place client: focus, a status line you can see, and drafts that are not lost silently
-The in-place client half: focus restored after a swap, a status slot the viewer cannot cover (and `inert` behind it), live-region timing and "Saving…", key handling, and the unsaved-draft guard. It rewires the in-place client, so it gets its own contract section and review before any code.
+The script half of the interaction work: guidelines items G1, G2, G4 and G13. The in-place client keeps every promise it makes today:
+- it never re-POSTs;
+- saves are serialized;
+- a batch never reloads.
+
+This slice changes where the script's words appear, when they are said and how long they last; where focus lands after a swap; and whether leaving a page with an unsent draft asks first. Key handling, the doc bar's summary, thumbnail dimensions, scroll padding and the reveal button's name (G6, G7, G14, G15, G17) are S5c.
+
+**What INV-6 covers here, stated so it is not read two ways.** INV-6 forbids the script from building markup, and from rendering any state the server owns (marks, answers, counts, dates, blur). The status line's words are about the script's own requests (saving, saved, could not save). The script already writes them today, through `say()`, and it keeps doing so. The script also sets two attributes on existing nodes: `data-tone` on the status line, and `tabindex="-1"` on a fallback focus target. Neither is markup.
+
+- **Focus survives a swap (G1).**
+ - **Recording.** Before a swap, if `document.activeElement` is a region being replaced, or is inside one, the script records three things:
+ - the region's `data-region` id;
+ - the focused element's key;
+ - its occurrence number `n` among the elements in that region with the same key, in document order.
+ - **The key:**
+ - a form control: `fieldKey`, which is the form's `formKey` plus the field name, plus the value for a radio or checkbox;
+ - a button inside a form: `formKey` plus the button's `name=value`, or plus `button` for an unnamed one;
+ - a link: `a|` plus its `href`;
+ - a ``: `summary|` plus the `formKey` of the form in its ``.
+ - Anything else, the region node included, has no key.
+ - **Restoring.** After the swap, the script focuses the `n`th element with that key inside the fresh node with the same `data-region` id, with `preventScroll`.
+ - **When that element cannot take focus** because it sits in a `` the fresh page renders closed (an answered pick's form folds into "change answer"), focus goes to that ``' summary, the control that opens it again.
+ - **When there is no such element** (for example, the × of a note just withdrawn), focus goes to that fresh region node. The script sets `tabindex="-1"` on it at that moment. The same happens for a focused element with no key, the region node included, so the NEXT swap lands on the region again. Focus never falls to `` through a swap.
+ - **Only a swap moves focus.** Focus outside every swapped region is not touched: the operator may have moved on while the save was in flight. A region the fresh page lacks is not a swap: the existing stale-tile or reload paths apply.
+ - *Falsifiable:*
+ - `test_focus_returns_to_the_pressed_control` (browser): the flag toggle and a note's Add button are each pressed from the keyboard. After `booth:swapped`, each is focused again and `scrollY` is unchanged. A pick's Submit, once answered, folds away, and focus is on its "change answer" summary.
+ - `test_focus_picks_the_same_one_of_two` (browser): with two same-key controls in one region, focusing the second and saving restores the second.
+ - `test_focus_lands_on_the_region_when_the_control_is_gone` (browser): withdrawing a note by keyboard leaves focus on the region. A second save then keeps it there. It is never on `body`.
+ - `test_focus_elsewhere_is_left_alone` (browser): focus moved outside the region mid-flight (the POST held by a route) stays where it was.
+ - `test_focus_restore_does_not_scroll` (browser): the page scrolled away mid-flight stays where the operator scrolled it.
+ - `test_focus_on_a_summary_survives_the_next_swap` (browser).
+- **One status line per page, where the operator can see it (G2).**
+ - Every page that extends `base.html` renders exactly one `data-region="status"`, from `_status.html`. It sits inside no other `data-region`, so a swap never replaces it. The swap already skips `status`, and the script re-finds the line on every write. The embed's per-form `.bk-ask-status` lines are not `data-region="status"` and are outside this rule.
+ - **The line floats**, fixed at the bottom centre of the viewport, above the fixed review stage (the viewer is z-index 50, the line 70). Every save now says something, and a line in the page flow moved the page under the reader: booth-dev's `test_a_flag_lands_in_place_and_every_region_catches_up` caught a 50px jump in this slice's gate. Floating, it moves nothing, and it is in view wherever the reader has scrolled, on the review and on compare included. The viewers' grids are unchanged.
+ - **The covered letterhead.** On the review and compare, when the viewer is fixed (wider than 900px, the same breakpoint that makes it fixed), the letterhead (`header.topbar`) and the footer (`footer.foot`) leave the Tab order and the accessibility tree through CSS (`visibility:hidden` under `body.page-stage`). No script is involved. At 900px and below the viewer is in the page flow, and both stay live.
+ - *Falsifiable:*
+ - `test_one_status_line_per_page`: on the Desk, a gallery, the board, the review, compare, the marks page and a doc view, exactly one `data-region="status"`, inside no other `data-region`.
+ - `test_the_status_line_is_visible_on_the_review` (browser, 1280): on the review and on compare, a forced failure's words are what `elementFromPoint` finds at the line's centre. The stage still takes the rest of the viewer, and no other row is taller than a quarter of it.
+ - `test_a_save_does_not_move_the_page` (browser): a tile halfway down the gallery does not move while "Saving…" shows, nor after "Saved.", and the words are inside the viewport.
+ - `test_the_covered_letterhead_leaves_the_tab_order` (browser): on the review and on compare, the topbar's and the footer's computed `visibility` is `hidden` at 1280 and `visible` at 390 (the negative control).
+- **The line speaks in time, and each message has one owner (G4).**
+ - **The line is a live region that is always displayed.** No `hidden` attribute, never `display:none`. While empty it takes no space (`.status:empty` has no padding, margin or border). A live region that is present and displayed before its text changes is the precondition for a screen reader to announce the change.
+ - **Every message the line can carry, with its tone and how long it lasts.** Every write sets the tone: it sets `data-tone="warn"` or removes the attribute. So a tone never outlives its words. The CSS colours only `warn`.
+
+ | when | words | tone | lasts |
+ |---|---|---|---|
+ | a save starts | Saving… | none | until that save's outcome is said |
+ | a press on a form already in flight | Still saving… | none | until that save's outcome is said |
+ | a save's swap lands | Saved. | none | cleared after 2s, if the line still holds this same "Saved." |
+ | a save fails, and the page may reload (below) | Could not save in place — reloading to show what was saved. (today's words) | warn | until the reload, which follows after today's 900ms beat |
+ | a save fails, and the page may not reload | Could not save in place. Reload to see what was saved; your other entries are still here. | warn | until that form's next save starts; another form's save that lands says it again |
+ | a save lands, but the form it sent was changed while it flew | Saved. You changed it while it was saving, and that change is not saved yet: press again to save it. | warn | until the next save starts |
+ | a save lands on a page that changed shape, and it may not reload | Saved. The page changed meanwhile; reload to see it. | warn | until the next save starts |
+ | a save lands (204), the page GET fails, and it may not reload | Saved. Could not refresh the page; reload to see it. | warn | until the next save starts |
+ | a batch in which the server refused at least one form (a "partial batch"), or whose refresh failed or found a changed page | today's batch words, unchanged | warn | until the next save starts |
+
+ - "Until the next save starts" is today's rule (`quiet()`): a new save clears the last one's words. A failure that stayed is the exception. It is said again after any other save lands, until its own form is pressed again or leaves the page, so an unrelated "Saved." never buries it.
+ - A POST that failed did not save. A POST answered 204 did save, even when the page GET after it fails, and it is never reported as "could not save": that would invite a second press, which writes the note twice.
+ - `aria-busy="true"` mirrors which forms are in flight, on the LIVE page. It is set at the press and re-synced whenever a save settles. A save settles in the same task as its swap, so a queued form whose node an earlier save's swap replaced is marked busy again before anything renders. It is removed when the save settles, on every path: success, failure, or a stale tile the swap never replaced. A form whose save failed and stayed is no longer in flight. A press on it is a new save. The queue settles a save on rejection too, so an unexpected throw cannot strand a form "Still saving…" (a hardening with no constructible failure today, so it has no falsifier).
+ - *Falsifiable:*
+ - `test_the_status_line_is_always_displayed`: no `hidden` on it in any page's markup; in the browser, its computed `display` is not `none` and its `visibility` is `visible`, empty and full.
+ - `test_an_empty_status_line_takes_no_space` (browser): the computed height is 0.
+ - `test_a_save_says_saving_then_saved` (browser, the POST held by a route): "Saving…" while held, then "Saved." with no tone, then empty after the beat.
+ - `test_a_repeat_press_says_still_saving` (browser): the message stays until the held save lands, past 2s.
+ - `test_a_new_save_is_not_cleared_by_the_last_ones_timer` (browser): "Saving…" started within 2s of a "Saved." is still there after the old timer fires.
+ - `test_a_queued_save_keeps_saying_saving` (browser): the first of two queued saves lands, and the line goes back to "Saving…", not "Saved.".
+ - `test_a_batch_speaks_too` (browser): a batch says "Saving…", then "Saved."; a press on a clean pick during it says "Still saving…".
+ - `test_the_form_in_flight_is_busy` (browser): `aria-busy` is set while the save is held and gone after it settles, on both the success and the failure path.
+ - `test_a_failure_then_an_edit_then_a_save` (browser, a request trace): a failure that stays, with warn tone past the beat; the operator edits the failed form and presses again; the new save starts ("Saving…", no tone) and lands ("Saved.").
+ - `test_an_unrelated_save_does_not_bury_a_failure`, `test_an_edit_made_while_saving_is_not_called_saved`, `test_a_save_whose_page_would_not_refresh_says_saved` (browser).
+ - `test_a_stale_tile_is_not_left_busy`, `test_a_queued_form_is_busy_on_the_live_page` (browser).
+ - **Sensitivity floor:** no test here hears a screen reader. What is held is the precondition: a displayed live region whose text changes.
+- **Leaving with an unsent draft asks first (G13).**
+ - A `beforeunload` guard asks when any in-place form on the page is dirty. "Dirty" is the script's `dirty()`: a control that differs from its server-rendered default. In the embed, it asks when any of our forms is dirty (`dirty()` there: against what the server last took).
+ - Arrow, Space and Esc on the review and compare, film-strip clicks, the home chip, and a native submit of a form that is not in-place all leave by a full page load (`window.location.href`, a link or a POST). So the one guard covers them all.
+ - **The in-place client never reloads over a draft.** It has two reloads: after a failed save (`fail`), and after a save whose fresh page changed shape (`refresh` on the one-form path). Each now runs only when every in-place form on the page is clean, except the one just sent, which must be unchanged since its press (its serialized fields equal the snapshot taken at the press). That keeps today's behaviour for the case it was built for: the reload reveals whether the write landed, and the only unsaved text is the text that was sent.
+ - If anything else is dirty, including text typed into the sent form while it was in flight, it does not reload. It says the matching warn message from the table and stays, as the batch path already does.
+ - A press inside the reload beat cancels the reload: that new save owns the page.
+ - When it does reload, the guard does not ask. There is nothing on the page except the sent text, whose fate the reload reveals.
+ - **The embed's own leaving does not ask.**
+ - A press when another form of ours is dirty is the existing batch, unchanged: never a native submit, so there is no leaving.
+ - A press when no other form of ours is dirty is the browser's native submit. The guard skips that form for the ONE navigation its submit starts. It asks again if a host handler cancels the submit after ours has run (checked once the event has been dispatched), or if the navigation does not replace the page (a stop, or a 204).
+ - After a clean batch, the embed reloads. Nothing is dirty then, so the guard has nothing to ask about, and no disarm is needed.
+ - *Falsifiable:*
+ - `test_leaving_with_a_draft_asks` (browser): a typed note, then a film-strip click, raises a `beforeunload` dialog. So does ArrowRight. Dismissing it keeps the page and the text.
+ - `test_leaving_a_clean_page_does_not_ask` (browser, negative control).
+ - `test_a_saved_draft_no_longer_asks` (browser): after a save lands, leaving does not ask.
+ - `test_a_failed_save_keeps_the_other_drafts` (browser): the POST fails (a route answers 500) while another note holds text. There is no reload, the other text is still there, and the line carries the stay message with warn tone.
+ - `test_a_failed_save_keeps_text_typed_while_it_flew` (browser): the POST is held, more text is typed into the same form, then the POST fails. There is no reload, and the text is still there.
+ - `test_a_changed_page_keeps_the_other_drafts` (browser): the same, for a save that lands while the page changes shape.
+ - `test_a_draft_typed_during_the_beat_stays` (browser): the reload is due and a draft is typed in the 900ms before it. The reload is asked again at the beat, and it stays.
+ - `test_its_own_reload_does_not_ask` (browser): a failed note, with nothing else dirty, reloads without a dialog.
+ - `test_embed_submit_does_not_ask` (browser): the plain one-form submit in an embedded report navigates without a dialog.
+ - `test_embed_a_cancelled_submit_is_guarded_again`, `test_embed_the_skip_covers_one_leave` (browser).
+ - `test_a_resubmit_in_the_beat_is_not_reloaded_away` (browser).
+ - The existing `test_a_failed_save_says_so_reloads_and_never_re_posts` holds unchanged: with nothing else dirty, the failure still reloads.
+
+**Existing tests and rows this slice edits** (booth-dev's):
+- Six browser tests in `test_flow_browser.py` read the line's `hidden` state at seven sites. The line is never hidden any more, so `:not([hidden])` would match at once and read "Saving…".
+ - Five waits now wait for the test's own words.
+ - Two checks that the line "went quiet" now check what they meant: no warn tone (`test_pressing_a_clean_pick_during_a_batch_sends_nothing`), and the stale "Not saved" gone (`test_a_later_save_clears_a_stale_not_saved_line`).
+- Two `r2_submit_all.toml` rows guarded code this slice moved, and both went vacuous. They are re-anchored to the same failure in the new code:
+ - "a new save does not clear the last one's words": `say()` no longer overwrites words already on the line.
+ - "a batch whose refresh fails reloads": a direct `reload()`, since `refresh` no longer reloads and `fail()` now protects drafts on its own.
+
+**Out of S5b:** the embed's own "Saving…". The embed's batch already has a per-form status line, and its one-form path is a page load. The in-place client is the one that goes quiet for seconds.
+
+**Known costs, stated:**
+- A page that reads `document.activeElement` straight after `booth:swapped` sees the restored element. Nothing in the Booth listens for focus.
+- A fallback region keeps `tabindex="-1"` until the next swap replaces it, so a click inside it can focus it.
+- A region drawn with `display:contents` (a `.region-wrap`) has no box and cannot take focus. If a focused control inside one vanished, focus would be lost. None vanishes today: the wraps hold the booth's status badges and the blur-booth toggle, which re-renders under the same key.
+- `refresh(recs, keep)` keeps its `keep` argument, though it no longer decides anything: the callers decide. The call sites stay byte-identical for booth-dev's mutation anchors.
+- The 2s clear is a timer. Under reduced motion it is the same: it is the words that go, not an animation.
+
+- **Folded from the heid contract review** (panel 4/4, thread `01M3MM7Y2GA4MQCBDJ4VTV92YJ`):
+ - every status message now has one owner, one tone and a stated lifetime (the table above);
+ - `aria-busy` ends when a save settles, including a failure that stays;
+ - text typed into the sent form while it was in flight blocks the reload;
+ - a tone is reset on every write;
+ - focus identity carries an occurrence number, and a region node has a key of its own;
+ - the script, not the server, sets the fallback `tabindex`;
+ - the letterhead and footer are named nodes, tested on both pages;
+ - the always-displayed rule is tested on computed style, with its sensitivity floor stated;
+ - the embed's skip is checked at unload time.
+- **Folded from this slice's gate:** the line floats instead of sitting at the top of `` or under the viewer's bar. In the flow, every save's "Saving…" moved the page (booth-dev's own test caught it), so the per-page placement and the viewers' extra grid row went away.
+- **Folded from the heid bug-hunt** (panel 4/4, thread `01M3MRTNTWEPJHTN4APRR81KH4`). These are the changes in the text above:
+ - `aria-busy` is synced to the live forms, and it ends on every settle path (R1, 4/4);
+ - a press inside the reload beat cancels it (R2);
+ - the embed's skip covers one navigation, and a cancelled submit is guarded again (R3, 4/4);
+ - a failure that stayed is not buried by an unrelated "Saved." (R4);
+ - a 204 followed by a failed page GET is "Saved." (R8);
+ - the queue settles on rejection (R9);
+ - a summary has a key (R10);
+ - an edit made mid-flight is not called saved (R12).
+- **Accepted as true today, and pinned** (`test_the_in_place_client_can_read_every_page`): no in-place form holds a control `dirty()` cannot read (R7), and no `data-region` nests inside another (R11).
+- **Reported to booth-dev, not changed here** (they predate S5b):
+ - the embed's reload after a clean batch can discard text the operator typed into the HOST page, which `ourForms()` cannot see (R5, U3 behaviour);
+ - `carry()` loses an edit that returns a control to its original default while the save flies, because it copies only controls that differ from their old defaults (R6, C3 behaviour).
+
+## S5c
+
+Keys and the doc bar: key handling that ignores keys from inside buttons, links, summaries and media, with real focus on the grid cursor (G6); the doc bar's controls out of its `` (G7); thumbnail dimensions (G14); scroll padding under the sticky rail (G15); and a reveal button whose name does not flip (G17). Its own contract section and review come before any code.
diff --git a/tests/mutations/antislop.toml b/tests/mutations/antislop.toml
index e951129..7b73983 100644
--- a/tests/mutations/antislop.toml
+++ b/tests/mutations/antislop.toml
@@ -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">'''
+new = '''aria-live="polite" hidden>'''
+
+[[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 = '''