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
View File
@@ -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;
+6
View File
@@ -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. #}
<p class="status" data-region="status" role="status" aria-live="polite"></p>
+189 -35
View File
@@ -169,6 +169,9 @@
line (brand + theme); the server marks those pages on <body> (<html> 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 @@
</div>
</header>
<main id="main">
{# 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). #}
<p class="status" data-region="status" role="status" aria-live="polite" hidden></p>
{# 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 %}</main>
<script>
/* IN-PLACE JUDGMENT (R2 C3). A form marked `data-inplace` is POSTed in the
@@ -1159,15 +1173,54 @@
truth. With scripts off, every one of these is a plain form and a 303. */
(function () {
if (!window.fetch || !window.DOMParser || !window.URLSearchParams) return;
function say(text) {
/* as S5b (G4): ONE writer for the status line. Every write sets the tone
or removes it, so a warning colour never outlives its words. The line is
never hidden: empty, it takes no space (.status:empty) and stays
displayed, so the next words are announced. A new save's "Saving…"
replaces the last one's words: a partial batch's "not saved" must not
outlive the save that fixes it. The words are about the script's own
requests; everything the server owns stays server-rendered (INV-6). */
var said = 0;
function say(text, tone) {
var st = document.querySelector('[data-region="status"]');
if (st) { st.textContent = text; st.hidden = false; }
said++;
if (!st) return said;
st.textContent = text;
if (tone) st.setAttribute('data-tone', tone); else st.removeAttribute('data-tone');
return said;
}
/* A new save clears the last one's words: a partial batch's "not saved"
must not outlive the save that fixes it. */
function quiet() {
var st = document.querySelector('[data-region="status"]');
if (st) { st.textContent = ''; st.hidden = true; }
function warn(text) { return say(text, 'warn'); }
/* What a landed save says (heid bug-hunt R4, R12). A form the operator
changed while it flew is carried back unsent: that is said, not "Saved.".
A failure that stayed is said again until its own form is pressed again
(an unrelated save does not bury it). With another save still queued,
the line goes back to that one's "Saving…". Otherwise "Saved.", the one
message that clears itself, and only if the line still holds that write. */
var CHANGED = 'Saved. You changed it while it was saving, and that change is not saved yet: press again to save it.';
var unsent = {};
function liveForms(key) {
return [].filter.call(document.querySelectorAll('form[data-inplace]'),
function (f) { return flightKey(f) === key; });
}
function landed(keys) {
keys.forEach(function (k) { delete unsent[k]; });
if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; }
var others = Object.keys(pending).some(function (k) { return keys.indexOf(k) < 0; });
if (others) { say('Saving…'); return; }
var failed = Object.keys(unsent).filter(function (k) { return liveForms(k).length; });
if (failed.length) { warn(STAY); return; }
var n = say('Saved.');
setTimeout(function () { if (said === n) say(''); }, 2000);
}
/* aria-busy mirrors `pending` on the LIVE forms: a queued form's node can
be replaced by an earlier save's swap, and a stale tile's form is never
replaced at all (heid bug-hunt R1). Synced at every press and every
settle; a save's settle follows its swap in the same task, so a replaced
queued form is marked busy again before anything renders. */
function markBusy() {
[].forEach.call(document.querySelectorAll('form[data-inplace]'), function (f) {
if (pending[flightKey(f)]) f.setAttribute('aria-busy', 'true'); else f.removeAttribute('aria-busy');
});
}
/* A form's IDENTITY: its action plus the hidden fields that say what it is
about (which pick, which item, which mark). Stable across renders, where
@@ -1249,6 +1302,62 @@
}
});
}
/* as S5b (G1): FOCUS SURVIVES A SWAP. Replacing the region that holds focus
drops it to <body>. Before the swap the focused element is recorded by
IDENTITY, never position: its region's id, its key (the keys carry()
already trusts), and which of that region's same-key elements it was.
After, the fresh one is focused without scrolling. None left (the × of a
note just withdrawn): the summary of a closed <details> holding it, or
else the fresh region itself, which the script makes focusable
(tabindex=-1, an attribute on a server-rendered node, not markup). A
focused region has no key, so the next swap lands on the region again.
Focus outside every swapped region is never touched. */
function focusKey(el) {
var t = el.tagName;
if ((t === 'INPUT' || t === 'TEXTAREA' || t === 'SELECT') && el.form) return 'field|' + fieldKey(el);
if (t === 'BUTTON' && el.form) {
return 'button|' + formKey(el.form) + '|' + (el.name ? el.name + '=' + el.value : 'button');
}
if (t === 'A' && el.hasAttribute('href')) return 'a|' + el.getAttribute('href');
if (t === 'SUMMARY') { /* its <details>' form says which (heid bug-hunt R10) */
var f = el.parentNode && el.parentNode.querySelector && el.parentNode.querySelector('form');
return 'summary|' + (f ? formKey(f) : '');
}
return null;
}
function keyed(region, key) {
return [].filter.call(region.querySelectorAll('input, textarea, select, button, a[href], summary'),
function (el) { return focusKey(el) === key; });
}
function focusRecord(live) {
var ae = document.activeElement;
if (!ae || ae === document.body) return null;
for (var i = 0; i < live.length; i++) {
var region = live[i];
if (region.getAttribute('data-region') === 'status') continue;
if (region === ae || region.contains(ae)) {
var key = focusKey(ae);
return {id: region.getAttribute('data-region'), key: key,
n: key ? keyed(region, key).indexOf(ae) : -1};
}
}
return null;
}
function focusRestore(rec, placed) {
if (!rec || !placed[rec.id]) return; /* not swapped (a stale tile): focus never left */
var region = placed[rec.id];
var el = rec.key && rec.n >= 0 && keyed(region, rec.key)[rec.n];
if (el) el.focus({preventScroll: true});
if (el && document.activeElement === el) return;
/* The fresh one cannot take focus: it sits in a <details> the server
renders closed (an answered pick's form). Its summary is the control
that opens it again. */
var shut = el && el.closest && el.closest('details:not([open])');
var sum = shut && shut.querySelector(':scope > summary');
if (sum) { sum.focus({preventScroll: true}); if (document.activeElement === sum) return; }
region.setAttribute('tabindex', '-1');
region.focus({preventScroll: true});
}
/* Returns false when the fresh page's STRUCTURE differs from the live one
beyond a tile falling out of a filter — a panel region that appeared or
vanished — or when the page has no region to swap at all. Then only a
@@ -1265,7 +1374,7 @@
for (var id in freshById) {
if (id !== 'status' && !liveIds[id]) return false; /* a region appeared */
}
var ok = true, swapped = 0;
var ok = true, swapped = 0, rec = focusRecord(live), placed = {};
live.forEach(function (el) {
var id = el.getAttribute('data-region');
if (id === 'status') return;
@@ -1274,6 +1383,7 @@
var node = document.importNode(next, true);
carry(el, node, isSent);
el.replaceWith(node);
placed[id] = node;
swapped++;
} else if (id.indexOf('item-') === 0) {
/* A TILE absent from the fresh page — one a filter no longer
@@ -1286,6 +1396,7 @@
ok = false; /* a panel vanished */
}
});
focusRestore(rec, placed);
document.dispatchEvent(new CustomEvent('booth:swapped'));
return ok && swapped > 0; /* a page with no regions shows nothing in place */
}
@@ -1326,31 +1437,66 @@
return false;
};
}
/* Resolves true when the fresh page was placed. `keep` (a batch): never
reload — a structural change or a failed GET is said, not acted on,
because a reload would take every unsent draft with it. */
/* Resolves true when the fresh page was placed. It never reloads: the
caller decides what an unplaced page means (`keep`, a batch, says so;
the one-form path reloads only when that takes no draft with it). */
function refresh(recs, keep) {
return fetch(window.location.href, {headers: {'Accept': 'text/html'}, credentials: 'same-origin'})
.then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
return r.text();
}).then(function (html) {
if (swap(html, sentSet(recs))) return true;
if (!keep) window.location.reload();
return false;
return swap(html, sentSet(recs));
});
}
function fail() {
/* as S5b (G13): THE IN-PLACE CLIENT NEVER RELOADS OVER A DRAFT. A reload
is allowed only when every in-place form is clean, except the one just
sent, and that one only while it still holds exactly what was sent: then
the reload reveals whether the write landed and loses nothing else. Its
own reload does not ask (the unload guard below). */
var leaving = false;
function reload() { leaving = true; window.location.reload(); }
function mayReload(key, snap) {
return [].every.call(document.querySelectorAll('form[data-inplace]'), function (f) {
return !dirty(f) || (flightKey(f) === key && serial(f) === snap);
});
}
var STAY = 'Could not save in place. Reload to see what was saved; your other entries are still here.';
var beat = null;
function fail(form, key, snap) {
unsent[key] = true;
if (!mayReload(key, snap)) { warn(STAY); return; }
/* Said, then reloaded after a beat, so the words are readable rather
than a flash before the page goes. */
say('Could not save in place — reloading to show what was saved.');
setTimeout(function () { window.location.reload(); }, 900);
than a flash before the page goes; asked again at the beat, since a
draft typed in the meantime must not go with it. A press inside the
beat cancels it: that new save owns the page (heid bug-hunt R2). */
warn('Could not save in place — reloading to show what was saved.');
beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900);
}
/* The POST and the page GET fail differently. A POST that failed did not
save. A POST that answered 204 DID save, even if the GET then failed:
saying "could not save" there invites a second press, which writes it
twice (heid bug-hunt R8). */
function run(form, data, snap) {
var key = flightKey(form);
return post(form, data).then(function () {
return refresh([{key: flightKey(form), snap: snap}]);
}).catch(fail);
return refresh([{key: key, snap: snap}]).then(function (placed) {
if (placed) { landed([key]); return; }
if (mayReload(key, snap)) { reload(); return; }
warn('Saved. The page changed meanwhile; reload to see it.');
}, function () {
if (mayReload(key, snap)) reload();
else warn('Saved. Could not refresh the page; reload to see it.');
});
}, function () { fail(form, key, snap); });
}
window.addEventListener('beforeunload', function (ev) {
if (leaving) return;
if ([].some.call(document.querySelectorAll('form[data-inplace]'), dirty)) {
ev.preventDefault();
ev.returnValue = '';
}
});
/* SEVERAL PICKS AT ONCE (C3 step 3a, 2026-09-27). One pick is one form
is one POST, so a page holding several picks saved only the one whose
button was pressed. The others' unsent radios SURVIVED the swap (carry,
@@ -1415,10 +1561,11 @@
: '';
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);
return shown.then(function (placed) {
if (!placed) say((told || 'Saved.') + ' The page changed meanwhile; reload to see it.');
else if (told) say(told);
if (!placed) warn((told || 'Saved.') + ' The page changed meanwhile; reload to see it.');
else if (told) warn(told);
else landed(forms.map(flightKey));
}, function () {
say((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') +
warn((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') +
' Could not refresh the page; reload to see what was saved.');
});
});
@@ -1427,27 +1574,34 @@
var form = ev.target;
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
ev.preventDefault();
if (beat) { clearTimeout(beat); beat = null; }
if (pending[flightKey(form)]) say('Still saving…');
if (pending[flightKey(form)]) return;
var batch = pickBatch(form);
if (batch) {
if (!batch.length) return; /* everything dirty is already in flight */
quiet();
if (!batch.length) { say('Still saving…'); return; } /* everything dirty is already in flight */
say('Saving…');
var keys = batch.map(flightKey);
var datas = batch.map(function (f) { return new FormData(f); }); /* read NOW */
var snaps = batch.map(serial);
keys.forEach(function (k) { pending[k] = true; });
keys.forEach(function (k) { pending[k] = true; delete unsent[k]; });
markBusy();
var settleAll = function () { keys.forEach(function (k) { delete pending[k]; }); markBusy(); };
queue = queue.then(function () { return runAll(batch, datas, snaps); })
.then(function () { keys.forEach(function (k) { delete pending[k]; }); });
.then(settleAll, settleAll); /* a rejection must not strand them in flight (R9) */
return;
}
var key = flightKey(form);
pending[key] = true;
quiet();
delete unsent[key];
say('Saving…');
markBusy();
var snap = serial(form); /* the form's own fields, before the submitter */
var data = new FormData(form);
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
var settle = function () { delete pending[key]; markBusy(); };
queue = queue.then(function () { return run(form, data, snap); })
.then(function () { delete pending[key]; });
.then(settle, settle); /* a rejection must not strand it in flight (R9) */
});
})();
</script>
+144 -2
View File
@@ -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-<id>` 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>`: `summary|` plus the `formKey` of the form in its `<details>`.
- 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 `<details>` the fresh page renders closed (an answered pick's form folds into "change answer"), focus goes to that `<details>`' 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 `<body>` 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 `<main>` 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 `<summary>` (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.
+344
View File
@@ -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">'''
+4 -3
View File
@@ -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"
+80
View File
@@ -551,3 +551,83 @@ def test_human_dur_never_raises():
from booth.app import human_dur
for bad in (float("nan"), float("inf"), float("-inf")):
assert isinstance(human_dur(bad), str), bad
# ---- S5b: one status line per page, never hidden --------------------------------
def test_one_status_line_per_page(client):
"""Exactly one `data-region="status"` on every kind of page, never `hidden`,
and inside no other `data-region` (a swap would replace it mid-message)."""
from html.parser import HTMLParser
from booth.links import LINKS_FILE
c, data = client
_s5_booth(data)
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
class Lines(HTMLParser):
def __init__(self):
super().__init__()
self.stack, self.found = [], []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if a.get("data-region") == "status":
self.found.append((tag, a, [r for r in self.stack if r]))
if tag not in void:
self.stack.append(a.get("data-region"))
def handle_endtag(self, tag):
if tag not in void and self.stack:
self.stack.pop()
assert (data / "links" / LINKS_FILE).exists()
for url, page in _pages(c).items():
p = Lines()
p.feed(page)
assert len(p.found) == 1, (url, len(p.found))
tag, attrs, outer = p.found[0]
assert "hidden" not in attrs, url
assert attrs.get("role") == "status" and attrs.get("aria-live") == "polite", (url, attrs)
assert not outer, (url, outer)
def test_the_in_place_client_can_read_every_page(client):
"""What the in-place client assumes about the pages it swaps (heid bug-hunt
R7, R11, accepted as true today and pinned here): no in-place form holds a
control `dirty()` cannot read (a <select>, or an input that is not text,
radio, checkbox, hidden or submit), and no `data-region` sits inside another
(a nested region is replaced inside a detached tree)."""
from html.parser import HTMLParser
c, data = client
_s5_booth(data)
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
class Pages(HTMLParser):
def __init__(self):
super().__init__()
self.stack, self.inplace, self.odd, self.nested = [], 0, [], []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "form" and "data-inplace" in a:
self.inplace += 1
if self.inplace and (tag == "select" or (tag == "input" and a.get("type", "text")
not in ("hidden", "radio", "checkbox", "text", "submit"))):
self.odd.append((tag, a.get("type")))
if a.get("data-region") and any(self.stack):
self.nested.append(a["data-region"])
if tag not in void:
self.stack.append(a.get("data-region"))
def handle_endtag(self, tag):
if tag == "form" and self.inplace:
self.inplace -= 1
if tag not in void and self.stack:
self.stack.pop()
for url, page in _pages(c).items():
p = Pages()
p.feed(page)
assert not p.odd, (url, p.odd)
assert not p.nested, (url, p.nested)
+808
View File
@@ -0,0 +1,808 @@
"""S5b of the anti-slop fix slices (docs/contracts/as_antislop.contract.md):
the in-place client's focus, its status line, and the unsent-draft guard.
Measured in the same real uvicorn + Chromium harness as test_flow_browser.
Skips, never fails, when no usable Chromium exists. No test here hears a
screen reader: what is held is the precondition (a displayed live region whose
text changes), not the announcement.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import PNG, _picks, _set, browser, live # noqa: E402,F401 (fixtures)
WIDE = {"width": 1400, "height": 900}
# Every POST waits for the test: window.__release() lets the oldest one through
# to the server, window.__release('fail') answers it 500 without sending it.
HOLD = """
(function () {
var real = window.fetch;
window.__held = [];
window.fetch = function (u, o) {
if (!(o && o.method === 'POST')) return real.apply(this, arguments);
var self = this, args = arguments;
return new Promise(function (res, rej) {
window.__held.push(function (how) {
if (how === 'fail') res(new Response('', {status: 500}));
else real.apply(self, args).then(res, rej);
});
});
};
window.__release = function (how) { var f = window.__held.shift(); if (f) f(how); return !!f; };
})();
"""
# Counts swaps, and marks the document so a reload is visible.
COUNT = """
document.addEventListener('booth:swapped', function () { window.__swaps = (window.__swaps || 0) + 1; });
"""
LINE = '[data-region="status"]'
def _page(browser, base, path, hold=False, viewport=WIDE):
page = browser.new_page(viewport=viewport)
page.add_init_script(COUNT)
if hold:
page.add_init_script(HOLD)
page.goto(f"{base}{path}", wait_until="networkidle")
page.evaluate("window.__same = 1")
return page
def _said(page):
return page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
return {text: s.textContent, tone: s.getAttribute('data-tone')}; }""")
def _wait_said(page, needle, timeout=10000):
page.wait_for_function(
"n => document.querySelector('[data-region=\"status\"]').textContent.includes(n)", arg=needle,
timeout=timeout)
def _wait_swaps(page, n, timeout=10000):
page.wait_for_function("n => (window.__swaps || 0) >= n", arg=n, timeout=timeout)
def _wait_held(page, n=1, timeout=5000):
page.wait_for_function("n => window.__held.length >= n", arg=n, timeout=timeout)
def _focused(page, selector, index=0):
return page.evaluate(
"([s, i]) => document.activeElement === document.querySelectorAll(s)[i]", [selector, index])
# ---- G1: focus survives a swap -------------------------------------------------------
def test_focus_returns_to_the_pressed_control(browser, live):
base, root = live
b = _set(root, 3)
page = _page(browser, base, "/b/g/")
flag = 'figure.item[data-item="02.png"] .flagtoggle button'
page.locator(flag).focus()
y = page.evaluate("scrollY")
page.keyboard.press("Enter")
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
_wait_swaps(page, 1)
assert _focused(page, flag), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
assert abs(page.evaluate("scrollY") - y) <= 1
page.locator(".verdict .mark-add textarea").fill("a note")
page.locator(".verdict .mark-add button").focus()
page.keyboard.press("Enter")
_wait_swaps(page, 2)
assert _focused(page, ".verdict .mark-add button")
page.close()
_picks(b, ("a1",))
page = _page(browser, base, "/b/g/marks")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").focus()
page.keyboard.press("Enter")
page.wait_for_function("document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
_wait_swaps(page, 1)
# answered, the pick's form folds into a closed <details>: focus goes to the
# summary that opens it again, never to <body>
assert page.locator("#mark-a1 details.mark-formwrap").get_attribute("open") is None
assert _focused(page, "#mark-a1 details.mark-formwrap > summary"), \
page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_focus_picks_the_same_one_of_two(browser, live):
from booth.marks import set_flag, write_note
base, root = live
b = _set(root, 3)
set_flag(b, "01.png", True)
write_note(b, "01.png", "one")
write_note(b, "01.png", "two")
page = _page(browser, base, "/b/g/")
same = '[data-region="verdict"] a[href="view?f=01.png"]'
assert page.locator(same).count() >= 2, "positive control: one region, two links with one key"
page.evaluate("s => document.querySelectorAll(s)[1].focus()", same)
page.evaluate("document.querySelector('figure.item[data-item=\"03.png\"] form.flagtoggle').requestSubmit()")
_wait_swaps(page, 1)
assert _focused(page, same, 1), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_focus_lands_on_the_region_when_the_control_is_gone(browser, live):
from booth.marks import write_note
base, root = live
b = _set(root, 3)
write_note(b, "01.png", "gone soon")
page = _page(browser, base, "/b/g/")
page.locator('figure.item[data-item="01.png"] .item-note .mark-x').focus()
page.keyboard.press("Enter")
page.wait_for_function(
"document.querySelectorAll('figure.item[data-item=\"01.png\"] .item-note').length === 0", timeout=10000)
_wait_swaps(page, 1)
region = '[data-region="item-01.png"]'
assert _focused(page, region), page.evaluate("document.activeElement.tagName")
page.evaluate("document.querySelector('figure.item[data-item=\"02.png\"] form.flagtoggle').requestSubmit()")
_wait_swaps(page, 2)
assert _focused(page, region), "a second swap dropped focus from the region"
assert page.evaluate("document.activeElement !== document.body")
page.close()
def test_focus_restore_does_not_scroll(browser, live):
base, root = live
_set(root, 30)
page = _page(browser, base, "/b/g/", hold=True)
flag = 'figure.item[data-item="01.png"] .flagtoggle button'
page.locator(flag).focus()
page.keyboard.press("Enter")
_wait_held(page)
page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
y = page.evaluate("scrollY")
assert y > 400, "positive control: the page is long enough to scroll away"
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _focused(page, flag)
assert abs(page.evaluate("scrollY") - y) <= 1, "restoring focus scrolled the page"
page.close()
def test_focus_elsewhere_is_left_alone(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="02.png"] .flagtoggle button').focus()
page.keyboard.press("Enter")
_wait_held(page)
away = '.theme button[data-theme-choice="system"]'
page.locator(away).focus()
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _focused(page, away)
page.close()
# ---- G2: one status line, where it can be seen -------------------------------------------
def _rows_left(page, stage):
return page.evaluate("""s => {
const v = document.querySelector('.viewer'), st = document.querySelector(s);
const others = [...v.children].filter(c => c !== st && getComputedStyle(c).display !== 'none'
&& getComputedStyle(c).position !== 'absolute'
&& getComputedStyle(c).position !== 'fixed');
const hs = others.map(c => c.getBoundingClientRect().height);
const vh = v.getBoundingClientRect().height;
return {stage: st.getBoundingClientRect().height, left: vh - hs.reduce((a, h) => a + h, 0),
tallest_other: Math.max(...hs), viewer: vh};
}""", stage)
def test_the_status_line_is_visible_on_the_review(browser, live):
base, root = live
_set(root, 3)
for path, stage, press in (("/b/g/view?f=01.png", ".review-body", "#rail form.vflag button"),
("/b/g/compare?a=01.png&b=02.png", ".cmp-body", '[data-region="flag-a"] button')):
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.goto(f"{base}{path}", wait_until="networkidle")
rows = _rows_left(page, stage)
# the stage keeps the 1fr row: it takes the rest, and no other row grew
assert abs(rows["stage"] - rows["left"]) <= 1 and rows["tallest_other"] < rows["viewer"] / 4, (path, rows)
page.locator(press).first.click()
_wait_said(page, "Could not save in place", timeout=5000)
hit = page.evaluate("""() => { const s = document.querySelector('[data-region="status"]');
const r = s.getBoundingClientRect();
const e = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return e === s || s.contains(e); }""")
assert hit, f"{path}: the stage covers the line"
page.close()
def test_a_save_does_not_move_the_page(browser, live):
"""Every save now says something. A line that took space 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 50px). The
words must be in view where the reader is, and move nothing."""
base, root = live
_set(root, 30)
page = _page(browser, base, "/b/g/", hold=True, viewport={"width": 1400, "height": 800})
tile = 'figure.item[data-item="15.png"]'
page.locator(tile).scroll_into_view_if_needed()
top = lambda: page.locator(tile).evaluate("e => e.getBoundingClientRect().top") # noqa: E731
before = top()
page.locator(f"{tile} .flagtoggle button").click()
_wait_held(page)
during = top()
seen = page.evaluate("""() => { const r = document.querySelector('[data-region="status"]').getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0; }""")
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
after = top()
page.close()
assert abs(during - before) <= 1 and abs(after - before) <= 1, (before, during, after)
assert seen, "the words are out of view where the reader is"
def test_the_covered_letterhead_leaves_the_tab_order(browser, live):
base, root = live
_set(root, 3)
for path in ("/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
for vp, want in (({"width": 1280, "height": 800}, "hidden"), ({"width": 390, "height": 844}, "visible")):
page = browser.new_page(viewport=vp)
page.goto(f"{base}{path}", wait_until="networkidle")
got = page.evaluate("""() => ['header.topbar', 'footer.foot'].map(
s => getComputedStyle(document.querySelector(s)).visibility)""")
assert got == [want, want], (path, vp, got)
page.close()
# ---- G4: the line speaks in time ------------------------------------------------------------
def test_the_status_line_is_always_displayed(browser, live):
base, root = live
_set(root, 2)
for path in ("/b/g/", "/b/g/view?f=01.png"):
page = _page(browser, base, path, hold=True)
shown = "() => { const c = getComputedStyle(document.querySelector('[data-region=\"status\"]')); return [c.display, c.visibility]; }"
empty = page.evaluate(shown)
page.locator("form.flagtoggle button, #rail form.vflag button").first.click()
_wait_said(page, "Saving")
full = page.evaluate(shown)
page.close()
for d, v in (empty, full):
assert d != "none" and v == "visible", (path, empty, full)
def test_an_empty_status_line_takes_no_space(browser, live):
base, root = live
_set(root, 2)
for path in ("/b/g/", "/b/g/view?f=01.png", "/b/g/compare?a=01.png&b=02.png"):
page = _page(browser, base, path)
h = page.evaluate("document.querySelector('[data-region=\"status\"]').getBoundingClientRect().height")
page.close()
assert h == 0, (path, h)
def test_a_save_says_saving_then_saved(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
assert _said(page) == {"text": "Saved.", "tone": None}
page.wait_for_timeout(2500)
assert _said(page) == {"text": "", "tone": None}
page.close()
def test_a_new_save_is_not_cleared_by_the_last_ones_timer(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.wait_for_timeout(2500)
assert _said(page)["text"] == "Saving…", "the last save's timer cleared the new one's words"
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_a_queued_save_keeps_saying_saving(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert _said(page)["text"] == "Saving…", "the first save said Saved. while the second was queued"
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_a_batch_speaks_too(browser, live):
base, root = live
b = _set(root, 1)
_picks(b)
page = _page(browser, base, "/b/g/marks", hold=True)
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a3 input[type=radio][value="no"]')
page.locator("#mark-a1 .mark-submit").click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.locator("#mark-a2 .mark-submit").click()
assert _said(page)["text"] == "Still saving…"
page.evaluate("window.__release()")
_wait_held(page)
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
assert _said(page)["tone"] is None
page.close()
def test_a_repeat_press_says_still_saving(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
btn = 'figure.item[data-item="01.png"] .flagtoggle button'
page.locator(btn).click()
_wait_held(page)
page.locator(btn).click()
assert _said(page)["text"] == "Still saving…"
page.wait_for_timeout(2500)
assert _said(page)["text"] == "Still saving…", "it cleared while the save was still held"
assert page.evaluate("window.__held.length") == 1, "the repeat press sent a second POST"
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
page.close()
def test_the_form_in_flight_is_busy(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
form = 'figure.item[data-item="01.png"] form.flagtoggle'
page.locator(f"{form} button").click()
_wait_held(page)
assert page.locator(form).get_attribute("aria-busy") == "true"
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert page.locator(form).get_attribute("aria-busy") is None
# the failure path that stays (another draft is on the page): busy ends too
page.locator(".verdict .mark-add textarea").fill("another draft")
page.locator(f"{form} button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place")
assert page.locator(form).get_attribute("aria-busy") is None
page.close()
def test_a_failure_then_an_edit_then_a_save(browser, live):
"""A request trace: a failure that stays, an edit, a second press."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("keep me")
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("first")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place. Reload to see what was saved")
page.wait_for_timeout(2500)
said = _said(page)
assert said["tone"] == "warn" and "Could not save" in said["text"], said
page.locator(f"{tile} .item-addnote textarea").fill("first, edited")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
assert _said(page) == {"text": "Saving…", "tone": None}
page.evaluate("window.__release()")
_wait_said(page, "Saved.")
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "keep me", (same, kept)
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["first, edited"]
# ---- G13: leaving with an unsent draft asks first ----------------------------------------------
def _dialogs(page):
seen = []
page.on("dialog", lambda d: (seen.append(d.type), d.dismiss()))
return seen
def test_leaving_with_a_draft_asks(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator("#vnote-text").fill("draft")
page.locator('.film a.film-f[href="?f=02.png"]').click(no_wait_after=True)
page.wait_for_timeout(600)
assert seen == ["beforeunload"], seen
assert "f=01.png" in page.url and page.locator("#vnote-text").input_value() == "draft"
page.evaluate("document.activeElement.blur()")
page.keyboard.press("ArrowRight")
page.wait_for_timeout(600)
assert seen == ["beforeunload", "beforeunload"], seen
assert "f=01.png" in page.url
page.close()
def test_leaving_a_clean_page_does_not_ask(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator('.film a.film-f[href="?f=02.png"]').click()
page.wait_for_url("**f=02.png", timeout=5000)
page.close()
assert seen == []
def test_a_saved_draft_no_longer_asks(browser, live):
base, root = live
_set(root, 3)
page = _page(browser, base, "/b/g/view?f=01.png")
seen = _dialogs(page)
page.locator("#vnote-text").fill("saved")
page.locator("form:has(#vnote-text) button[type=submit]").click()
_wait_swaps(page, 1)
page.locator('.film a.film-f[href="?f=02.png"]').click()
page.wait_for_url("**f=02.png", timeout=5000)
page.close()
assert seen == []
def test_a_failed_save_keeps_the_other_drafts(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.locator(".verdict .mark-add textarea").fill("other")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_said(page, "Could not save in place. Reload to see what was saved; your other entries are still here.")
page.wait_for_timeout(1500)
said = _said(page)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
def test_a_failed_save_keeps_text_typed_while_it_flew(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
box = ".verdict .mark-add textarea"
page.locator(box).fill("first")
page.locator(".verdict .mark-add button").click()
_wait_held(page)
page.locator(box).fill("first and more")
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
kept = page.locator(box).input_value()
page.close()
assert same and kept == "first and more", (same, kept)
def test_a_changed_page_keeps_the_other_drafts(browser, live):
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("other")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
(b / "03.png").write_bytes(PNG) # a region the live page does not have
page.evaluate("window.__release()")
_wait_said(page, "Saved. The page changed meanwhile; reload to see it.")
page.wait_for_timeout(1500)
busy = page.locator('figure.item[data-item="01.png"] form.flagtoggle').get_attribute("aria-busy")
assert busy is None, "a settled save left its form busy"
said = _said(page)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert same and kept == "other" and said["tone"] == "warn", (same, kept, said)
def test_a_draft_typed_during_the_beat_stays(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
page.route("**/b/g/flag", lambda route: route.fulfill(status=500, body=""))
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_said(page, "reloading to show what was saved")
page.locator(".verdict .mark-add textarea").fill("typed in the beat")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
kept = page.locator(".verdict .mark-add textarea").input_value()
said = _said(page)
page.close()
assert same and kept == "typed in the beat", (same, kept)
assert "your other entries are still here" in said["text"], said
def test_its_own_reload_does_not_ask(browser, live):
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/")
seen = _dialogs(page)
page.route("**/b/g/note", lambda route: route.fulfill(status=500, body=""))
page.locator(".verdict .mark-add textarea").fill("sent, and failed")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "reloading to show what was saved")
page.wait_for_function("window.__same !== 1", timeout=5000)
page.close()
assert seen == [], seen
def test_embed_submit_does_not_ask(browser, live):
from booth.marks import declare_pick, marks_for
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
with page.expect_navigation(timeout=10000):
page.locator(".bk-ask-go").click()
page.close()
assert seen == []
assert [m.answer for m in marks_for(b)][0], "the plain submit did not land"
def test_embed_leaving_with_a_draft_asks(browser, live):
"""The positive control for the embed's guard: a changed answer, then the
home chip, asks."""
from booth.marks import declare_pick
base, root = live
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
# ---- folded from the heid bug-hunt (HNITTA, thread 01M3MRTNTWEPJHTN4APRR81KH4) -------------
def test_a_stale_tile_is_not_left_busy(browser, live):
"""R1: a save whose own tile survives the swap as a stale tile (un-flagged
under the flagged filter) settles like any other: its form is not busy."""
from booth.marks import set_flag
base, root = live
b = _set(root, 2)
set_flag(b, "01.png", True)
set_flag(b, "02.png", True)
page = _page(browser, base, "/b/g/?filter=flagged")
form = 'figure.item[data-item="01.png"] form.flagtoggle'
page.locator(f"{form} button").click()
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
page.wait_for_timeout(300)
assert page.locator(form).get_attribute("aria-busy") is None
page.close()
def test_a_queued_form_is_busy_on_the_live_page(browser, live):
"""R1's twin: an earlier save's swap replaces a queued form's node, and the
live copy is the one that must say it is busy."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
_wait_held(page)
page.locator(".verdict .mark-add textarea").fill("queued")
page.locator(".verdict .mark-add button").click()
page.evaluate("window.__release()")
_wait_swaps(page, 1)
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") == "true"
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 2)
page.wait_for_timeout(200)
assert page.locator(".verdict form.mark-add").get_attribute("aria-busy") is None
page.close()
def test_a_resubmit_in_the_beat_is_not_reloaded_away(browser, live):
"""R2: the failure's reload is due, and the operator presses again inside
the beat. The new save owns the page; the old reload must not abort it."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/")
calls = {"n": 0}
def first_fails(route):
calls["n"] += 1
if calls["n"] == 1:
route.fulfill(status=500, body="")
else:
route.continue_()
page.route("**/b/g/note", first_fails)
page.locator(".verdict .mark-add textarea").fill("again")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "reloading to show what was saved")
page.locator(".verdict .mark-add button").click()
_wait_said(page, "Saved.")
page.wait_for_timeout(1500)
same = page.evaluate("window.__same === 1")
page.close()
assert same, "the old failure's reload fired over the new save"
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["again"]
def test_an_unrelated_save_does_not_bury_a_failure(browser, live):
"""R4: a note fails and stays; a different save then lands. The line keeps
saying the note is not saved, rather than "Saved."."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
page.locator(".verdict .mark-add textarea").fill("another draft") # so the failure stays
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("did not go")
page.locator(f"{tile} .item-addnote button").click()
_wait_held(page)
page.evaluate("window.__release('fail')")
_wait_said(page, "Could not save in place. Reload to see what was saved")
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
_wait_held(page)
page.evaluate("window.__release()")
_wait_swaps(page, 1)
page.wait_for_timeout(300)
said = _said(page)
page.close()
assert said["tone"] == "warn" and "Could not save in place" in said["text"], said
def test_a_save_whose_page_would_not_refresh_says_saved(browser, live):
"""R8: the POST lands (204) and the page GET fails. That is a save, and the
line must not say it could not save (a second press would write it twice)."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = _page(browser, base, "/b/g/")
gate = {"on": False}
page.route("**/b/g/", lambda route: route.fulfill(status=500, body="")
if gate["on"] and route.request.method == "GET" else route.continue_())
page.locator(".verdict .mark-add textarea").fill("landed")
tile = 'figure.item[data-item="01.png"]'
page.locator(f"{tile} .item-addnote summary").click()
page.locator(f"{tile} .item-addnote textarea").fill("another draft")
gate["on"] = True
page.locator(".verdict .mark-add button").click()
_wait_said(page, "Saved. Could not refresh the page; reload to see it.")
page.wait_for_timeout(1500)
said = _said(page)
same = page.evaluate("window.__same === 1")
page.close()
assert said["tone"] == "warn" and same, (said, same)
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["landed"]
def test_focus_on_a_summary_survives_the_next_swap(browser, live):
"""R10: focus that was restored to an answered pick's summary stays there
through the next swap."""
base, root = live
b = _set(root, 1)
_picks(b, ("a1", "a2"))
page = _page(browser, base, "/b/g/marks")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").focus()
page.keyboard.press("Enter")
_wait_swaps(page, 1)
summary = "#mark-a1 details.mark-formwrap > summary"
assert _focused(page, summary)
page.evaluate("document.querySelector('#mark-a2 input[type=radio][value=\"no\"]').checked = true;"
"document.querySelector('#mark-a2 form.mark-form').requestSubmit()")
_wait_swaps(page, 2)
assert _focused(page, summary), page.evaluate("document.activeElement.outerHTML.slice(0, 120)")
page.close()
def test_an_edit_made_while_saving_is_not_called_saved(browser, live):
"""R12: text added to a note while it was saving is on screen and not on
the server. The line says so, instead of "Saved."."""
base, root = live
_set(root, 2)
page = _page(browser, base, "/b/g/", hold=True)
box = ".verdict .mark-add textarea"
page.locator(box).fill("hello")
page.locator(".verdict .mark-add button").click()
_wait_held(page)
page.locator(box).fill("hello!")
page.evaluate("window.__release()")
_wait_swaps(page, 1)
page.wait_for_timeout(300)
said = _said(page)
kept = page.locator(box).input_value()
page.close()
assert kept == "hello!", kept
assert said["tone"] == "warn" and "not saved yet" in said["text"], said
def _embed_page(browser, base, root, host_script=""):
from booth.marks import declare_pick
b = root / "r"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which render wins?", "options": ["A", "B"]})
(b / "index.html").write_text(
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script></head>'
f'<body><h1>Report</h1><div data-booth-ask="winner"></div>{host_script}</body></html>')
page = browser.new_page()
page.goto(f"{base}/b/r/", wait_until="networkidle")
page.wait_for_selector(".bk-ask-opt input[type=radio]", timeout=10000)
return page
def test_embed_a_cancelled_submit_is_guarded_again(browser, live):
"""R3: a host handler cancels our native submit. Nothing was sent, so the
answer is an unsent draft again, and leaving asks."""
base, root = live
host = ("<script>document.addEventListener('DOMContentLoaded', function () {"
" document.addEventListener('submit', function (e) { e.preventDefault(); }); });</script>")
page = _embed_page(browser, base, root, host)
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".bk-ask-go").click()
page.wait_for_timeout(300)
assert "/b/r/" in page.url and seen == [], "positive control: the host cancelled it"
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
def test_embed_the_skip_covers_one_leave(browser, live):
"""R3: the skip for our own submit covers the one navigation it started. A
submit whose response does not replace the page (a 204 here; a stop or Esc
in life) leaves the answer unsent on screen, and the next leave asks."""
base, root = live
page = _embed_page(browser, base, root)
page.route("**/b/r/answer", lambda route: route.fulfill(status=204, body=""))
seen = _dialogs(page)
page.locator(".bk-ask-opt input[type=radio]").first.check()
page.locator(".bk-ask-go").click()
page.wait_for_timeout(500)
assert "/b/r/" in page.url and seen == [], "positive control: the page stayed, and our submit did not ask"
page.locator(".booth-nav-home").click(no_wait_after=True)
page.wait_for_timeout(600)
page.close()
assert seen == ["beforeunload"], seen
+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")