Files
booth/docs/contracts/r3_compare.contract.md
T
vh f8d136a521 fix(r3): fold heid's bug hunt — no link offers a pair that 404s, NUL booth names, a FIFO marker, encoded view-state names
Navigation was built from the review ring while the compare GET also demands
containment, so an outside symlink (which stays in the ring) was offered by
the strip, the steps, the review's Compare control and the flag landing, and
404ed on arrival. Every one is now built from the compare ring (the review
ring filtered by the same conjunction, _in_booth).

Two pre-existing gaps compare inherits, fixed at the source: resolve_booth
caught only OSError, so a NUL in the booth segment was a 500; record_view
opened its marker blocking, so a planted FIFO hung every look. Plus: the page
treats %73ide=a as side=a, and the subgrid engine floor is stated. Two
findings refuted (a chorded click mid-drag never fires pointerup, measured;
booth_items never yields an unquotable rel). r3.toml: 57 rows.
2026-09-24 14:39:06 -07:00

26 KiB
Raw Blame History

contract_version, status, module, purpose, depends_on, language, complexity, estimated_loc, confidence, touches, assumptions
contract_version status module purpose depends_on language complexity estimated_loc confidence touches assumptions
0.1 PROPOSED 2026-09-24 by design-dev. The operator ruled compare into this arc on 2026-09-23 (the `flow` mark: compare `this_arc`, as C 'The Bench' made a view toggle). He ruled its two open questions on 2026-09-24, in design-dev's session: pairs are PICKED, never detected; the verdict is a FLAG on the winner, with no A/same/B record. booth-dev agreed with both beforehand (thread 01M3952NCDRRJX5XDFSPMSP5HJ), and asked that the URL be keyed by rel. It reuses r2c's stage machinery rather than growing a second copy. GET /b/{name}/compare + templates/compare.html (two stages, one judgment each), with the stage machinery shared with view.html Put two items of a booth side by side in two equal stages (and, for pictures of the same size in 1:1, at the same crop), so the operator can judge which is better and flag the winner, then step to the next pair. This is the job the ladders and bakeoffs already run by eye across two tabs. sindra-bakeoff is the proving case: m against r, the same scene and seed, 16 pairs. It is laid out as two parallel runs in sorted order, so a linked step walks it pair by pair with no pairing rule.
items.py: `booth_items`, `review_chain` (the media ring, in item order), `find_item`, `REVIEW_KINDS`, `Item.{rel,url,kind,ordinal,caption,blurred,thumb}`.
app.py: `resolve_booth`, `record_view`, `record_seen` (never raises), `marks_for` / `flagged_targets`, `_mark_redirect` (the JS-off landing, gains `back=compare`), `_mark_done` (the 204 path, unchanged).
view.html (R2 C6 + r2c): the review this is entered from; its stage script (Fit/1:1 as `stage-one` on <html>, drag-pan) is the machinery this unit shares.
base.html: the head script that sets `stage-one` from `localStorage['booth.fit']` before any stage exists; the in-place client (`form[data-inplace]` -> 204 -> swap every `data-region`, then `booth:swapped`); Reveal all.
python (one route, one redirect branch) + jinja + css + javascript medium 420 0.75
booth/app.py (the compare route; `_mark_redirect` gains `back=compare`)
booth/templates/compare.html (new)
booth/templates/_stage_js.html (new: the stage machinery, moved out of view.html and shared)
booth/templates/view.html (includes _stage_js.html; gains the Compare entry and its C key)
booth/templates/base.html (compare layout CSS)
tests/test_compare.py, tests/test_compare_browser.py (new); tests/mutations/r3.toml (new)
ONE VIEWER, as in R2: the linked toggle and the active side are per page load; the stage mode stays the r2c per-browser preference.
No new storage and no new mark shape (the operator's ruling): the verdict is the existing flag, through the existing in-place POST.
A pair is two items of the same booth's review ring. Comparing across booths is not this unit.

R3 — compare

C1 — the route and the pair

  • GET /b/{name}/compare?a=<rel>&b=<rel>. Both are booth-relative paths, exactly as view?f=<rel> takes one (U1 identity). Never ordinals: an ordinal is a position in the set as it is now (r2_flow, row 4). A file added mid-bakeoff shifts every later ordinal, so a bookmarked compare would open two different pictures and nothing would look wrong (booth-dev's seam note). The page PRINTS both ordinals.
  • The rule for each side is a CONJUNCTION (booth-dev's seam pass, S2). The review's rule alone is not enough, and neither is ring membership alone:
    1. the view route's resolve / containment / is_file check passes. That is what 404s a symlink pointing outside the booth, which booth_items DOES list, because it follows symlinks;
    2. AND the rel is in review_chain(items). The review does not 404 a doc item (it renders it); compare does. Anything else is a 404, never a 500: an embedded NUL raises ValueError and is a 404.
  • A missing or empty param is a 404, not FastAPI's 422. The review declares f: str and so answers 422 when f is absent. Compare declares a: str = "" and b: str = "", 404s an empty one, and checks each with the same isinstance(str) that _mark_redirect's back=view branch gives f (the view route itself declares f: str and checks nothing more).
  • Route order: the compare route is registered BEFORE the catch-all /b/{name}/{filepath:path}, as view is. Accepted, and written down: a booth FILE literally named compare is unreachable at /b/<name>/compare. This is the same shadowing view, marks, asks and embed.json already cause.
  • a == b is allowed. It is pointless but harmless: the same picture twice.
  • A look records both. record_view(booth) once, and record_seen for a and then for b, below the 404s and gated on the records, as the view route gates it. Both calls never raise.
  • The compare ring is the review ring filtered by that same conjunction: item order, media only, less anything compare would 404 (an outside symlink stays in the review ring). EVERY compare link is built from it: the strip, the steps, the review's Compare control and the back=compare landing. So no navigation offers a pair that 404s, and a step walks over such an item.
  • The response carries, per side: the rel, its quoted url, ordinal, kind, caption, blurred, flagged and thumb. It also carries the compare ring as a filmstrip in RING ORDER (the view route's film, one line in the route's docstring), the linked and per-side step targets (C3), the back link (the review of a, which is also where Esc goes), and ord_width.

C2 — picking the two

  • From the review: a Compare control in the review's top bar, and the key C, open compare?a=<this item>&b=<the next item in the ring>. With a ring of one item, b is the item itself.
  • On the compare page, the filmstrip is the picker.
    • Each frame is marked A, B, or nothing (both marks when a == b).
    • With JS, a click on a frame sets the ACTIVE side to that item and stays on the compare page. The active side defaults to B.
    • The active side wears the SVOS reticle (the one selection device). A press on either stage (pointerdown, so starting a 1:1 pan there also makes it active), or the key X, makes that side (or the other) active.
    • The active side lives on a NON-region element, the side's wrapper around its stage, and in the URL's side (C2), rewritten in place when it changes. An in-place save swaps regions (strip, labels, flags), and the in-place client carries only revealed and is-closed across a swap, so state kept on a region would be dropped. The strip's markers for the ACTIVE side are re-applied on booth:swapped. Strip clicks are delegated at the document, because the frames are replaced.
    • Without JS, every frame is a link that sets the active side from the URL (B by default): compare?a=<a>&b=<frame>.
  • The view state rides in the URL too, because every pick and step is a navigation, and state kept only in the page would reset on each one:
    • side=a makes A the active side (absent means B);
    • link=0 unlinks the stepping (absent means linked). These are view state, not identity: an unknown value reads as the default, never as an error, and every server-built link carries the current values forward. The PAIR is still only the two rels. The route declares side: str = "" and link: str = "", NOT an int or a Literal, which would bring S1's 422 back for link=maybe.
  • Why B is the default active side: A is where you came from, the anchor. B is what you are weighing it against, so a strip pick changes the comparison and not the anchor. X or a click on A makes A active.
  • The picked pair is ALWAYS in the URL. Every pick and every step is a navigation (a FULL page load) to a compare URL. The linked state and the active side survive it only because they ride the URL too (above); nothing else about the page is carried across a step, so the back button walks back through the pairs, and a reload shows the same pair.

C3 — stepping

  • Linked (the default): ← and → move BOTH sides one place along the ring, keeping their distance: (ia ± 1, ib ± 1), each modulo the ring length (the review's wrap). This walks a bakeoff's parallel runs: #09 · #25, then #10 · #26.
  • Unlinked: ← and → move only the ACTIVE side.
  • The Linked toggle sits in the top bar, with the key L. Its state is the URL's link (C2): toggling it rewrites the current URL in place (history.replaceState) and the step links, so the next step keeps it. A fresh compare from the review starts linked. It is not stored anywhere else: a remembered unlinked state would surprise the next compare.
  • Space and Shift+Space act as → and ←, with the review's guard: never from a focused control, and never while a player on either stage has focus.
  • Every key on this page (← → Space A B X L Z C Esc) is ignored while focus is in something editable, and whenever a modifier (Ctrl, Meta, Alt) is held: the review's isEditable rule, applied to all of them, not only to Space.
  • C and Esc both return to the review of A. C is the view toggle: C in the review opens compare with that item as A, and C again goes back to it.
  • Without JS, the page renders plain links for "both back", "both forward", and each side's back and forward, with server-computed targets.

C4 — the stages

  • Two stages side by side when the viewport is wider than 900px. Each is half the body and labelled A #09 <name> / B #25 <name>. At 900px and below they STACK, A above B, each at most 45vh tall. The stack break is the review's.
  • The two stages are always the same size. The sides share one set of rows (subgrid), so a caption under one side takes its height from both stages, never from that side's alone, and the separator between them is a column gap, never a border that comes out of one side's width. Two stages of different sizes would draw the same picture at two scales in Fit.
  • At phone width (600px and below) a top bar that cannot hold its controls WRAPS instead of scrolling the page sideways or crushing a control. This applies to the review's bar too, which gains the Compare control (only its glyph below 600px). The review's bar was already full: a fogged booth overflowed it by 3px at 390px before r3.
  • Each stage is the r2c stage: Fit fills (up or down, contain, never cropped), or 1:1 at natural pixels with every pixel reachable. Drag pans a 1:1 picture that overflows. The picture cannot be dragged away. Video and audio play in their own stage.
  • One mode for both: the SAME stage-one class on <html> and the same localStorage['booth.fit']. Choosing 1:1 on the compare page is choosing it for the review, and back, because the mode is a per-browser preference (r2c S2). The Fit | 1:1 toggle is in the top bar. The key Z switches it ON THE COMPARE PAGE ONLY, bound by compare.html and not by the shared include, so the review gains no key beyond C (INV-6). Provenance: r2c's out-of-scope lists "a key for the mode toggle" as its own line, not parked into r3. Compare takes it because comparing detail means switching modes often.
  • Synced pan (parked into r3 by r2c): in 1:1, panning either stage pans the other to the SAME FRACTION of its scrollable range, on each axis independently.
    • For two pictures of the same size, that is the same crop: the same pixels under the same point.
    • A side with nothing to scroll on an axis ignores that axis.
    • A scroll caused by the sync never re-triggers a sync, so there is no loop and no drift.
    • Scrollbars and wheel/trackpad scrolling sync the same way as drags, because the sync listens to scroll, not only to drags.
  • One copy of the machinery, behind a stated interface (S7: today's script is single-instance and ID-keyed, and settle is place + pannable). The include _stage_js.html defines two things and binds nothing by itself:
    • BoothMode.bind({toggle, fit, one, onChange}): page level. It owns the stage-one class, setMode, the pressed state, the storage writes and the cross-tab storage listener. It never raises. A page binds it ONLY when at least one of its stages is an image, which is the review's rule today; two videos get no toggle.
    • BoothStage.attach(stageEl, {img, onSettle}): once per stage. It owns pannable(), drag-to-pan (the 4px threshold, capture, scrollbar exclusion, dragstart prevention) and the stage's can-pan / is-grabbing classes. It calls onSettle() after its own settle, and it returns {settle, pannable}.
    • view.html: attaches its one stage with onSettle = place, so the arrows stay view's own code. It binds BoothMode exactly as today, and keeps EVERY id it has (vstage, vimg, vtoggle, btn-fit, btn-one, vreveal, vmedia, vflag-btn), because test_flow_browser queries them. Its reveal, keys, centreFilm and ResizeObserver stay in view.html. It has no floating arrows to add.
    • compare.html: attaches both stages and binds BoothMode once, with an onChange that settles both. Its per-side reveal buttons are its own code. It owns its OWN ResizeObserver over both stages, calling each stage's settle, because pannable changes on resize. BoothStage.attach does NOT own a ResizeObserver, so view.html's observer, and its mutation row, stay where they are.

C5 — judging

  • Each side has its own flag control, the existing flag form (POST /b/{name}/flag, data-inplace, 204 with JS). The verdict is "flag the winner", and flagging both is allowed. What a flag records, stated plainly because it is the ruled trade-off: a flag says "this one is good". Both flagged means both are good. Neither flagged means no call, OR a tie, and the flags cannot tell those apart. That is exactly why the A/same/B record was parked, not an oversight. Flagging the loser is the operator's prerogative; the page does not police it.
    • Keys: A toggles A's flag and B toggles B's. No F on this page, because which side it meant would be a guess. Each key LOOKS UP ITS BUTTON AGAIN at press time, because a save may have replaced it (the review's vflag-btn rule).
    • The flag controls, the filmstrip and each side's label are data-regions, so an in-place save refreshes them. The stages are never regions, because swapping one would restart a playing track (the review's rule).
    • Region ids are unique on the page and keyed by SIDE (S4): flag-a, flag-b, label-a, label-b, film. The swap keeps only the FIRST fresh node for each id and copies it over EVERY live node with that id. A shared flag id would therefore turn B's control into A's after any save, so that pressing B flagged A, and nothing would show it. The ids are NOT keyed by rel, which a == b would duplicate, and NOT prefixed item-, which the swap reads as a stale tile.
  • Without JS, a flag lands back on the same compare page: the form carries back=compare, a, b, side and link, and _mark_redirect builds /b/<name>/compare?a=<quote(a, safe="/")>&b=<quote(b, safe="/")> from them, appending &side=a ONLY when the form's value is exactly a and &link=0 ONLY when it is exactly 0, in that order. The view state is mapped from that closed set and never echoed. The URL has NO fragment (the flags sit beside the stages, so there is nothing to scroll to). It does that ONLY when both are strings in the ring, quoting each as the view branch does. It is built from the checked rels and never echoed from the form. Anything else takes the no-back landing. Every other back value is byte-identical to today (R2 INV-4).
  • Notes and the booth's open questions stay on the review. Compare carries only the flag, plus a review A / review B link on each side to the item's full review.

C6 — blur and captions

  • Blur honesty per side: a blurred item renders blurred, with its own reveal button over its stage (the review's pattern: JS-only, hidden until bound, never inside the scrolled content). Reveal all reveals both.
    • The blur CSS is scoped to .review (base.html: .review .vstage.is-blurred img, the .revealed and is-img rules, and Reveal all's .reveal-all .review .vstage.is-blurred …). The compare root is therefore class="viewer review compare", and .compare overrides the review's 4-row grid and the 360px rail column. The blur rules are not re-scoped: they and their r2b mutation rows stay as they are.
    • Each stage carries vstage and is-img (for a picture) and is-blurred, exactly as the review's does, so the 1:1 and blur rules apply unchanged.
    • Reveal all hides the review's stage reveal by ID (.reveal-all #vreveal). Compare's per-side reveals use a class, cmp-reveal, and base.html gains .reveal-all .cmp-reveal{display:none}.
    • compare.html carries {% block html_attrs %} data-booth="{{ name }}", because without it Reveal all's script and the head script's reveal restore both bail (S6, r2b's mutation row for the review).
  • Each side's caption shows under its stage in .cmp-cap: the review's .vcap type (size, leading, colour, pre-wrap), clamped to 20vh rather than the review's 30vh, with its own scroll. Two sides share the height.

Invariants

  • INV-1 — rel identity. The pair is two rels, in the URL, always. Nothing about the pair is stored, and no ordinal ever addresses an item.
  • INV-2 — ring only. Both sides are media in the review ring that pass the view route's containment. Every server-computed link (the steps, the filmstrip, the review's Compare control, the flag landing) stays inside the compare ring (C1).
  • INV-3 — no new storage and no new mark. The judgment is the existing flag, through the existing route and the existing in-place path.
  • INV-4 — JS-off parity. Without JS (and so without the head script that would apply a stored 1:1, which is itself a script): two Fit stages. Both sides' flag forms are present, so choosing which side to flag needs no picker; per-side and linked step links; filmstrip links that replace the URL's active side (B by default, C2); flag forms that land back on the same pair. Nothing judgment-bearing hides behind a script.
  • INV-5 — one record of the stage mode, shared with the review: stage-one on <html>. Storage never raises.
  • INV-6 — the review is unchanged in behaviour. It gains a Compare control and a C key. The stage refactor changes no r2c assertion.

TESTS

Server (tests/test_compare.py):

  • compare_renders_the_pair [tracer]: a booth of four images; compare?a=<#1>&b=<#3> → 200; both names and both ordinals are printed; the filmstrip marks #1 A and #3 B.
  • a_bad_side_is_a_404: a missing a or b, .. traversal, a NUL, a dotfile, a doc item, a non-item file → 404 each, never 500.
  • a_look_records_both_seen: after a compare GET, .seen holds both rels; a 404 records nothing.
  • linked_steps_keep_the_distance_and_wrap: THE FIXTURE PUTS A DOC BETWEEN THE MEDIA (03-notes.md), so an ordinal is not a ring position. In a ring of 6 media with a at ring position 2 and b at ring position 5, "both forward" targets ring positions (3, 6), then (4, 1), wrapped; "both back" from (1, 4) targets (6, 3). The assertions name rels, never ordinals.
  • the_urls_are_keyed_by_rel: every step and filmstrip link carries a=/b= rels, url-quoted; no link carries an ordinal parameter.
  • a_flag_without_js_lands_on_the_same_pair: POST /flag with back=compare&a=..&b=.. → 303 to exactly /b/<name>/compare?a=..&b=..; with side=a&link=0 added → exactly …&side=a&link=0; with side=A or link=00 → neither appended; with a rel not in the ring → the no-back landing; Accept: application/json → 204, unchanged.
  • every_other_landing_is_byte_identical: the existing back=view / back=marks / no-back redirects are unchanged (R2 INV-4).
  • the_review_offers_compare_with_the_next_item: with the doc fixture, the review of a media item links compare?a=<it>&b=<the next media item in the ring>, skipping the doc; the last media item links to the first.
  • view_state_rides_the_links: with side=a&link=0, every step and strip link carries both; an unknown side=z or link=maybe renders as B-active and linked, never an error.
  • no_data_region_repeats: on a compare page, including a == b, every data-region value is unique, and the side regions are flag-a, flag-b, label-a and label-b.
  • a_missing_param_is_404_not_422: compare?a=<x> without b → 404.
  • an_outside_symlink_in_the_ring_is_404: a booth symlink pointing outside the booth is in review_chain, and compare with it as either side → 404.
  • compare_carries_data_booth: the page's <html> carries data-booth.

Browser (tests/test_compare_browser.py):

  • two_stages_side_by_side_wide_and_stacked_narrow [tracer]: at 1440 both stages sit in one row; at 390 A sits above B and each is at most 45vh.
  • one_mode_for_both_and_for_the_review: Z switches both stages to 1:1; localStorage['booth.fit'] is one; the review then opens in 1:1.
  • synced_pan_lands_on_the_same_crop: two equal-size pictures larger than the stage in 1:1. A drag on A of (+80, +60) scrolls both by (−80, −60). A scrollbar or wheel scroll on B moves A to the same fraction. Neither stage drifts after a second of idle.
  • synced_pan_by_fraction_for_different_sizes: a 2000px and a 3000px picture, one scrolled to its middle, puts the other at its middle; and one scrolled to 25% of its range puts the other at 25% of ITS range (not at the same pixel offset). This is the test that sees a fraction bug; the equal-size test above cannot, because equal overflow makes offsets and fractions coincide.
  • a_flags_A_in_place_and_the_stages_survive: press A → A's flag shows flagged with no navigation; the stage elements are the same nodes (a stage was not swapped).
  • linked_arrow_walks_a_bakeoff: in a booth shaped like sindra-bakeoff (lanes m and r, 4 pairs), open m#1 vs r#1 and press → three times: each pair shares its scene and seed suffix.
  • unlinked_moves_only_the_active_side_and_the_strip_picks_it: L, then →, moves only B, and a SECOND → still moves only B (the unlinked state survived the navigation); a click on a strip frame sets the active side's item; X swaps the active side and the reticle follows it, and survives the next step.
  • blur_is_honest_on_both_sides: a blurred B's image has a COMPUTED filter containing blur(, not just a class. Its own reveal clears it. Reveal all clears both, and hides both cmp-reveal buttons.
  • a_save_keeps_the_active_side: make A active, flag B in place → A is still active (reticle, strip marker), and a strip click after the swap still sets A.
  • without_js_every_judgment_and_step_still_works: JS off — the pair renders, the step and strip links navigate, and a flag lands back on the same pair.
  • the_review_still_behaves_exactly_as_r2c_says: the r2c browser suite passes unchanged against the refactored view.html. This is a gate, not a new test.

Assertions that change (declared before the code)

test today after why
no behavioural assertion — — view.html's behaviour is unchanged (INV-6). The only additions are the Compare control and the C key, which no current test pins.
tests/mutations/r2c.toml: 21 rows anchor in view.html's script; the 15 on the toggle, the storage listener and drag-pan file = view.html, anchors in the inline script file = _stage_js.html, anchors re-pointed to the parameterised code (e.g. stage.scrollLeft becomes the attached stage's name) the code moved (C4); every re-pointed row must still FALSIFY
tests/mutations/r2c.toml: the other 6 of those 21, the arrow placement, including the resize row ("S3 the arrows do not follow a resize") view.html unchanged: place() and view's ResizeObserver stay in view.html (C4) —
tests/mutations/r2b.toml, the row on Space from a focused button view.html's keydown unchanged: the keydown handler stays in view.html —
tests/mutations/r2b.toml, "the top-bar controls squeeze into multi-line stacks at phone width" (declared during the build) removes the no-wrap rules removes the no-wrap rules AND the phone-width wrap a wrapping bar never squeezes, so removing the no-wrap rules alone went vacuous; r3.toml rows the wrap on its own
new: tests/mutations/r3.toml — rows for: the side-keyed region ids, the conjunction 404, back=compare's ring check, the linked distance, the synced-pan loop guard, the data-booth attribute, the cmp-reveal Reveal-all rule the r3 falsifiers

The gate for the refactor is the TABLE, not only the suite: scripts/mutation_check.py tests/mutations/r2c.toml (and r2b.toml) with every row falsifying after the move. A green r2c browser suite proves the behaviour survived. The table proves the tests still bind to the code that moved (booth-dev, S8).

Out of scope

  • Detecting pairs from filenames (ruled out: 1 of 26 live booths pairs by a name rule, and none of sindra-h2h does).
  • An A-better / same / B-better record (ruled: parked). If it is ever ruled in, it is a new mark keyed by an ORDERED pair of rels, in booth-dev's storage, with the JSON sessions read stated (booth-dev's note).
  • A zoom between Fit and 1:1, wheel zoom, and pinch (parked into r3 by r2c; parked again here: compare works at Fit and 1:1, and a third level is its own unit if the operator asks for it).
  • Three or more panes, onion-skin or swipe overlays, and a difference view.
  • Synced playback of two videos or two tracks (each stage plays on its own).
  • A grid multi-select to start a compare from the lightbox (the review's C and the picker strip cover picking).
  • Comparing across booths.