docs(contract): r2 — fold the heid contract panel (round "Lark", 4/4 arms)
Triaged, not adopted wholesale. Folded: - Reviewing refreshes .viewed, as it already did. It is now stated, so the two clocks cannot read as disagreeing. - INV-4 is scoped to pre-R2 request shapes. back=view is the declared exception. - back=view lands on the review only for media items. Anything else falls back to the booth page. - In-place regions: every element whose content can depend on marks is a region, including the rail counts, the filmstrip and the tape. The stage never is. - The script never re-POSTs. A lost response must not duplicate a note or re-date an answer. - The dangling "invariant 5" now points at the Booth's CLAUDE.md invariant 5. - "M" is defined once. Needs-you is picks only. Every key is suppressed in editable fields. - The toggle and the narrow collapse are classified against INV-3. - Every Booth state file is a dotfile, stated. So are "no generated thumbnails" and the audio placeholder. - The requirement wording is tightened, and C7 records the voice and emblem rulings.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
contract_version: "0.1-PROPOSED"
|
||||
status: "PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b, compare this_arc, voice plain, emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record."
|
||||
status: "PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b; compare MODE to be built in this arc; voice plain; emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record."
|
||||
module: "booth.app + booth.items + templates (the review flow)"
|
||||
purpose: "Make the Booth a place where judgment happens rather than a place where files are shown. The operator's bar is 'did anything change when I opened it'. A reskin cannot clear that bar; this contract changes the flow. There are three surfaces and one plumbing change. THE DESK: the index triaged by what needs the operator. THE LIGHTBOX: a booth page with the set on the left and the verdict beside it. THE REVIEW: full size with the judgment on screen, a filmstrip, and seen-tracking. The plumbing is IN-PLACE JUDGMENT: a mark POST that does not reload the page or eject you from full size."
|
||||
depends_on:
|
||||
@@ -31,7 +31,7 @@ touches:
|
||||
- "booth/templates/base.html (layout CSS; the in-place script)"
|
||||
- "booth/templates/doc.html (NOT restructured — a doc keeps its reading page; named because it was checked)"
|
||||
- "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)"
|
||||
- "tests/test_booth.py (FOUR assertions change: the kept-lane pair at L785-786 and L810-811. See 'Assertions that change')"
|
||||
- "tests/test_booth.py (TWO assertions change: L785-786, the kept-lane presence pair. L810-811, the absence pair, survive unchanged. See 'Assertions that change')"
|
||||
- "tests/test_flow.py (NEW)"
|
||||
assumptions:
|
||||
- "ONE VIEWER. `.seen` records what has been seen at full size, not WHO saw it. ROADMAP parks 'per-viewer state (who has seen what)' on the one-viewer premise; this contract keeps that premise and does not reopen the parked item."
|
||||
@@ -52,11 +52,23 @@ open_questions:
|
||||
| 1 | show me what needs me | the Desk's *needs you* section |
|
||||
| 2 | picking winners is the main judgment | the lightbox's flag tray; F in the review |
|
||||
| 3 | flag without losing my place | in-place judgment + `back=view` |
|
||||
| 4 | position is identity | `Item.ordinal`, printed on every tile |
|
||||
| 4 | position is identity (within the set as it is now — not a durable id) | `Item.ordinal`, printed on every tile |
|
||||
| 5 | the question stays beside the work | the verdict aside (sticky) |
|
||||
| 6 | reports keep their author's layout | verbatim path untouched |
|
||||
| 7 | listening sets are real | `review_chain` includes audio and video |
|
||||
| 8 | lifetime is not an organising principle | the Desk drops the kept/ephemeral lanes |
|
||||
| 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes |
|
||||
|
||||
## Terms used below
|
||||
|
||||
- **Reticle**: the SVOS selection mark in base.html — four corner brackets drawn
|
||||
inside a box. It marks the one current or selected thing and nothing else.
|
||||
- **Tape**: a row of small segments, one per item in the review ring, each
|
||||
showing *seen*, *flagged* or *current*.
|
||||
- **Stage**: the area of the review page where the artifact itself renders.
|
||||
- **All Booth state files are dotfiles.** That covers `.marks.json`
|
||||
(MARKS_FILE), `.viewed`, `.blurred`, `.seen`, `.forever`, `.pins`,
|
||||
`.booth.json` and every `*.lock`. "Non-dot entries" means the posted content
|
||||
and nothing the Booth or the operator wrote.
|
||||
|
||||
## Components
|
||||
|
||||
@@ -92,7 +104,19 @@ no route derives it.
|
||||
- **`SEEN_FILE = ".seen"`**: one rel per line, same shape as `.blurred`.
|
||||
- Written by `record_seen(booth, rel)` from the review route, below the 404s
|
||||
and gated on the item record — the same gate `record_view` has.
|
||||
- Atomic replace, per invariant 5.
|
||||
- Each write rewrites the whole file: the previous set plus `rel`, minus
|
||||
rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so
|
||||
it never grows past the booth's item count.
|
||||
- Atomic replace, per the Booth's CLAUDE.md invariant 5 ("sidecar writes are
|
||||
atomic"), not this contract's INV-5.
|
||||
- Seen is keyed by rel. A file replaced at the same path stays seen; a
|
||||
deleted file drops out at the next write, and every count below
|
||||
intersects with the current `review_chain`.
|
||||
- **The review route ALSO calls `record_view` (existing U4 behaviour,
|
||||
unchanged).** So reviewing a booth at full size refreshes "you looked" for
|
||||
the Desk exactly as opening its grid does. `.viewed` and `.seen` never
|
||||
disagree about whether you looked at the booth; `.seen` only adds WHICH
|
||||
items.
|
||||
- NEVER RAISES, like `record_view`: failing to record a look costs the
|
||||
marker, not the page.
|
||||
- `read_seen(booth) -> set[str]` is lenient, like `read_blurred`.
|
||||
@@ -124,8 +148,11 @@ route's forms together with `f=<rel>`. It lands on
|
||||
`/b/<name>/view?f=<quote(rel)>#rail`. This fixes the JS-off bounce too:
|
||||
today's zoom flag form carries no `back`, so it lands on the gallery.
|
||||
|
||||
- `f` must name an item in the booth, else the landing falls back to the
|
||||
booth page.
|
||||
- `back=view` lands on the review only when `f` names an item in
|
||||
`review_chain`, i.e. a media item. For anything else (a doc, a missing rel,
|
||||
an empty `f`) the landing falls back to the booth page, exactly as a form
|
||||
with no `back` does today. `doc.html` carries no forms, so no shipped page
|
||||
sends `back=view` with a doc.
|
||||
- The URL is built server-side from `name` + `quote(f)`, never echoed, so this
|
||||
is not an open redirect.
|
||||
|
||||
@@ -133,16 +160,30 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
|
||||
`data-inplace`.
|
||||
|
||||
1. POST the form with `Accept: application/json`.
|
||||
2. On 204, GET the current URL and replace the regions marked
|
||||
`data-region="<id>"` with the same-id regions from the response.
|
||||
3. On anything else, submit the form normally.
|
||||
2. On 204, GET the current URL and replace **every** element carrying
|
||||
`data-region="<id>"` with the same-id element from the response.
|
||||
- The rule is "every region whose content can depend on marks is a
|
||||
region". On the lightbox that means the verdict aside, each tile, and the
|
||||
rail (its filter counts change when you flag). On the review it means the
|
||||
rail, the filmstrip and the tape.
|
||||
- The stage is never a region: replacing it would restart a playing video
|
||||
or audio track.
|
||||
- A region absent from the response is left alone and never deleted.
|
||||
3. **The script never re-POSTs.** A retry after a lost response would re-apply
|
||||
the judgment: a duplicate note, or a re-dated answer.
|
||||
- On a non-204 HTTP response, or a network failure, it writes a fixed
|
||||
message into the page's server-rendered status element
|
||||
(`data-region="status"`, via textContent), then reloads the page with a
|
||||
GET, so what you see is the server's truth.
|
||||
- The one case where a non-JS submit happens is a script that cannot run at
|
||||
all. That is the plain form.
|
||||
|
||||
**The server renders every state; the script only places it.** This is U3's
|
||||
rule — a second renderer in JavaScript would be the same bug in a new language.
|
||||
|
||||
### C4 — the Desk (index.html, app.index, list_booths)
|
||||
|
||||
`list_booths` gains four fields, all read in the one pass it already makes:
|
||||
`list_booths` gains five fields, all read in the one pass it already makes:
|
||||
|
||||
- **`open_since`**: the `created` of the OLDEST open pick in the booth, or
|
||||
None. Computed via `open_marks`, INV-2.
|
||||
@@ -151,26 +192,36 @@ rule — a second renderer in JavaScript would be the same bug in a new language
|
||||
legacy-import stamp, sort wrong as text.
|
||||
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
|
||||
tie.
|
||||
- **`flags`**: the count of flag marks.
|
||||
- **`flags`**: the count of flag marks, shown on every Desk row that has any.
|
||||
- **`landed_at`**: the newest mtime among the booth's NON-DOT entries — its
|
||||
content. **Deliberately not `_newest_mtime`** (INV-5).
|
||||
- **`viewed_at`**: the mtime of `.viewed`, or None.
|
||||
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
|
||||
order. A blurred one renders blurred, the same rule as the cover.
|
||||
- A booth with no images (an audio set, a report) shows today's kind
|
||||
placeholder instead (`♪ audio`, `▦ page`, `▶ video`, `◆ files`).
|
||||
- These are the original files displayed small with `loading=lazy`. No
|
||||
thumbnail is GENERATED anywhere in R2; see Out of scope.
|
||||
|
||||
**The index renders three sections, always in this order:**
|
||||
|
||||
1. **Needs you** — `marks_open > 0`, **or** `hold == "unreadable"`.
|
||||
- `marks_open` counts `open_marks(...)`, which only ever returns PICKS. A
|
||||
booth whose marks are only flags or notes is the operator's own judgment,
|
||||
not a question to them, so it is NOT here.
|
||||
- A damaged `.marks.json` holds its booth but is not open by `open_marks`
|
||||
(errored picks are not open). Somebody has to fix it, so it must not hide
|
||||
in 'everything else'. It renders with the existing "marks unreadable"
|
||||
lifetime line.
|
||||
- Ordered by `(open_since, name)`, oldest question first. Unreadable booths
|
||||
have no `open_since` and sort after every booth that has one.
|
||||
2. **New since you looked** — not in (1), and `viewed_at is None` or
|
||||
`landed_at > viewed_at`. Ordered by `(-landed_at, name)`, newest first.
|
||||
2. **New since you looked** — `not in_needs_you and (viewed_at is None or
|
||||
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
|
||||
3. **Everything else** — ordered by `(-mtime, name)`, where `mtime` is today's
|
||||
`_newest_mtime`: last activity first.
|
||||
- Flagging or viewing a booth moves it up this section. That is intended:
|
||||
it is activity. It never moves the booth into (2), because (2) reads
|
||||
`landed_at` (INV-5).
|
||||
|
||||
The side column holds:
|
||||
|
||||
@@ -202,7 +253,7 @@ unchanged) remain on every row.
|
||||
- **Layout.** Two panes on a gallery booth: the set on the left, the
|
||||
**verdict aside** on the right (`position:sticky`, `data-region="verdict"`).
|
||||
Under 1000px the aside stacks above the set, with its flags and notes
|
||||
collapsed.
|
||||
collapsed as `<details>`, which needs no script.
|
||||
- **Board booths are unchanged.** Anything with `links.md` keeps today's
|
||||
single column.
|
||||
- **The aside holds, top to bottom:**
|
||||
@@ -211,7 +262,9 @@ unchanged) remain on every row.
|
||||
3. notes;
|
||||
4. the booth-note form.
|
||||
- **The flag tray is ordered by ORDINAL** — a declared change from the marks
|
||||
panel's `(created, id)`. It shows each flagged tile's thumbnail and its #.
|
||||
panel's `(created, id)`. It shows each flagged item's original file
|
||||
displayed small (no generated thumbnail), blurred if the item is blurred,
|
||||
with its #.
|
||||
The order is total with no tie-break, because rels are unique.
|
||||
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
|
||||
and base.html's `--rail-h` script both read it), same filter hrefs, same
|
||||
@@ -230,11 +283,19 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
|
||||
- The toggle and its script are rendered and bound only when the stage is
|
||||
an `<img>`.
|
||||
- The toggle is a JS-only VIEWING convenience, as it is today: the button
|
||||
starts hidden and the script shows it. With scripts off the image shows at
|
||||
fit size, and no judgment depends on the toggle (INV-3).
|
||||
- Video and audio get their native controls and no toggle. A toggle that
|
||||
renders on audio and silently no-ops (today's script binds
|
||||
`getElementById('vimg')`) is the failure this names.
|
||||
- **The rail** (`data-region="rail"`) holds:
|
||||
- `#NN of M`, and position within the group;
|
||||
- the item's ordinal `#NN` (its number in the whole set, the same number
|
||||
its tile shows);
|
||||
- `K of M`, where K is its position in `review_chain` and M is the length
|
||||
of `review_chain`. The tape's "N of M seen" uses the SAME M, and N counts
|
||||
`.seen` ∩ `review_chain`;
|
||||
- its position within its group, when the booth has groups;
|
||||
- the caption;
|
||||
- the flag form (`back=view`);
|
||||
- notes and the add-note form (`back=view`);
|
||||
@@ -247,7 +308,9 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
- **The end of the set** is not a separate page. On the last ring item the
|
||||
rail adds a summary block: seen count, flag tray, and every open booth-level
|
||||
pick answerable in place.
|
||||
- **Keys** (additive; editable targets keep their keys, as today):
|
||||
- **Keys** (additive). **Every** key here, new and old, is ignored while focus
|
||||
is in an `input`, `textarea`, `select` or `contenteditable`, the same
|
||||
`isEditable` guard view.html carries today, so F never fires mid-note:
|
||||
|
||||
| key | action |
|
||||
|---|---|
|
||||
@@ -256,6 +319,14 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
| N | focus the note |
|
||||
| Esc | back to the grid, at `#item-<url>` so the grid scrolls to where you were |
|
||||
|
||||
### C7 — copy and brand (the two rulings that are not layout)
|
||||
|
||||
- **Voice: plain and direct** (ruling `voice=plain`). Every NEW string R2
|
||||
introduces says what it means, with no villainy and no jokes. Existing
|
||||
strings are unchanged unless their surface is rewritten.
|
||||
- **No SVS emblem** anywhere in the Booth's chrome (ruling `emblem=no`). The
|
||||
brand dot and the reticle favicon from the SVOS retheme stay.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-1 — one resolver.** `ordinal` is set in `booth_items`. No route computes
|
||||
@@ -277,11 +348,20 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
|
||||
The notes list keeps `(created, id)`.
|
||||
- **INV-3 — JS-off parity.** Every judgment, filter and jump works with
|
||||
scripts disabled. The only JS-only affordances are the keys and the in-place
|
||||
swap.
|
||||
- **INV-4 — 303 byte-identity.** For a request where `wants_json` is False,
|
||||
each mark route's response (status, headers, body) is byte-identical to its
|
||||
pre-R2 response. This includes the two existing `back` landings.
|
||||
scripts disabled. The only JS-only affordances are:
|
||||
- the keys;
|
||||
- the in-place swap;
|
||||
- the image 1:1 toggle (a viewing convenience, unchanged from today);
|
||||
- the existing copy buttons and blur reveal.
|
||||
|
||||
The narrow-screen collapse is `<details>` and needs no script.
|
||||
- **INV-4 — 303 byte-identity, for every request shape that existed before
|
||||
R2.**
|
||||
- For a request where `wants_json` is False and `back` is absent or
|
||||
`marks`, each mark route's response (status, headers, body) is
|
||||
byte-identical to its pre-R2 response.
|
||||
- `back=view` is a NEW request shape with no pre-R2 counterpart. Its landing
|
||||
is specified in C3 and is the one declared exception.
|
||||
- **INV-5 — two named clocks.**
|
||||
- `mtime` / `_newest_mtime`: activity. It includes dotfiles and excludes
|
||||
locks, and it feeds lifetime and 'everything else'.
|
||||
@@ -296,7 +376,7 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
escaping path as the grid.
|
||||
- **INV-8 — blur honesty.** A blurred item stays blurred on every new surface:
|
||||
the Desk preview strip, the flag tray, the filmstrip and the review stage.
|
||||
Reveal stays per-viewer and client-side. Copy keeps admitting it is cosmetic.
|
||||
Reveal stays per-BROWSER and client-side (nothing persisted). Copy keeps admitting it is cosmetic.
|
||||
|
||||
## Assertions that change (declared before the code, per CLAUDE.md)
|
||||
|
||||
@@ -304,7 +384,7 @@ This applies to image, video and audio items. Docs keep `doc.html`.
|
||||
|---|---|---|---|
|
||||
| test_booth.py L785 | `class="grid kept-grid"` present when a booth is kept | absent; the kept booth appears in its Desk section with the `kept` lifetime line | requirement 8: the lanes sort nothing |
|
||||
| test_booth.py L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same |
|
||||
| test_booth.py L810-811 | lane absent when nothing is kept | unchanged in spirit (no lane), and trivially true | same |
|
||||
| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — |
|
||||
|
||||
Every other existing assertion is expected to survive, and one of the TDD
|
||||
slices is "the whole suite green before any new test". Named because they were
|
||||
@@ -321,5 +401,6 @@ checked:
|
||||
- Thumbnails.
|
||||
- 1–9 answer keys.
|
||||
- Lifetime policy for answered picks.
|
||||
- The verbatim path.
|
||||
- The verbatim path. A verbatim booth's media items remain reachable at
|
||||
`view?f=` by URL, as today, and nothing in the verbatim page links there.
|
||||
- The link-board page (`/b/links/`) beyond CSS.
|
||||
|
||||
Reference in New Issue
Block a user