Files
booth/docs/contracts/as_antislop.contract.md
T
vh 72d6c61629 fix(as-S5c): whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names
The last of the anti-slop interaction work (guidelines G6, G7, G14, G15,
G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no
re-POST, serialized saves, a batch never reloads, focus survives a swap.
- Keys (G6): one rule in base.html's <head>, BoothKeys.theirs(e), called
  first by the grid, the review and compare. A field or a player owns every
  key but Escape (Esc still goes back from a focused player); a control
  owns Space; a focused 1:1 stage that can pan owns the
  arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are
  the browser's. The field check lives on as BoothKeys.isEditable. Before:
  an arrow on a focused video left the review, and Enter on any control
  also opened the grid cursor's tile.
- The grid cursor is real focus: the tile it moves to gets tabindex=-1
  (script-set, one tile at a time) and focus, without a scroll; the cursor
  is an item (its data-item), and a doc closed with its ✕ is skipped; focus that
  lands on a tile (S5b's fallback) makes it the cursor; Enter opens the
  review only from the body, the grid or the tile, by its view?f= link;
  n opens a closed doc's fold; Escape clears the cursor
  and releases the tile's focus. The reticle is its focus mark (no second
  ring).
- The doc bar (G7): the controls leave the <summary>. div.doc-bar holds
  details.doc-fold (its summary is the label only) and div.doc-tools beside
  it; the body and notes follow in div.doc-inline, hidden with a closed
  fold by :has(), scripts on or off. A closed doc keeps its tools. Renders
  pixel-identical to today at 1280 and 390, light and dark.
- Tile sizes (G14): a gallery tile's <img> carries width/height, the
  picture as the browser draws it (EXIF 5-8 swap), read from the header
  only (no decode; PNG getexif is skipped unless the header carried it),
  opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime
  included, so cp -p over a file is seen), in a separate
  step (items.image_dims over thumbs.drawn_size) so the Desk never pays it.
  Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on
  its mark. content-visibility:auto, which the report proposed too, is NOT
  added: a swapped-in tile has no remembered size, and a flag far down moved
  the page 2929px (3/3; 0px without it).
- The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's
  scroll-margin-top (the two add), so a control reached by Tab stops below
  the sticky rail too. Measured before: a Tab-focused flag button at 19.6px,
  under the rail's bottom at 47.6px. The scripts-off fallbacks are the old
  rules' numbers (132px, 217px at <=480), now pinned by a test. The height
  script follows the live rail after every in-place save (it watched the
  replaced node, and read 0px after one flag), and the rail's own controls
  cancel the padding (a Tab between stuck group links scrolled 357px).
- Reveal names (G17): no aria-label on any reveal control; the name is the
  words on it, the glyph in an aria-hidden span, the item's name as
  .sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops
  aria-pressed (its words already say the state; r2b rules them) and its
  "on" look reads the .reveal-all class on <html>. No pixel changes.
- booth-dev's note: a refused batch's forms enter `unsent` with the
  refusal's words, and a later save says every standing failure's words
  (each once, in order) instead of "Saved.", and every warning says the
  other standing failures first, so no failure buries another. Test first:
  test_a_batch_refusal_outlives_an_unrelated_save.
- Rows re-anchored to the same failure: r2b "Space on a focused review
  button", r3 "C3 a held modifier" and both "C3 Space on a focused ..."
  (now in BoothKeys), r2c "the stage reveal shows with scripts off", and
  this contract's S3 doc-bar row and five S5b status-line rows.

Folded from the heid contract review (BEINKA, panel 4/4, thread
01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the
contract states the tile/focus/cursor seam with S5b, the helper's union and
scope, the size's source and every path to none, Reveal all's name, the
refusal sentence's lifetime, and the fallback arithmetic (one test added).

Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread
01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2
the rail's height after a save, R3 no warning buries another, R5 the view?f=
link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own
controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused
picks re-send together), R6 (Chrome takes the same header's size with or
without the attributes; measured), R11 (by design).

From this slice's own falsifier runs: a "one row wide" row that mutated a
flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and
a Reveal-all "on look" read under the clicking pointer, where :hover draws
the same border (the pointer now leaves first; 3/3 proved).

Contract: as_antislop S5c.
Falsifiers: antislop.toml S5c section.
2026-09-29 01:09:15 -07:00

67 KiB
Raw Blame History

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.
app.py: the Jinja Environment and its filters (`human_dur`, `date_iso`, `date_stamp`, `date_day`, `date_ago`); the note/answer routes that record `who = request.client.host`.
marks.py: `Mark.created`, `Mark.by`; asks.py: `answer.answered_at`, `answer.answered_by` (ISO strings with microseconds and an offset, written by `now_stamp`).
links.py: `parse_link_entries` -> `when` (the text a `booth link` row carries, today `YYYY-MM-DD HH:MM`).
items.py: `booth_items`, `Item.kind`, and `render_doc_body` as the pattern for a per-surface step; thumbs.py: the EXIF orientation rule in `ensure_thumb` (S5c).
base.html's in-place client as S5b left it: `unsent`, `landed`, `runAll`, `carry`/`detailsMap`, `focusRecord`/`focusRestore` (S5c).
python + jinja low per slice
booth/app.py (filters)
booth/templates/_marks.html, _ask_inline.html, booth.html (S1)
booth/templates/base.html, view.html; booth/static/embed.js (S2)
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)
booth/templates/base.html (BoothKeys, the doc bar, scroll padding, Reveal all, `unsent`), booth.html (the grid keys, the doc bar, tile sizes, reveals), view.html, compare.html, doc.html (keys, reveals); booth/items.py (`image_dims`), booth/thumbs.py (`drawn_size`), booth/app.py (`build_gallery`) (S5c)
tests/test_antislop.py; tests/mutations/antislop.toml
ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume.
Stored data does not change shape. Every slice changes only what is RENDERED: `.marks.json`, `links.md` and the answer records keep their exact bytes.
Hardening of `render_doc` (raw HTML in docs) and front matter are booth-dev's, by agreement on 2026-09-28; this contract does not touch `render_doc`.

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 HHMM in local time (for example 28 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 clock that formats %H:%M fails test_clock_forms. A clock that raises on garbage fails test_clock_never_raises.
  • One filter decides who is shown: byline. It returns the recorded by / answered_by unless 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 byline that passes IPs through fails test_byline_hides_addresses.
  • Where the filters apply. Every visible stamp goes through clock and every byline through byline, and each clock sits in a <time> whose datetime carries 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, no T08: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.
  • The date tooltips follow suit. date_stamp (the title of every created/updated <time>) renders YYYY-MM-DD HHMM.

    • Falsifiable: %H:%M in date_stamp fails test_date_stamp_is_house_form.
  • Folded from the heid bug-hunt (panel 4/4, thread 01M3MGPFKWBX0SJK5HFE0P3AFM):

    • clock converts a number inside its guard: an int past float range was an OverflowError (Q1).
    • A date or an ISO week renders its day and no invented 0000 (Q8).
    • byline also hides an address dressed as addr:port, [v6]:port or addr/prefix, or behind invisible characters (Q7).
    • The board row's author goes through byline like every other surface (Q5).
    • Falsifiable: the rows marked Q1, Q5, Q7 and Q8 in antislop.toml.
  • Refuted, with the reason: a malformed answer missing unanswered does not 500 the marks panel (Q3). The Booth's Jinja uses the default Undefined, whose |length is 0; the no-op fix was reverted when its falsifier stayed green. test_a_malformed_answer_costs_its_line_not_the_page stays, as a guard against a switch to StrictUndefined.

  • Accepted as known risk, with reasons:

    • Zone-less mark stamps are read as local by clock and as UTC by the ordering path (Q4). No writer produces one: now_stamp and 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.

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.
  • The filmstrip numbers are labels: --size-micro, not 9.5px.

    • Falsifiable: 9.5px fails test_film_numbers_meet_the_label_floor.
  • 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:.5 back on the row fails test_retired_benches_are_not_faded.
  • The marks' state stamp (? open, ✓ answered) is a label at --size-micro, not 10.5px.

    • Falsifiable: test_mark_state_meets_the_label_floor.
  • 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.
  • 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 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.
  • Folded after the first gate run: three more labels were below the 11px floor, and they are now --size-micro like 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:.62 on .bk-ask-title contradicted "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 at opacity: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.
  • 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, not word-break:break-all); the actions wrap under it.
    • Falsifiable: test_doc_name_keeps_a_readable_line_on_a_phone.
  • 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.
  • 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.
  • 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> carries data-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.

Measured, not changed (the detector's rows that are misreads here):

  • .vname already 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 most 72ch wide. 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.
  • Heading scale. h3 : body, h2 : h3 and h1 : h2 are each at least 1.18 (h3 1.2em, h2 1.44em, h1 1.73em). Before this, h3 was 1.08em over its body.
    • Falsifiable: test_doc_headings_step_by_size.

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 no text-transform:uppercase on .tagline.
  • "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.
  • 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.
  • 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.

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-only text. 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 from aria-label, else the text plus each image's alt), and test_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.
  • 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 takes bk-ask-<id>-title: it used to reuse bk-ask-<id>, which the question already holds.
    • Falsifiable: test_radio_groups_are_named_and_ids_are_unique, checked on each placement the embed makes: either whole, or the questions plus submit.
  • 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.
  • The browser chrome matches the theme: theme-color for light (#f0f4f5) and for dark (#15191d).
    • Falsifiable: test_theme_color_for_both_schemes.
  • 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.
  • 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 inline confirm('Wipe this booth now?'). With JS off the form still submits, as before.
    • Falsifiable: test_wipe_now_asks_by_name (markup), and test_wipe_now_asks_by_name_in_the_browser: the dialog's text, and dismissing it wipes nothing.
  • Focus rings and hit areas.
    • The embed draws its own focus rings, so a host's outline:none cannot remove them.
    • Rings inside overflow:hidden containers 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), and test_touch_and_scroll_behaviour (computed style).
  • 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, not 167h 12m).
    • Falsifiable: test_a_truncated_why_carries_its_full_text, test_human_dur_rolls_up_to_days.
  • 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.
    • WORDS has no prototype, so a data-confirm of __proto__ or constructor is 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 inline confirm() 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>:prompt and bk-ask-<id>:title. -prompt or -title collided with valid keys. The asks chip still jumps to it by URL fragment; booth-dev's test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix now looks the target up by [id=…], since a # selector cannot hold a :.
    • human_dur returns "—" for a value that is not finite, instead of raising.
    • The tile's copy of a note drops its id, because mark-<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" in antislop.toml.
  • 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 — 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.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 nth 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 — whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names

The last of the interaction work: guidelines items G6, G7, G14, G15 and G17, plus booth-dev's note carried from S5b's gate (a refused batch buried by an unrelated save). Every S5b promise holds: the client never re-POSTs, saves are serialized, a batch never reloads, and focus survives a swap.

INV-6 here. The script builds no markup and renders no server state. It sets tabindex="-1" on the tile it moves the cursor to (S5b sets the same attribute on a fallback region; how the two meet is under "The grid cursor is real focus"), and it writes the reveal controls' glyph and word into spans the server rendered. Both are the script's own view state.

G6 — a page's keys never take a key the focused element uses

One rule, in one place. BoothKeys.theirs(e), defined in base.html's <head>, answers "does this key belong to the focused element?" The grid (booth.html), the review (view.html) and compare (compare.html) each call it first and act only when it says no. Their own copies of the field check (isEditable, the tag test) and of the Space exceptions are removed. The field check lives on as BoothKeys.isEditable, which test_the_zoom_view_does_not_navigate_away_from_a_note_being_typed (booth-dev's structural guard) still finds on the review page.

It answers only WHOSE key it is. What a key then does stays each page's own: the grid's Enter and Escape rules below, the review's Space, compare's letters. doc.html keeps its one key (Escape) and its own field guard, unchanged. The helper says yes when ANY of these four cases holds (they are a union, so no case outranks another), and in no other:

  1. A field owns every key, and a player every key but Escape. The target is, or is inside, an input, textarea, select or an editable element; or a video or an audio, for any key but Escape. A player has no use for Escape outside fullscreen, where the browser takes it first, so Esc from a focused player still goes back (the review, compare) or clears the cursor (the grid).
    • The grid gains select and the players. Today an arrow on a focused tile video moves the cursor instead of seeking.
    • The review and compare gain every key but Escape from a player. Today only Space is left to it, so an arrow on a focused video navigates away.
  2. A control owns Space. The target is, or is inside, a button, an a[href] or a summary: Space presses it, follows it or opens it. Arrows and letters from a control still reach the page. booth-dev's review test presses 1:1 with the mouse and then → to move on, and that flow stays.
    • Enter is not in the helper. Only the grid binds Enter, and it takes Enter only from the page itself (below), which already leaves every control its own Enter. A clause for it here would be code no test could reach.
  3. A stage that can pan owns the arrows and Space while it has focus. The target is, or is inside, a .vstage.can-pan, and those keys scroll it.
    • Measured on the test Chromium (151): a 1:1 stage larger than its box sits in the Tab order between the ‹ and › arrows, and focus() takes. A mouse press does not focus it, so drag-to-pan followed by → still moves on.
    • Today → on a focused 1:1 stage leaves the page instead of panning.
  4. A held Ctrl, Meta or Alt belongs to the browser. All three pages already had this rule; it now lives in the helper.
  • Falsifiable:
    • test_a_player_keeps_its_keys (browser): with a video focused, ArrowRight, ArrowLeft, F and Space change nothing on the review (no navigation, no POST) and nothing on the grid (no cursor). Escape from the focused video goes back to the grid. The negative control is in the same test: with focus back on the page, ArrowRight navigates, or moves the cursor.
    • test_enter_on_a_control_does_not_open_the_cursor_tile (browser): the cursor is set, focus moves to another tile's "+ note" summary, and Enter opens that summary and does not navigate. Nor does Enter from another focused node that is not the page (the rail, made focusable, stands in for one). Enter on the focused cursor tile opens its review (the positive control).
    • test_a_focused_pannable_stage_pans_with_the_arrows (browser): 1:1 on a large picture, the stage focused, ArrowRight scrolls it and does not navigate. Blurred, ArrowRight navigates.
    • Existing: test_space_on_a_focused_review_button_presses_it_and_does_not_move_on, test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view (compare), and the review's 1:1-then-→ flow in test_flow_browser.py.

The grid cursor is real focus. "The tile" below always means the tile element itself (figure.item), never a node inside it. The cursor visits every tile except a doc closed with its ✕ (.is-closed, display:none, which cannot take focus). The cursor is an item, not a position: the script holds the cursor tile's data-item (its rel) and finds its place again before every key and after every swap, so a doc closed since cannot shift the cursor onto a neighbour. A cursor whose tile is closed is no cursor.

  • Moving the cursor focuses the tile. The script sets tabindex="-1" on the tile it moves to, focuses it without scrolling, then scrolls it into view as today (block:'nearest'). Among tiles, only the cursor tile carries a tabindex: every move removes it from every other tile. The server renders none, so a mouse press can focus only the tile that is already the cursor.

  • Focus that lands on a tile makes it the cursor, whatever put it there. The case today is S5b's fallback after a swap, when the focused control vanished (a note's × withdrawn by keyboard): S5b sets tabindex="-1" on the fresh tile and focuses it, and that tile becomes the cursor, so it keeps its tabindex under the rule above. S5b's fallback on a region that is not a tile is not the grid's, and the grid never touches it. Focus on a control INSIDE a tile does not move the cursor: the cursor is a tile position, and a control is not a tile. So a focused tile is always the cursor, and the next arrow moves on from it.

  • Enter opens the cursor tile's review only from the page itself: the body, the grid, or the tile itself. From a control, inside the cursor tile or anywhere else, Enter is the control's own and the grid does nothing. It never fires from any other focused node either (an S5b fallback region, for instance).

  • Escape clears the cursor. When the cursor tile itself is the focused element, Escape releases focus to the page, so no focus ring is left behind without the reticle. Focus on a control is left where it is.

  • The reticle is the cursor tile's focus mark. .item.is-cursor:focus-visible draws no outline, since the reticle and the accent border already mark it. A focused tile without the cursor cannot occur, and if it did it would keep the house ring.

  • Falsifiable:

    • test_the_grid_cursor_is_real_focus (browser): after →, document.activeElement is the is-cursor tile and exactly one tile carries a tabindex. After → again, the next tile holds both. After Escape, no tile is the cursor and focus is on body. The focused cursor tile computes outline-style: none.
    • test_focus_returned_to_a_tile_makes_it_the_cursor (browser): a note's × on tile 5 is pressed from the keyboard. After the swap, focus is on tile 5 and tile 5 is the cursor, and → focuses tile 6.
    • Existing, unchanged: test_an_arrow_after_a_group_jump_does_not_scroll_back, test_the_keyboard_flag_actually_submits, and S5b's test_focus_returns_to_the_pressed_control.
  • n on a doc opens its fold too, since a closed fold hides the note field with the body.

  • Enter follows the tile's view?f= link, never a link that merely starts with "view". A media tile's download link comes first, and for a file named views.webm its href starts with "view" too.

  • Falsifiable (folded from the heid bug-hunt): test_the_cursor_skips_a_closed_doc, test_n_opens_the_note_on_a_closed_doc, test_enter_reviews_a_file_named_view (a video named views.webm opens its review, and viewer.zip, which has no review, is not downloaded).

Not in S5c: a switch to turn the letter shortcuts off (WCAG 2.1.4). The letters act from anywhere except fields and players. A setting to disable them is a new operator-facing control, so it is reported to the operator and not built.

G7 — the doc bar's controls leave its <summary>

A <summary> is one button to a screen reader, so the links, forms and ✕ inside it were read as one control, and a <form> is not valid inside one.

  • The structure.
    • div.doc-bar is the bar, with the same look as today.
    • Inside it, details.doc-fold holds only its summary.doc-sum: the chevron, the number and the name.
    • Beside that sits div.doc-tools: open full page, download, blur, flag and ✕, the same controls with the same names.
    • The doc's body and its notes follow in div.doc-inline.
  • Why the fold holds only its summary. A closed <details> hides everything inside it but its summary, so tools inside it would vanish with the body, and a collapsed doc would lose its controls. Placing the tools beside the summary in the same row needs the summary's box outside the fold's content, which ::details-content{display:contents} could do, but only in the newest browsers.
  • The fold still works with scripts off. .item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none} hides the body and notes when the fold is closed. :has() is in every current engine. The bar and its tools stay.
  • One row wide, the name first on a phone. Wide, the fold takes the bar's free width and the tools sit at its end. The tools' vertical padding sets the bar's height, as the old summary's padding did around them, and the summary stretches to fill it, so its hit area is still the whole bar left of the tools. At ≤600px the bar wraps: the summary takes the full width, and the tools go under it (S3's claim, unchanged).
    • Measured: the bar renders pixel-identical to today at 1280 and 390, light and dark, open and closed (before/after shots of the same booth).
  • A save carries the fold. The fold holds no form, so S5b's carry() matches it by class and occurrence within its tile and keeps it open or closed across a swap, as it did the old details.
  • Removed as dead: the handler that stopped a click in the bar's forms from toggling the fold. The forms are no longer in the summary. The ✕ handler keeps working and still hides the tile.
  • Known cost: Chrome's find-in-page opens a closed <details> to show a match. A closed fold's body is now hidden by CSS outside it, so find-in-page does not reach a collapsed doc. An open doc is unaffected.
  • Falsifiable:
    • test_the_doc_summary_holds_no_controls (markup): no a, button, form, input or textarea inside summary.doc-sum, and each of the five controls is inside .doc-tools.
    • test_a_closed_doc_keeps_its_tools (browser, run once with scripts on and once with scripts off): a click on the summary hides the body (no rendered box), and the tools stay visible. With scripts on, the flag still lands in place.
    • test_the_doc_bar_is_one_row_wide_and_two_on_a_phone (browser): at 1280 the summary and the tools share a row; at 390 the tools sit below the name. Also S3's test_doc_name_keeps_a_readable_line_on_a_phone, with its row re-anchored.
    • test_a_closed_doc_stays_closed_through_a_save (browser): the fold is closed, another tile is flagged in place, and after the swap the fold is still closed.

A lazy <img> with no size is a zero-height box until it loads. So a link to a tile far down the gallery lands where the boxes above it WILL be, not where they are.

  • Measured on the test Chromium, 40 portrait images, #item-30.png, 3 runs each, identical: without sizes the tile's top sits 44px below where the rail's offset puts it (103.4 against 59.6); with sizes it is on target (60.4).

The rule. A gallery tile's <img> carries width and height: the ORIGINAL picture's pixel size, oriented as the browser draws it (a 300×200 PNG is 300×200, whether the tile loads the original or its thumbnail). The tile's CSS (width:100%; height:auto) uses the two only as a ratio, so the thumbnail needs no numbers of its own: Pillow's thumbnail() keeps the original's ratio, rounding aside, and the orientation rule is the one ensure_thumb applies (5–8 swap the numbers).

  • Read from the file's header only. Pillow's open reads the header, and nothing is decoded. The EXIF orientation is read only when the header already carries it ("exif" in im.info). Pillow's PNG getexif() otherwise decodes the whole image to look for a late eXIf chunk. Orientations 5–8 swap the two numbers, as ensure_thumb does, so a phone's portrait keeps its portrait box. The thumbnail keeps the original's ratio, so the numbers fit it too.
  • A separate step, like render_doc_body: items.image_dims(booth, item), over thumbs.drawn_size(path). booth_items and the Item record are unchanged, so the Desk, which calls booth_items for every booth, pays nothing. build_gallery adds dims to each tile's dict.
  • Cached by the file's identity (device, inode, size, mtime and change time, after an lstat), so a gallery render reads each header once while it is unchanged. The change time is there because cp -p over a file keeps its inode and restores its mtime, and a same-length replacement would keep the old size. No write can restore a ctime.
    • Measured on this box over the audit data copy (312 pictures, 3 runs): 243ms on a cold disk the first time, 15–16ms with the page cache warm, 2.5ms from the size cache.
  • Opened safely.
    • The identity is taken with lstat, never through a link.
    • The file is opened O_NOFOLLOW, so a planted link is refused.
    • It is opened O_NONBLOCK, so a planted FIFO reads as empty (not an image) instead of holding the render.
  • Never raises. drawn_size returns None for every failure: a link refused by O_NOFOLLOW, a file Pillow cannot open (an SVG, a broken PNG), a FIFO read as empty, any error. The None is cached for that identity like a size. A None renders no attributes, which is today's markup. The size only shapes the box before the picture loads. aspect-ratio: auto w / h gives way to the loaded picture's own ratio, so a wrong number costs a jump, never a distorted picture.
  • Falsifiable:
    • test_tile_images_carry_their_drawn_size: a 300×200 PNG renders width="300" height="200". A JPEG stored 300×200 with orientation 6 renders 200×300. A broken .png, an SVG and a symlinked PNG render no size.
    • test_reading_a_size_decodes_nothing: with Image.load made to raise, a PNG's size and a rotated JPEG's size are still read.
    • test_a_planted_fifo_or_link_costs_its_size_and_never_hangs: a FIFO with no writer returns no size within 3s, a link returns none, and the real file returns its size.
    • test_a_size_is_read_once_per_version_of_the_file: three reads open the file once, and a file rewritten in place is read again.
    • test_a_file_replaced_in_place_keeps_no_stale_size: the same inode, the same length and a restored mtime, read again.
    • test_a_link_to_a_tile_lands_where_it_points (browser): the measurement above, as a test. The tile's top is within 2px of the rail's bottom plus 12 once the page is idle.

Measured, not changed.

  • content-visibility:auto on .item, which the report proposed alongside the sizes. It fights the in-place swap. A swapped-in tile is a new node with no remembered size, so tiles off-screen collapse to the placeholder height. A flag far down the gallery then moved the page by 2929px, in 3 runs of 3. Without it the page moved 0px, again 3 of 3. The sizes fix the landing, which was the finding. A 270-tile gallery renders without it.
  • The filmstrip, the tray and the Desk strip draw their thumbnails into fixed boxes (object-fit:cover), so a size there moves nothing.

G15 — nothing reached by Tab sits under the sticky rail

The rail sticks to the top of a gallery page. .item{scroll-margin-top} kept a TILE clear of it on a jump or a cursor move, but a control inside a tile, reached by Tab, scrolled only until it touched the viewport, which is under the rail.

  • Measured: a tile's flag button reached by Tab sat at 19.6px, under the rail's bottom at 47.6px.

  • The page, not the tile, keeps the rail's height clear. html:has(.rail){scroll-padding-top:calc(var(--rail-h,120px) + 12px)} REPLACES the two .item{scroll-margin-top} rules.

    • --rail-h is the rail's measured height, set by the script. With scripts off it is unset, and the fallback stands in for it: 120px, or at ≤480px the measured worst case, 205px. The 12px is added either way, so scripts off computes 132px, or 217px on a phone. These are the old rules' numbers.
    • Scroll padding applies to every scroll-into-view: a Tab, a fragment jump, scrollIntoView from the cursor.
    • The two cannot stay together, because they add: a tile would land two rails down.
    • --rail-h is still measured by the same script; only its consumer moves. The script now finds the live rail on every measure and moves its observer onto it. The rail is a region (filters) that each save replaces, and the old script watched the first node. What triggers the new measure is the observer's own rule: a removed element reports a zero size, so the old rail's removal calls the measure, which then finds its replacement. A booth:swapped listener was tried alongside and removed: its row stayed green, because the observer already covers it. Measured before: after one flag, --rail-h read 0px against a live 48px rail, so the padding collapsed to 12px.
    • The padding is carried as --rail-pad, and a control IN the rail (.rail :is(a,button,input,summary)) takes the same value as a negative scroll-margin-top. The rail is sticky, so its own controls are never under it. Measured before: every Tab between two group links of the stuck rail scrolled the page up 357px, in 3 runs of 3 (0px on the base).
    • Pages without a rail get no padding.
  • Comments that name the consumer (base.html's .rail note and the --rail-h script, and booth.html's fromViewport note) now say scroll-padding-top. The cross-file contract on the .rail class is unchanged.

  • Falsifiable:

    • test_a_tabbed_control_does_not_hide_under_the_rail (browser, 1400×700): the measurement above, as a test. The Tab-focused flag button's top is at or below the rail's bottom.
    • test_a_jump_lands_just_below_the_rail (browser): the tile's top is at the rail's bottom plus 12, ±2. It guards against a doubled offset.
    • test_pages_without_a_rail_have_no_scroll_padding (browser, the negative control): the Desk and the review compute scroll-padding-top: auto.
    • test_the_rail_height_follows_the_live_rail_after_a_save (browser): after an in-place flag, and again after a resize wraps the rail taller, --rail-h equals the live rail's height.
    • test_tab_between_rail_links_does_not_move_the_page (browser).
    • test_the_rail_padding_falls_back_with_scripts_off (browser, scripts off): a gallery computes 132px at 1400 wide and 217px at 390.

G17 — a reveal control's name is the words on it

The reveal controls flip their words: 👁 reveal ↔ 🙈 hide on a tile, the review stage, a compare side and a doc page, and 👁 reveal all ↔ 🙈 blur again (r2b's ruled copy). Two defects follow.

  • A tile's, the review's and compare's reveal carried a fixed aria-label="reveal …". After the flip the screen said "hide" and the name said "reveal", which fails WCAG 2.5.3 (label in name).
  • The emoji were read aloud.

The report's fix was "a constant label plus aria-pressed". That needs constant visible words too (2.5.3), and r2b rules those words. So the name follows the words instead:

  • No aria-label on any reveal control (.reveal on a tile, #vreveal, .cmp-reveal, #docreveal, [data-reveal-all]). The name is the visible words.
  • The glyph sits in its own aria-hidden span, and the word in its own span. The script writes those two spans, never the button's whole text. It hides the "— blur is cosmetic" tail when revealed with the hidden attribute, so the tail leaves the name too: "reveal all — blur is cosmetic", then "blur again". (Below 600px CSS already hides the tail, and the name is the shorter words on screen.)
  • A control that reveals one item carries that item's name as .sr-only text: the file on a tile, the review and a doc page, and the side's letter on compare. The name is "reveal a.png" before the flip and "hide a.png" after. S5a's per-item names are kept.
  • Reveal all carries no aria-pressed. Its words already say which state it is in. A toggle whose label changes must not also announce a pressed state (WAI-ARIA APG, toggle button), because a screen reader would say "blur again, pressed". Its "on" look moves from [aria-pressed="true"] to .reveal-all .reveal-all-btn, which reads the one class on <html> that IS the state.
  • Falsifiable:
    • test_reveal_names_follow_their_words (browser): by accessible role and name, before and after a click, on a tile, the review, a compare side, a doc page and Reveal all. The "on" look of Reveal all still computes after the click, so the look did not silently go with the attribute.
    • test_reveal_glyphs_are_hidden_from_the_name (markup): every reveal control's glyph is inside aria-hidden="true", and no reveal control carries aria-label or aria-pressed.

booth-dev's note — a refused batch is not buried by an unrelated save

S5b made a one-form failure outlive an unrelated "Saved." (R4) by keeping its key in unsent. A refused BATCH's keys never entered unsent. So after a batch said "Saved 1 of 2. Not saved: a2 (…)", an unrelated flag's "Saved." replaced those words while a2's pick was still unsent on the page.

  • Every failure that stays is remembered with its own words. unsent[key] holds the words said when that form failed: S5b's stay message for a one-form failure, and the batch's whole refusal sentence for each refused form of a batch. The anchored refusal line in runAll is unchanged: the refused forms are the batch's forms the server did not take.
    • A refusal sentence cannot outlive part of what it names. A refused pick stays dirty (a radio cannot be un-picked), so the next press on any pick re-sends every refused pick in one batch. That batch's own words then replace the old sentence for every form it refused, and a form it saved leaves unsent.
  • landed() says them again. It says every standing failure's words: each distinct message once, in the order they failed, while its form is still on the page. It says them in place of "Saved.", as S5b does for a one-form failure. A new press on the refused form clears it, as today.
  • And no warning buries another. Every warning the client says (a one-form failure, a partly refused batch, "you changed it while it was saving", a page that changed or would not refresh) says the other standing failures first, each once, in the order they failed. "Other" means those not of the forms the warning is about.
  • Falsifiable:
    • test_a_batch_refusal_outlives_an_unrelated_save (browser, written first). A batch with a2 refused, then an unrelated flag lands. Past the 2s beat, the line still says "Not saved: a2", in the warn tone.
    • test_two_standing_failures_are_both_said_once_each (browser): a note that could not be saved, then a batch with a2 and a3 refused, then an unrelated flag. At the refusal and after the flag, the line says the note's words once and the batch's words once, in that order.
    • test_a_later_failure_does_not_bury_an_earlier_one (browser): a batch refusal, then a note that fails. The line says the refusal first, then the note's failure.
    • The negative control is existing: test_a_later_save_clears_a_stale_not_saved_line, where a2's own press clears the words.
    • S5b's test_an_unrelated_save_does_not_bury_a_failure holds unchanged, with its row re-anchored.

Folded from the heid contract review

BEINKA (panel 4/4, thread 01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, 9 themes and 3 solos. All were prose the tests left open, and none changed behaviour. The text above now states:

  • the fallback arithmetic, with a new test (test_the_rail_padding_falls_back_with_scripts_off);
  • that "the tile" is the element itself, and how the tile, focus and cursor rules meet S5b's fallback;
  • that the helper's cases are a union and it answers only whose key it is;
  • that doc.html is unchanged;
  • where the picture's size comes from, and every path to no size;
  • how Reveal all's tail leaves its name;
  • why a batch's refusal sentence cannot outlive part of what it names;
  • the measured pixel-identity of the doc bar.

From this slice's own falsifier runs:

  • The first "one row wide" row mutated the fold's flex basis, which a bar that does not wrap simply shrinks. It stayed green. The row now takes the bar's flex away.
  • "Reveal all loses its on look" proved in one run and went vacuous in the full gate. The test read the border under the pointer that had just clicked, and :hover draws the same border as the "on" look. The pointer now leaves before the read, and the row proved 3 runs of 3.

Folded from the heid bug-hunt

HRÖSKVA (panel 4/4, thread 01M3P0ZPRSASFSE5K3PR4NTQP6): 18 findings in 11 rows.

Fixed. The text above states each fix; each has its test and its row.

  • R1: the cursor skips a doc closed with its ✕, and it is held as an item.
  • R2: the rail's height follows the live rail after a save.
  • R3: no warning buries a standing failure.
  • R5: Enter follows view?f=.
  • R7: the change time is in the size cache's key.
  • R8: Escape passes a focused player.
  • R9: the rail's own controls cancel the padding.
  • R10: n opens a closed doc's fold.

Refuted or accepted, with the reason.

  • R4 (a shared refusal sentence names a pick that was later saved): it cannot happen through the UI. See the batch paragraph above: refused picks stay dirty and are re-sent together.
  • R6 (no pixel cap on the size read): a header's size costs nothing new. Chrome takes the same header's size for the picture whether or not the tile carries it. Measured: a PNG whose header says 8×200000 and whose body is garbage draws a 10,000,002px tall tile with the attributes and without them. A booth writer can already post that picture. The read's cost is measured above, and the gallery route runs off the event loop.
  • R8, second face (Escape from a focused control clears the cursor): unchanged from before S5c, and nothing the control uses.
  • R11 (after a swap the cursor is a class until the next key): by design. S5b leaves focus where the operator put it, and it restores focus to the cursor tile only when that tile held it. The invariant is that a focused tile is the cursor, not that the cursor always holds focus.
  • The cached None (R7's second face): a transient open failure is cached until the file changes. It costs the placeholder, which is today's markup.

Existing tests and rows this slice edits

  • booth-dev's rows, re-anchored to the same failure (their old anchors are gone):
    • r2b.toml "Space on a focused review button moves to the next item", now in the helper;
    • r3.toml "C3 a held modifier does not make the keys inert", "C3 Space on a focused control steps instead of pressing it" and "C3 Space on a focused player steps the pair", now in the helper;
    • r2c.toml "the stage reveal shows with scripts off", now anchored on id="vreveal" hidden>, since the button lost its aria-label.
  • This contract's own rows, re-anchored:
    • antislop.toml "S3 the doc bar squeezes the name again" and "S5b an unrelated save buries a failure";
    • after the bug-hunt fold, the S5b rows "a batch that lands says nothing", "the reload is not asked again at the beat", "a refresh failure after a 204 says it could not save" and "an edit made while saving is called Saved.", whose lines now go through also().
  • No existing assertion changes.