The markup and CSS half of the anti-slop interaction work. The in-place
client is untouched (that is S5b).
- Glyph-only controls carry a name: the withdraw ×s, downloads, open full
page, the viewers' ✕, the board's pin, copy and remove, the bench's
remove, the 1:1 toggle ("1:1, natural pixels"). Film-strip and tray
frames carry the file's name as sr-only text instead of reading "01".
A Desk row's wipe names its booth.
- Fields are named by aria-label, not by their placeholder.
- The inline ask's options are a radiogroup labelled by the prompt; a
single-question fieldset gets an sr-only legend; a titled ask's title
takes bk-ask-<id>-title (it duplicated the question's id).
- One h1 per page (sr-only on the Desk, review and compare), a skip link
to <main id="main">, theme-color for light and dark.
- The review tape is one picture (role=img); its segments leave the tab
order (the film strip holds the same links, named).
- Wipe now uses the Desk's delegated prompt, moved to base.html: it names
the booth and asks the kept-booth question for a kept booth.
- Embed focus rings of its own; rings drawn inside clipping containers;
the withdraw × at least 24px, 44px under a coarse pointer;
touch-action:manipulation; strips contain their overscroll; a long
slug wraps on a phone.
- A truncated why carries its full text in title; a countdown of 48h or
more reads in days.
Two r2_flow.toml rows for the confirm helper now name base.html, where
the helper moved (anchors unchanged; the gate found them drifted).
Contract: as_antislop S5a. Falsifiers: antislop.toml 86/86 proved (S1-S6, S5a);
all 12 tables 366/366 proved on this tree.
18 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
- 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 half: focus restored after a swap, a status slot the viewer cannot cover (and inert behind it), live-region timing and "Saving…", key handling, and the unsaved-draft guard. It rewires the in-place client, so it gets its own contract section and review before any code.