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.
39 KiB
contract_version, status, module, purpose, depends_on, language, complexity, touches, assumptions
| contract_version | status | module | purpose | depends_on | language | complexity | touches | assumptions | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.1 | PROPOSED 2026-09-28 by design-dev. The operator ordered the fix slices from the anti-slop run (booth `booth-antislop`, report `~/.local/share/design-dev/research/booth-antislop-2026-09-28.md`) in design-dev's session: "go with your recommendations, push, start the fix slices". Each slice is staged as its own ref (`design-dev/antislop-sN`) for booth-dev's gate: suite, mutation tables and a bug-hunt. | the Booth's rendered surface: filters in booth/app.py, templates, booth/static/embed.js | Fix what the anti-slop run found (the Impeccable detector at 1280 and 390 in light and dark, plus a Vercel Web Interface Guidelines review), one slice at a time, without moving any invariant. |
|
python + jinja | low per slice |
|
|
The anti-slop fix slices
The run found that the Booth is sound on desktop and has a set of problems a viewer feels: clock times that break the house form, layouts that break at phone width, controls you can barely see in the light theme, and keyboard and screen-reader plumbing. The slices below fix them in an order that keeps each ref small enough to gate. Every slice keeps the six invariants (CLAUDE.md), and in particular:
- the server renders every state, and scripts only place it;
- autoescape stays on;
- every ordered surface keeps its stated order;
- blur honesty holds.
S1 — the house clock
The rule (operator convention, 2026-09-24): a clock time the operator reads is 24-hour local time (US Pacific), written as four digits with no colon (0848). Raw ISO stamps, HH:MM, microseconds, offsets and a poster's IP address do not appear in visible text.
-
One filter decides the visible form:
clock.- It takes an ISO-8601 string (with or without microseconds and an offset), an epoch number, or the link board's
YYYY-MM-DD HH:MM. - It returns
D Mon HHMMin local time (for example28 Sep 0848), with the year after the month only when it is not the current year (6 Sep 2025 2335). - A value it cannot read is returned as given, never a guess and never an exception: the Desk and the board render many rows in one response, and one bad stamp must not 500 the page. An empty value returns
"". - Falsifiable: a
clockthat formats%H:%Mfailstest_clock_forms. Aclockthat raises on garbage failstest_clock_never_raises.
- It takes an ISO-8601 string (with or without microseconds and an offset), an epoch number, or the link board's
-
One filter decides who is shown:
byline. It returns the recordedby/answered_byunless it parses as an IP address (v4 or v6), in which case it returns"". The stored value is unchanged; the u2 contract still records the client host.- Falsifiable: a
bylinethat passes IPs through failstest_byline_hides_addresses.
- Falsifiable: a
-
Where the filters apply. Every visible stamp goes through
clockand every byline throughbyline, and each clock sits in a<time>whosedatetimecarries the value exactly as stored:- a pick's answer line and a memo's line (
_marks.html); - the inline ask's state tag (
_ask_inline.html, so the embed chrome inherits it); - the link board's row time (
booth.html). - Falsifiable: the marks page, the lightbox's verdict aside, the embed fragments and the board carry no visible
HH:MM, noT08:48-shaped stamp and no IP:test_rendered_marks_use_the_house_clock,test_board_rows_use_the_house_clock,test_embed_fragment_uses_the_house_clock. Removing the filter from any one of those templates turns its test red.
- a pick's answer line and a memo's line (
-
The date tooltips follow suit.
date_stamp(thetitleof every created/updated<time>) rendersYYYY-MM-DD HHMM.- Falsifiable:
%H:%Mindate_stampfailstest_date_stamp_is_house_form.
- Falsifiable:
-
Folded from the heid bug-hunt (panel 4/4, thread
01M3MGPFKWBX0SJK5HFE0P3AFM):clockconverts a number inside its guard: an int past float range was anOverflowError(Q1).- A date or an ISO week renders its day and no invented
0000(Q8). bylinealso hides an address dressed asaddr:port,[v6]:portoraddr/prefix, or behind invisible characters (Q7).- The board row's author goes through
bylinelike every other surface (Q5). - Falsifiable: the rows marked Q1, Q5, Q7 and Q8 in
antislop.toml.
-
Refuted, with the reason: a malformed answer missing
unanswereddoes not 500 the marks panel (Q3). The Booth's Jinja uses the defaultUndefined, whose|lengthis 0; the no-op fix was reverted when its falsifier stayed green.test_a_malformed_answer_costs_its_line_not_the_pagestays, as a guard against a switch toStrictUndefined. -
Accepted as known risk, with reasons:
- Zone-less mark stamps are read as local by
clockand as UTC by the ordering path (Q4). No writer produces one:now_stampand the legacy import both stamp with.astimezone(). Only a hand-edited file could. - A board time inside the spring-forward gap renders the normalised hour (Q6). No clock can write a local time that does not exist.
- Zone-less mark stamps are read as local by
Out of S1: the CLI keeps writing its board rows as it does today. The board is a multi-writer file other sessions parse, so its storage form is not changed; clock reads both forms.
S2 — legibility
The rule.
-
Faded is not legible. A de-emphasised line is quieter by size, weight or a muted colour, never by
opacity: opacity takes whatever contrast the line had and divides it. -
Labels are 11px or larger (
--size-micro). -
A sentence-like line is 12px or larger (
--size-caption). -
Review arrows on a light stage. The ‹ › glyph sits on a translucent dark chip. At 60% the chip let a light stage through, and the thin glyph sampled at a median of 2.9:1 (the detector's pixel method; by colour it is about 4:1). The chip is at least 80% dense, so the glyph clears 7:1 over the lightest stage by colour, and reads at pixel level too.
- Falsifiable: a chip back at 60% fails
test_review_arrows_hold_over_a_white_stage, which composites the glyph over the chip over white.
- Falsifiable: a chip back at 60% fails
-
The filmstrip numbers are labels:
--size-micro, not 9.5px.- Falsifiable: 9.5px fails
test_film_numbers_meet_the_label_floor.
- Falsifiable: 9.5px fails
-
Retired benches keep their contrast. The row carries no
opacity. The link and URL take--text-muted, and the state word says RETIRED.- Falsifiable:
opacity:.5back on the row failstest_retired_benches_are_not_faded.
- Falsifiable:
-
The marks' state stamp (
? open,✓ answered) is a label at--size-micro, not 10.5px.- Falsifiable:
test_mark_state_meets_the_label_floor.
- Falsifiable:
-
The inline ask's state tag (the embed chrome's
✓ answered 28 Sep 0848) is a label at 11px, not 10.5px. S1's<time>made the detector measure it on its own.- Falsifiable:
test_the_ask_tag_meets_the_label_floor.
- Falsifiable:
-
Hint lines are sentences:
- the Desk's section rules (
.desk-rule: "oldest question first", "running things"); - the board's note;
- the bench head's note.
They sit at
--size-caption.- Falsifiable:
test_hint_lines_meet_the_sentence_floor.
- the Desk's section rules (
-
The embed chrome fades nothing. An answered ask's option details and its "recorded:" line inherit the host page's own text colour, with no
opacity, so they carry the host's contrast whatever the host is. The embed cannot know the host's palette; its own palette follows the Booth theme, not the report. The notes field's placeholder inherits that colour at 75%, where it had been the browser's grey (3.5 to 4.3:1 on dark).- Falsifiable:
test_embed_fades_nothing.
- Falsifiable:
-
Folded after the first gate run: three more labels were below the 11px floor, and they are now
--size-microlike the rest. They are the flagged tray's number (10px), the tile's "flagged" stamp (10.5px) and compare's A/B badge (9.5px). The report's list had named only the film numbers. Falsifiable: the same computed-style test, and three more rows. -
Folded from the heid bug-hunt. The embed's ask title no longer fades either (
opacity:.62on.bk-ask-titlecontradicted "nothing fades", Q9). The claims above are also held on the browser's computed style (tests/test_antislop_s2_browser.py): a stylesheet grep cannot see a later rule in the cascade (font-size:1px,color:transparent,filter:grayscale, a placeholder atopacity:0), and the browser can.
S3 — phone layouts
The rule: at phone width (≤600px), no text overprints other text, and no single word is set in a column narrower than itself. These claims are measured in a real browser at 390×844 (tests/test_antislop_browser.py), because a layout claim read off a stylesheet is a guess.
- Bench rows wrap instead of squeezing. At ≤600px a row wraps. The state word and the bench (name over URL) take the first line; who, when, the state buttons and × take the second, indented under the name.
- Falsifiable: without the wrap, the name, owner and date boxes intersect:
test_bench_rows_do_not_overprint_on_a_phone.
- Falsifiable: without the wrap, the name, owner and date boxes intersect:
- An inline doc's name keeps its line. At ≤600px the doc bar wraps. The name takes the full width and breaks only where it must (
overflow-wrap:anywhere, notword-break:break-all); the actions wrap under it.- Falsifiable:
test_doc_name_keeps_a_readable_line_on_a_phone.
- Falsifiable:
- The link board's headers stay compact. At ≤600px the note drops under the count, so the count ("33 links · 1 pinned", "3 benches") stays on one line, in both the board head and the benches head.
- Falsifiable:
test_board_head_stays_compact_on_a_phone.
- Falsifiable:
- A file tile's number clears its download link. The ordinal badge sits in the tile's top-left corner, so a file tile's link starts below it.
- Falsifiable:
test_file_tile_number_clears_the_download_link.
- Falsifiable:
- The review and compare pages keep their header to one line on a phone. At ≤600px they drop the tagline (every other page keeps it), so the header is the brand plus the theme toggle and the stage starts near the top. The class that scopes this is set by the server on
<body>(<html>carriesdata-booth, which the reveal scripts and their tests pin exactly).- Falsifiable:
test_review_header_is_one_line_on_a_phone, which also checks that the Desk keeps its tagline.
- Falsifiable:
Measured, not changed (the detector's rows that are misreads here):
.vnamealready ellipsises; the detector measures the clipped inner width.- The filmstrip clips its next frame at the edge on purpose: the clipped frame is the "there is more" cue of a horizontal scroller.
S4 — reading measure
The rule: a rendered document reads at a book's measure and says its structure with size.
- Measure. Prose blocks in
.markdown-body(paragraphs, lists, block quotes, headings, definition lists) are at most72chwide. Wide blocks (pre, tables) keep the full width, where they scroll.- Falsifiable: on the doc view at 1280 wide, a long paragraph measures at most 76 characters of its own font across:
test_doc_prose_reads_at_a_book_measure.
- Falsifiable: on the doc view at 1280 wide, a long paragraph measures at most 76 characters of its own font across:
- Heading scale. h3 : body, h2 : h3 and h1 : h2 are each at least 1.18 (h3
1.2em, h21.44em, h11.73em). Before this, h3 was1.08emover its body.- Falsifiable:
test_doc_headings_step_by_size.
- Falsifiable:
S6 — the operator's rulings (2026-09-28: "go with your recommendations")
- The tagline is a sentence:
held for review · wipes in {ttl}h unless kept. It is mono, muted and 12px, in sentence case (no tracked capitals). "Ephemeral" goes, and it stays true to held, kept and counting down, as agreed with booth-dev.- Falsifiable:
test_the_tagline_is_a_sentence: the rendered text, and notext-transform:uppercaseon.tagline.
- Falsifiable:
- "Needs you" rows carry no side stripe. The row's "? N OPEN" stamp says it. The flagged frame's bottom stripe in the filmstrip stays: it marks state on a thumbnail.
- Falsifiable:
test_needs_you_rows_carry_no_side_stripe.
- Falsifiable:
- The brand dot is matte. Glow means live power (SVOS), and the brand mark is not live. A live bench's dot keeps its glow.
- Falsifiable:
test_the_brand_dot_is_matte.
- Falsifiable:
- The hazard stripe sits on a
::before, not on the button's background, for Wipe now and the armed bulk delete. The button's own background is honestly transparent (a detector read the 3px background band as the whole background, 1.0:1), and the stripe renders exactly as before.- Falsifiable:
test_the_hazard_stripe_is_a_pseudo_element, in a real browser: no gradient on the button, a 3px striped::before.
- Falsifiable:
S5a — names, landmarks, focus rings, hit areas
The markup and CSS half of the interaction work. It changes no script behaviour except where Wipe now's prompt comes from. The in-place client is untouched; that half is S5b.
- Every link and button has a word for a name. A control a screen reader would announce as "×", "⬇", "⤢", "☆", "1:1" or "01" carries an
aria-label, or the file's name as.sr-onlytext. The visible label stays inside the name (1:1, natural pixels).- Covered: the withdraw ×s, downloads, open-full-page, the viewers' close ✕, the board's pin, copy and remove, the bench's remove, the zoom toggle, and the film-strip and flagged-tray frames.
- A Desk row's wipe names its booth (
wipe the booth alpha), so a list of rows is not a list of identical "wipe booth"s. - Falsifiable:
test_every_control_has_a_word_for_a_name(every page, the link board included; the name is computed fromaria-label, else the text plus each image'salt), andtest_desk_row_controls_name_their_booth.
- Every field has a name that is not its placeholder. Every note field, the bench's two inputs and the inline ask's notes carry an
aria-label.- Falsifiable:
test_fields_are_named, on every page and on both embed placements.
- Falsifiable:
- An ask's options are a named group, and its ids are unique. The inline ask's options are
role="radiogroup", labelled by the question's prompt. The marks page's single-question fieldset gets a visually hidden<legend>. A titled ask's title takesbk-ask-<id>-title: it used to reusebk-ask-<id>, which the question already holds.- Falsifiable:
test_radio_groups_are_named_and_ids_are_unique, checked on each placement the embed makes: eitherwhole, or the questions plussubmit.
- Falsifiable:
- Every page has one h1, a skip link and a named main. The Desk, the review and compare get a visually hidden h1. A doc's own h1 is content and is not counted.
- Falsifiable:
test_every_page_has_one_h1_and_a_skip_link.
- Falsifiable:
- The browser chrome matches the theme:
theme-colorfor light (#f0f4f5) and for dark (#15191d).- Falsifiable:
test_theme_color_for_both_schemes.
- Falsifiable:
- The review's progress tape is one picture (
role="img", named "N of M seen"). Its segments leave the tab order: the film strip below it holds the same links, named.- Falsifiable:
test_the_tape_is_one_picture.
- Falsifiable:
- Wipe now asks by name. The Desk's delegated prompt moves to
base.html, and a booth page's Wipe now uses it. It names the booth, and asks the kept-booth question for a kept booth. This replaces an inlineconfirm('Wipe this booth now?'). With JS off the form still submits, as before.- Falsifiable:
test_wipe_now_asks_by_name(markup), andtest_wipe_now_asks_by_name_in_the_browser: the dialog's text, and dismissing it wipes nothing.
- Falsifiable:
- Focus rings and hit areas.
- The embed draws its own focus rings, so a host's
outline:nonecannot remove them. - Rings inside
overflow:hiddencontainers are drawn inside (outline-offset:-2px), where they cannot be clipped. - The withdraw × is at least 24px, and 44px under a coarse pointer.
- Controls take
touch-action:manipulation, and the scrolling strips contain their overscroll. - A long booth slug wraps on a phone.
- Falsifiable:
test_embed_chrome_draws_its_own_focus_rings,test_focus_rings_are_drawn_inside_clipping_containers,test_withdraw_buttons_are_big_enough_to_hit(measured at 1280, and at 390 with touch), andtest_touch_and_scroll_behaviour(computed style).
- The embed draws its own focus rings, so a host's
- Small truths.
- A why truncated with an ellipsis carries its full text in
title. - A countdown of 48h or more rolls up to days (
6d 23h, not167h 12m). - Falsifiable:
test_a_truncated_why_carries_its_full_text,test_human_dur_rolls_up_to_days.
- A why truncated with an ellipsis carries its full text in
- Fixup from booth-dev's gate (a hulda bug-hunt plus heid's second voice, BRINGA, thread
01M3MVGQ7QSCCK8WT59TQ4J469):- The booth page's "★ kept — release" asks by name, as the Desk's release does.
WORDShas no prototype, so adata-confirmof__proto__orconstructoris an unknown word, and it asks.- The confirm helper lives in
<head>, so its capture listener is registered before any form exists. A click during load is asked too; the inlineconfirm()it replaced had that property. shown()also marks U+2028, U+2029, U+200B–U+200D, U+2060 and U+FEFF.- Derived ids take a
:, which no ask id or question key can contain:bk-ask-<id>-<key>:promptandbk-ask-<id>:title.-promptor-titlecollided with valid keys. The asks chip still jumps to it by URL fragment; booth-dev'stest_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefixnow looks the target up by[id=…], since a#selector cannot hold a:. human_durreturns "—" for a value that is not finite, instead of raising.- The tile's copy of a note drops its
id, becausemark-<id>names the panel's article (booth-dev's ruling). - The guards the gate found asserting source patterns now also hold on computed effects: the embed's rings are a solid, opaque 2px line under a host that removes outlines; the rings inside clipping containers compute to
-2px; the withdraw × is measured on both axes; and question-level notes fields are named. - Falsifiable:
test_no_id_repeats_on_any_page,test_release_on_the_booth_page_asks_by_name(and its browser twin),test_a_prototype_word_still_asks,test_the_confirm_helper_is_listening_before_the_body_exists,test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly,test_human_dur_never_raises,test_rings_inside_clipping_containers_are_drawn_inside,test_the_embed_draws_visible_rings, and the rows marked "S5a fixup" inantislop.toml.
- Existing rows this slice edits (booth-dev's): two
r2_flow.tomlrows for the confirm helper now namebase.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 — the in-place client: focus, a status line you can see, and drafts that are not lost silently
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.activeElementis a region being replaced, or is inside one, the script records three things:- the region's
data-regionid; - the focused element's key;
- its occurrence number
namong the elements in that region with the same key, in document order.
- the region's
- The key:
- a form control:
fieldKey, which is the form'sformKeyplus the field name, plus the value for a radio or checkbox; - a button inside a form:
formKeyplus the button'sname=value, or plusbuttonfor an unnamed one; - a link:
a|plus itshref; - a
<summary>:summary|plus theformKeyof the form in its<details>. - Anything else, the region node included, has no key.
- a form control:
- Restoring. After the swap, the script focuses the
nth element with that key inside the fresh node with the samedata-regionid, withpreventScroll. - 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. Afterbooth:swapped, each is focused again andscrollYis 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 onbody.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).
- Recording. Before a swap, if
- One status line per page, where the operator can see it (G2).
- Every page that extends
base.htmlrenders exactly onedata-region="status", from_status.html. It sits inside no otherdata-region, so a swap never replaces it. The swap already skipsstatus, and the script re-finds the line on every write. The embed's per-form.bk-ask-statuslines are notdata-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_upcaught 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:hiddenunderbody.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 onedata-region="status", inside no otherdata-region.test_the_status_line_is_visible_on_the_review(browser, 1280): on the review and on compare, a forced failure's words are whatelementFromPointfinds 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 computedvisibilityishiddenat 1280 andvisibleat 390 (the negative control).
- Every page that extends
- The line speaks in time, and each message has one owner (G4).
-
The line is a live region that is always displayed. No
hiddenattribute, neverdisplay:none. While empty it takes no space (.status:emptyhas 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 onlywarn.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: nohiddenon it in any page's markup; in the browser, its computeddisplayis notnoneand itsvisibilityisvisible, 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-busyis 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
beforeunloadguard asks when any in-place form on the page is dirty. "Dirty" is the script'sdirty(): 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 (refreshon 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 abeforeunloaddialog. 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_postsholds unchanged: with nothing else dirty, the failure still reloads.
- A
Existing tests and rows this slice edits (booth-dev's):
- Six browser tests in
test_flow_browser.pyread the line'shiddenstate 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.tomlrows 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(), sincerefreshno longer reloads andfail()now protects drafts on its own.
- "a new save does not clear the last one's words":
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.activeElementstraight afterbooth:swappedsees 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 itskeepargument, 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-busyends 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-busyis 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 controldirty()cannot read (R7), and nodata-regionnests 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).
- the embed's reload after a clean batch can discard text the operator typed into the HOST page, which
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.