Konubinix' opinionated web of thoughts

A Photos/Videos Organiser With Solid

Fleeting

Why this note

Memories is a photo/video archive browser and triage tool that doubles as a fullscreen photo frame — an always-on tablet that sits on a cabinet and cycles through the archive. It is built on the same stack the frise proved — PostGraphile (GraphQL) + the free-text labels field — but with a third render technology, on purpose: the slider is Alpine, the frise is Preact, so this tries Solid and its fine-grained reactivity. No build step, no bundler: an import map pulls the framework from esm.sh and the html tagged-template avoids JSX. The source of truth is Postgres; the app reads and edits labels/state.

Same discipline as the frise: each feature is a chapter — prose, a Playwright test, the code, the CSS — written test-first, blocks short, rewrite over patch.

A chapter per feature adds up, and the note runs long — so here is the shape of the whole, every chapter a click away.

Table of Contents

Use cases

Nobody opens the archive to exercise a download button. Someone opens it to get three photos onto a phone before dinner, to sort out a morning’s shooting, or to leave the cabinet running through a good afternoon. Those sessions are what memories promises, so they are what it is checked against — and every chapter after this one takes one piece of one and says how it is kept.

Sharing a few photos with someone

Somebody asks for the photos from an afternoon. You search the tag, a handful comes back, and you take what you need: the web copies to hand over now, and the originals of the ones you mean to rework. Both have to open where they land, and the second must not overwrite the first. Nothing here is edited; this is the one session that only reads.

It needs three photos that differ in what can be taken from them: one complete, one that was never downscaled, and a video. Their bytes are served from the app’s own origin, since a download only starts on a real 200.

SEND_LABEL = "zzsend"
SEND_BYTES = ["send-c", "send-w", "send-t",        # the complete photo
              "send-nc", "send-nt",                # never downscaled: no web copy
              "send-vc", "send-vw", "send-vt"]     # the video
def send_docs():
    b = {n: serve_bytes(n) for n in SEND_BYTES}
    return [
        {"cid": b["send-c"], "date": "2020-05-15T12:00:00Z", "mimetype": "image/jpeg",
         "thumbnailCid": b["send-t"], "webCid": b["send-w"], "filename": "holiday.jpg",
         "labels": SEND_LABEL, "state": "todo"},
        {"cid": b["send-nc"], "date": "2020-05-16T12:00:00Z", "mimetype": "image/jpeg",
         "thumbnailCid": b["send-nt"], "labels": SEND_LABEL, "state": "todo"},
        {"cid": b["send-vc"], "date": "2020-05-17T12:00:00Z", "mimetype": "video/quicktime",
         "thumbnailCid": b["send-vt"], "webCid": b["send-vw"],
         "labels": SEND_LABEL, "state": "todo"},
    ]

They are told apart by where they sit, oldest first, so the complete photo leads and the clip closes. What becomes of each in turn is selecting and downloading, a step at a time — and the session below is that story in the order it happens: pick one and take both its forms, check the two files can live side by side, then add the photo that has no web copy, and finally swap to the clip.

@testcase
def test_sharing_a_few_photos(page):
    """Someone asks for an afternoon's photos: search the tag, pick, and take each
    at the size that suits."""
    docs = send_docs()
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        search_for(page, SEND_LABEL)
        expect(tiles(page)).to_have_count(len(docs))
        photo, never_downscaled, video = 0, 1, 2      # shown oldest first
        tiles(page).nth(photo).click()                          # the one worth sending
        expect(checks(page)).to_have_count(1)
        tb, got = toolbar(page), {}
        for res in ["web", "orig"]:
            with page.expect_download() as di:
                tb.get_by_role("button", name=res, exact=True).click()
            got[res] = (di.value.url, di.value.suggested_filename)
        assert docs[photo]["webCid"] in got["web"][0], f"web → webCid: {got}"
        assert docs[photo]["cid"] in got["orig"][0], f"orig → the doc's own cid: {got}"
        print("  PASS: download selection")
        names = {res: name for res, (_, name) in got.items()}   # the two the photo just yielded
        assert len({*names.values()}) == len(names), f"same name → they collide in the folder: {names}"
        for res in names:
            assert res in names[res], f"rendition missing from the name: {names}"
        print("  PASS: download names by resolution")
        tiles(page).nth(never_downscaled).click()
        expect(checks(page)).to_have_count(2)
        saved = []                                              # armed here: only this click's files count
        page.on("download", lambda d: saved.append(d.url))
        toolbar(page).get_by_role("button", name="web", exact=True).click()
        wait_until(page, lambda: len(saved) >= 1, label="the doc that has a web copy saves it")
        page.wait_for_timeout(500)                              # room for a second, unwanted save to land
        assert len(saved) == 1, f"expected the one web copy, got {len(saved)}: {saved}"
        assert docs[photo]["webCid"] in saved[0], f"the wrong rendition came down: {saved[0]}"
        print("  PASS: download skips a missing rendition")
        tiles(page).nth(photo).click()
        tiles(page).nth(never_downscaled).click()
        tiles(page).nth(video).click()
        expect(checks(page)).to_have_count(1)
        tb, ext = toolbar(page), {}
        for res in ["orig", "web"]:
            with page.expect_download() as di:
                tb.get_by_role("button", name=res, exact=True).click()
            ext[res] = di.value.suggested_filename.rsplit(".", 1)[-1]
        assert ext["orig"] == "mov", f"original keeps its real type: {ext}"
        assert ext["web"] == "mp4", f"a video's web copy is mp4: {ext}"
        print("  PASS: download extensions by rendition")
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        unserve_bytes(*SEND_BYTES)

The cabinet plays on its own

A tablet sits on the cabinet showing the archive, and the point of it is that nobody tends it. You set a search going and walk off; it plays through by itself. Somebody passing touches the glass, gets the controls, reads what per is looking at, and leaves — and the controls tidy themselves away. Relaunch it and it is still showing, on the slide it stopped on. Only when you deliberately leave the show does it stay left.

Its tempo and the patience of its control bar are the two knobs the show runs on — a minute a slide and twenty seconds of bar by default, both far too slow to sit and watch, so the cabinet here is wound to a second or two. The third number is the suite’s own: how long to leave a just-exited show alone before believing it really isn’t coming back.

CABINET_MS, CABINET_UI_IDLE_MS = 1200, 2000
CABINET_QS = f"?ms={CABINET_MS}&uiidle={CABINET_UI_IDLE_MS}"
FRAME_REENTRY_GRACE_MS = 300

The three fixtures give it a short loop to play, and the show opens on the wall they make.

@testcase
def test_the_cabinet_plays_on_its_own(page):
    """A tablet left on the cabinet: it advances by itself, yields its controls to a
    passer-by, and comes back where it was after a relaunch."""
    make_fixtures()
    open_app(page, CABINET_QS)
    search_for(page, FIXTURE_LABEL)
    chip(page, "all").click()
    expect(tiles(page)).to_have_count(len(FIXTURES))
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    bar = page.get_by_role("toolbar", name="frame actions")
    for src in ["thumb-0", "thumb-1", "thumb-2"]:                   # date order
        wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
    print("  PASS: frame auto-advances")
    strip.click()                                                  # a tap reveals the frame bar
    expect(bar).to_be_visible()
    colours = [bar.get_by_role("button", name=st, exact=True).evaluate("el => getComputedStyle(el).color")
               for st in ["todo", "next", "done", "delete"]]
    assert len(set(colours)) == 4, f"each state pill should have its own colour, got {colours}"
    print("  PASS: frame state pills are colour coded")
    def shown(k):     # the date the bar should be reading, as the browser renders it
        return page.evaluate("d => new Date(d).toLocaleString('fr-FR')", FIXTURES[k]["date"])
    i = centred_slide(strip)                                   # whichever slide the show stopped on
    expect(bar.locator(".frame-date")).to_have_text(shown(i))
    page.keyboard.press("ArrowRight")                          # step to the next slide
    j = (i + 1) % len(FIXTURES)
    wait_until(page, lambda: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{j}")
    expect(bar.locator(".frame-date")).to_have_text(shown(j))  # follows the slide
    print("  PASS: frame shows date")
    expect(bar).to_be_visible()                       # still up from the tap that raised it
    expect(bar).to_be_hidden()                        # and gone once the window has run out
    print("  PASS: frame bar auto-hides when idle")
    strip.click()                                     # bring the bar back up
    expect(bar).to_be_visible()
    pause = bar.get_by_role("button", name=re.compile("pause|play"))
    for _ in range(3):
        pause.click(); page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)
    page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)    # now well past a window since the first press
    expect(bar).to_be_visible()                       # still up: each press restarted the count
    expect(bar).to_be_hidden()                        # now left alone → it finally hides
    print("  PASS: frame bar idle resets on use")
    if centred_slide(strip) == 0:                              # park it away from the cold-start slide
        page.keyboard.press("ArrowRight")
        wait_until(page, lambda: centred_slide(strip) != 0,
                   label="the show steps off the slide a cold start would pick")
    i = centred_slide(strip)
    was = strip.evaluate(CENTERED)                             # the slide it is showing when it goes down
    remembered = lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')")
    wait_until(page, lambda: remembered() == FIXTURES[i]["cid"],
               label="the slide it is on is the one written down",
               detail=lambda: f"written down: {remembered()}")
    open_app(page, CABINET_QS)                                 # reboot: a plain relaunch
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    expect(tiles(page)).to_have_count(len(FIXTURES))           # the docs it places from are in
    page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
    came_back_on = strip.evaluate(CENTERED)                    # read at once, not waited for
    assert came_back_on == was, f"resumed on {came_back_on}, not the {was} it went down on"
    print("  PASS: frame resumes position")
    page.keyboard.press("Escape")                              # leaving turns the memory off
    expect(strip).to_be_hidden()
    open_app(page, CABINET_QS)
    expect(page.get_by_role("list", name="slideshow")).to_be_hidden()   # stays on the wall
    print("  PASS: frame mode persists")
    expect(tiles(page)).to_have_count(len(FIXTURES))           # the persisted query brought the wall back
    page.get_by_role("button", name=re.compile("frame", re.I)).click()   # set it going again → remembered
    expect(page.get_by_role("list", name="slideshow")).to_be_visible()
    open_app(page, CABINET_QS)                                 # relaunch: remembered → auto-enters, no click
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    page.keyboard.press("Escape")                              # exit within this launch
    page.wait_for_timeout(FRAME_REENTRY_GRACE_MS)              # long enough for a re-entry to fire
    expect(strip).to_be_hidden()                               # once per launch — it doesn't
    print("  PASS: frame autostart")

Somebody stops to look

The show is playing to an empty room and you walk up to it. From here on it is yours: you want to go back a couple, hold on one, run forward through a stretch you don’t care about. Sometimes that happens at a desk with arrow keys under your hands, and sometimes at the cabinet where the only instrument is a finger — the same show either way, so both have to drive it. The finger is the harder half, because one instrument has to say four things:

  • a tap near an edge — step the show
  • a tap in the middle — show me the controls
  • two fingers at once — let me look closer
  • one finger travelling — run me forward a stretch

Nothing distinguishes them but where they land, how many there are, and whether they move; read one as another and the show lurches when you meant to pause it.

Then you have seen what you came for and give the show back — the way out has to leave the app standing, not navigate off it — and once it is running unattended again it behaves as it did before you arrived: it yields the moment it is touched, and picks up again once you have gone.

So it wants a long strip — twelve years, one photo each, enough that a hard fling has somewhere to travel. A strip that loops carries a copy of its last photo before the first and a copy of its first after the last, so stepping off either end has somewhere to land; that padding is why the opening photo sits one along rather than at the very start.

It also wants two tempos. For the deliberate half, one wound so far down that nothing moves unless you move it; for the closing beat, one brisk enough to watch, with a short patience so its resumption can be seen rather than waited out. Two further spans are the suite’s own, both of them room for something to happen: one for a step already under way to finish before a reading is taken, and one in which a gesture that should have moved the show would have shown it.

SWIPE_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzswipe-{i}", "date": f"20{10+i:02d}-01-15T12:00:00Z",
               "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzswipe-t-{i}",
               "labels": "zzswipe", "state": "todo"} for i in range(12)]
STILL_QS = "?ms=999999"                        # a tempo no test will ever outwait
LIVE_MS, LIVE_RESUME_MS = 400, 2500
LIVE_QS = f"?ms={LIVE_MS}&idleresume={LIVE_RESUME_MS}"
LIVE_SETTLE_MS = 300
FRAME_STEP_GRACE_MS = 300

@testcase
def test_somebody_stops_to_look(page):
    """Taking the show over by hand — arrows and taps step it, two fingers don't, a
    fling coasts under control — then giving it back and letting it run on."""
    for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, STILL_QS)
        search_for(page, "zzswipe")
        expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) > 0, label="the strip settles")
        first = strip.evaluate(ON_SLIDE)
        box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
        page.keyboard.press("ArrowLeft")                               # off the front → the last doc
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
                   label="a step back off the first lands on the last",
                   detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
        page.keyboard.press("ArrowRight")                              # off the end → the first
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
        print("  PASS: frame wraps both ways")
        page.mouse.click(box["x"] + box["width"] / 2, midY)            # centre third → the bar
        expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
        assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
        print("  PASS: frame centre tap reveals the bar")
        page.keyboard.press("ArrowRight")                              # focus is on the body, not a control
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
        page.keyboard.press("ArrowLeft")                               # back where we started
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
        print("  PASS: frame arrow steps off control")
        page.mouse.click(box["x"] + box["width"] * 0.92, midY)         # right third → forward
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
        page.mouse.click(box["x"] + box["width"] * 0.08, midY)         # left third → back
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
        print("  PASS: frame tap zones step the show")
        x = box["x"] + box["width"] * 0.92                             # the right third — a lone tap here would step forward
        cdp = page.context.new_cdp_session(page)
        cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
        cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
                 "touchPoints": [{"x": x - 20, "y": midY}, {"x": x + 20, "y": midY}]})
        cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
        page.wait_for_timeout(FRAME_STEP_GRACE_MS)                     # long enough for a step to show
        assert strip.evaluate(ON_SLIDE) == first, "a two-finger gesture must not step the show"
        print("  PASS: frame two-finger gesture does not step")
        CONTROLLED_SLIDES = 4
        before = strip.evaluate(ON_SLIDE)
        hard_frame_flick(page, strip)
        landed = strip.evaluate(ON_SLIDE)
        assert landed - before <= CONTROLLED_SLIDES, \
            f"the swipe flung {landed - before} slides on — past the controlled range"
        print("  PASS: frame swipe does not overshoot")
        CENTRED_PX = 20
        w = strip.evaluate(SLIDE_W); rest = strip.evaluate("el => el.scrollLeft")
        off = min(rest % w, w - (rest % w))                           # distance to the nearest slide boundary
        assert off < CENTRED_PX, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
        print("  PASS: frame swipe settles centered")
        page.go_back()
        expect(strip).to_be_hidden()
        expect(heading(page)).to_be_visible()                          # still on the app, not gone
        print("  PASS: frame exits on back")
        # …and set it going again, unattended, at a tempo worth watching
        open_app(page, LIVE_QS)
        expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        box = strip.bounding_box()
        opened = strip.evaluate(ON_SLIDE)                              # wherever the show resumed
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened,    # …and it is advancing from there
                   label="the show is running before we touch it")
        page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)  # a centre tap: real interaction, no nav
        page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS)                # let any in-flight step settle
        held = strip.evaluate(ON_SLIDE)
        page.wait_for_timeout(LIVE_RESUME_MS // 2)                     # several tempo ticks, still inside the resume span
        assert strip.evaluate(ON_SLIDE) == held, f"interaction must stop the show; it drifted {held}{strip.evaluate(ON_SLIDE)}"
        wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held)      # quiet long enough → resumes on its own
        print("  PASS: frame pauses on interaction")
    finally:
        for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]})

Fixing a date that came out wrong

A camera with a flat battery stamps everything with the day it was switched on, and a scan carries the day it was scanned. So a run of photos lands in the archive under a date that is plainly not theirs, and putting it right is triage work you do a photo at a time: open one, see what it says, correct it, and watch it move to where it belongs among the others.

Correcting is the easy half. The hard half is not correcting — reaching for the picker and thinking better of it has to leave the photo exactly as it was, and the key that opens the picker must not go off while you are typing a label or standing on the wall with nothing open at all. A date editor that fires by accident is worse than none, because a wrong date you introduced looks exactly like a wrong date the camera gave you.

The three fixtures are a month apart, so a date moved across them has somewhere visible to land. One span is the suite’s own: a save travels to the archive and back before the wall shows it, so claiming a save did not happen means waiting out the time one would have taken.

SAVE_GRACE_MS = 800

@testcase
def test_fixing_a_date(page):
    """A photo dated wrong: open it, reach for the picker (and back out of it), then
    correct the date and watch the wall re-order."""
    open_fixtures(page)
    page.keyboard.press("d")                                      # no doc open
    open_doc(page, 0)
    d = dialog(page)
    expect(d.get_by_role("button", name="edit date")).to_be_visible()   # opens in display mode…
    expect(d.get_by_label("date", exact=True)).to_have_count(0)         # …not the picker
    print("  PASS: date shortcut ignored without a doc")
    page.keyboard.press("d")
    expect(d.get_by_label("date", exact=True)).to_be_focused()
    print("  PASS: date shortcut focuses editor")
    want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');"
                         " return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
                         FIXTURES[0]["date"])
    expect(d.get_by_label("date", exact=True)).to_have_value(want)   # seeded with the stored instant, in local time
    print("  PASS: date editor seeds current value")
    unmoved = tile_dates(page)                                            # the wall as it stands
    box = d.get_by_label("date", exact=True)
    box.fill("1999-01-01T00:00")
    box.press("Escape")
    expect(d).to_be_visible()                                             # still open
    page.wait_for_timeout(SAVE_GRACE_MS)                                  # room for a save to land
    assert tile_dates(page) == unmoved, f"the backed-out date was written anyway: {tile_dates(page)}"
    print("  PASS: date edit escape cancels")
    d.get_by_placeholder("add a label…").click()                  # focus the label box
    page.keyboard.press("d")
    expect(d.get_by_label("date", exact=True)).to_have_count(0)   # the picker stayed shut
    print("  PASS: date shortcut stands aside in a field")
    d.get_by_role("button", name="edit date").click()          # the date is a button — open the picker
    box = d.get_by_label("date", exact=True)
    box.fill("2020-12-15T12:00")                     # push it past the other two
    box.press("Enter")                               # save
    d.get_by_role("button", name="close").click()    # back to the wall
    expect(tiles(page)).to_have_count(len(FIXTURES))
    # the tile's alt is its day (set straight from the date, no lazy load), so the edited
    # doc — now the latest — sits last on the date-sorted wall.
    expect(thumb_imgs(page).nth(2)).to_have_attribute("alt", "2020-12-15")
    print("  PASS: lightbox edit date re-orders")

Putting words on a photo

Labelling is the whole of triage, really — the state chips say how far along a photo is, but the labels are what make it findable again in ten years. So this is the session that runs most: open a photo, put the words on it, move to the next.

What makes it bearable is that a run of photos usually wants the same words. Typing vacances 2019 onto forty photos one letter at a time is how a person gives up halfway, so the box takes several at once, the keyboard does add and remove without reaching for a chip’s ×, and the word you just used is offered on the next photo for a single tap. And the archive has to actually keep them: a label that shows as a chip but was never written is worse than no label, because you will not go back and check.

@testcase
def test_putting_words_on_a_photo(page):
    """The labelling run: several words at once, the keyboard for add and remove, the
    last word reused on the next photo, and all of it written down."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    expect(d).to_be_visible()
    box = d.get_by_placeholder("add a label…")
    box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20)   # one existing + two new, typed
    box.press("Enter")
    expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
    expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
    expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1)  # not duplicated
    print("  PASS: lightbox adds several labels")
    box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
    expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible()    # added
    box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
    expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0)   # removed
    print("  PASS: lightbox enter adds, shift-enter removes")
    box.fill("zzreuse"); box.press("Enter")
    expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible()  # applied here
    d.get_by_role("button", name="next photo").click()
    reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
    expect(reuse).to_be_visible()                                                # offered on the next
    reuse.click()
    expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible()  # reused, no retype
    print("  PASS: lightbox reuse last label")
    box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
    d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
    d.get_by_role("button", name="close").click()
    search_for(page, "lbadded")                             # the word put on: the photo answers
    expect(tiles(page)).to_have_count(1)
    search_for(page, FIXTURE_LABEL)                         # the word taken off: it no longer does
    expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
    print("  PASS: lightbox edits labels")

Saying no, quickly

Most of a burst is not worth keeping. Eleven near-identical shots of the same moment, and one of them is the one — so sweeping a stack down to the keepers means saying no far more often than yes, and the no has to be the cheapest gesture in the app.

It is also the only destructive one, which pulls the other way: cheap enough to repeat forty times, dear enough that it cannot happen by accident. So it asks the first time, and there is a way to tell it you know what you are doing — and, because the same keyboard is being used to type labels a second earlier, it has to know the difference between condemning a photo and rubbing out a letter.

A run like this is read with nothing filtered out. Working the todo pile instead, each rejection would drop the photo off the wall as you condemned it and the run would shrink under you — which is its own session, and a different feeling. Here you are going along a row looking at everything, so a photo you have just condemned stays where it is and the next one is still next. None of them starts out condemned — whatever else they are — so a photo reading condemned afterwards can only have been marked by the key just pressed.

@testcase
def test_saying_no_quickly(page):
    """Running down a stack marking rejects: it asks the first time, takes Shift for the
    rest, and stays out of the way while you are typing."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    del_btn = d.get_by_role("button", name="delete", exact=True)
    expect(del_btn).to_have_attribute("aria-pressed", "false")
    asked = []
    page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
    page.keyboard.press("Delete")
    expect(del_btn).to_have_attribute("aria-pressed", "true")   # moved to delete
    assert asked, "Delete should have asked to confirm"
    print("  PASS: lightbox delete confirms")
    d.get_by_role("button", name="next photo").click()
    expect(del_btn).to_have_attribute("aria-pressed", "false")   # a fresh doc, not yet condemned
    asked.clear()
    page.keyboard.press("Shift+Delete")
    expect(del_btn).to_have_attribute("aria-pressed", "true")
    assert not asked, "Shift+Delete should not ask to confirm"
    print("  PASS: lightbox shift-delete skips confirm")
    d.get_by_role("button", name="next photo").click()
    expect(del_btn).to_have_attribute("aria-pressed", "false")
    box = d.get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("zzoops", delay=10)
    box.press("ArrowLeft"); box.press("Delete")                  # rubbing out the last letter
    expect(box).to_have_value("zzoop")
    expect(del_btn).to_have_attribute("aria-pressed", "false")   # the photo is untouched
    print("  PASS: lightbox delete in a field is text editing")

Judging a run down to nothing

This is the session the whole triage view exists for. A filter holds everything still waiting on you, you open the first of them, and from then on you should not have to steer: judge, and the next one is in front of you; judge again, and so on until there is nothing left and the app gets out of your way. If you have to reach for the wall between each one, the run is not a run — it is a hundred separate little chores.

What makes it awkward to build is that judging a photo removes it from the filter you are reading. The list shifts under the open doc at the very moment you need it to say where to go next, and the obvious implementations all land you on the one you just finished with, or on the one before it that you dealt with a minute ago.

So the run needs four todos rather than the usual three. The wrap has to be judged from a ring of at least three — in a ring of two, the one after and the one before are the same photo, and a step forward cannot be told from a step back — and one is already gone by then, so four is the smallest run that can show the difference at every stage.

@testcase
def test_judging_a_run_down_to_nothing(page):
    """Working a filter to the end: each judgement hands you the next photo, the last
    comes round to the first, and emptying the run closes it."""
    make_fixtures()
    fourth = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-3", "date": "2020-04-15T12:00:00Z", "mimetype": "image/jpeg",
              "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-3", "labels": FIXTURE_LABEL, "state": "todo"}
    gql(DELETE, {"cid": fourth["cid"]}); gql(CREATE, {"p": fourth})
    for f in FIXTURES[1:]:
        gql(UPDATE, {"cid": f["cid"], "patch": {"state": "todo"}})    # four todos, Jan..Apr
    try:
        open_app(page)
        search_for(page, FIXTURE_LABEL)
        expect(tiles(page)).to_have_count(4)                  # default filter is todo
        expect(thumb_imgs(page).nth(3)).to_have_attribute("src", re.compile(r"/ipfs/"))  # loaded, not still blank
        behind, ahead = (thumb_imgs(page).nth(i).get_attribute("src") for i in (0, 2))
        open_doc(page, 1)                                     # the second of four
        d = dialog(page)
        img = d.get_by_role("img")
        d.get_by_role("button", name="done", exact=True).click()   # leaves the todo filter
        expect(d).to_be_visible()                             # still open…
        expect(img).to_have_attribute("src", ahead)           # …on the doc that took its place
        assert img.get_attribute("src") != behind, "the modal stepped backwards"
        print("  PASS: judging one lands on the next, not the previous")
        onto_last = thumb_imgs(page).nth(2).get_attribute("src")   # the last of the three left
        d.get_by_role("button", name="next photo").click()
        expect(img).to_have_attribute("src", onto_last)            # settled on it before judging it
        d.get_by_role("button", name="done", exact=True).click()
        expect(d).to_be_visible()
        expect(img).to_have_attribute("src", behind)          # round to the first, not back to the end
        print("  PASS: judging the last comes round to the first")
        d.get_by_role("button", name="done", exact=True).click()
        expect(img).to_have_attribute("src", ahead)           # round again, to the one still standing…
        d.get_by_role("button", name="done", exact=True).click()
        expect(d).to_be_hidden()                              # …and with that one gone, nothing to show
        expect(tiles(page)).to_have_count(0)
        print("  PASS: the last one judged closes the run")
    finally:
        gql(DELETE, {"cid": fourth["cid"]})

Doing one thing to forty photos

Some of triage is per-photo and some of it plainly is not. A whole afternoon wants the same word on it; a whole card-dump wants the same verdict. Doing that one at a time is the difference between a job you finish and a job you abandon, so the wall lets you take a run in hand and act on all of it at once.

Picking things up has to be quiet: the toolbar appears the moment the first one is taken, and nothing on the wall may move as it does. Then the same box that labels one photo labels the lot — by button or by keyboard, the two never drifting apart — and takes words off again as readily as it puts them on, because a batch applied to the wrong run is the other thing that happens at this speed.

@testcase
def test_doing_one_thing_to_forty_photos(page):
    """Taking a run in hand: pick it up without the wall moving, then put a word on all
    of it and take it off again, by button and by key."""
    open_fixtures(page)
    n = tiles(page).count()
    t = tiles(page)
    expect(toolbar(page)).to_be_hidden()       # nothing picked yet, so the bar is not there
    before = t.first.bounding_box()
    t.nth(0).click()                           # the first pick — the bar arrives on this one
    expect(toolbar(page)).to_be_visible()
    after = t.first.bounding_box()
    assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
    print("  PASS: select doesn't shift grid")
    t.nth(1).click()                           # a second one joins it
    expect(checks(page)).to_have_count(2)
    expect(toolbar(page).get_by_text("2 selected")).to_be_visible()
    t.nth(0).click()                           # toggle one back off
    expect(checks(page)).to_have_count(1)
    print("  PASS: select toggles tiles")
    select_all(page).click()
    tb = toolbar(page)
    tb.get_by_placeholder("add a label…").fill("addedbybatch")
    tb.get_by_role("button", name="add label").click()
    expect(checks(page)).to_have_count(0)            # selection clears once applied
    search_for(page, "addedbybatch")                # the fresh word now finds them all
    expect(tiles(page)).to_have_count(n)
    print("  PASS: batch add label")
    select_all(page).click()
    tb.get_by_placeholder("add a label…").fill("addedbybatch")
    tb.get_by_role("button", name="remove label").click()
    expect(tiles(page)).to_have_count(0)
    print("  PASS: batch remove label")
    search_for(page, FIXTURE_LABEL)                  # back to the run itself
    expect(tiles(page)).to_have_count(n)
    select_all(page).click()
    box = toolbar(page).get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("zzbatchret")
    expect(box).to_have_value("zzbatchret")          # the word is in the live box
    box.press("Enter")                               # + via RET
    expect(checks(page)).to_have_count(0)            # applied → selection clears
    search_for(page, "zzbatchret")
    expect(tiles(page)).to_have_count(n)             # every selected photo got it
    print("  PASS: batch enter adds")
    select_all(page).click()
    expect(checks(page)).to_have_count(n)            # selection settled
    box.click(); box.press_sequentially("zzbatchret")
    expect(box).to_have_value("zzbatchret")          # the word is in the live box
    box.press("Shift+Enter")                         # - via S-RET
    expect(tiles(page)).to_have_count(0)             # the docs no longer carry the label → gone from the wall
    print("  PASS: batch shift-enter removes")
    search_for(page, FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(n)
    select_all(page).click()
    tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ")   # as a picked suggestion leaves it
    tb.get_by_role("button", name="remove label").click()
    expect(tiles(page)).to_have_count(0)             # the trailing sep didn't defeat the match
    print("  PASS: batch remove label trailing separator")

Hunting for a photo you half-remember

You know the photo exists and you know almost nothing that would find it. It was summer. It was one of mine, not Ayla’s. Five or six years back. That is the whole of what you have, and none of it is a word anybody typed onto the photo at the time.

So the search box has to take guesses rather than facts, and take them a piece at a time: each token you add cuts the wall down, and a guess you withdraw costs you only itself. The pieces are the ones memory actually keeps — whose it was, what month, what year, what day — and they combine, so a hunt is a funnel rather than a single lucky query.

The set below is built so each guess in turn has something to cut away: six photos, five of them in June and spread over three years, one taken in March, and one of the Junes somebody else’s.

HUNT_LABEL = "zzhunt"
HUNT_DOCS = [
    # (day, owner) — June across three years, a March, and one that is not mine
    ("2019-06-15", "konubinix"), ("2020-06-15", "konubinix"), ("2021-06-10", "konubinix"),
    ("2020-03-20", "konubinix"), ("2020-06-20", "aylapomme"), ("2020-06-25", "konubinix"),
]

def hunt_docs():
    return [{"cid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}", "date": f"{day}T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}-t", "labels": HUNT_LABEL, "state": "todo",
             "owner": owner}
            for i, (day, owner) in enumerate(HUNT_DOCS)]

Asking is always asking afresh: the box holds one query and committing it replaces whatever stood there, so a guess is added by re-stating the question with the new piece in it. That is why each step below names every guess made so far.

def narrow(page, *tokens):
    search_for(page, "; ".join([HUNT_LABEL, *tokens]))

@testcase
def test_hunting_for_a_photo(page):
    """Finding a photo from what memory kept: whose, what month, what year, what day —
    each guess cutting the wall down, and one taken back giving its share straight
    back."""
    docs = hunt_docs()
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        narrow(page)
        expect(tiles(page)).to_have_count(len(docs))      # nothing guessed yet
        narrow(page, "owner:konubinix")
        expect(tiles(page)).to_have_count(5)                  # everyone else's set aside
        print("  PASS: filter by owner")
        narrow(page, "owner:konubinix", "month:6")
        expect(tiles(page)).to_have_count(4)                  # every June, whatever the year
        narrow(page, "owner:konubinix", "month:march")        # named, not numbered
        expect(tiles(page)).to_have_count(1)
        print("  PASS: filter by month")
        narrow(page, "owner:konubinix", "month:6", "year:2020")
        expect(tiles(page)).to_have_count(2)                  # of those Junes, the one year
        print("  PASS: filter by year")
        narrow(page, "owner:konubinix", "month:6")            # the year guess withdrawn
        expect(tiles(page)).to_have_count(4)                  # its Junes returned, and no more
        print("  PASS: a withdrawn guess costs only itself")
        narrow(page, "owner:konubinix", "date:2020-06")       # the month and the year, in one token
        expect(tiles(page)).to_have_count(2)                  # the same two the pair had left
        narrow(page, "owner:konubinix", "date:2020-06-15")    # and down to the day
        expect(tiles(page)).to_have_count(1)
        expect(thumb_imgs(page).first).to_have_attribute("alt", "2020-06-15")   # the one asked for
        print("  PASS: filter by date")
    finally:
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

Typing a word you only half-know

The archive’s words are your own, and you will not remember them. Was it balade or rando? Was Élodie spelt with the accent? A search box that demands the exact word is a box that only finds what you can already recall, which is the small part of the archive you did not need help with.

So the box offers what it knows as you type, and the offer has to survive the ways a half-memory arrives: the fragment you have may sit in the middle of the word rather than at its start, and it will come without the capital or the accent you cannot be sure of. Whatever you took from the list goes in exactly as it is written down.

Then it gets out of the way. A word accepted ends with the separator for the next one already typed, the list comes back the moment you carry on, and the whole of it works without the hand leaving the keys — because the box is where a query is built up over several guesses, not somewhere you visit once.

Six words, covering four ways this can go wrong. Two of them are the shapes just named, a word with its distinctive part buried in the middle and a word spelt with a capital and an accent. The other four are there for the list itself: a pair, because a list of one has no bottom to reach and no second row to prefer over its first, and two to be taken in turn, so the list can be caught going away and coming back.

VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"

HALF_KNOWN = ["zzcosmo", "Zzélodie", "zzupa", "zzupb", "zzbalade", "zzalois"]

def seed_vocab(words):
    for w in words: gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})

def drop_vocab(words):
    for w in words:
        try: gql(VOCAB_DEL, {"w": w})
        except Exception: pass

A guess that comes to nothing is rubbed out and another tried, which is why nearly every step below starts its term over rather than adding to it. The one that does not is the one asking whether the list survives a word being accepted — and that question only exists if you carry straight on from the word you accepted.

def guess(page, fragment):
    sb = search_box(page)
    sb.fill(""); sb.click()
    sb.press_sequentially(fragment, delay=20)
    expect(options(page).first).to_be_visible()

@testcase
def test_typing_a_word_you_half_know(page):
    """Building a query out of half-memories: a fragment from the middle of a word, a
    word whose spelling you are unsure of, and the list staying with you throughout."""
    seed_vocab(HALF_KNOWN)
    try:
        open_app(page)
        sb = search_box(page)
        guess(page, "osmo")                              # sits inside zzcosmo, nowhere near its start
        expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
        print("  PASS: completion matches inside label")
        guess(page, "zzelod")                            # no capital, no accent
        texts = [t.strip() for t in options(page).all_inner_texts()]
        assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
        options(page).filter(has_text="Zzélodie").first.click()
        expect(sb).to_have_value("Zzélodie; ")
        print("  PASS: completion preserves case and accent")
        guess(page, "zzup")                              # two words, so there is a bottom to enter at
        items = [t.strip() for t in options(page).all_inner_texts()]
        assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
        sb.press("ArrowUp")                              # from nothing selected — must enter at the bottom
        sel = page.get_by_role("option", selected=True)
        assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
            "ArrowUp from none should select the last suggestion"
        print("  PASS: suggestion up enters from none")
        guess(page, "zzbal")                                     # a fragment of one word we know
        word = options(page).first.inner_text().strip()
        options(page).first.click()
        expect(sb).to_have_value(word + "; ")                    # the picked label, then a ';' for the next
        print("  PASS: label completion")
        page.keyboard.type("zzalo", delay=20)                    # straight on, into whatever holds the keys
        expect(options(page).filter(has_text="zzalois").first).to_be_visible()   # must reappear
        print("  PASS: completion reopens after pick")
        guess(page, "zzup")
        offered = [t.strip() for t in options(page).all_inner_texts()]
        assert len(offered) == 2, f"this needs two to choose between, got {offered}"
        sb.press("ArrowDown"); sb.press("ArrowDown")             # past the first, onto the second
        sb.press("Enter")                                        # apply the highlighted one
        expect(sb).to_have_value(offered[1] + "; ")              # the second — taking the first is the easy bug
        print("  PASS: search suggestion keyboard")
        guess(page, "owner")                                        # 2+ chars → the key is offered
        expect(options(page).filter(has_text="owner:").first).to_be_visible()
        sb.press_sequentially(":k", delay=20)                       # owner:k → its values
        opt = options(page).filter(has_text="konubinix").first
        expect(opt).to_be_visible()
        opt.click()
        expect(sb).to_have_value("owner:konubinix; ")               # a closed-vocab value is done — a ';' opens the next token
        print("  PASS: owner token completes")
    finally:
        drop_vocab(HALF_KNOWN)

Working the wall without the mouse

Triage is a rhythm — glance, judge, move on — and the hand that keeps leaving the keys to fetch the mouse is the hand that breaks it. Somebody working a card-dump down wants what every file manager gives: a tile the eye can follow, walked with the arrows, opened and picked and gathered up without reaching for anything.

That means the keyboard has to carry the whole gesture set the mouse already carries, and agree with it. The two are not alternatives used by different people; they are used by the same person within seconds of each other — a click here, an arrow there — so the cursor a click leaves behind is the cursor the next arrow moves.

It wants a wall too big for the window: enough photos for several rows, on a screen short enough that the last of them sits below the fold and behind the selection bar. A run dragged down to it is where the two hardest promises live — that a row is however many columns are on show, and that the tile you land on is whole and in front of you.

KEY_N = 24

def key_docs():
    return [{"cid": f"https://ipfs.konubinix.eu/p/zzkey-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzkey-t-{i}",
             "labels": "zzkey", "state": "todo"} for i in range(KEY_N)]

def key_thumb(i):
    return f"https://ipfs.konubinix.eu/p/zzkey-t-{i}"

@testcase
def test_working_the_wall_without_the_mouse(page):
    """Running a wall down from the keys: walk it, open one, pick one, drag a run to
    the foot of it, and gather the lot."""
    docs = key_docs()
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 420, "height": 600})   # narrow and short: it overflows
        open_app(page)
        search_for(page, "zzkey")
        expect(tiles(page)).to_have_count(KEY_N)
        page.keyboard.press("ArrowRight")               # the first arrow focuses the first tile
        page.keyboard.press("ArrowRight")               # → the second
        page.keyboard.press("Enter")                    # Enter opens the focused doc
        d = dialog(page)
        expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
        page.keyboard.press("Escape")
        expect(d).to_be_hidden()
        print("  PASS: grid cursor opens focused doc")
        expect(checks(page)).to_have_count(0)           # nothing picked by walking about
        page.keyboard.press(" ")                        # Space picks the focused one
        expect(checks(page)).to_have_count(1)
        page.keyboard.press(" ")                        # Space again unpicks it
        expect(checks(page)).to_have_count(0)
        print("  PASS: grid cursor space toggles selection")
        tiles(page).nth(5).click()                       # reach for the mouse for one tile
        page.keyboard.press("ArrowRight")               # the arrows carry on from there
        page.keyboard.press("Enter")                     # open the now-focused tile
        expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(6))
        page.keyboard.press("Escape")
        toolbar(page).get_by_role("button", name="clear").click()   # drop what the click picked up
        expect(checks(page)).to_have_count(0)
        print("  PASS: cursor starts at last clicked tile")
        page.keyboard.press("ArrowRight")               # a plain move re-anchors where it lands
        expect(checks(page)).to_have_count(0)           # and selects nothing
        page.keyboard.press("Shift+ArrowRight")         # reach on…
        page.keyboard.press("Shift+ArrowRight")         # …and on again
        expect(checks(page)).to_have_count(3)           # the whole run from the anchor
        print("  PASS: grid cursor shift extends selection")
        page.keyboard.press("Shift+ArrowLeft")          # back one → the far end drops off
        expect(checks(page)).to_have_count(2)
        page.keyboard.press("Shift+ArrowLeft")          # back onto the anchor → only it remains
        expect(checks(page)).to_have_count(1)
        toolbar(page).get_by_role("button", name="clear").click()
        expect(checks(page)).to_have_count(0)
        print("  PASS: grid cursor shift reduces on return")
        tiles(page).nth(0).click()                      # plant the cursor at the top-left
        toolbar(page).get_by_role("button", name="clear").click()
        cols = grid(page).evaluate("el => getComputedStyle(el).gridTemplateColumns.split(' ').length")
        page.keyboard.press("ArrowDown")                # → one row down, i.e. index `cols`
        page.keyboard.press("Enter")
        expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(cols))
        page.keyboard.press("Escape")
        print("  PASS: grid cursor down steps a row")
        tiles(page).nth(0).click()                               # start the run at the top-left
        for _ in range(KEY_N): page.keyboard.press("Shift+ArrowDown")   # drag it down past the fold
        last = tiles(page).nth(KEY_N - 1).bounding_box()
        bar = toolbar(page).bounding_box()                       # the run raised the toolbar
        assert last["y"] >= 0, f"tile head scrolled above the fold: {last}"
        assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
        toolbar(page).get_by_role("button", name="clear").click()
        print("  PASS: grid cursor reveals tile above toolbar")
        for _ in range(KEY_N): page.keyboard.press("ArrowUp")    # climb back out, plain moves
        first = tiles(page).nth(0).bounding_box()
        assert first["y"] >= 0, f"the cursor climbed above the fold and stayed there: {first}"
        print("  PASS: grid cursor reveals a tile climbed back to")
        page.keyboard.press("Control+a")
        expect(checks(page)).to_have_count(KEY_N)       # the whole wall at once
        print("  PASS: ctrl+a selects whole wall")
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})

Getting on screen

Three things stand between a tap on the icon and the wall: the app has to boot, handle being turned away when it isn’t authorized, and cope when the data won’t load.

It boots

First, prove the no-build Solid stack loads at all: the import map resolves Solid from esm.sh and the html template renders its titled shell — the first thing on screen, and the signal the tests wait on to know the app has booted. Solid’s web and html submodules must share one core instance (?external=solid-js in the map dedupes them, else reactivity is dead).

@testcase
def test_boots_into_titled_shell(page):
    """The Solid app loads from the import map and renders its title."""
    open_app(page)
    expect(heading(page)).to_have_text("Memories")
    print("  PASS: boots into titled shell")

import { render } from 'solid-js/web';
import html from 'solid-js/html';
import { createSignal, createResource, createMemo, createEffect, on, For, Index, Show, onMount, onCleanup } from 'solid-js';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { client, getAuthNeeded, subscribeAuth, queryFresh } from '../shared/gql.js';

The data layer is shared: memories’ reactivity is Solid’s createResource, but its transport is the shared urql client — the same one the frise uses — so there is one client and one auth gate across the apps. The triage wall reads through the shared queryFresh, which posts straight to the server, bypassing the client’s document cache and its in-flight dedup. That last part is what earns its keep: at the end of every edit memories refetch-es the wall, and a re-read fired the instant a mutation commits, going through the client, can be handed a query that went out just before the edit — a removed tag still lingering. Straight to the server, that can’t happen; the wall always shows the true current result, and on the LAN the round-trip is cheap. (createResource isn’t wired to the cache’s reactivity the way the frise’s hooks are, which is why memories re-reads the wall itself.) Label completion still reads network-only through the client — a vocab word added moments ago has to appear at once — so it carries the same in-flight-dedup risk the wall sheds; it hasn’t yet been worth a second queryFresh caller. A Solid signal mirrors the shared gate’s store into the banner.

const IPFS = '';   // https://ipfs.konubinix.eu/p/... is on the same origin as the app
const [authNeeded, setAuthNeeded] = createSignal(getAuthNeeded());
subscribeAuth(setAuthNeeded);
const PV_CTX = { additionalTypenames: ['Photovideo'] };
async function gql(query, variables, ctx){
    const r = await (/^\s*mutation\b/.test(query)
        ? client.mutation(query, variables, ctx)
        : client.query(query, variables, ctx ?? { requestPolicy: 'network-only' })).toPromise();
    if(r.error) throw new Error(r.error.message);
    return r.data;
}

render(App, document.getElementById('app'));
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});

What it boots into is the whole screen, because it asks for the whole screen: the app sets its viewport to cover the display rather than stop where the phone’s status bar and navigation bar begin, which is what lets a photo fill the glass. Those bars are still painted over the page, though, and they still take the touches that land on them. So the screen the app is handed is really three bands, and only the middle one is its own to put a control in.

So env(safe-area-inset-*), the phone’s own report of what the two strips are taking, is what every surface at an edge stands back by — starting with the body.

:root{ --bg:#1b1d2e; --fg:#e8e8f0; }
body{ background:var(--bg); color:var(--fg); font-family:system-ui,sans-serif; margin:0;
      padding: calc(12px + env(safe-area-inset-top))    calc(12px + env(safe-area-inset-right))
               calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left)); }
h1{ font-size:18px; margin:0 0 12px; }

When the device isn’t authorized

The test forces the failure deterministically: it routes /graphql to a 401 (overriding the fixture forward, since a page route wins over the context one) and asserts an alert that mentions authorization.

@testcase
def test_shows_auth_required_when_unauthorized(page):
    """A 401 from /graphql surfaces a clear 'authorization required' banner."""
    page.route("**/graphql", lambda route: route.fulfill(
        status=401, content_type="text/plain", body="Unauthorized"))
    open_app(page)
    expect(page.get_by_role("alert")).to_contain_text(re.compile("authoriz", re.I))
    print("  PASS: shows auth required when unauthorized")

The gql seam (It boots) already flips authNeeded on a 401; the banner is just a Show on it, dropped in at the top of the App so it’s the first thing seen. It does not try to authenticate — the app can’t mint a grant — it only tells the user a fresh access link is needed on this device.

<${Show} when=${() => authNeeded()}>
  <div class="authwall" role="alert">
    <strong>Authorization required.</strong>
    This device can't read your photos yet  open a fresh access link on it.
  </div>
<//>

.authwall{ background:#f9a826; color:#1b1d2e; padding:10px 14px; border-radius:8px;
           margin:0 0 12px; line-height:1.45; }
.authwall strong{ display:block; }

When the load fails

A 401 is the courteous failure — the gate knows the device only needs a fresh link, and says so. Every other failure hands back the wall empty for a reason the user cannot see: the server down, a 500 on a bad query, a 404 where the route should answer. An empty wall reads as “nothing matches here” — a lie when the truth is that the read never landed.

So a load that fails says so, in the same top-of-app banner the authorization notice uses.

page.route("**/graphql", lambda route: route.fulfill(
    status=500, content_type="text/plain", body="Server Error"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("load|reach|server", re.I))

And the wall holds back its own “nothing here” line, which would only compound the lie.

expect(page.get_by_text("No photos.", exact=True)).to_have_count(0)

The banner rides on the wall resource’s own error, and shows only when the failure isn’t the authorization one the gate already owns.

<${Show} when=${() => photos.error && !authNeeded()}>
  <div class="loadwall" role="alert">
    <strong>Couldn't load your photos.</strong>
    The server returned an error  try again in a moment.
  </div>
<//>

.loadwall{ background:#e5484d; color:#fff; padding:10px 14px; border-radius:8px;
           margin:0 0 12px; line-height:1.45; }
.loadwall strong{ display:block; }

In practice, the wall reads its docs straight from the resource, and that read re-throws the load error — with no error boundary above it the throw would freeze the render before the banner paints. So the wall skips its own read once the load has errored: the resource’s error is left to the banner alone, and the empty-state and the tiles fall to nothing.

The wall

The wall is the home surface — a grid of every doc the query matches, drawn only as far as the eye reaches, each tile carrying what the archive knows about it.

A grid of thumbnails

The point of the app: a wall of thumbnails from the archive. It fills from the search term — memories draws its own even ~2000 spread over the frise’s shared filter (see Drawing our own spread) — one lazily-loaded tile per photo, a placeholder while the fetch is in flight and a word when nothing matches.

So, on the whole archive — the all chip, since the default todo view may be empty once everything’s triaged — the wall fills with thumbnails.

@testcase
def test_shows_thumbnails(page):
    """The grid fills with thumbnail images from the archive."""
    open_app(page)
    chip(page, "all").click()                        # the whole archive — not the (possibly empty) todo default
    # clicking "all" keeps the previous (todo) tiles until the heavy full-archive fetch
    # resolves, so *wait* for the wall to populate rather than reading an instant count —
    # which otherwise catches a single leftover todo tile, or the wall mid-load, and flakes.
    wait_until(page, lambda: tiles(page).count() > 10, timeout=15000)
    wait_until(page, lambda: "/ipfs/" in (thumb_imgs(page).first.get_attribute("src") or ""))
    print("  PASS: shows thumbnails")

A tile earns its keep by letting you recognize the doc at a glance, so the one thing the thumbnail must never do is crop away what identifies it — a face at the frame’s edge, a landscape’s horizon. The tile shows the image whole, fitting it entire rather than filling the square with a cropped centre; the tile background shows through on whichever axis the image leaves spare.

@testcase
def test_thumbnail_shows_whole_image(page):
    """A grid thumbnail fits the whole image, never a cropped centre."""
    open_fixtures(page)
    img = thumb_imgs(page).first
    assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
        "thumbnail crops instead of fitting whole"
    print("  PASS: thumbnail shows whole image")

The window spans the whole dated archive (floored at 2007, like the frise); the server samples it down. Each tile is an img that holds its /ipfs/ thumbnail only while the thumbnail is worth holding — a wall this long cannot keep them all, and letting them go is the next chapter. The date rides along as alt=/=title, so a screen reader and the eye can name each tile the way you read it.

Memories doesn’t spell out the server filter itself — the parse-to-variables mapping and the GraphQL declarations and arguments that carry it are the same in every app, so they live in the shared data module beside photoVars and the PhotoCore field set. Memories splices those in, adding only its own: a states filter, a sampling cap (the shared SAMPLE_CAP), the pick that says which rows fill it, and the state=/=myrandom fields. GraphQL’s own page size first is set well above that cap, so it is the sampler that thins the wall, never the page limit.

const SEARCH = `query(${PHOTO_FILTER_DECL}, $states:[State!], $cap:Int!, $pick:String){
  photovideosSample(${PHOTO_FILTER_ARGS}, states:$states, cap:$cap, pick:$pick, first:8000){
    nodes{ ...PhotoCore state myrandom owner }
  }
  photovideosCount(${PHOTO_FILTER_ARGS}, states:$states)
}
${PHOTO_CORE}`;
const fetchPhotos = async (key, signal) => {
    const d = await queryFresh(SEARCH, { ...photoVars(key), states: key.state === 'all' ? null : [key.state],
                                         cap: key.cap ?? SAMPLE_CAP, pick: key.pick ?? null }, signal);
    const nodes = d?.photovideosSample?.nodes ?? [];
    const total = d?.photovideosCount ?? nodes.length;
    return { items: nodes, total, sampled: nodes.length < total, pick: key.pick ?? null };
};

The box is a tiny query language — bare words search the labels, while since:=/=until: bound a date range, type:=/=sort:=/=owner: filter, onthisday opens the anniversary view, month:=/=day: keep a recurring month or day across every year, year: pins a single calendar year, and first:=/=last:=/=sample: say how many to bring back. That language isn’t memories’ own: it’s how every photo app addresses the shared contract, so it lives once in the data layer’s DSL module and memories imports its parser together with the completion primitives the box will need.

import { parseQuery, segSpan, segAt, replaceSeg, dslSuggestions, DSL_KEYS } from '../shared/dsl.js';
import { SAMPLE_CAP, photoVars, UPDATE_PHOTO, PHOTO_CORE, PHOTO_FILTER_DECL, PHOTO_FILTER_ARGS, LABEL_COMPLETIONS } from '../shared/data.js';

The archive holds more than one person’s photos, so an owner:NAME token narrows the wall to a chosen owner (repeatable for several). It threads through to the very photovideos_match filter the frise owns, so the wall and its count agree on whose photos are in scope.

narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5)                  # everyone else's set aside
print("  PASS: filter by owner")

A month: token keeps a month across every year, threaded through the same photovideos_match filter as the rest, so the wall and its count agree on which months are in scope. Junes from three different years standing together is what tells it apart from a date range: a range wide enough to reach the last of them must swallow every March and December on the way, and this one holds none. The month can be named as readily as numbered, because march is how one says it.

narrow(page, "owner:konubinix", "month:6")
expect(tiles(page)).to_have_count(4)                  # every June, whatever the year
narrow(page, "owner:konubinix", "month:march")        # named, not numbered
expect(tiles(page)).to_have_count(1)
print("  PASS: filter by month")

A year: token pins one calendar year — sugar for a since:=/=until: pair on that year, threaded through the same filter. Laid over the Junes, it keeps the ones from the year named and drops the rest.

narrow(page, "owner:konubinix", "month:6", "year:2020")
expect(tiles(page)).to_have_count(2)                  # of those Junes, the one year
print("  PASS: filter by year")

Each token stands on its own, so a guess can be taken back as easily as it was made: drop the year and the Junes it was hiding come back, with the guesses either side of it — whose, and which month — still holding.

narrow(page, "owner:konubinix", "month:6")            # the year guess withdrawn
expect(tiles(page)).to_have_count(4)                  # its Junes returned, and no more
print("  PASS: a withdrawn guess costs only itself")

year: names a bounded period in a single token, but only ever a whole year; a month or a day still means spelling the since:2020-06; until:2020-06 pair out twice. date: is that pair in one token at any granularity — date:2020 a year, date:2020-06 a month, date:2020-06-15 a day — each snapped to the period’s edges, exactly its since:=/=until: form. So the year just taken back can come again in a smaller form: one token saying what the month and the year said together, and then carrying on where the pair of them runs out, down to the day itself.

narrow(page, "owner:konubinix", "date:2020-06")       # the month and the year, in one token
expect(tiles(page)).to_have_count(2)                  # the same two the pair had left
narrow(page, "owner:konubinix", "date:2020-06-15")    # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", "2020-06-15")   # the one asked for
print("  PASS: filter by date")

function App(){
    // the search box persists locally, so a reload (or the frame's reboot) restores
    // the same query — and thus the same frame show.
    const SEARCH_KEY = 'memories.search';
    const [search, setSearch] = createSignal(localStorage.getItem(SEARCH_KEY) || '');
    createEffect(() => localStorage.setItem(SEARCH_KEY, search()));
    const [searchFocus, setSearchFocus] = createSignal(false);
    const [caret, setCaret] = createSignal(0);        // cursor position, so completion follows it
    // keyboard-driving the suggestion list (only one box is focused at a time, so a single
    // highlight is enough): the focused box's Suggest reports its items here and reads the
    // active index back. ↓ enters at the top and ↑ at the bottom — both cycle through the
    // -1 "none" slot so either key reaches the list and either escapes it. Enter applies the
    // highlighted word (or, with nothing highlighted, runs the box's own commit).
    const [sugItems, setSugItems] = createSignal([]);
    const [sugActive, setSugActive] = createSignal(-1);
    const [sugLoading, setSugLoading] = createSignal(false);   // the focused box's list is fetching
    const reportSug = list => { setSugItems(list); setSugActive(-1); };   // a new list clears the highlight
    const sugNav = (e, commit) => {
        const n = sugItems().length;
        if(e.key === 'ArrowDown' && n){ e.preventDefault(); setSugActive(i => i >= n - 1 ? -1 : i + 1); }
        else if(e.key === 'ArrowUp' && n){ e.preventDefault(); setSugActive(i => i < 0 ? n - 1 : i - 1); }
        else if(e.key === 'Enter'){ e.preventDefault();
            commit(sugActive() >= 0 ? sugItems()[sugActive()] : null); setSugActive(-1); }
    };
    const pickSearch = w => { const [s, e] = segSpan(search(), caret());
        const lead = (search().slice(s, e).match(/^\s*/) || [''])[0];
        // still drillable → hold the box on this token
        const drilling = w.endsWith(':') || dslSuggestions(w).some(v => v.length > w.length);
        const tail = drilling ? '' : '; ';
        const next = search().slice(0, s) + lead + w + tail + search().slice(e);
        setSearch(next); setCaret(s + lead.length + w.length + tail.length); setSearchFocus(true); };
    // the chip choice persists; the first-ever visit defaults to the triage view (todo)
    const STATE_KEY = 'memories.state';
    const [stateFilter, setStateFilter] = createSignal(localStorage.getItem(STATE_KEY) || 'todo');
    createEffect(() => localStorage.setItem(STATE_KEY, stateFilter()));
    // the last label applied (lightbox or batch), offered for one-tap reuse on the next
    // doc so labelling a run of photos doesn't mean retyping the same word each time.
    const [lastLabel, setLastLabel] = createSignal('');
    const SIZE_KEY = 'memories.thumbSize';
    const [thumbSize, setThumbSize] = createSignal(+localStorage.getItem(SIZE_KEY) || 96);
    createEffect(() => localStorage.setItem(SIZE_KEY, thumbSize()));
    const bumpSize = d => setThumbSize(s => Math.max(80, Math.min(320, s + d * 40)));
    let zoomAt = 0;
    const onGridWheel = e => {
        if(!e.ctrlKey) return;
        e.preventDefault();
        const now = performance.now();
        if(now - zoomAt < 120) return;
        zoomAt = now; bumpSize(e.deltaY < 0 ? 1 : -1);
    };
    const REFINE_CELL = 300;                    // drawn px
    const [cellPx, setCellPx] = createSignal(0);
    const measureCell = () => gridEl && setCellPx(parseFloat(getComputedStyle(gridEl).gridTemplateColumns) || 0);
    const watchCell = el => { const ro = new ResizeObserver(measureCell);
                              ro.observe(el); onCleanup(() => ro.disconnect()); };
    createEffect(() => { thumbSize(); measureCell(); });   // the slider moves the columns, not the grid's box
    const [thumbsInFlight, setThumbsInFlight] = createSignal(0);
    const refining = () => cellPx() > REFINE_CELL && thumbsInFlight() === 0;
    // the fetch key excludes sort (a client-side reorder shouldn't re-query); compared
    // as a string so a sort-only change doesn't refetch the same sample.
    // the wall reads a COMMITTED query, not the live text — see the commit-search prose. Typing
    // updates the box + its completions; the wall re-reads only on commit (Enter / search button)
    // or a state chip. Seeded from the persisted search so the wall loads on first paint.
    const [committed, setCommitted] = createSignal(search());
    const commit = () => setCommitted(search());
    const dirty = () => search() !== committed();   // typed but not yet run → the wall is stale (see prose)
    const queryStr = createMemo(() => { const p = parseQuery(committed());
        return JSON.stringify({ search: p.search, since: p.since, until: p.until,
                                kinds: p.kinds, owners: p.owners, aday: p.aday, awin: p.awin,
                                month: p.month, mwin: p.mwin, events: p.events, eventsX: p.eventsX,
                                cap: p.cap, pick: p.pick, state: stateFilter() }); });
    const [mutating, setMutating] = createSignal(0);   // in-flight edit count → aria-busy
    // FREEZE: advance to the committed query only when idle — see the commit-search prose.
    const target = createMemo(() => ({ key: queryStr(), label: committed().trim() }));
    const [running, setRunning] = createSignal(target());
    let readCtl = null;                                // the in-flight read's AbortController, for the escape hatch
    const [photos, { refetch }] = createResource(() => JSON.parse(running().key),
        async (key, info) => { readCtl = new AbortController();
            try { return await fetchPhotos(key, readCtl.signal); }
            catch(e){ if(e && e.name === 'AbortError') return info.value ?? { items: [], total: 0, sampled: false }; throw e; } });
    createEffect(() => { if(!photos.loading && target().key !== running().key) setRunning(target()); });
    // ESCAPE HATCH: flush to lastSettled + abort the in-flight read — see the cancel-flow prose.
    const [lastSettled, setLastSettled] = createSignal(null);
    createEffect(() => { if(!photos.loading && !photos.error) setLastSettled(running()); });
    const cancelRead = () => {
        if(!photos.loading) return;
        const ctl = readCtl, s = lastSettled();
        if(s && s.key !== running().key){ setSearch(s.label); setCommitted(s.label); setRunning(s); }
        else { setSearch(''); setCommitted(''); setRunning(target()); }   // boot / no prior → everything
        ctl?.abort();
    };
    onMount(() => {   // Esc bails out of a frozen read (Back bails too, via the exit guard)
        const onEsc = e => { if(e.key === 'Escape' && photos.loading && !opened() && !frame()){ e.preventDefault(); cancelRead(); } };
        window.addEventListener('keydown', onEsc);
        onCleanup(() => window.removeEventListener('keydown', onEsc));
    });
    onMount(() => {   // suppress the OS context menu on our surfaces; text fields keep it (paste/select)
        const noMenu = e => { if(!e.target.closest('input, textarea, [contenteditable]')) e.preventDefault(); };
        window.addEventListener('contextmenu', noMenu);
        onCleanup(() => window.removeEventListener('contextmenu', noMenu));
    });
    const sortKey = createMemo(() => parseQuery(search()).sort);
    const total = () => photos()?.total ?? 0;          // how many actually match
    const items = createMemo(() => { const a = (photos.error ? undefined : photos())?.items ?? [];   // shown docs, in wall order
        return sortKey() === 'random' ? [...a].sort((x, y) => (x.myrandom ?? 0) - (y.myrandom ?? 0)) : a; });
    const eventWindow = createMemo(() => { const k = JSON.parse(running().key);
                                           return JSON.stringify({ since: k.since, until: k.until }); });
    const [wallEvents] = createResource(eventWindow, w => fetchWindowEvents(JSON.parse(w)));
    const docEvents = photo => { const evs = wallEvents(); if(!evs || !photo.owner) return [];
        const t = new Date(photo.date).getTime();
        return evs.filter(e => e.owner === photo.owner
            && new Date(e.starttime).getTime() <= t && t <= new Date(e.endtime).getTime()); };
    const EVENT_HUES = ['#8ecae6', '#ffb703', '#90be6d', '#ff8fab', '#bdb2ff', '#ffd6a5', '#a0c4ff', '#caffbf'];
    const eventKey = e => e.summary + '|' + e.starttime + '|' + e.owner;
    const eventColour = createMemo(() => { const m = new Map(); let i = 0;
        for(const p of items()) for(const e of docEvents(p))
            if(!m.has(eventKey(e))) m.set(eventKey(e), EVENT_HUES[i++ % EVENT_HUES.length]);
        return m; });
    const STATES = ['todo', 'next', 'done', 'delete'];
    const SEL_KEY = 'memories.selected';
    const [selected, setSelected] = createSignal(new Set(JSON.parse(localStorage.getItem(SEL_KEY) || '[]')));
    createEffect(() => localStorage.setItem(SEL_KEY, JSON.stringify([...selected()])));
    const [labelText, setLabelText] = createSignal('');
    const [labelFocus, setLabelFocus] = createSignal(false);
    const [anchor, setAnchor] = createSignal(null);
    const [rangeMode, setRangeMode] = createSignal(false);
    const [allMatching, setAllMatching] = createSignal(false);
    const canRange = () => !photos()?.sampled || !!photos()?.pick;
    createEffect(() => { if(!canRange()) setRangeMode(false); });
    const isSel = cid => selected().has(cid);
    const selCount = () => selected().size;
    const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
    const toggle = cid => { setAllMatching(false); setSelected(s => {
        const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
    }); };
    const shownCids = () => items().map(p => p.cid);
    const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
    const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));
    const extendTo = cid => {
        const list = items().map(p => p.cid);
        const a = list.indexOf(anchor()), b = list.indexOf(cid);
        if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
        const [lo, hi] = a < b ? [a, b] : [b, a];
        setSelected(s => { const n = new Set(s);
            for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
    };
    const extendRun = cid => {
        if(!canRange()) return;
        const list = items();
        const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
        if(a < 0 || ni < 0) return;
        if(!extending){ extending = true; rangeBase = new Set(selected()); }
        const [lo, hi] = a < ni ? [a, ni] : [ni, a];
        setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
    };
    const onTileClick = (e, cid) => {
        setCursor(cid);
        if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
            extendTo(cid); setRangeMode(false); return;
        }
        toggle(cid); setAnchor(cid);
    };
    const armRange = cid => {
        if(!canRange()) return;
        setSelected(s => { const n = new Set(s); n.add(cid); return n; });
        setAnchor(cid); setRangeMode(true); extending = false;
    };
    const tileCidAt = (x, y) => {
        const tile = document.elementFromPoint(x, y)?.closest('.tile');
        if(!tile || !gridEl) return null;
        const i = [...gridEl.children].indexOf(tile);
        return i < 0 ? null : items()[i]?.cid;
    };
    let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false, lpPos = null, lpRaf = 0;
    const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
    const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
    const EDGE = 48, SCROLL_STEP = 14;
    const dragTick = () => {
        lpRaf = 0;
        if(!lpFired || !lpPos) return;
        const bar = document.querySelector('.toolbar');
        const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
        const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
        if(!dy) return;                                    // finger left the margin → stop the loop
        scrollBy(0, dy);
        dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1)));   // hit-test clear of the bar
        lpRaf = requestAnimationFrame(dragTick);
    };
    const onTileDown = (e, photo) => {
        lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
        lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
        lpTimer = setTimeout(() => { lpFired = true; lpCancel();
            try { lpEl.setPointerCapture(lpId); } catch(_){}
            armRange(photo.cid); }, 500);
    };
    const onTileMove = e => {
        if(lpFired){
            lpPos = { x: e.clientX, y: e.clientY };
            dragExtendAt(e.clientX, e.clientY);
            if(!lpRaf) dragTick();
            return;
        }
        if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
    };
    const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
    const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; } dragStop(); lpCancel(); };
    const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
    onMount(() => gridEl?.addEventListener('touchmove',
        e => { if(lpFired) e.preventDefault(); }, { passive: false }));
    async function patchSelected(patchFor){
        setMutating(m => m + 1);
        try {
            const byCid = new Map(items().map(p => [p.cid, p]));
            for(const cid of selected()){
                const patch = patchFor(byCid.get(cid));
                if(patch) await gql(UPDATE_PHOTO, { cid, patch });
            }
            clearSel();
            await refetch();
        } finally { setMutating(m => m - 1); }
    }
    const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
    const splitLabels = p => splitWords(p.labels);
    const filterVars = () => ({ ...photoVars(parseQuery(search())),
                                states: stateFilter() === 'all' ? null : [stateFilter()] });
    const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
      photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
    async function applyBulk(kind, v){
        setMutating(m => m + 1);
        try { await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); clearSel(); await refetch(); }
        finally { setMutating(m => m - 1); }
    }
    const addLabel = async () => {
        const words = splitWords(labelText()); if(!words.length) return;
        setLastLabel(words[words.length - 1]);
        if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
        else await patchSelected(p => { const cur = splitLabels(p);
            for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
        setLabelText('');
    };
    const removeLabel = async () => {
        const words = splitWords(labelText()); if(!words.length) return;
        if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
        else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
        setLabelText('');
    };
    const setStateFor = st => allMatching() ? applyBulk('SetState', st)
                                            : patchSelected(() => ({ state: st }));
    const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
                       'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
                       'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
                       'video/x-msvideo': 'avi' };
    const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
    function downloadSelection(res){
        const byCid = new Map(items().map(p => [p.cid, p]));
        for(const cid of selected()){
            const p = byCid.get(cid); if(!p) continue;
            const path = res === 'web' ? p.webCid : p.cid;
            if(!path) continue;
            const isVid = (p.mimetype || '').startsWith('video');
            const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
            const raw = p.filename || p.cid.split('/').pop() || 'download';
            const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
            const a = document.createElement('a');
            a.href = IPFS + path;
            a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
            document.body.appendChild(a); a.click(); a.remove();
        }
    }
    const [moveText, setMoveText] = createSignal('');
    const [moveFocus, setMoveFocus] = createSignal(false);
    const [moveTarget, setMoveTarget] = createSignal(null);   // the armed occasion, or null → nothing to apply
    const [moveEvents] = createResource(moveFocus,
        f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
    const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
        return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
    const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
        return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
    const eventDist = (e, mean) => { if(mean == null) return 0;
        const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
        return mean < s ? s - mean : mean > en ? mean - en : 0; };
    const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
    const moveCandidates = () => { const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
        return (moveEvents() || []).filter(e => owners.has(e.owner))
            .filter(e => !q || (e.summary || '').toLowerCase().includes(q))
            .sort((a, b) => eventDist(a, mean) - eventDist(b, mean) || new Date(a.starttime) - new Date(b.starttime))
            .slice(0, 8); };
    const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
    const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
        await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
        setMoveText(''); setMoveTarget(null); };
    createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
    createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
    const [datingSel, setDatingSel] = createSignal(false);   // is the picker open?
    const [selDate, setSelDate] = createSignal('');          // its datetime-local value
    const openSelDate = () => { const m = selMean();
        setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
        setDatingSel(true); };
    const stampSelDate = async () => { const v = selDate(); if(!v) return;
        await patchSelected(() => ({ date: new Date(v).toISOString() }));
        setDatingSel(false); };
    const [opened, setOpened] = createSignal(null);
    const [lbText, setLbText] = createSignal('');
    const [lbFocus, setLbFocus] = createSignal(false);
    const [editingDate, setEditingDate] = createSignal(false);
    const isVideo = p => (p?.mimetype || '').startsWith('video');
    const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
    const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
    const labelsOf = p => splitWords(p?.labels);
    const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
    const closePhoto = () => { setOpened(null); setLbText(''); };
    const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
    async function applyLabels(labels){
        const cid = opened().cid;
        setOpened({ ...opened(), labels });
        await gql(UPDATE_PHOTO, { cid, patch: { labels } });
        await refetch();
    }
    const lbAdd = input => {
        const words = splitWords(input); if(!words.length) return;
        setLastLabel(words[words.length - 1]);
        const cur = labelsOf(opened());
        for(const w of words) if(!cur.includes(w)) cur.push(w);
        setLbText('');
        return applyLabels(cur.join('; '));
    };
    const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
    const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
        setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
    async function lbPatch(patch){
        const list = items(), cur = opened(); if(!cur) return;
        const i = list.findIndex(p => p.cid === cur.cid);
        const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
        await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
        await refetch();
        requestAnimationFrame(() => {
            const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
            const stay = l2.find(p => p.cid === cur.cid);
            setOpened(stay || (nextCid && l2.find(p => p.cid === nextCid)) || l2[0]);
        });
    }
    const lbSetState = st => lbPatch({ state: st });
    const step = delta => {
        const list = items(); if(!list.length || !opened()) return;
        const i = list.findIndex(p => p.cid === opened().cid);
        setOpened(list[((i < 0 ? 0 : i) + delta + list.length) % list.length]); setLbText('');
    };
    let lbVideo = null;
    const seekOrStep = dir => {
        const v = lbVideo;
        if(v && isVideo(opened()) && !v.paused &&
           (dir > 0 ? v.currentTime < v.duration - 0.25 : v.currentTime > 0.25))
            v.currentTime = Math.max(0, Math.min(v.duration, v.currentTime + dir * 5));
        else step(dir);
    };
    let wheelAt = 0;
    const onWheel = e => {
        if(!e.shiftKey) return;
        const d = e.deltaY || e.deltaX;
        const now = performance.now();
        if(Math.abs(d) < 1 || now - wheelAt < 200) return;
        wheelAt = now; step(d > 0 ? 1 : -1);
    };
    onMount(() => {
        const onKey = e => {
            if(!opened()) return;
            const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
            if(e.key === 'Escape') dismissPhoto();
            else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); seekOrStep(1); }
            else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); seekOrStep(-1); }
            else if(!editing && e.key === 'Delete'){ e.preventDefault(); lbDelete(e.shiftKey); }
            else if(!editing && e.key === 'Enter'){ e.preventDefault(); toggle(opened().cid); }
            else if(!editing && e.key === ' ' && isVideo(opened()) && lbVideo){
                e.preventDefault(); const v = lbVideo;
                if(v.currentTime >= v.duration - 0.25){ v.currentTime = 0; v.play(); }
                else if(v.paused) v.play(); else v.pause();
            }
        };
        window.addEventListener('keydown', onKey);
        onCleanup(() => window.removeEventListener('keydown', onKey));
    });
    const [frame, setFrame] = createSignal(false);
    const [playing, setPlaying] = createSignal(true);
    const [frameUI, setFrameUI] = createSignal(false);     // controls revealed on tap
    const [frameLabel, setFrameLabel] = createSignal('');
    const [frameLabelFocus, setFrameLabelFocus] = createSignal(false);
    const [frameCenterCid, setFrameCenterCid] = createSignal(null);   // the settled slide
    const frameDoc = () => items().find(p => p.cid === frameCenterCid());   // the centred doc
    const [frameEditingDate, setFrameEditingDate] = createSignal(false);
    const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);
    const [frameDir, setFrameDir] = createSignal(1);                  // last travel direction (+1 forward)
    const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
    const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
    const FRAME_IDLE_MS = Number(new URLSearchParams(location.search).get('idleresume')) || 60000;
    const [pokes, setPokes] = createSignal(0);
    const nudge = () => setPokes(n => n + 1);
    const [interacting, setInteracting] = createSignal(false);
    const [zoomed, setZoomed] = createSignal(false);
    const frameSlides = () => { const o = items();
        return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
    let stripEl, wakeLock = null;
    const slideW = () => stripEl && stripEl.children.length ? stripEl.scrollWidth / stripEl.children.length : (stripEl?.clientWidth || 1);
    const slideAt = () => Math.round((stripEl?.scrollLeft || 0) / slideW());      // nearest slide index
    const slideLeft = i => { const k = stripEl && stripEl.children[i]; return k ? k.offsetLeft : i * slideW(); };
    const frameGo = delta => { if(!stripEl) return;
        stripEl.scrollTo({ left: slideLeft(slideAt() + delta), behavior: 'smooth' }); };
    const FRAME_CID_KEY = 'memories.frame.cid';
    let snapT;
    const onFrameScroll = () => {
        if(stripEl){ const i = slideAt(), c = frameCenterIdx();
            if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
        clearTimeout(snapT); snapT = setTimeout(() => {
        if(!stripEl) return;
        if(zoomed()) return;
        const n = items().length;
        let i = slideAt();
        if(i <= 0){ stripEl.scrollLeft = slideLeft(n); i = n; }       // leading clone(last) → real last
        else if(i >= n + 1){ stripEl.scrollLeft = slideLeft(1); i = 1; }  // trailing clone(first) → real first
        const target = slideLeft(i);
        if(Math.abs(stripEl.scrollLeft - target) > 1) stripEl.scrollTo({ left: target, behavior: 'smooth' });
        const doc = items()[i - 1];
        setFrameCenterIdx(i);
        if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
    }, 150); };
    const FRAME_ON_KEY = 'memories.frame.on';
    async function enterFrame(){
        if(!items().length) return;
        setFrame(true); setPlaying(true); setFrameUI(false);
        localStorage.setItem(FRAME_ON_KEY, '1');
        history.pushState({ frame: true }, '');
        try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
    }
    function closeFrame(){
        setFrame(false);
        localStorage.setItem(FRAME_ON_KEY, '0');
        try { wakeLock?.release(); } catch(e) {} wakeLock = null;
    }
    const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();
    const frameIndex = () => Math.max(0, Math.min(items().length - 1, slideAt() - 1));
    async function frameEdit(patchFor){
        const list = items(); if(!list.length || !stripEl) return;
        const i = frameIndex(), cur = list[i], prevCid = i > 0 ? list[i - 1].cid : null;
        await gql(UPDATE_PHOTO, { cid: cur.cid, patch: patchFor(cur) });
        setFrameLabel('');
        await refetch();
        requestAnimationFrame(() => {
            const l2 = items(); if(!l2.length) { exitFrame(); return; }
            const stay = l2.findIndex(p => p.cid === cur.cid);
            let t = stay >= 0 ? stay : (prevCid ? l2.findIndex(p => p.cid === prevCid) : 0);
            stripEl.scrollLeft = slideLeft(Math.max(0, t) + 1);
        });
    }
    const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
        return frameEdit(() => ({ state: st })); };
    const frameAddWord = input => { const words = splitWords(input); if(!words.length) return;
        return frameEdit(p => { const cur = splitLabels(p);
            for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; }); };
    const frameAddLabel = () => frameAddWord(frameLabel());
    const frameDropLabel = () => { const words = splitWords(frameLabel()); if(!words.length) return;
        return frameEdit(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') })); };
    let frameCancelDate = false;
    const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false; setFrameEditingDate(false);
        if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };
    const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1';
    let autoEntered = false;
    createEffect(() => {
        if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
    });
    createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
        const saved = localStorage.getItem(FRAME_CID_KEY);
        const r = saved ? items().findIndex(p => p.cid === saved) : -1;
        stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1);   // +1 for the leading clone
        setFrameCenterIdx(r >= 0 ? r + 1 : 1);                             // seed the centre before any scroll
        setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
    }); });
    createEffect(on([items, frame], () => {
        if(!frame() || !stripEl) return;
        const io = new IntersectionObserver(
            es => es.forEach(e => { if(e.intersectionRatio < 0.5) e.target.pause(); }),
            { root: stripEl, threshold: 0.5 });
        requestAnimationFrame(() => stripEl.querySelectorAll('video').forEach(v => io.observe(v)));
        onCleanup(() => io.disconnect());
    }));
    createEffect(() => { if(!frame()) return; const vv = window.visualViewport; if(!vv) return;
        const read = () => setZoomed(vv.scale > 1);
        read(); vv.addEventListener('resize', read); vv.addEventListener('scroll', read);
        onCleanup(() => { vv.removeEventListener('resize', read); vv.removeEventListener('scroll', read); }); });
    createEffect(() => {
        if(!frame() || !playing() || zoomed() || interacting()) return;
        const id = setInterval(() => {
            if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
            frameGo(1);
        }, intervalMs());
        onCleanup(() => clearInterval(id));
    });
    createEffect(() => { if(!pokes()) return;
        setInteracting(true);
        const id = setTimeout(() => setInteracting(false), FRAME_IDLE_MS); onCleanup(() => clearTimeout(id)); });
    createEffect(() => { if(!frame() || !stripEl) return;
        stripEl.addEventListener('pointerdown', nudge);
        onCleanup(() => stripEl.removeEventListener('pointerdown', nudge)); });
    onMount(() => {
        const onKey = e => {
            if(!frame()) return;
            const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
            if(e.key === 'Escape') exitFrame();
            else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); frameGo(1); }
            else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); frameGo(-1); }
        };
        const onVis = async () => {
            if(frame() && document.visibilityState === 'visible' && !wakeLock)
                try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
        };
        const onPop = () => {
            if(frame()) closeFrame();
            const st = history.state || {};
            if(opened() && !st.lb) closePhoto();
            if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }
        };
        window.addEventListener('keydown', onKey);
        window.addEventListener('popstate', onPop);
        document.addEventListener('visibilitychange', onVis);
        onCleanup(() => { window.removeEventListener('keydown', onKey);
                          window.removeEventListener('popstate', onPop);
                          document.removeEventListener('visibilitychange', onVis); });
    });
    const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
    createEffect(() => { if(!frame() || !zoomed()) return; pokes();   // any touch re-arms the countdown
        const id = setTimeout(() => { const u = new URL(location.href);
            u.searchParams.set('z', String(Date.now()));   // an address the browser hasn't seen zoomed → it lands at 1:1
            location.href = u.href; }, ZOOM_IDLE_MS);
        onCleanup(() => clearTimeout(id)); });
    const WEB_BEHIND = 1, WEB_AHEAD = 3, KEEP_BEHIND = 6, KEEP_AHEAD = 14;
    const inReach = (k, behind, ahead) => { const t = (k - frameCenterIdx()) * frameDir();  // signed steps, in the way you're heading
        return t >= -behind && t <= ahead; };
    const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD)              // base layer:
        ? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK;                   // thumbnail kept in the window, blank past it
    const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid)   // overlay:
        ? IPFS + p.webCid : '';                                                  // full-res leads the way you're going
    const FrameSlide = (slide, k) => {
        const thumbSrc = createMemo(() => thumbBand(slide(), k));   // base layer's source
        const webSrc = createMemo(() => webBand(slide(), k));       // overlay's source ('' when far from centre)
        const [thumbOn, setThumbOn] = createSignal(false);          // the base thumbnail has painted
        const [webOn, setWebOn] = createSignal(false);              // the full-res overlay has painted
        createEffect(() => { thumbSrc(); setThumbOn(false); });     // each layer re-arms its mark on its own source change
        createEffect(() => { webSrc(); setWebOn(false); });
        return html`
        <div class="slide" role="listitem">
          <${Show} when=${() => hasMedia(slide())}
                   fallback=${html`<div class="slide-media noimg">
                     <span class="ph">${() => isVideo(slide()) ? '🎬' : '🖼'}</span></div>`}>
            <${Show} when=${() => isVideo(slide())}
                     fallback=${html`<div class="slide-pic">
                       <${Show} when=${() => !thumbOn()}>
                         <span class="ph load-ph" aria-label="loading">🖼</span><//>
                       <img class="slide-media" loading="lazy"
                            src=${thumbSrc} onLoad=${() => setThumbOn(true)} />
                       <${Show} when=${() => webSrc()}>
                         <img class="slide-media web" classList=${() => ({ shown: webOn() })}
                              loading="lazy" src=${webSrc} onLoad=${() => setWebOn(true)} />
                         <${Show} when=${() => thumbOn() && !webOn()}>
                           <span class="upgrading" aria-label="fetching full resolution"></span><//>
                       <//></div>`}>
              <video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
            <//>
          <//>
        </div>`;
    };
    const onFrameTap = e => {
        const w = window.innerWidth || 1;
        if(e.clientX < w / 3) frameGo(-1);
        else if(e.clientX > w * 2 / 3) frameGo(1);
        else setFrameUI(v => !v);
    };
    const TAP_SLOP = 10;
    let tapFrom = null;                                   // where a lone finger went down, while it could still be a tap
    const tapPtrs = new Set();
    const onTapDown = e => { tapPtrs.add(e.pointerId);
        tapFrom = tapPtrs.size === 1 ? { x: e.clientX, y: e.clientY } : null; };
    const onTapMove = e => { if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null; };
    const onTapUp = e => { tapPtrs.delete(e.pointerId);
        if(e.type === 'pointerup' && tapFrom) onFrameTap(e);
        if(!tapPtrs.size) tapFrom = null; };
    createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
        const on = (t, h) => el.addEventListener(t, h), off = (t, h) => el.removeEventListener(t, h);
        on('pointerdown', onTapDown); on('pointermove', onTapMove); on('pointerup', onTapUp); on('pointercancel', onTapUp);
        onCleanup(() => { off('pointerdown', onTapDown); off('pointermove', onTapMove);
            off('pointerup', onTapUp); off('pointercancel', onTapUp); }); });
    const FRAME_UI_IDLE_MS = Number(new URLSearchParams(location.search).get('uiidle')) || 20000;
    createEffect(() => { if(!frameUI()) return; pokes();
        const id = setTimeout(() => setFrameUI(false), FRAME_UI_IDLE_MS); onCleanup(() => clearTimeout(id)); });
    const frameParam = new URLSearchParams(location.search);
    const SYNC_URL = frameParam.get('yws') || location.origin.replace(/^http/, 'ws') + '/ywebsocket';
    const SYNC_ROOM = frameParam.get('room') || 'memories-nowshowing';
    const [roomLink, setRoomLink] = createSignal('idle');
    let showingRoom = null;
    const nowShowing = () => {
        if(!showingRoom){
            const shared = new Y.Doc();
            const provider = new WebsocketProvider(SYNC_URL, SYNC_ROOM, shared);
            setRoomLink('connecting');
            provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
            showingRoom = shared.getMap('showing');
        }
        return showingRoom;
    };
    createEffect(() => {
        if(!frame()) return;
        pokes();
        const d = frameDoc(); if(!d) return;
        nowShowing().set('doc', { cid: d.cid, webCid: d.webCid, mimetype: d.mimetype, date: d.date });
    });
    const frameFromHere = () => { const cur = opened(); if(!cur) return;
        localStorage.setItem(FRAME_CID_KEY, cur.cid);    // the slide the frame will open on
        closePhoto();                                    // hide the modal but leave its history entry, so Back returns to it
        enterFrame(); };
    const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };
    const toLocalInput = iso => { if(!iso) return '';
        const d = new Date(iso), p = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
    let cancelEdit = false;
    const commitDate = v => { const skip = cancelEdit; cancelEdit = false; setEditingDate(false);
        if(!skip && v) lbPatch({ date: new Date(v).toISOString() }); };
    onMount(() => {
        const onKey = e => {
            if(e.key !== 'd' || !opened()) return;                     // only while a doc is open
            if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;     // a field already owns the key
            e.preventDefault(); setEditingDate(true);
        };
        window.addEventListener('keydown', onKey);
        onCleanup(() => window.removeEventListener('keydown', onKey));
    });
    const EVENTS_FOR_DOC = `query($d:Datetime!,$o:OwnerType!){ eventsAt(d:$d, o:$o){ nodes{ summary starttime endtime } } }`;
    const fetchDocEvents = async o => (await gql(EVENTS_FOR_DOC, { d: o.date, o: o.owner }))?.eventsAt?.nodes ?? [];
    const dayBased = (s, en) => s.getUTCHours() === 0 && s.getUTCMinutes() === 0 && s.getUTCSeconds() === 0
                             && en.getUTCHours() === 23 && en.getUTCMinutes() === 59 && en.getUTCSeconds() === 59;
    const hhmm = t => t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
    const eventWhen = e => {
        const s = new Date(e.starttime), en = new Date(e.endtime);
        const sd = s.toLocaleDateString('fr-FR'), ed = en.toLocaleDateString('fr-FR');
        if (dayBased(s, en)) return sd === ed ? sd : `${sd}${ed}`;
        return sd === ed ? `${sd} ${hhmm(s)}${hhmm(en)}` : `${sd} ${hhmm(s)}${ed} ${hhmm(en)}`;
    };
    // run the occasion's own event: search — the pill's jump into it
    const searchEvent = summary => { setSearch('event:' + summary); commit(); };
    const [lbEvents] = createResource(
        () => { const o = opened(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
    const [frameEvents] = createResource(
        () => { const o = frameDoc(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
    const CURSOR_KEY = 'memories.cursor';
    const [cursor, setCursor] = createSignal(localStorage.getItem(CURSOR_KEY));
    createEffect(() => { const c = cursor(); c ? localStorage.setItem(CURSOR_KEY, c) : localStorage.removeItem(CURSOR_KEY); });
    let gridEl, rangeBase = new Set(), extending = false;
    const gridCols = () => gridEl ? getComputedStyle(gridEl).gridTemplateColumns.split(' ').length : 1;
    const moveCursor = (delta, extend) => {
        const list = items(); if(!list.length) return;
        const at = list.findIndex(p => p.cid === cursor());
        const ni = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + delta));
        setCursor(list[ni].cid);
        if(extend && anchor() !== null){ extendRun(list[ni].cid); }   // grow-or-shrink the run to the cursor
        else { extending = false; setAnchor(list[ni].cid); }   // a plain move re-anchors and ends the run
        requestAnimationFrame(scrollCursorIntoView);
    };
    const scrollCursorIntoView = () => {
        const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
        const r = tile.getBoundingClientRect();
        const bar = document.querySelector('.toolbar');
        const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
        if(r.top < 0) scrollBy(0, r.top);                          // head above the fold → pull it down
        else if(r.bottom > floor) scrollBy(0, r.bottom - floor);   // foot under the bar → push it up
    };
    createEffect(() => {
        const shown = selCount() > 0;   // tracked synchronously; the toolbar mounts on this turn
        requestAnimationFrame(() => {
            const bar = shown && document.querySelector('.toolbar');
            document.body.style.paddingBottom = bar ? bar.getBoundingClientRect().height + 'px' : '';
        });
    });
    onMount(() => {
        const onKey = e => {
            if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
            const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
            if(e.key === 'ArrowRight') step(1);
            else if(e.key === 'ArrowLeft') step(-1);
            else if(e.key === 'ArrowDown') step(gridCols());
            else if(e.key === 'ArrowUp') step(-gridCols());
            else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
            else if(e.key === 'Enter' && cursor()){ e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
            else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){ e.preventDefault(); setSelected(new Set(shownCids())); }
        };
        window.addEventListener('keydown', onKey);
        onCleanup(() => window.removeEventListener('keydown', onKey));
    });
    history.scrollRestoration = 'manual';   // Back must not undo the follow (see prose)
    createEffect(() => { const c = opened()?.cid || (frame() ? frameCenterCid() : null); if(!c) return;
        setCursor(c);
        gridEl?.children[items().findIndex(p => p.cid === c)]?.scrollIntoView({ block: 'nearest' }); });
    const [marquee, setMarquee] = createSignal(null);   // {x0,y0,x1,y1} in client coords, or null
    let marqueeFrom = null;
    const marqueeRect = m => ({ l: Math.min(m.x0, m.x1), r: Math.max(m.x0, m.x1),
                                t: Math.min(m.y0, m.y1), b: Math.max(m.y0, m.y1) });
    const marqueeSelect = () => {
        const m = marquee(); if(!m) return;
        const r = marqueeRect(m), list = items(), kids = gridEl.children, next = new Set(selected());
        for(let i = 0; i < kids.length && i < list.length; i++){
            const b = kids[i].getBoundingClientRect();
            if(b.left < r.r && b.right > r.l && b.top < r.b && b.bottom > r.t) next.add(list[i].cid);
        }
        setAllMatching(false); setSelected(next);
    };
    const onGridDown = e => {
        if(e.pointerType === 'touch' || e.button !== 0 || e.target !== gridEl || !canRange()) return;
        marqueeFrom = { x: e.clientX, y: e.clientY };
        setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
        gridEl.setPointerCapture?.(e.pointerId);
    };
    const onGridMove = e => {
        if(!marqueeFrom) return;
        setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
        marqueeSelect();
    };
    const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };
    onMount(() => {
        const onKey = e => {
            if((e.key !== 'l' && e.key !== '.') || frame()) return;    // the frame has its own bar
            if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;    // a field already owns the key
            const box = opened() ? document.querySelector('.lb .batch-label')
                      : selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
            if(!box) return;
            e.preventDefault();
            if(e.key === '.' && lastLabel())                           // '.' re-drops the label applied last
                (opened() ? setLbText : setLabelText)(lastLabel());
            box.focus();
        };
        window.addEventListener('keydown', onKey);
        onCleanup(() => window.removeEventListener('keydown', onKey));
    });
    onMount(() => {
        history.pushState({ app: true }, '');          // the root entry the back button stops on
        const onExit = () => {
            const st = history.state || {};
            if(!frame() && !opened() && !st.lb && !st.app){      // popped below the root with nothing open
                if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; }   // bail out of a frozen read; stay
                if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; }   // retract the list; keep the root beneath
                if(confirm('Leave Memories?')) history.back();   // really leave
                else history.pushState({ app: true }, '');       // stay — restore the root
            }
        };
        window.addEventListener('popstate', onExit);
        onCleanup(() => window.removeEventListener('popstate', onExit));
    });
    return html`
      <${Show} when=${() => authNeeded()}>
        <div class="authwall" role="alert">
          <strong>Authorization required.</strong>
          This device can't read your photos yet — open a fresh access link on it.
        </div>
      <//>
      <${Show} when=${() => photos.error && !authNeeded()}>
        <div class="loadwall" role="alert">
          <strong>Couldn't load your photos.</strong>
          The server returned an error — try again in a moment.
        </div>
      <//>
      <h1>Memories</h1>
      <div class="complete">
        <input class=${() => dirty() ? 'search dirty' : 'search'} type="search" role="combobox" aria-label="search labels"
               disabled=${() => photos.loading}
               aria-description=${() => dirty() ? 'search edited — not yet applied; press Enter or the search button' : undefined}
               aria-expanded=${() => searchFocus() && !opened() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
               placeholder="labels; since:2010; until:2015; type:video; sort:random"
               value=${() => search()}
               onInput=${e => { setSearch(e.target.value); setCaret(e.target.selectionStart);
                                setSearchFocus(true); }}
               onKeyUp=${e => setCaret(e.target.selectionStart)}
               onClick=${e => setCaret(e.target.selectionStart)}
               onFocus=${() => setSearchFocus(true)}
               onKeyDown=${e => sugNav(e, w => { if(w) pickSearch(w); else commit(); })}
               onBlur=${() => setSearchFocus(false)} />
        <button class="search-run" aria-label="run search" disabled=${() => photos.loading}
                onMouseDown=${e => e.preventDefault()} onClick=${commit}>🔍</button>
        <${Show} when=${() => searchFocus() && !opened()}>
          <${Suggest} text=${search} caret=${caret} dsl=${true}
                      active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading} onPick=${pickSearch} />
        <//>
      </div>
      <div class="chips" role="group" aria-label="filter by state">
        ${['all', ...STATES].map(st => html`
          <button class="chip" data-st=${st} disabled=${() => photos.loading}
                  aria-pressed=${() => stateFilter() === st ? 'true' : 'false'}
                  onClick=${() => setStateFilter(st)}>${st}</button>`)}
        <button class="chip selall" aria-pressed=${() => allSelected() ? 'true' : 'false'}
                onClick=${toggleAll}>${() => allSelected() ? 'clear' : 'select all'}</button>
        <button class="chip frame-start" onClick=${enterFrame}>▶ frame</button>
      </div>
      <${Show} when=${() => selCount() > 0}>
        <div class="toolbar" role="toolbar" aria-label="selection actions">
          <!-- scope: what the actions apply to -->
          <span class="count">${() => allMatching() ? `all ${total()} matching` : `${selCount()} selected`}</span>
          <button class="allmatch" aria-pressed=${() => allMatching() ? 'true' : 'false'}
                  onClick=${() => setAllMatching(m => !m)}>all ${() => total()} matching</button>
          <button class="range" aria-pressed=${() => rangeMode() ? 'true' : 'false'}
                  disabled=${() => !canRange()}
                  title=${() => canRange() ? undefined : 'a spread is not a run — range select is off here'}
                  onClick=${() => setRangeMode(m => !m)}>↔ range</button>
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- label edit -->
          <div class="complete">
            <input class="batch-label" role="combobox" placeholder="add a label…" aria-label="label for the selection"
                   aria-expanded=${() => labelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
                   value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
                   onFocus=${() => setLabelFocus(true)}
                   onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
                     sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
                   onBlur=${() => setLabelFocus(false)} />
            <${Show} when=${() => labelFocus()}>
              <${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
                          onPick=${w => setLabelText(replaceSeg(labelText(), w) + '; ')} />
            <//>
          </div>
          <button aria-label="add label" onClick=${addLabel}>+ label</button>
          <button aria-label="remove label" onClick=${removeLabel}>- label</button>
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- move the selection onto an occasion -->
          <div class="complete">
            <input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
                   aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
                   value=${() => moveText()}
                   onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
                   onFocus=${() => setMoveFocus(true)}
                   onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
                   onBlur=${() => setMoveFocus(false)} />
            <${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
              <ul class="suggest" role="listbox" aria-label="events">
                <${For} each=${() => moveCandidates()}>${(e, i) => html`
                  <li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
                      aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
                      onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
                    ${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
                <//>
              </ul>
            <//>
          </div>
          <button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
                  onClick=${moveToEvent}>→ event</button>
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- stamp the selection with one instant -->
          <div class="batch-date">
            <button class="datebtn" aria-label="set date" title="set the selection's date"
                    aria-expanded=${() => datingSel() ? 'true' : 'false'}
                    onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>&#x1F553;</button>
            <${Show} when=${() => datingSel()}>
              <div class="date-pop">
                <input type="datetime-local" aria-label="date for the selection"
                       value=${() => selDate()} onInput=${e => setSelDate(e.target.value)}
                       onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); stampSelDate(); } }} />
                <button class="datestamp" aria-label="apply date" disabled=${() => !selDate()}
                        onClick=${stampSelDate}>&#x2192; date</button>
              </div>
            <//>
          </div>
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- state -->
          ${STATES.map(st => html`
            <button class="st" data-st=${st} onClick=${() => setStateFor(st)}>${st}</button>`)}
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- download the selection, one file per doc, at a chosen resolution -->
          <span class="dl-grp" aria-hidden="true">&#x2193;</span>
          <button class="dl" onClick=${() => downloadSelection('orig')}>orig</button>
          <button class="dl" onClick=${() => downloadSelection('web')}>web</button>
          <span class="tb-sep" aria-hidden="true"></span>
          <button class="clear" onClick=${clearSel}>clear</button>
        </div>
      <//>
      <${Show} when=${() => !photos.loading && !photos.error && items().length === 0 && !authNeeded()}>
        <p class="empty">No photos.</p>
      <//>
      <${Show} when=${() => items().length > 0}>
        <p class="countline">${() => {
          if(!photos()?.sampled) return `showing all ${items().length}`;
          const end = photos()?.pick;
          return end ? `showing the ${end} ${items().length} of ${total()}`
            : `showing a spread of ${items().length} from ${total()} — narrow the search or date range to see them all`;
        }}</p>
      <//>
      <div class="sizer">
        <button aria-label="smaller thumbnails" onClick=${() => bumpSize(-1)}>−</button>
        <button aria-label="bigger thumbnails" onClick=${() => bumpSize(1)}>+</button>
      </div>
      <${Show} when=${() => photos.loading || mutating()}>
        <${Show} when=${() => photos.loading && !mutating()}
                 fallback=${html`<div class="wall-busy" aria-hidden="true">updating…</div>`}>
          <button class="wall-busy busy-cancel" onClick=${cancelRead}
                  aria-label=${() => 'cancel search: ' + (running().label || 'everything')}>${() => `searching «${running().label || 'everything'}»`}</button>
        <//>
      <//>
      <div class="grid" role="list" aria-label="photos" aria-busy=${() => photos.loading || mutating() ? 'true' : 'false'}
           style=${() => '--tile:' + thumbSize() + 'px'}
           ref=${el => { gridEl = el; watchCell(el); }} onWheel=${onGridWheel}
           onPointerDown=${onGridDown} onPointerMove=${onGridMove}
           onPointerUp=${onGridUp} onPointerCancel=${onGridUp}>
        <${For} each=${() => items()}>${photo => {
          const [near, setNear] = createSignal(false);
          const [thumbOn, setThumbOn] = createSignal(false);
          const thumbSettled = () => setThumbOn(true);           // painted, or failed and never coming
          createEffect(() => { near(); setThumbOn(false); });     // re-shown → its source is refetched → waiting again
          createEffect(() => {                                   // one of the thumbnails the wall waits for…
              if(!(near() && photo.thumbnailCid && !thumbOn())) return;
              setThumbsInFlight(n => n + 1);
              onCleanup(() => setThumbsInFlight(n => n - 1));     // …until it settles or leaves
          });
          const [sharp, setSharp] = createSignal(false);
          createEffect(() => { if(!near()) setSharp(false);       // gone → both layers go
                               else if(thumbOn() && refining()) setSharp(true); });
          const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
          const [webOn, setWebOn] = createSignal(false);
          createEffect(() => { webSrc(); setWebOn(false); });     // a new source → fade the overlay in again
          return html`
          <div class="tile" role="listitem" data-selected=${() => isSel(photo.cid) ? '1' : '0'}
               data-cursor=${() => cursor() === photo.cid ? '1' : '0'}
               onPointerDown=${e => onTileDown(e, photo)} onPointerMove=${onTileMove}
               onPointerUp=${onTileUp} onPointerCancel=${onTileUp}
               onClick=${e => onTilePress(e, photo.cid)}
               onDblClick=${() => openPhoto(photo)}>
            <${Show} when=${() => photo.thumbnailCid}
                     fallback=${html`<div class="thumb noimg" title=${photo.filename || photo.date.slice(0, 10)}>
                       <span class="ph">${isVideo(photo) ? '🎬' : '🖼'}</span></div>`}>
              <img class="thumb" draggable="false" alt=${photo.date.slice(0, 10)} title=${photo.date.slice(0, 10)}
                   ref=${el => watchVisible(el, setNear)}
                   src=${() => near() ? IPFS + photo.thumbnailCid : BLANK}
                   onLoad=${thumbSettled} onError=${thumbSettled} />
              <${Show} when=${webSrc}>
                <img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
                     src=${webSrc} onLoad=${() => setWebOn(true)} />
              <//>
            <//>
            <${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
              <span class="play" title="video"></span>
            <//>
            <${Show} when=${() => isSel(photo.cid)}><span class="check"></span><//>
            <span class="badge">${photo.state || ''}</span>
            <div class="foot">
              <${Show} when=${() => !!photo.labels}>
                <span class="labels" title=${photo.labels}>${photo.labels}</span>
              <//>
              <${Show} when=${() => docEvents(photo).length}>
                <div class="events">
                  <${For} each=${() => docEvents(photo)}>${e => html`
                    <span class="ev-pill" style=${() => 'background:' + eventColour().get(eventKey(e))}>${() => e.summary}</span>`}<//>
                </div>
              <//>
            </div>
          </div>`;
        }}
        <//>
      </div>
      <${Show} when=${() => marquee()}>
        <div class="marquee" style=${() => { const r = marqueeRect(marquee());
          return `left:${r.l}px;top:${r.t}px;width:${r.r - r.l}px;height:${r.b - r.t}px`; }}></div>
      <//>
      <${Show} when=${() => opened()}>
        <div class="lb" onClick=${e => {
               if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]')) dismissPhoto(); }}>
          <div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
            <button class="lb-close" aria-label="close" onClick=${dismissPhoto}></button>
            <button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
                    onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid) ? '✓ selected' : 'select'}</button>
            <button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}> frame</button>
            <button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}></button>
            <button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}></button>
            <${Show} when=${() => hasMedia(opened())}
                     fallback=${html`<div class="lb-media noimg">
                       <span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
                       <span class="mt">no preview · ${() => opened()?.filename || opened()?.mimetype || ''}</span></div>`}>
              <${Show} when=${() => isVideo(opened())}
                       fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
                <video class="lb-media" controls autoplay ref=${el => lbVideo = el} src=${() => IPFS + opened()?.webCid}></video>
              <//>
            <//>
            <div class="lb-meta">
              <${Show} when=${() => editingDate()}
                       fallback=${html`<button class="lb-date" aria-label="edit date"
                           onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
                <input class="lb-date-edit" type="datetime-local" aria-label="date"
                       ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
                       onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
                       onBlur=${e => commitDate(e.target.value)} />
              <//>
              <a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
                 aria-label="original" title="original — full resolution, new tab"></a>
            </div>
            <div class="lb-events">
              <${For} each=${() => lbEvents() || []}>${e => html`
                <button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
              <//>
            </div>
            <div class="lb-states">
              <${For} each=${() => STATES}>${st => html`
                <button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
                        onClick=${() => lbSetState(st)}>${st}</button>`}
              <//>
            </div>
            <div class="lb-labels">
              <${For} each=${() => labelsOf(opened())}>${w => html`
                <span class="lb-chip"><button class="lb-chip-word"
                      onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
                      aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
              <//>
              <${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
                <button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}> ${() => lastLabel()}</button>
              <//>
              <div class="complete">
                <input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
                       aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
                       value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
                       onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
                       onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
                         sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
                <${Show} when=${() => lbFocus()}>
                  <${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
                              onLoading=${setSugLoading} onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
                <//>
              </div>
            </div>
          </div>
        </div>
      <//>
      <${Show} when=${() => frame()}>
        <div class="frame">
          <div class="strip" role="list" aria-label="slideshow"
               ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
            <${Index} each=${() => frameSlides()}>${(slide, k) => FrameSlide(slide, k)}<//>
          </div>
          <${Show} when=${() => frameUI()}>
            <div class="frame-bar" role="toolbar" aria-label="frame actions" onPointerDown=${nudge}>
              <button aria-label=${() => playing() ? 'pause' : 'play'}
                      onClick=${() => setPlaying(p => !p)}>${() => playing() ? '⏸' : '▶'}</button>
              <label>every <input class="ivl" type="number" min="2" aria-label="seconds per photo"
                     value=${() => Math.round(intervalMs() / 1000)}
                     onChange=${e => setIntervalMs(Math.max(2, +e.target.value) * 1000)} />s</label>
              <${Show} when=${() => frameEditingDate()}
                       fallback=${html`<button class="frame-date" aria-label="edit date"
                           onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
                             return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
                <input class="frame-date-edit" type="datetime-local" aria-label="date"
                       ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
                       onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
                       onBlur=${e => frameCommitDate(e.target.value)} />
              <//>
              <span class="room-link" data-state=${roomLink}>${roomLink}</span>
              <${For} each=${() => frameEvents() || []}>${e => html`
                <button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
              <//>
              ${STATES.map(st => html`
                <button class="st" data-st=${st} onClick=${() => frameSetState(st)}>${st}</button>`)}
              <div class="complete">
                <input class="frame-label" role="combobox" placeholder="add a label…" aria-label="add a label in the frame"
                       aria-expanded=${() => frameLabelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
                       value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
                       onFocus=${() => setFrameLabelFocus(true)}
                       onBlur=${() => setFrameLabelFocus(false)}
                       onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
                         sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
                <${Show} when=${() => frameLabelFocus()}>
                  <${Suggest} text=${frameLabel} present=${() => labelsOf(frameDoc())}
                              active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
                              onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
                <//>
              </div>
              <button aria-label="exit frame" onClick=${exitFrame}> exit</button>
            </div>
          <//>
        </div>
      <//>
    `;
}

.search{ width:100%; box-sizing:border-box; margin-bottom:12px; padding:8px 40px 8px 12px; font-size:15px;
         background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:6px; }
/* edited-but-not-run: an amber tint says the wall is stale (paired with aria-description for
   non-colour a11y — see the commit-search prose) */
.search.dirty{ border-color:#d9a441; background:#2b2718; }
/* the commit button sits at the input's right edge (the completion popover opens below both) */
.search-run{ position:absolute; top:5px; right:6px; width:30px; height:30px; border:0; border-radius:6px;
             background:#33395a; color:#dfe6ff; font-size:14px; cursor:pointer; }
.search-run:disabled{ opacity:.5; cursor:default; }
/* frozen while a read is in flight — dimmed so it reads as locked, not broken (see prose) */
.search:disabled, .chip:disabled{ opacity:.5; cursor:default; }
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(var(--tile, 96px), 1fr)); gap:4px; }
/* dim + pill while the wall is settling (photos.loading || mutating()) */
.grid[aria-busy="true"]{ opacity:.55; transition:opacity .12s; }
.wall-busy{ position:fixed; top:12px; left:50%; transform:translateX(-50%); z-index:30;
            background:#33395a; color:#dfe6ff; font-size:13px; padding:5px 12px; border-radius:999px;
            box-shadow:0 4px 14px #0006; pointer-events:none; }
/* while searching, the pill is a cancel button — re-enable pointer events, reset button chrome */
.busy-cancel{ pointer-events:auto; cursor:pointer; border:0; font:inherit; }
.sizer{ display:flex; gap:6px; justify-content:flex-end; margin:0 0 6px; }
.sizer button{ width:28px; height:28px; padding:0; font-size:16px; line-height:1; cursor:pointer;
               border:1px solid #3a3f5a; border-radius:6px; background:#262a40; color:var(--fg); }
.sizer button:hover{ background:#33395a; }
.tile{ position:relative; aspect-ratio:1; cursor:pointer; border-radius:4px; overflow:hidden;
       user-select:none; touch-action:manipulation; -webkit-touch-callout:none; }
/* manipulation: kill the double-tap zoom + tap delay (a double-tap opens the doc); no
   callout: a long-press starts a selection, so don't let the OS claim it for save-image */
.thumb{ width:100%; height:100%; object-fit:contain; background:#262a40; display:block; }
.thumb.noimg{ display:flex; align-items:center; justify-content:center; }
.thumb.noimg .ph{ font-size:28px; opacity:.55; }
.tile[data-selected='1']{ outline:3px solid #6cf; outline-offset:-3px; }
.check{ position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%;
        background:#6cf; color:#08111e; font-size:13px; line-height:20px; text-align:center; font-weight:700; }
.badge{ position:absolute; top:3px; right:3px; font-size:10px; padding:1px 5px; border-radius:8px;
        background:#000a; color:#cdd; text-transform:uppercase; letter-spacing:.04em; }
/* the caption strip pinned along the tile's foot */
.tile .foot{ position:absolute; left:0; right:0; bottom:0; }
.labels{ display:block; padding:6px 5px 3px; font-size:10px;
         line-height:1.2; color:#eef; background:linear-gradient(transparent, #000d);
         white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
.empty{ color:#8a8ea5; }
.countline{ margin:0 0 10px; font-size:12px; color:#c8b27a; }
/* the rubber-band box — fixed to the viewport (its coords are client-space), inert to the pointer */
.marquee{ position:fixed; z-index:50; border:1px solid #6cf; background:rgba(108,204,255,.18); pointer-events:none; }

Load only the thumbnails on screen

A wall of hundreds — soon thousands — of thumbnails can’t hold a decoded image per tile: the browser runs out of memory long before the page does. loading“lazy”= defers the first fetch but, once an image has loaded, never lets it go — scroll a 10K-doc wall top to bottom and every image stays resident. So the tile takes charge of its own image: a single shared IntersectionObserver flips a per-tile near signal, and the <img> carries its real /ipfs/ source only while near the viewport, falling back to a 1×1 blank when it leaves. Loading and unloading — the decoded image is released the moment the tile scrolls away. This is the groundwork for raising the sampling cap toward the whole archive.

Dropping an image only to want it again would be a poor bargain if it were paid twice on the wire. It isn’t: an /ipfs/ path names its own content and can never come to stand for anything else, and the gateway says so, serving every rendition public, max-age=29030400, immutable. Scrolling back is a cache hit. The memory goes; the bytes never do.

So on a wall that overflows its fold, a tile waiting far below carries no image at all. Bring it into view and it takes one; leave again and it gives it back.

@testcase
def test_thumbs_load_in_view_and_unload(page):
    docs =[{"cid": f"https://ipfs.konubinix.eu/p/zzvpl-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzvpl-t-{i}",
             "labels": "zzvpl", "state": "todo"} for i in range(60)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 400, "height": 500})   # small, so 60 tiles overflow well past the fold
        open_app(page)
        search_for(page, "zzvpl")                    # default chip = todo; all 60 are todo
        expect(tiles(page)).to_have_count(60)
        last = thumb_imgs(page).last
        expect(last).not_to_have_attribute("src", re.compile(r"/ipfs/"))   # below the fold → unloaded
        last.scroll_into_view_if_needed()
        expect(last).to_have_attribute("src", re.compile(r"/ipfs/"))       # scrolled in → loaded
        tiles(page).first.scroll_into_view_if_needed()
        expect(last).not_to_have_attribute("src", re.compile(r"/ipfs/"))   # left again → unloaded
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: thumbs load in view and unload when they leave")

One shared observer serves every tile (a WeakMap from element to its setter), with a 300px margin so an image is ready a little before it scrolls in and dropped a little after it scrolls out. Each image registers on mount and unobserves on cleanup, so a search that swaps the wall doesn’t leak observations.

// a 1×1 transparent gif — what a tile shows when its image is unloaded (the .thumb
// background fills the square)
const BLANK = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const watchVisible = (() => {
    const cbs = new WeakMap();
    const io = new IntersectionObserver(
        entries => entries.forEach(e => cbs.get(e.target)?.(e.isIntersecting)),
        { rootMargin: '300px' });
    return (el, set) => {
        if(!el) return;
        cbs.set(el, set);
        io.observe(el);
        onCleanup(() => { io.unobserve(el); cbs.delete(el); });
    };
})();

Saying when the wall is sampled

When the range holds more than the ~2000 memories samples to, silently showing a slice reads as “this is everything”, which is a lie. So the query also asks photovideosCount for the true total, and a line appears naming the slice against it — showing a spread of N from M — narrow… when the sampler is the one that chose.

The subtle part is when: the notice must fire only on genuine sampling. Every match is shown (no thumbnail → a placeholder tile), so “shown” equals what the server returned, and the honest signal is simply whether the server dropped rows — it returned fewer nodes than match. That’s the sampled flag, and it’s what the notice watches; a thumbnail-less match never raises it.

@testcase
def test_sampling_notice(page):
    """A genuinely over-cap range (the whole archive) shows the spread notice."""
    open_app(page)
    chip(page, "all").click()                        # the whole archive dwarfs the ~2000 sample
    expect(page.get_by_text(re.compile(r"showing a spread of \d+ from \d+"))).to_be_visible()
    print("  PASS: sampling notice")

@testcase
def test_count_always_shown(page):
    """Even within the cap, the count is shown — 'showing all N'."""
    open_fixtures(page)                              # 3 fixtures, well under the cap
    expect(page.get_by_text("showing all " + str(len(FIXTURES)))).to_be_visible()
    print("  PASS: count always shown")

A spread is not the only slice the wall can be holding. Ask it for the first or last twenty and twenty is what arrives, but calling those a spread would be the same lie in smaller form — they are one end of the match, not a taste of the whole of it. So the notice names the slice it is holding. It advises only where advice helps: a count you typed is one you can raise yourself. And the distinction reaches past the notice — an end is a run of tiles where a spread is not, and a run is what a range selection needs to mean anything.

the wall holds the line reads it advises range selection
every match showing all N yes
the first N showing the first N of M yes
the last N showing the last N of M yes
a spread showing a spread of N from M narrow it no

open_app(page)
search_for(page, "zzpick; first:2")
expect(page.get_by_text(re.compile(r"showing the first 2 of \d+$"))).to_be_visible()   # $: nothing follows
search_for(page, "zzpick; last:2")
expect(page.get_by_text(re.compile(r"showing the last 2 of \d+$"))).to_be_visible()
search_for(page, "zzpick; sample:4")
expect(page.get_by_text(re.compile(r"showing a spread of 4 from \d+"))).to_be_visible()
expect(page.get_by_text(re.compile(r"narrow the search or date range"))).to_be_visible()

The wall re-queries on every filter change; while that query is in flight it marks the grid aria-busy, so a screen reader holds its reading — and anything watching can wait for the settled result rather than read a wall mid-load.

@testcase
def test_wall_reports_busy(page):
    """The wall marks the grid aria-busy while a query is in flight, false once settled."""
    open_app(page)
    g = grid(page)
    expect(g).to_have_attribute("aria-busy", "false")          # settled at rest
    # hold the wall query so a fresh fetch stays in flight (the page.route trick the
    # completion in-flight test uses) — the busy state is then deterministically observable.
    page.route("**/graphql", lambda route:
               route.fallback() if "photovideosSample" not in (route.request.post_data or "") else None)
    search_box(page).fill("zzbusyprobe")
    page.get_by_role("button", name="run search").click()      # commit → a wall fetch that never resolves
    expect(g).to_have_attribute("aria-busy", "true")           # in flight → busy
    print("  PASS: wall reports busy")

The busy state also covers an edit’s writes: a mutating counter wraps each batch edit from its first write to the finally after it, so aria-busyphotos.loading || mutating() — reads true across the whole edit and only falls quiet once the edit’s re-read has landed the committed result on the wall. That span is what a screen reader and the updating… cue read.

@testcase
def test_wall_busy_during_edit(page):
    """aria-busy spans an edit's write phase too — held mid-write, before any refetch, the
    grid already reads busy, so a screen reader hears the region working through the edit."""
    open_fixtures(page)
    select_all(page).click()
    box = toolbar(page).get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("zzeditbusy")
    # hold the write in flight so the write phase (before any refetch) is observable
    page.route("**/graphql", lambda route:
               route.fallback() if "updatePhotovideo" not in (route.request.post_data or "") else None)
    box.press("Enter")                                 # fires the (held) writes
    expect(grid(page)).to_have_attribute("aria-busy", "true")   # busy during the WRITE phase
    print("  PASS: wall busy during edit")

aria-busy speaks to a screen reader; a sighted user needs to see it too. So while the wall is settling it dims and shows a small cue: updating… while an edit writes, and searching «…», naming the very query in flight, while a read is out — so the wait reads as working on a known thing, not as broken or already done.

@testcase
def test_wall_shows_updating_feedback(page):
    """While a batch edit is in flight, the wall shows a visible 'updating…' cue."""
    open_fixtures(page)
    select_all(page).click()
    box = toolbar(page).get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("zzfeedback")
    page.route("**/graphql", lambda route:
               route.fallback() if "updatePhotovideo" not in (route.request.post_data or "") else None)
    box.press("Enter")                                 # edit held → the wall is settling
    expect(page.get_by_text("updating…")).to_be_visible()
    print("  PASS: wall shows updating feedback")

Naming the query only helps if there is one to name — so the wall does not chase the keyboard. Typing updates the box and its completions, but the wall re-reads only when you commit: press Enter (which applies a highlighted completion if one is selected, else runs the search) or tap the run-search button beside the box. The box is a query language — event:, since:, type:, bare labels — so composing a whole query and then running it beats firing a read at every keystroke, which would search half-typed tokens. A state chip re-reads at once on the last committed query — a chip is a deliberate filter, not typing.

Because the box can now hold a query the wall hasn’t run, it says so: while the typed text differs from the committed one, the box is dirty — tinted amber to read as stale, not yet applied. Colour alone would be invisible to a screen reader, so the same state also sets an aria-description on the box (the run button keeps its plain name, since a control’s label shouldn’t churn); committing, or backing out via Esc/Back, clears both at once.

A committed query runs one-at-a-time and stays legible: a running query drives the wall and advances to the newest committed query only when the wall is idle, so exactly one read is in flight at a time and it is never swapped out from under itself — which is what lets the searching «…» cue name a single, stable query.

And while that read is out the query controls are locked: the search box, the run button, and the state chips disabled (dimmed — read as held, not broken), so what is on screen cannot drift from what is being fetched. It frees the instant the read lands. (A slow read is thus felt as a locked box — the honest cost of a query that should be quick, and the reason a slow event: read is worth making fast rather than papering over.)

The network is held in the test, so “typing does not read, committing does” is checked as a fact on the wire — no clock, no wait. (One read runs at a time by construction: the controls lock during a read, so a second commit can’t even be issued — that lock is covered by its own test.)

@testcase
def test_wall_reads_only_on_commit(page):
    """The wall re-reads only on commit, not on typing: typing a query fires NO wall read; the
    run-search button fires exactly one, for what was typed. Reads are held, so what fires (and
    doesn't) is a fact on the wire."""
    open_fixtures(page)
    seen = []
    page.on("request", lambda r: "photovideosSample" in (r.post_data or "")
            and seen.append((json.loads(r.post_data).get("variables") or {}).get("search", "")))
    page.route("**/graphql", lambda r:
               None if "photovideosSample" in (r.request.post_data or "") else r.fallback())   # hold every read
    base = len(seen)
    sb = search_box(page)
    sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzcommit", delay=0)
    expect(sb).to_have_value("zzcommit")                 # the box shows it (the reactive tick ran)…
    assert len(seen) == base, f"typing re-read the wall without a commit: {seen[base:]}"   # …but no read fired
    page.get_by_role("button", name="run search").click()   # commit
    expect(page.get_by_text("searching «zzcommit»")).to_be_visible()   # now it reads (held)
    assert seen[base:] == ["zzcommit"], f"commit fired the wrong reads: {seen[base:]}"
    print("  PASS: wall reads only on commit")

Enter commits too, when no completion is highlighted — so a keyboard user runs a query without reaching for the button. (Enter with a suggestion highlighted applies it instead; that path is the completion test’s.)

@testcase
def test_enter_commits_search(page):
    """Enter with no completion highlighted commits the search — the wall reads it (held)."""
    open_fixtures(page)
    page.route("**/graphql", lambda r:
               None if ("photovideosSample" in (r.request.post_data or "") and "zzenter" in (r.request.post_data or "")) else r.fallback())
    sb = search_box(page)
    sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzenter", delay=0)
    sb.press("Enter")                                    # commit — nothing highlighted
    expect(page.get_by_text("searching «zzenter»")).to_be_visible()   # the wall read the typed query
    print("  PASS: enter commits search")

Since the box can hold an unrun query, it flags itself stale until committed — a different border colour, and (for non-colour a11y) an aria-description. Both clear on commit.

@testcase
def test_search_bar_marks_uncommitted_edit(page):
    """A query typed but not run marks the box stale — a different border colour plus an
    aria-description — and committing clears both."""
    open_fixtures(page)
    sb = search_box(page)
    clean = sb.evaluate("el => getComputedStyle(el).borderColor")
    assert sb.get_attribute("aria-description") is None                # committed → nothing stale
    sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzdirty", delay=0)
    expect(sb).to_have_value("zzdirty")                                # the edit landed in the box
    expect(sb).to_have_attribute("aria-description", re.compile("not yet applied"))   # non-colour cue
    assert sb.evaluate("el => getComputedStyle(el).borderColor") != clean, "edited box wasn't recoloured"
    page.get_by_role("button", name="run search").click()             # commit
    expect(sb).to_be_enabled()                                        # the read settled
    expect(sb).not_to_have_attribute("aria-description", re.compile(".+"))   # cleared on commit
    assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "colour didn't revert after commit"
    print("  PASS: search bar marks uncommitted edit")

The empty query is a query too: with the box cleared the cue names the whole library — «everything» — rather than a blank. Held open at load, that empty-search read shows it.

@testcase
def test_wall_cue_names_empty_query_everything(page):
    """With no search, the in-flight cue reads «everything», not a blank term. The initial
    read is held open so the empty-query label is observable on its own."""
    page.route("**/graphql", lambda r:              # hold the wall read; let everything else through
               None if "photovideosSample" in (r.request.post_data or "") else r.fallback())
    open_app(page)                                  # fresh context → empty search → read held
    expect(page.get_by_text("searching «everything»")).to_be_visible()
    print("  PASS: wall cue names empty query everything")

Locking is observable as disabled: hold a read open and the search box and the state chips report themselves disabled, so the query cannot drift from what is in flight.

@testcase
def test_query_controls_freeze_while_reading(page):
    """While a wall read is in flight the query is locked: the search box and the state chips
    are disabled, so what's on screen can't drift from what's being fetched. The read is held
    open, so the locked state is observed as a fact, not on a timer."""
    open_fixtures(page)
    page.route("**/graphql", lambda r:                  # hold the next wall read open
               None if "photovideosSample" in (r.request.post_data or "") else r.fallback())
    search_box(page).fill("zzlockcheck")
    page.get_by_role("button", name="run search").click()   # commit → its (held) read starts
    expect(search_box(page)).to_be_disabled()           # search frozen while the read is out
    expect(chip(page, "todo")).to_be_disabled()         # …and the state chips
    print("  PASS: query controls freeze while reading")

A lock needs a key. A read that drags — a slow event: filter over the whole archive — would otherwise trap you behind the frozen controls, so three gestures bail out: tapping the searching «…» pill, Escape, or the back button. Any of them aborts the in-flight read and falls back to the last query the wall actually showed (or, on the first read with nothing settled yet, to everything): the controls free at once, the box reverts to that last good query, and the abandoned read’s connection is aborted — not left running against the server, which for a slow query is the whole point. The pill is the cancel button (a screen reader hears cancel search: …); Back reaches the same hatch through the exit guard that retracts the completion list, one rung before the leave prompt; Escape through a window key listener, since the disabled box can’t hear its own keydown. An edit’s updating… is not cancellable — its refetch is re-reading a committed change, not a query worth abandoning.

@testcase
def test_escape_cancels_frozen_read(page):
    """Esc bails out of a read in flight: it aborts the read, unlocks the controls, and reverts
    the box to the last query the wall actually showed. Only the slow read is held, so the
    fall-back read settles and unlocks — and the abort is asserted, not just the unlock."""
    open_fixtures(page)                                  # settles on FIXTURE_LABEL → 3 tiles
    aborted = []
    page.on("requestfailed", lambda r: "/graphql" in r.url and aborted.append(1))
    page.route("**/graphql", lambda r:                   # hold ONLY the slow query; let the fall-back through
               None if ("photovideosSample" in (r.request.post_data or "") and "zzheld" in (r.request.post_data or "")) else r.fallback())
    search_box(page).fill("zzheld")
    page.get_by_role("button", name="run search").click()   # commit → its (held) read → frozen
    expect(search_box(page)).to_be_disabled()
    expect(page.get_by_text("searching «zzheld»")).to_be_visible()
    page.keyboard.press("Escape")
    expect(search_box(page)).to_be_enabled()             # unlocked
    expect(search_box(page)).to_have_value(FIXTURE_LABEL)   # reverted to the last settled query
    expect(tiles(page)).to_have_count(len(FIXTURES))     # those results again → no error state
    wait_until(page, lambda: bool(aborted), label="held read aborted")   # connection cancelled, not leaked
    print("  PASS: escape cancels frozen read")

The back button reaches the same hatch — and one Back further still asks before leaving, so the history root stays intact.

@testcase
def test_back_cancels_frozen_read(page):
    """Back bails out of a frozen read like Esc, and stays in the app — a further Back still
    meets the leave guard."""
    open_fixtures(page)
    page.route("**/graphql", lambda r:
               None if ("photovideosSample" in (r.request.post_data or "") and "zzheld" in (r.request.post_data or "")) else r.fallback())
    search_box(page).fill("zzheld")
    page.get_by_role("button", name="run search").click()   # commit → held read → frozen
    expect(search_box(page)).to_be_disabled()
    page.go_back()                                       # Back bails out
    expect(search_box(page)).to_be_enabled()
    expect(search_box(page)).to_have_value(FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(len(FIXTURES))
    asked = []
    page.on("dialog", lambda d: (asked.append(1), d.dismiss()))
    page.go_back()                                       # a further Back reaches the leave guard
    wait_until(page, lambda: bool(asked))
    expect(search_box(page)).to_be_visible()             # cancelled → still in Memories
    print("  PASS: back cancels frozen read")

The boot edge: on the very first read there is no settled query to fall back to, so bailing out lands on an unlocked wall rather than re-locking on the same slow read.

@testcase
def test_escape_during_boot_read_unlocks(page):
    """Esc during a slow FIRST read (nothing settled yet) unlocks rather than re-locking on the
    same query — the abort resolves the read to an empty wall."""
    page.route("**/graphql", lambda r:                   # hold every wall read → the boot read hangs
               None if "photovideosSample" in (r.request.post_data or "") else r.fallback())
    open_app(page)
    expect(search_box(page)).to_be_disabled()            # frozen on the boot read
    page.keyboard.press("Escape")
    expect(search_box(page)).to_be_enabled()             # unlocked, not re-locked
    print("  PASS: escape during boot read unlocks")

And the pill itself is the cancel button: tapping it reaches the same hatch, found by its accessible name rather than a keystroke.

@testcase
def test_pill_tap_cancels_frozen_read(page):
    """Tapping the searching pill bails out like Esc/Back — the pill IS the cancel button,
    reached by its accessible name."""
    open_fixtures(page)
    aborted = []
    page.on("requestfailed", lambda r: "/graphql" in r.url and aborted.append(1))
    page.route("**/graphql", lambda r:
               None if ("photovideosSample" in (r.request.post_data or "") and "zzheld" in (r.request.post_data or "")) else r.fallback())
    search_box(page).fill("zzheld")
    page.get_by_role("button", name="run search").click()   # commit → held read → frozen
    expect(search_box(page)).to_be_disabled()
    page.get_by_role("button", name=re.compile("cancel search")).click()   # tap the pill
    expect(search_box(page)).to_be_enabled()             # unlocked
    expect(search_box(page)).to_have_value(FIXTURE_LABEL)   # reverted to the last settled query
    expect(tiles(page)).to_have_count(len(FIXTURES))
    wait_until(page, lambda: bool(aborted), label="held read aborted")
    print("  PASS: pill tap cancels frozen read")

Placeholders for docs with no media

Not every row has its renditions. A match without a thumbnail still gets a tile — a placeholder with an icon — so it’s reachable rather than silently dropped, and it raises no sampling notice.

open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)     # the no-thumb one shows too
expect(thumb_imgs(page)).to_have_count(len(FIXTURES))    # but it isn't an image
expect(grid(page).get_by_text("🖼")).to_have_count(1)     # it wears the icon, so it reads as a placeholder
expect(page.get_by_text(re.compile(r"showing a spread"))).to_have_count(0)  # and no notice

Opening it, when there’s no web_cid either, shows a “no preview” placeholder instead of a broken image.

search_for(page, NOMEDIA_LABEL)                     # a label only this doc carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
expect(d.get_by_text("no preview")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0)            # nothing broken to show
expect(d.locator("video")).to_have_count(0)

Which placeholder you get turns on what the doc is. An image missing its web copy still has its thumbnail, and a stretched thumbnail is the same picture, only softer — it answers what was asked. So the lightbox enlarges it rather than refusing the doc.

d.get_by_role("button", name="close").click()       # back to the wall
search_for(page, "zznwimg")                         # an image with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zznw-img-t")   # its poster, stretched

A video in the same state is refused: the lightbox opens a clip in order to play it, and a poster cannot be played — offering the still would put a photograph where a film was asked for.

d.get_by_role("button", name="close").click()
search_for(page, "zznwvid")                         # a clip with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_text("no preview · video/mp4")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0)        # and the poster is not offered in its place

Drawing our own spread

The wall’s spread is ours to draw. The frise’s photovideos_search quantises its keep-threshold so that panning its timeline keeps the same photos on screen — but memories never pans, and that quantisation only ever under-fills the cap: ask for the cap and you get a few short of it. So memories samples for itself, over the filter the frise factored out (photovideos_match): the cap rows with the smallest cid-hash — an even, stable spread — re-ordered by date. Exactly the cap, no quantisation.

An over-cap query — the whole archive — shows exactly the cap.

@testcase
def test_sample_fills_cap(page):
    """An over-cap query shows exactly the cap — memories draws its own spread, unquantised."""
    open_app(page)
    chip(page, "all").click()                        # the whole archive ≫ the cap
    expect(page.get_by_text(re.compile(r"showing a spread of 2000 from \d+"))).to_be_visible()
    print("  PASS: sample fills the cap")

An even spread is one answer to the question a big match forces — which of it to show — and often it is the wrong one. You want the start of a holiday, or the last few frames of a run, not a taste of the whole. So the box says which: first:20 keeps the twenty earliest, last:20 the twenty latest, sample:20 twenty taken from across the whole match, as before. Each is also how many, so a count stands in for the wall’s own cap when you name one.

Eight photos, one a day through the first eight days of a January, make the difference plain: asked for two, the wall hands back either end of those eight.

search_for(page, "zzpick; first:2")
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-01", "2020-01-02"], f"not the earliest two: {tile_dates(page)}"
search_for(page, "zzpick; last:2")
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-07", "2020-01-08"], f"not the latest two: {tile_dates(page)}"

Ask instead for a spread of four and you get neither end but days from across the eight — with gaps, which is what tells a spread from any run of four. However the rows were chosen the wall reads the way it always does, oldest first, so last:2 is the last two in date order, not handed back reversed.

search_for(page, "zzpick; sample:4")
expect(tiles(page)).to_have_count(4)
d = tile_dates(page)
assert d == sorted(d), f"a spread must still read oldest first: {d}"
days = [int(x[-2:]) for x in d]
assert max(b - a for a, b in zip(days, days[1:])) > 1, f"contiguous — that is a run, not a spread: {d}"

It’s a thin function over photovideos_match, and the LIMIT makes the count exact, with none of the threshold’s variance; the frise can’t use a LIMIT because it would reshuffle as the window pans, but memories holds still. That same LIMIT is where the choice of rows lives: all a count token changes is what it counts along — the date upward, the date downward, or the cid-hash that scatters — while the ordering outside it stays put, which is what keeps the wall oldest-first whichever end the rows came from. It carries the same filter arguments, so when the filter gains one — now events_x, the -event: exclusion (eventsX to the app, snake-cased for Postgres) — the sample inherits it and an excluded-event search samples like any other.

Applying it takes two steps together: the docs DB, then a PostGraphile restart so the new pick arg is reflected. The app sends that arg, so a build meeting an un-restarted PostGraphile is turned away for an unknown argument.

BEGIN;
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[]);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[], int, int);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[], int, int, text);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[], int, int, text, text);
CREATE OR REPLACE FUNCTION photovideos_sample(search text, since timestamptz, until timestamptz,
                                              states state[] DEFAULT NULL, kinds text[] DEFAULT NULL,
                                              aday date DEFAULT NULL, awin int DEFAULT 1, cap int DEFAULT 300,
                                              owners owner_type[] DEFAULT NULL,
                                              amonth int DEFAULT NULL, mwin int DEFAULT 0,
                                              events text DEFAULT NULL, events_x text DEFAULT NULL,
                                              pick text DEFAULT NULL)
  RETURNS SETOF photovideo LANGUAGE sql STABLE AS $f$
  SELECT * FROM (
    SELECT * FROM photovideos_match(search, since, until, states, kinds, aday, awin, owners, amonth, mwin, events, events_x)
    ORDER BY CASE pick WHEN 'first' THEN extract(epoch FROM date)
                       WHEN 'last'  THEN -extract(epoch FROM date)
                       ELSE hashtext(cid) END
    LIMIT cap
  ) q ORDER BY q.date
$f$;
COMMIT;
BEGIN
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
DROP FUNCTION
CREATE FUNCTION
COMMIT

Seeing a doc’s labels

You can’t triage what you can’t see: each tile shows the labels already on the doc, as a caption across its foot (over a dark gradient so it reads on any photo, one line with an ellipsis when there are many — the title attribute carries the full list on hover). Tiles with no labels show nothing. The state stays as a small badge in the corner.

@testcase
def test_tiles_show_labels(page):
    """Each tile captions the labels already assigned to the doc."""
    open_fixtures(page)
    expect(grid(page).get_by_text(FIXTURE_LABEL).first).to_be_visible()
    print("  PASS: tiles show labels")

Events on the wall

A thumbnail is a moment with its occasion stripped away: you see the scene, not that it was a swim, a birthday, a week in the mountains. The calendar holds the occasion, and the lightbox already names it for the open doc — here it rides onto the tile itself, so the occasion stays with the photo while you triage. That is worth doing for two reasons. You read a photo back in its context, remembering what it belonged to. And because the wall runs in date order, one occasion’s photos land in a run — so a photo whose date has drifted out of its occasion, a wrong timestamp carrying it weeks off, strands outside that run, where the eye catches it.

A run only reads as a run once the occasions are told apart at a glance, and that is colour’s job: each event takes its own contrasted hue, so a stretch of one occasion carries one colour and a break in the colour is a break in the occasion. We reach it in two steps — first the pill, then its colour. A photo taken during an event shows a small pill on its tile, captioned with the event’s summary.

@testcase
def test_tiles_show_event_pill(page):
    """A photo taken during a calendar event captions that event on its tile."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-wallpill", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
          "summary": "zzWallOccasion", "owner": "konubinix", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzwallpill", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzwallpill-t", "labels": "zzwallpill", "owner": "konubinix", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzwallpill")
        expect(tiles(page)).to_have_count(1)
        expect(grid(page).get_by_text("zzWallOccasion")).to_have_count(1)   # the occasion, pilled on the tile
    finally:
        gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: tiles show event pill")

The events can’t ride on each photo the way its labels do: a photo’s occasions come from the calendar, the lookup the lightbox runs one doc at a time. Per tile that would be a query per photo, hundreds for a screen — so the wall reads the shared layer’s events_in_window once: every event overlapping the range the wall shows, capped above the whole calendar so the connection never truncates the window before we can use it.

const EVENTS_IN_WINDOW = `query($since:Datetime!,$until:Datetime!){
  eventsInWindow(since:$since, until:$until, first:5000){ nodes{ summary starttime endtime owner } } }`;
const fetchWindowEvents = async w => (await gql(EVENTS_IN_WINDOW, w))?.eventsInWindow?.nodes ?? [];

That read follows the wall’s own window — a resource keyed on the running query’s date range, so it refetches only when the range moves and a label-only search keeps its events. In practice the key is that range serialised to a string, not the object holding it, so an unrelated re-render with the same window doesn’t refetch (object identity would).

const eventWindow = createMemo(() => { const k = JSON.parse(running().key);
                                       return JSON.stringify({ since: k.since, until: k.until }); });
const [wallEvents] = createResource(eventWindow, w => fetchWindowEvents(JSON.parse(w)));

A photo’s own events are the ones that bracket it: the same owner — two people can hold an occasion on the same day — and its date inside the event’s span.

const docEvents = photo => { const evs = wallEvents(); if(!evs || !photo.owner) return [];
    const t = new Date(photo.date).getTime();
    return evs.filter(e => e.owner === photo.owner
        && new Date(e.starttime).getTime() <= t && t <= new Date(e.endtime).getTime()); };

A pill that only names an occasion marks it but doesn’t set its run apart from the neighbours. So each event is given a colour of its own, from a small palette of contrasted light pastels: adjacent occasions land on different hues, so a stretch of one occasion reads as a single colour band and the eye catches where one ends and the next begins. Far-apart occasions may reuse a hue — the palette cycles — which costs nothing, because the reading that matters is local, one run against the next. The colours aren’t fixed to particular occasions: stability across searches buys nothing here, so they are handed out afresh each render, in the order occasions first appear down the wall, keyed to each occasion’s identity so two same-named ones still differ.

@testcase
def test_event_pills_coloured_per_event(page):
    """One event's pills share a colour; a different event takes a different one — so a
    run of an occasion reads as a single colour band."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    evs = [{"rowId": "zzev-cola", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
            "summary": "zzColA", "owner": "konubinix", "status": "confirmed"},
           {"rowId": "zzev-colb", "starttime": "2038-08-01T00:00:00Z", "endtime": "2038-08-30T23:59:59Z",
            "summary": "zzColB", "owner": "konubinix", "status": "confirmed"}]
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzcol-a1", "date": "2038-06-10T12:00:00Z"},   # inside zzColA
            {"cid": "https://ipfs.konubinix.eu/p/zzcol-a2", "date": "2038-06-20T12:00:00Z"},   # inside zzColA
            {"cid": "https://ipfs.konubinix.eu/p/zzcol-b",  "date": "2038-08-10T12:00:00Z"}]   # inside zzColB
    for d in docs: d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
                             "labels": "zzcol", "owner": "konubinix", "state": "todo"})
    for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzcol; since:2038; until:2038")
        expect(tiles(page)).to_have_count(3)
        bg = "el => getComputedStyle(el).backgroundColor"
        a = grid(page).get_by_text("zzColA", exact=True)
        expect(a).to_have_count(2)
        a0, a1 = a.nth(0).evaluate(bg), a.nth(1).evaluate(bg)
        b = grid(page).get_by_text("zzColB", exact=True).evaluate(bg)
        assert a0 == a1, f"same-event pills should share a colour: {a0} vs {a1}"
        assert a0 != b, f"different events should differ in colour: A={a0} B={b}"
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: event pills coloured per event")

That assignment is a single pass over the shown wall:

const EVENT_HUES = ['#8ecae6', '#ffb703', '#90be6d', '#ff8fab', '#bdb2ff', '#ffd6a5', '#a0c4ff', '#caffbf'];
const eventKey = e => e.summary + '|' + e.starttime + '|' + e.owner;
const eventColour = createMemo(() => { const m = new Map(); let i = 0;
    for(const p of items()) for(const e of docEvents(p))
        if(!m.has(eventKey(e))) m.set(eventKey(e), EVENT_HUES[i++ % EVENT_HUES.length]);
    return m; });

On the tile the pill sits along the very bottom edge, beneath the label — so the coloured strips line up tile to tile and the band carries unbroken across a run. Each is filled with its occasion’s hue, dark on the light pastel so the name still reads.

@testcase
def test_event_pill_sits_below_label(page):
    """The event pill sits along the tile's foot, beneath the label — so the colour band
    carries unbroken from one tile to the next."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-foot", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
          "summary": "zzFootEv", "owner": "konubinix", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzfoot", "date": "2038-06-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfoot-t", "labels": "zzfootlbl", "owner": "konubinix", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzfootlbl; since:2038; until:2038")
        expect(tiles(page)).to_have_count(1)
        lbl = grid(page).get_by_text("zzfootlbl").bounding_box()
        pill = grid(page).get_by_text("zzFootEv").bounding_box()
        assert pill["y"] > lbl["y"], f"event pill should sit below the label: pill={pill['y']} label={lbl['y']}"
    finally:
        gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event pill sits below label")

<${Show} when=${() => docEvents(photo).length}>
  <div class="events">
    <${For} each=${() => docEvents(photo)}>${e => html`
      <span class="ev-pill" style=${() => 'background:' + eventColour().get(eventKey(e))}>${() => e.summary}</span>`}<//>
  </div>
<//>

.tile .events{ display:flex; flex-wrap:wrap; gap:3px; padding:4px 5px 0; pointer-events:none; }
.tile .ev-pill{ font-size:9px; line-height:1.35; padding:0 6px; border-radius:999px;
                background:#cbd5e1; color:#101426; max-width:100%; overflow:hidden;
                text-overflow:ellipsis; white-space:nowrap; }

An occasion rarely has a photo to itself, and occasions can overlap. A photo caught inside two at once — an afternoon out within a week away — wears a pill for each, in each event’s own colour, so both read at a glance.

@testcase
def test_overlapping_events_show_two_pills(page):
    """A photo inside two overlapping events wears a pill for each, in each event's colour."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    evs = [{"rowId": "zzev-ova", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
            "summary": "zzOvA", "owner": "konubinix", "status": "confirmed"},
           {"rowId": "zzev-ovb", "starttime": "2038-06-10T00:00:00Z", "endtime": "2038-06-20T23:59:59Z",
            "summary": "zzOvB", "owner": "konubinix", "status": "confirmed"}]
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzov", "date": "2038-06-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzov-t", "labels": "zzov", "owner": "konubinix", "state": "todo"}
    for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzov; since:2038; until:2038")
        expect(tiles(page)).to_have_count(1)
        a = grid(page).get_by_text("zzOvA", exact=True)
        b = grid(page).get_by_text("zzOvB", exact=True)
        expect(a).to_have_count(1); expect(b).to_have_count(1)     # both occasions, on the one tile
        bg = "el => getComputedStyle(el).backgroundColor"
        assert a.evaluate(bg) != b.evaluate(bg), "overlapping events should differ in colour"
    finally:
        gql(DELETE, {"cid": doc["cid"]})
        for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: overlapping events show two pills")

A photo dated outside every occasion wears no pill — the gap in the band that flags a stray date, the wrong timestamp that dropped it clear of the run it belongs to.

@testcase
def test_out_of_event_doc_has_no_pill(page):
    """A photo dated outside every occasion wears no pill — only the in-span photo does."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-out", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
          "summary": "zzOutEv", "owner": "konubinix", "status": "confirmed"}
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzout-in",  "date": "2038-06-15T12:00:00Z"},   # inside the occasion
            {"cid": "https://ipfs.konubinix.eu/p/zzout-off", "date": "2038-08-15T12:00:00Z"}]   # months past it → no occasion
    for d in docs: d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
                             "labels": "zzout", "owner": "konubinix", "state": "todo"})
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzout; since:2038; until:2038")
        expect(tiles(page)).to_have_count(2)
        expect(grid(page).get_by_text("zzOutEv", exact=True)).to_have_count(1)   # only the in-span doc
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: out-of-event doc has no pill")

An occasion is its owner’s alone — two people can hold one on the same day — so a photo takes only its own owner’s, and a namesake belonging to someone else never colours it.

@testcase
def test_event_pill_owner_scoped(page):
    """A photo takes only its own owner's occasions — a namesake owned by another never colours it."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-scopew", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
          "summary": "zzScopeEv", "owner": "aylapomme", "status": "confirmed"}   # aylapomme's alone
    K = {"cid": "https://ipfs.konubinix.eu/p/zzscopew-k", "date": "2038-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscopew-k-t",
         "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzscopew", "state": "todo"}
    A = {"cid": "https://ipfs.konubinix.eu/p/zzscopew-a", "date": "2038-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscopew-a-t",
         "owner": "aylapomme", "mimetype": "image/jpeg", "labels": "zzscopew", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in (K, A): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzscopew; since:2038; until:2038")
        expect(tiles(page)).to_have_count(2)
        expect(grid(page).get_by_text("zzScopeEv", exact=True)).to_have_count(1)   # only aylapomme's photo
    finally:
        for d in (K, A): gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event pill owner scoped")

Telling a video from a photo

A video’s poster thumbnail looks like any still — on the wall you can’t tell you could press play. The mimetype says which is which, so a video wearing a poster earns a play badge. One with no poster at all already shows the film placeholder (placeholder tiles), so it needs no extra mark.

The test searches a fixture set holding one video among the stills, and checks exactly one tile — the video’s — carries the badge.

@testcase
def test_video_tiles_are_marked(page):
    """A video tile wears a play badge; a photo tile doesn't — so the two are
    distinguishable on the wall."""
    make_fixtures()                                  # 3 stills
    gql(CREATE, {"p": VIDEO_FIXTURE})                # + one video, all carry FIXTURE_LABEL
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, FIXTURE_LABEL)
        expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
        expect(grid(page).get_by_title("video")).to_have_count(1)   # only the video tile is badged
    finally:
        gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
    print("  PASS: video tiles are marked")

The badge is a play triangle laid over the poster — shown for a video that carries a thumbnail. It is pure decoration, so it lets pointer events fall through to the tile, leaving the tap, the double-click, and the long-press untouched.

<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
  <span class="play" title="video"></span>
<//>

A small translucent disc, centred, with the triangle nudged right so it reads as centred to the eye.

.tile .play{ position:absolute; top:50%; left:50%; transform:translate(-50%, -50%);
             width:34px; height:34px; border-radius:50%; background:#000a; color:#fff;
             display:flex; align-items:center; justify-content:center;
             font-size:15px; padding-left:3px; pointer-events:none; }

Searching and filtering

You narrow the wall to what you want: a free-text label search, a small query language, and the calendar as a filter.

Search by label

Typing a label narrows the wall to matching photos — the search term is a Solid signal, and createResource re-fetches whenever it changes (no manual wiring, the resource tracks the signal). The shared filter behind photovideosSample does the FTS.

The test types a label that exists and checks the wall shrinks to a non-empty set.

@testcase
def test_search_narrows(page):
    """Typing a label narrows the grid to matching photos."""
    make_fixtures()                                    # 3 fixtures, all carrying FIXTURE_LABEL
    narrow = {"cid": "https://ipfs.konubinix.eu/p/zznarrow", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
              "thumbnailCid": "https://ipfs.konubinix.eu/p/zznarrow-t", "labels": FIXTURE_LABEL + "; zznarrowonly", "state": "todo"}
    gql(DELETE, {"cid": narrow["cid"]}); gql(CREATE, {"p": narrow})   # one also wears a rarer label
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, FIXTURE_LABEL)                # the broad label: every fixture + the narrow doc
        expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
        before = tiles(page).count()
        search_for(page, "zznarrowonly")               # the rarer label narrows to just its one doc
        wait_until(page, lambda: 0 < tiles(page).count() < before)
        expect(tiles(page)).to_have_count(1)
    finally:
        gql(DELETE, {"cid": narrow["cid"]})
    print("  PASS: search narrows")

@testcase
def test_search_persists(page):
    """The search box is saved locally, surviving a reload (and the frame's reboot)."""
    open_app(page)
    search_box(page).fill("type:image; since:2010")
    page.reload(wait_until="commit")
    heading(page).wait_for(timeout=8000)
    expect(search_box(page)).to_have_value("type:image; since:2010")
    print("  PASS: search persists")

Completing labels

Free-text labels rot into near-duplicates (cosmo, cosmos, cosmo =) unless you can see what already exists while typing. The vocabulary and the =labelCompletions function this reads aren’t ours — they live in the frise’s labels schema (the label_vocab table and the label_completions SQL function); this app only consumes them, through the shared LABEL_COMPLETIONS query the frise asks too.

One trap, worth carrying when chasing why a freshly-saved label fails to suggest: photovideo is an inheritance parent, so the rows — and the trigger that keeps label_vocab in step on every edit — live on its photo and video children. A trigger or row check against photovideo itself comes back empty and misleads; look at the children, where the frise’s labels schema wires the trigger up (and rebuilds the vocabulary) — see its apply-vocab-child-triggers block.

A small Suggest dropdown reads it as you type and lets you pick an existing word — the same component under the search box and every add-label box, so they stay consistent. It completes the fragment under the cursor, not the whole field: the search box splits on whitespace (its query is space-separated terms), while an add-label box completes the segment after the last ; (its content is a ;-separated list), so balade;aure offers aurelie and a pick swaps just that segment.

Suggest is the shared completion dropdown: mounted under any box that wants it, it offers words for the live text and hands the picked one back to whoever mounted it. (Under the hood the pick cancels the press that carried it, so pointing at the list never moves the focus off the box at all; the panel then closes because the segment it was completing is finished, not because anything was blurred.) A box that edits a known doc can also hand it the labels already on that doc, which it drops from the offers — so completion never proposes a label the doc already wears. Below two characters it returns nothing — completion on one letter is just noise.

Each box reports its current list to the App and reads the highlight index back — only one box is focused at a time, so a single shared highlight (sugNav) suffices. In the lightbox and the frame this is also why ←=/=→ step the wall only when no field has focus — inside the label box the arrows belong to the text.

Type a fragment of a known label, pick the first suggestion, and the box holds exactly that word — followed by a ;, so the next term can start without reaching for it.

guess(page, "zzbal")                                     # a fragment of one word we know
word = options(page).first.inner_text().strip()
options(page).first.click()
expect(sb).to_have_value(word + "; ")                    # the picked label, then a ';' for the next
print("  PASS: label completion")

Completion drives the DSL’s own tokens, not just labels. A suggestion that completes a token — a label, a full date, a type:=/=owner: value, an event — appends a ;, so the next term starts without your typing the separator, in the search box and the add-label boxes alike. A suggestion that still has somewhere to go keeps the box on that token instead, no ;: a bare key (since:, event:) about to take a value, or a partial date the picker will drill from year to month to day. Which it is isn’t wired token by token — the box asks the suggester whether the picked word still has a longer completion to offer. So owner offers the key, then its values, and owner:konubinix — a closed-vocab value with nothing longer — is done, and a ; opens the next term.

guess(page, "owner")                                        # 2+ chars → the key is offered
expect(options(page).filter(has_text="owner:").first).to_be_visible()
sb.press_sequentially(":k", delay=20)                       # owner:k → its values
opt = options(page).filter(has_text="konubinix").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value("owner:konubinix; ")               # a closed-vocab value is done — a ';' opens the next token
print("  PASS: owner token completes")

The list is keyboard-driven, so a hand never leaves the keys: ↑=/=↓ move a highlight and Enter applies the highlighted word; Enter with nothing highlighted runs the box’s own action instead — in an add-label box it adds the typed labels and Shift+Enter removes them (the keyboard twins of the / buttons), while in the search box it just leaves the live query be. What Enter takes is the word the highlight is on, which is only the same as the first word when you stopped there — so walking past the first and pressing it has to bring back the second.

guess(page, "zzup")
offered = [t.strip() for t in options(page).all_inner_texts()]
assert len(offered) == 2, f"this needs two to choose between, got {offered}"
sb.press("ArrowDown"); sb.press("ArrowDown")             # past the first, onto the second
sb.press("Enter")                                        # apply the highlighted one
expect(sb).to_have_value(offered[1] + "; ")              # the second — taking the first is the easy bug
print("  PASS: search suggestion keyboard")

reaches the list from the other end: with nothing highlighted it enters at the bottom, on the last suggestion.

guess(page, "zzup")                              # two words, so there is a bottom to enter at
items = [t.strip() for t in options(page).all_inner_texts()]
assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
sb.press("ArrowUp")                              # from nothing selected — must enter at the bottom
sel = page.get_by_role("option", selected=True)
assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
    "ArrowUp from none should select the last suggestion"
print("  PASS: suggestion up enters from none")

When the list runs longer than the dropdown can show at once, the moving highlight scrolls it into view, so ↑=/=↓ never land on a row hidden below the fold.

@testcase
def test_completion_keeps_highlight_in_view(page):
    """A list taller than the popover scrolls as ↓ walks it, so the highlight stays visible."""
    open_app(page)
    box = search_box(page)
    box.click()                                       # empty box → all the DSL keys, taller than the popover
    listbox = page.get_by_role("listbox", name="suggestions")
    expect(listbox).to_be_visible()
    assert listbox.evaluate("el => el.scrollHeight > el.clientHeight + 4"), \
        "setup: the menu must overflow the popover for scrolling to matter"
    opts = page.get_by_role("option")
    for _ in range(opts.count()): box.press("ArrowDown")   # walk down to the last key
    active = page.get_by_role("option", selected=True)
    expect(active).to_have_count(1)
    assert active.evaluate("""el => { const b = el.getBoundingClientRect(),
        l = el.closest('[role=listbox]').getBoundingClientRect();
        return b.top >= l.top - 1 && b.bottom <= l.bottom + 1; }"""), \
        "the highlighted option is not within the visible completion area"
    print("  PASS: completion keeps highlight in view")

Applying a suggestion leaves the box on a fresh, empty segment, so the list momentarily blanks. The next keystroke has to bring it back — otherwise completing one term would silently kill completion for the rest, and you would have to click out and in. What makes that work is that picking never costs the box its focus to begin with: a press on the list is stopped from taking it, so the caret stays where you were typing and the next character simply arrives.

In practice that is why what follows types at the page rather than at the box: aimed at the box, a keystroke would focus it on the way in, which is the very click-out-and-in this says you will not need. Typed at the page, the keys go wherever focus actually is, and if the pick had taken it they go nowhere.

page.keyboard.type("zzalo", delay=20)                    # straight on, into whatever holds the keys
expect(options(page).filter(has_text="zzalois").first).to_be_visible()   # must reappear
print("  PASS: completion reopens after pick")

The match reaches inside a word, not only its start: a mid-word smo surfaces cosmo.

guess(page, "osmo")                              # sits inside zzcosmo, nowhere near its start
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
print("  PASS: completion matches inside label")

And it folds case and accent while offering the label exactly as written — a bare zzelod surfaces Zzélodie, its capital and accent kept. Kept on the way in, too: what the box takes is the word as the archive holds it, not the plain thing you typed to find it.

guess(page, "zzelod")                            # no capital, no accent
texts = [t.strip() for t in options(page).all_inner_texts()]
assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
options(page).filter(has_text="Zzélodie").first.click()
expect(sb).to_have_value("Zzélodie; ")
print("  PASS: completion preserves case and accent")

The vocabulary sits behind a GraphQL round-trip, and on a cold box that takes a noticeable beat — long enough that an empty dropdown reads as broken rather than thinking. So the popover opens while the query is still in flight, not only once it has results, and renders a pulsing completing… row; the words replace it the moment they arrive, and on a refine the previous segment’s list stays underneath so the panel never blanks while it catches up.

@testcase
def test_completion_shows_in_flight_hint(page):
    """A completion query in flight shows a 'completing…' hint, so a slow round-trip never
    reads as a broken box. We hold the query open so the in-flight state is observable."""
    open_app(page)
    # let every /graphql through except the label-completion query, which we leave unanswered
    # so the resource stays loading rather than flashing in a sub-second.
    page.route("**/graphql", lambda route:
               route.fallback() if "labelCompletions" not in (route.request.post_data or "") else None)
    search_box(page).click()
    search_box(page).press_sequentially("au", delay=20)      # 2+ chars → a held label-completion query
    expect(page.get_by_text("completing")).to_be_visible()
    print("  PASS: completion shows in-flight hint")

The popover has one collision to win. On a short screen, selecting a doc raises the fixed bottom toolbar, and the search box’s list opens downward far enough to reach it. Where the two overlap, the suggestion you can see must be the one you touch — so the completion draws above the bar, never behind it.

@testcase
def test_search_completion_sits_above_selection_toolbar(page):
    """On a short screen, selecting a doc raises the fixed bottom toolbar. The top search box's
    completion opens downward and can reach the bar — where they overlap the completion must be
    what the user touches, drawn above the bar, not hidden behind it."""
    VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
    VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
    words = [f"zzov{c}" for c in "abcdefghijkl"]        # 12 words → a list tall enough to reach the bar
    for w in words: gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
    try:
        page.set_viewport_size({"width": 360, "height": 300})   # short: the list overlaps the bottom bar
        open_fixtures(page)
        tiles(page).nth(0).click()                     # select one → the bottom toolbar appears
        sb = search_box(page)
        sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20)   # replace search → its completion
        expect(options(page).first).to_be_visible()    # completion open
        expect(toolbar(page)).to_be_visible()          # selection kept → bottom bar still up
        tb = toolbar(page).bounding_box()
        sug = page.get_by_role("listbox", name="suggestions").bounding_box()
        lo, hi = max(tb["y"], sug["y"]), min(tb["y"] + tb["height"], sug["y"] + sug["height"])
        assert hi > lo, f"setup: toolbar {tb} and completion {sug} don't overlap — nothing to test"
        x, y = tb["x"] + tb["width"] / 2, (lo + hi) / 2
        # what the user actually touches at the overlap must be the completion, not the bar behind it
        hit = page.evaluate(
            "([x,y]) => { const el = document.elementFromPoint(x,y);"
            " return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }",
            [x, y])
        assert hit["list"] and not hit["bar"], f"completion is behind the toolbar at the overlap: {hit}"
        print("  PASS: search completion sits above the selection toolbar")
    finally:
        for w in words: gql(VOCAB_DEL, {"w": w})

The search box’s completion wears a few read-only faces — none commits a query — so they share one boot: type, read the popover, and hand back an empty box for the next. The year: token completes like the others — typing it offers the key, then its years.

# the search box offers the year: token, then its year values, like month:/day: do
box = search_box(page)
box.click(); box.press_sequentially("year", delay=20)       # 2+ chars → the key is offered
expect(options(page).filter(has_text="year:").first).to_be_visible()
box.press_sequentially(":202", delay=20)                    # year:202 → its year values
expect(options(page).filter(has_text=re.compile(r"year:202\d")).first).to_be_visible()
print("  PASS: year token completes")
box.fill(""); box.blur()                                    # hand back an empty box
expect(page.get_by_role("listbox", name="suggestions")).to_have_count(0)   # popover gone

The query language is only useful if you can find it. So the empty search box, the moment it takes focus, drops down the whole token menu — every prefix at once — turning the box from something you must already know into something you can discover.

# focusing the empty box offers the whole token menu (so the language is discoverable)
search_box(page).click()                              # empty box, just focused — no typing
expect(options(page)).to_have_count(14)               # the whole menu, at once
got = sorted(t.strip() for t in options(page).all_inner_texts())
assert got == sorted(["since:", "until:", "type:", "sort:", "owner:", "month:", "day:",
                      "year:", "date:", "event:", "first:", "last:", "sample:",
                      "onthisday"]), got              # every prefix present (order-free)
print("  PASS: empty box suggests all prefixes")
search_box(page).blur()                                     # hand back closed
expect(page.get_by_role("listbox", name="suggestions")).to_have_count(0)   # popover gone

The box and its list form a combobox: the input carries role=combobox and an aria-expanded that reads true whenever the completion popover is up — while it loads (the completing… flash) and while it lists results. The popover shows in both states, so an aria-expanded that tracked only the results would announce collapsed over a visible loading popover; keyed on either, it is the one place — for a screen reader and anything else — that says whether the popover is up. Closing it is a state flip, not a timed fade: blur the box and the list is gone at once, nothing to wait out.

# the box reports its popover via aria-expanded; closing is a state flip, no timing
sb = search_box(page)
sb.click(); sb.press_sequentially("cos")
expect(sb).to_have_attribute("aria-expanded", "true")     # open popover → state true
sb.blur()
expect(sb).to_have_attribute("aria-expanded", "false")    # closed → state false (what search_for waits on)
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover must be gone the instant the state flips"
print("  PASS: search completion state, no timing")
sb.fill(""); sb.blur(); expect(sb).to_have_attribute("aria-expanded", "false")   # hand back an empty box

@testcase
def test_search_expanded_while_loading(page):
    """The search box reads aria-expanded=true while its completion list is loading."""
    open_app(page); hold_completions(page)
    expanded_while_loading(page, search_box(page))
    print("  PASS: search expanded while loading")

@testcase
def test_lightbox_label_expanded_while_loading(page):
    """The lightbox add-label box reads aria-expanded=true while its list is loading."""
    open_fixtures(page); open_doc(page); hold_completions(page)
    expanded_while_loading(page, dialog(page).get_by_placeholder("add a label…"))
    print("  PASS: lightbox label expanded while loading")

@testcase
def test_frame_label_expanded_while_loading(page):
    """The frame add-label box reads aria-expanded=true while its list is loading."""
    open_fixtures(page)
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    page.get_by_role("list", name="slideshow").click()      # a tap reveals the frame bar
    hold_completions(page)
    expanded_while_loading(page, page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…"))
    print("  PASS: frame label expanded while loading")

@testcase
def test_batch_label_expanded_while_loading(page):
    """The selection's add-label box reads aria-expanded=true while its list is loading."""
    open_fixtures(page); select_all(page).click(); hold_completions(page)
    expanded_while_loading(page, toolbar(page).get_by_placeholder("add a label…"))
    print("  PASS: batch label expanded while loading")

Completion draws on two capped vocabularies, each its own query: the labels already in use (labelCompletions), and the calendar’s event summaries (eventCompletions), scoped to a date window.

const fetchCompletions = async word => {
    if((word || '').length < 2) return [];
    const d = await gql(LABEL_COMPLETIONS, { prefix: word, first: 8 });
    return d?.labelCompletions?.nodes ?? [];
};
const EVENT_COMPLETIONS = `query($prefix:String!,$since:Datetime,$until:Datetime,$first:Int){
  eventCompletions(prefix:$prefix, since:$since, until:$until, first:$first){ nodes } }`;
const fetchEventCompletions = async (prefix, since, until) => {
    const d = await gql(EVENT_COMPLETIONS, { prefix: prefix || '', since, until, first: 8 });
    return d?.eventCompletions?.nodes ?? [];
};

What the current word wants depends on where the caret sits: in the search box’s DSL an empty box offers every key, an event: token the calendar’s summaries, a since:=/date fragment the date picker, a bare key its own name. A bare word completes a label — and, in the search box, also any event whose name it matches, offered as a ready =event: token, so the same letters reach both a photo’s label and the occasion it was shot during. The label boxes stay labels-only: a doc takes a label, not an event.

async function suggestFor(text, caret, dsl, present){
    if(dsl && !(text || '').trim()) return DSL_KEYS;
    const word = segAt(text, caret);
    const ev = dsl && /^event:(.*)$/i.exec(word);
    if(ev){ const q = parseQuery(text);
        return (await fetchEventCompletions(ev[1], q.since, q.until)).map(s => 'event:' + s); }
    if(dsl){ const d = dslSuggestions(word); if(d.length) return d; }
    if(word.includes(':')) return [];
    const has = (present || []).map(s => s.toLowerCase());
    const drop = ws => has.length ? ws.filter(w => !has.includes(w.toLowerCase())) : ws;
    if(!dsl) return drop(await fetchCompletions(word));
    const q = parseQuery(text);
    const [labels, events] = await Promise.all([
        fetchCompletions(word),
        word.length >= 2 ? fetchEventCompletions(word, q.since, q.until) : Promise.resolve([]),
    ]);
    return [...drop(labels).slice(0, 5), ...events.map(s => 'event:' + s).slice(0, 3)];
}

Suggest is that shared popover made concrete: it recomputes its offers whenever the text or caret moves, the caret defaulting to end-of-text so a box with no cursor of its own still completes its last segment.

function Suggest(props){
    const caret = () => props.caret == null ? (props.text || '').length : props.caret;
    const [items] = createResource(() => [props.text, caret(), props.present],
                                   ([t, c, p]) => suggestFor(t, c, props.dsl, p));
    createEffect(() => props.onItems?.(items() || []));
    createEffect(() => props.onLoading?.(items.loading));
    const active = () => props.active == null ? -1 : props.active;   // a 0-arg accessor prop arrives unwrapped
    let listEl;
    createEffect(() => { const i = active();
        if(i >= 0 && listEl) listEl.querySelectorAll('[role=option]')[i]?.scrollIntoView({ block: 'nearest' }); });
    return html`
      <${Show} when=${() => items.loading || (items() || []).length > 0}>
        <ul class="suggest" role="listbox" aria-label="suggestions" ref=${el => listEl = el} aria-busy=${() => items.loading ? 'true' : 'false'}>
          <${Show} when=${() => items.loading}>
            <li class="sug-loading" aria-disabled="true">completing…</li>
          <//>
          <${For} each=${() => items() || []}>${(w, i) => html`
            <li class=${() => 'sug' + (i() === active() ? ' active' : '')} role="option"
                aria-selected=${() => i() === active() ? 'true' : 'false'}
                onMouseDown=${e => { e.preventDefault(); props.onPick(w); }}>${w}</li>`}
          <//>
        </ul>
      <//>`;
}

The screen stacks in named layers: the content wall at the base, the fixed selection toolbar above it (z-index:20), then the transient overlays — a completion popover (25), the busy updating… pill (30), the drag marquee (50), the lightbox (100), the frame (200). The popover sits just above the toolbar because the two are the only pair that share the screen at once: on a short screen the search box’s list opens downward far enough to reach the bottom bar, and a suggestion you can see but not touch is worse than none. It stays below busy/marquee/ lightbox/frame, which never coexist with an open search completion.

.complete{ position:relative; }
/* completion-popover layer — see the stacking order in prose */
.complete .suggest{ z-index:25; }
/* a long list still scrolls, but with a thin themed bar — the chunky default one clashes
   with the dark panel (scrollbar-color for Firefox/modern Chromium, ::-webkit for the rest). */
.suggest{ position:absolute; z-index:10; left:0; right:0; top:100%; margin:2px 0 0; padding:4px;
          list-style:none; background:#1b1d2e; border:1px solid #3a3f5a; border-radius:6px;
          max-height:240px; overflow:auto; box-shadow:0 6px 20px #0008;
          scrollbar-width:thin; scrollbar-color:#4a4f6e transparent; }
.suggest::-webkit-scrollbar{ width:8px }
.suggest::-webkit-scrollbar-thumb{ background:#4a4f6e; border-radius:8px }
.suggest::-webkit-scrollbar-track{ background:transparent }
.sug{ padding:9px 10px; border-radius:4px; cursor:pointer; font-size:14px; }
.sug:hover, .sug.active{ background:#33395a; }
.sug-loading{ padding:9px 10px; font-size:14px; color:#8a8ea5; cursor:default;
              animation:sug-pulse 1s ease-in-out infinite; }
@keyframes sug-pulse{ 0%,100%{ opacity:.45 } 50%{ opacity:.9 } }

A query language in the search box

The search box wears the shared query language — bare labels beside since:=/=until:=/=type:=/=sort:=/=owner:=/=onthisday tokens, ;-separated so a label keeps its spaces (parc des oiseaux, famille sam). (State stays on the chips for now — folding it in would mean either retiring the chips or syncing them, a separate decision.) What’s memories’ own is how the box carries it: completion is segment-aware, working on the segment the cursor sits in — offering the language’s tokens when you start one and vocabulary labels otherwise — and a picked label gets a trailing ; so the next one starts without your typing the separator.

The free text — every segment that isn’t a typed token — is a boolean label search, handed to Postgres websearch_to_tsquery untouched: space-separated terms must all match, or unions them, a leading - excludes, and "…" forces a phrase. Two docs, one tagged zza and one zzb (both zzbool), pin the operators down.

@testcase
def test_boolean_search(page):
    """Free text is a websearch query: =or= unions results, a leading =-= excludes."""
    drop_fixtures()
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbool-a", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbool-a-t", "labels": "zzbool zza", "state": "todo"},
            {"cid": "https://ipfs.konubinix.eu/p/zzbool-b", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbool-b-t", "labels": "zzbool zzb", "state": "todo"}]
    for d in docs: gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zza or zzb")
        expect(tiles(page)).to_have_count(2)                  # or unions the two
        search_for(page, "zzbool -zza")
        expect(tiles(page)).to_have_count(1)                  # - excludes the zza doc
        expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbool-b-t")
        print("  PASS: boolean search")
    finally:
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

The date test starts from the three fixtures (dated Jan/Feb/Mar 2020) and tightens the bounds: since:2020-02 drops January, then adding until:2020-02 leaves only February.

@testcase
def test_date_range(page):
    """since:/until: tokens in the box bound the search to a date range."""
    open_fixtures(page)                                   # 3 fixtures: 2020-01, -02, -03
    search_for(page, FIXTURE_LABEL + "; since:2020-02")
    expect(tiles(page)).to_have_count(2)                  # January falls before the bound
    search_for(page, FIXTURE_LABEL + "; since:2020-02; until:2020-02")
    expect(tiles(page)).to_have_count(1)                  # only February is in range
    print("  PASS: date range")

The default upper bound reaches the end of today, not its midnight — so a photo shot today is on the wall like any other, with no until: token needed.

@testcase
def test_today_photos_shown_by_default(page):
    """The default window runs to the end of today, so a photo shot today is on the wall."""
    from datetime import date
    doc = {"cid": "https://ipfs.konubinix.eu/p/zztoday", "date": date.today().isoformat() + "T12:00:00Z",
           "mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zztoday-t",
           "labels": "zztodayphoto", "state": "todo"}
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page)                          # default view, default window (no until: token)
        search_for(page, "zztodayphoto")
        expect(tiles(page)).to_have_count(1)    # today's photo is in range
    finally:
        gql(DELETE, {"cid": doc["cid"]})
    print("  PASS: today photos shown by default")

A bound doesn’t have to be a spelled-out date. The shared query language also reads a relative phrasesince:2 months ago, until:3 days ago, a bare lastweek — resolved against today, so a rolling window costs no arithmetic. A ;-split segment keeps the spaces, so the whole phrase reaches the parser intact.

@testcase
def test_relative_date_bound(page):
    """A relative phrase bounds the window — since:N months ago keeps only the recent side."""
    from datetime import datetime, timedelta, timezone
    now = datetime.now(timezone.utc)
    recent = (now - timedelta(days=30)).strftime("%Y-%m-%dT12:00:00Z")    # ~1 month ago
    old    = (now - timedelta(days=180)).strftime("%Y-%m-%dT12:00:00Z")   # ~6 months ago
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzrel-recent", "date": recent, "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzrel-recent-t", "labels": "zzreldate", "state": "todo"},
            {"cid": "https://ipfs.konubinix.eu/p/zzrel-old", "date": old, "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzrel-old-t", "labels": "zzreldate", "state": "todo"}]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzreldate")
        expect(tiles(page)).to_have_count(2)
        search_for(page, "zzreldate; since:2 months ago")
        expect(tiles(page)).to_have_count(1)                       # only the ~1-month-old doc
        expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzrel-recent-t")
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: relative date bound")

A bound can also drop its year and name just the season you mean: until:july is the July just gone, until:07-14 the last 14th of July, until:july-14 the same in words. The year it takes is whichever one that date last came round in, so the token keeps meaning last without ever being retyped. Here two docs sit either side of one 14th of July, and each spelling of it separates them the same way — the same way exactly, since both land on the identical instant.

@testcase
def test_bare_month_and_day_bounds(page):
    """until: takes a month, or a month and a day, with no year — the most recent one."""
    from datetime import date
    # last July's 14th, whichever year that was: this year if it has been, else the year before
    y = date.today().year - (0 if (date.today().month, date.today().day) >= (7, 14) else 1)
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbare-before", "date": f"{y}-07-10T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-before-t", "labels": "zzbare", "state": "todo"},
            {"cid": "https://ipfs.konubinix.eu/p/zzbare-after", "date": f"{y}-07-20T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-after-t", "labels": "zzbare", "state": "todo"}]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        for spelling in ("07-14", "july-14"):
            search_for(page, "zzbare")
            expect(tiles(page)).to_have_count(2)                # both, unbounded
            search_for(page, f"zzbare; until:{spelling}")
            expect(tiles(page)).to_have_count(1)                # only the 10th survives the bound
            expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbare-before-t")
        search_for(page, "zzbare; since:july")                  # the whole of that July, both back
        expect(tiles(page)).to_have_count(2)
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: bare month and day bounds")

And you shouldn’t have to type a whole date: once a since: / until: token is open, completion turns into a progressive date picker — years (recent first), then the months of a chosen year — so a range is a couple of taps. Picking a partial date keeps the segment open so you can drill on — year to month to day; picking the full day (or a relative word) closes it with a ;, the same as a label does, and the list waits, empty, for the next token.

@testcase
def test_date_completion(page):
    """since:/until: completes year → month → day — tap a date, don't type it."""
    open_app(page)
    search_box(page).click()
    search_box(page).fill("since:")
    year = options(page).get_by_text("since:2020", exact=True)
    expect(year).to_be_visible()                          # years are offered
    year.click()
    expect(search_box(page)).to_have_value("since:2020")
    month = options(page).get_by_text("since:2020-06", exact=True)
    expect(month).to_be_visible()                         # then months
    month.click()
    day = options(page).get_by_text("since:2020-06-15", exact=True)
    expect(day).to_be_visible()                           # then days
    day.click()
    expect(search_box(page)).to_have_value("since:2020-06-15; ")   # a full date is done — a ';' opens the next token
    print("  PASS: date completion")

@testcase
def test_relative_dates(page):
    """Relative since: shortcuts resolve to a recent date, and complete from a prefix."""
    open_fixtures(page)                                   # fixtures are dated 2020
    search_for(page, FIXTURE_LABEL + "; since:lastyear")
    expect(tiles(page)).to_have_count(0)                  # last year is well past 2020
    search_box(page).fill("since:last")
    expect(options(page)).to_have_text(["since:lastweek", "since:lastmonth", "since:lastyear"])
    print("  PASS: relative dates")

The fixed shortcuts cover the common cases, but sometimes the offset is specific. since:N days ago — and weeks, months, years, singular or plural — resolves N units back from today. The fixtures sit in 2020, so any recent offset puts the lower bound past them (nothing shown), while a long-enough offset reaches back before them (all shown) — which also proves the count N actually moves the bound.

@testcase
def test_relative_dates_ago(page):
    """since:N days/weeks/months/years ago resolves a bound that scales with N."""
    open_fixtures(page)                                   # fixtures are dated 2020
    for token in ["7 days ago", "3 weeks ago", "6 months ago", "1 year ago"]:
        search_for(page, FIXTURE_LABEL + "; since:" + token)
        expect(tiles(page)).to_have_count(0)              # a recent bound is past the 2020 fixtures
    search_for(page, FIXTURE_LABEL + "; since:20 years ago")
    expect(tiles(page)).to_have_count(len(FIXTURES))      # far enough back, they return
    print("  PASS: relative dates (N ago)")

The picker only ever offers a date you could really tap: a month is one through twelve, a day one to that month’s last. So a half-typed 2022-0 names no month and 2022-06-0 no day, and there the picker stays silent rather than proposing a date that isn’t.

@testcase
def test_date_picker_offers_only_real_dates(page):
    """The date picker drills only real dates — an out-of-range month or day offers nothing."""
    open_app(page)
    box = search_box(page)
    box.click()
    box.fill("since:2022")
    expect(options(page).get_by_text("since:2022-06", exact=True)).to_be_visible()   # picker is live: real months show
    for bad in ["since:2022-0", "since:2022-13"]:                                     # a 0 month and a 13th month
        box.fill(bad)
        expect(options(page)).to_have_count(0)                                        # no such month → nothing offered
    box.fill("since:2022-06-0")                                                       # the 0th day of a real month
    expect(options(page)).to_have_count(0)
    print("  PASS: date picker offers only real dates")

onthisday is the anniversary view: today’s month-day ±1 (or onthisday:N) across all years — what the frame shows to surface “this day in years past”. It’s a server predicate (anniv_dist, year ignored, wrapping at the year boundary), so it samples and bulk-edits like any other filter, and composes with since:=/=until: to bound the years.

@testcase
def test_onthisday(page):
    """onthisday matches today's month-day ±N across every year."""
    from datetime import date, timedelta
    t = date.today()
    anchored = lambda days, yr: (t + timedelta(days=days)).replace(year=yr).isoformat() + "T12:00:00Z"
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzanniv-{i}", "date": anchored(off, yr), "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzanniv-t-{i}", "labels": "zzanniv", "state": "todo"}
            for i, (off, yr) in enumerate([(0, 2010), (1, 2015), (5, 2018)])]   # ±0, ±1, ±5 days
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)                                      # default state todo; all three are todo
        search_for(page, "zzanniv; onthisday")
        expect(tiles(page)).to_have_count(2)                # ±0 and ±1 only
        search_for(page, "zzanniv; onthisday:5")
        expect(tiles(page)).to_have_count(3)                # ±5 now included
        search_box(page).fill("ontH")                       # and it completes
        expect(options(page)).to_have_text(["onthisday"])
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: onthisday")

Completion follows the cursor, not the end of the line: with the caret inside an earlier segment it completes that one. The box tracks the caret position and hands it to Suggest, which completes the segment the caret sits in and replaces exactly that segment.

@testcase
def test_completion_at_cursor(page):
    """With the caret inside an earlier token, completion works on that token."""
    open_app(page)
    box = search_box(page)
    box.click()
    box.fill("until:; since:2026")                     # two date segments
    box.press("Home")
    for _ in range(6): box.press("ArrowRight")         # caret to just after "until:"
    expect(options(page).get_by_text("until:2026", exact=True)).to_be_visible()   # until:, not since:
    expect(options(page).get_by_text("since:2026", exact=True)).to_have_count(0)
    print("  PASS: completion at cursor")

Completion knows the language: a prefix of a key offers the key, not a label.

@testcase
def test_dsl_key_completion(page):
    """Typing a key prefix offers since:/until:, and picking inserts the token."""
    open_app(page)
    search_box(page).click()
    search_box(page).fill("sin")
    expect(options(page)).to_have_text(["since:"])        # the key, not some label
    options(page).first.click()
    expect(search_box(page)).to_have_value("since:")
    print("  PASS: dsl key completion")

The date: token completes like the rest — a prefix offers the key, and once open it drills the same year→month→day picker as since:=/=until:.

@testcase
def test_date_token_completes(page):
    """The search box offers the date: token and drills its period like since:/until: do."""
    open_app(page)
    box = search_box(page)
    box.click(); box.press_sequentially("date", delay=20)       # 2+ chars → the key is offered
    expect(options(page).filter(has_text="date:").first).to_be_visible()
    box.press_sequentially(":2020", delay=20)                   # date:2020 → its months
    expect(options(page).filter(has_text=re.compile(r"date:2020-\d\d")).first).to_be_visible()
    print("  PASS: date token completes")

A type:image / type:video token filters by kind — server-side, before the sample, via the shared functions’ kinds argument — and completes the same way.

@testcase
def test_type_filter(page):
    """type:video / type:image narrow the wall to that kind."""
    make_fixtures()                                  # 3 images
    gql(CREATE, {"p": VIDEO_FIXTURE})                # + one video, all carry FIXTURE_LABEL
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, FIXTURE_LABEL + "; type:video")
        expect(tiles(page)).to_have_count(1)         # just the video
        search_for(page, FIXTURE_LABEL + "; type:image")
        expect(tiles(page)).to_have_count(len(FIXTURES))   # just the images
    finally:
        gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
    print("  PASS: type filter")

@testcase
def test_type_completion(page):
    """type: completes to the two kinds."""
    open_app(page)
    search_box(page).click()
    search_box(page).fill("type:")
    expect(options(page)).to_have_text(["type:image", "type:video"])
    print("  PASS: type completion")

@testcase
def test_sort_random(page):
    """sort:random reorders the wall by myrandom (and completes); date is the default."""
    open_fixtures(page)                                        # date order → thumb-0 first
    expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    search_box(page).fill(FIXTURE_LABEL + "; sort:random")     # myrandom asc → thumb-1 first
    expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    search_box(page).fill("sort:")                             # and it completes
    expect(options(page)).to_have_text(["sort:date", "sort:random"])
    print("  PASS: sort random")

@testcase
def test_multiword_label_completion(page):
    """A spaced label completes and is inserted as one unit, then a ';' is added for the next."""
    open_app(page)
    sb = search_box(page)
    sb.click(); sb.press_sequentially("parc des oise", delay=10)   # part of a multi-word label
    opt = options(page).filter(has_text="oiseaux").first
    expect(opt).to_be_visible()
    word = opt.inner_text().strip()                            # the label as written (any casing)
    opt.click()
    expect(sb).to_have_value(word + "; ")                      # whole label, not "parc des parc des oiseaux"
    print("  PASS: multiword label completion")

Filtering by event

A search can ask when by name: event:NAME keeps only the photos taken during a calendar event whose summary matches — the wall narrowed to an occasion. It is a server-side span test, threaded through the very photovideos_* filter the other tokens use (so the sample and its count agree), and it composes with the rest of the query — a label beside it still narrows first.

@testcase
def test_event_token_filters_wall(page):
    """event:NAME narrows the wall to the photos whose date falls inside that event's span."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-span", "starttime": "2020-08-10T00:00:00Z", "endtime": "2020-08-20T23:59:59Z",
          "summary": "zzSpanEvent", "owner": "konubinix", "status": "confirmed"}
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzef-in1", "date": "2020-08-12T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzef-in1-t"},
            {"cid": "https://ipfs.konubinix.eu/p/zzef-in2", "date": "2020-08-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzef-in2-t"},
            {"cid": "https://ipfs.konubinix.eu/p/zzef-out", "date": "2020-08-25T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzef-out-t"}]
    for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzeventf", "owner": "konubinix", "state": "todo"})
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzeventf")
        expect(tiles(page)).to_have_count(3)                              # all three, unfiltered
        search_for(page, "zzeventf; event:zzSpanEvent")
        expect(tiles(page)).to_have_count(2)                              # only those inside the span
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzef-in1-t']")).to_have_count(1)
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzef-in2-t']")).to_have_count(1)
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzef-out-t']")).to_have_count(0)   # the out-of-span one, gone
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event token filters wall")

The same token, negated, does the opposite. -event:NAME keeps every photo except those taken during an occasion of that name — the way to say “everything from that trip, but not the theme park itself”. A leading - is the shared query language’s one negation mark, and an event: name has no closed vocabulary to complement, so the exclusion rides all the way to the same span filter, which drops instead of keeps. It composes with the rest of the query exactly as the include does — a label beside it still narrows first.

@testcase
def test_event_exclusion_filters_wall(page):
    """-event:NAME keeps every photo except those taken during that occasion."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-disney", "starttime": "2020-08-10T00:00:00Z", "endtime": "2020-08-20T23:59:59Z",
          "summary": "zzDisneyland", "owner": "konubinix", "status": "confirmed"}
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzx-in",  "date": "2020-08-12T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzx-in-t"},    # during the event
            {"cid": "https://ipfs.konubinix.eu/p/zzx-out", "date": "2020-08-25T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzx-out-t"}]   # outside it
    for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzexcl", "owner": "konubinix", "state": "todo"})
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzexcl")
        expect(tiles(page)).to_have_count(2)                             # both, unfiltered
        search_for(page, "zzexcl; -event:zzDisneyland")
        expect(tiles(page)).to_have_count(1)                            # the in-span one dropped
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzx-out-t']")).to_have_count(1)
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzx-in-t']")).to_have_count(0)   # excluded
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: -event: exclusion filters wall")

Three edges the span filter must hold: a cancelled event is no occasion (its span pulls in nothing); an accented summary is matched by a plain-typed search (both sides accent-folded); and the span is inclusive at its end, so a photo dated exactly at endtime still counts.

@testcase
def test_event_filter_edges(page):
    """event: excludes cancelled events, matches accented summaries (both sides unaccented),
    and is inclusive at the span's end."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    events = [
        {"rowId": "zzev-acc",  "starttime": "2021-05-01T00:00:00Z", "endtime": "2021-05-31T23:59:59Z",
         "summary": "zzÉdgéFest", "owner": "konubinix", "status": "confirmed"},        # accented summary
        {"rowId": "zzev-canc", "starttime": "2021-09-01T00:00:00Z", "endtime": "2021-09-30T23:59:59Z",
         "summary": "zzCancelledFest", "owner": "konubinix", "status": "cancelled"}]   # a cancelled event
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzedge-in",   "date": "2021-05-15T12:00:00Z"},              # inside the accented event
            {"cid": "https://ipfs.konubinix.eu/p/zzedge-bnd",  "date": "2021-05-31T23:59:59Z"},              # exactly at endtime
            {"cid": "https://ipfs.konubinix.eu/p/zzedge-canc", "date": "2021-09-15T12:00:00Z"}]              # inside the CANCELLED event only
    for d in docs: d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
                             "labels": "zzedgef", "owner": "konubinix", "state": "todo"})
    for e in events: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzedgef; event:zzedgefest")                # accented summary, plain-typed search
        expect(tiles(page)).to_have_count(2)                         # the in-span doc + the endtime-boundary doc
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzedge-bnd-t']")).to_have_count(1)   # boundary counts (inclusive)
        search_for(page, "zzedgef; event:zzCancelledFest")
        expect(tiles(page)).to_have_count(0)                         # a cancelled event pulls in nothing
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        for e in events: gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: event filter edges (cancelled, accent, boundary)")

The filter is owner-scoped, like the lightbox’s events: event:NAME asks each photo whether one of its own owner’s events matches, so a namesake event belonging to someone else never pulls a photo in. Two people can hold a “Piscine” on the same day; the search keeps each owner’s own.

@testcase
def test_event_filter_is_owner_scoped(page):
    """event:NAME scopes by each photo's own owner — a namesake event of another owner won't pull it in."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-scope", "starttime": "2020-09-10T00:00:00Z", "endtime": "2020-09-20T23:59:59Z",
          "summary": "zzScopeEvent", "owner": "aylapomme", "status": "confirmed"}   # only aylapomme has it
    P = {"cid": "https://ipfs.konubinix.eu/p/zzscope-k", "date": "2020-09-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscope-k-t",
         "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzscope", "state": "todo"}
    R = {"cid": "https://ipfs.konubinix.eu/p/zzscope-a", "date": "2020-09-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscope-a-t",
         "owner": "aylapomme", "mimetype": "image/jpeg", "labels": "zzscope", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in (P, R): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzscope")
        expect(tiles(page)).to_have_count(2)                              # both, same day, same label
        search_for(page, "zzscope; event:zzScopeEvent")
        expect(tiles(page)).to_have_count(1)                             # only the aylapomme photo — its owner's event
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzscope-a-t']")).to_have_count(1)
        expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzscope-k-t']")).to_have_count(0)   # konubinix has no such event
    finally:
        for d in (P, R): gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event filter is owner-scoped")

You don’t even need the event: token: a plain word matches the calendar too. Search karate and it turns up the photos tagged karate and the ones taken during a Karate occasion that were never tagged — the shared filter runs the free text against event summaries just as it runs it against labels. That reach is read-only, though: a label can be lifted off a doc, an event cannot — it is the calendar’s.

@testcase
def test_search_matches_events_like_labels(page):
    """A plain search term matches a photo by its label OR by an event it was taken during."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-karate", "starttime": "2043-06-15T09:00:00Z", "endtime": "2043-06-15T18:00:00Z",
          "summary": "zzKaratefest", "owner": "konubinix", "status": "confirmed"}   # a short occasion in an empty future window
    by_label = {"cid": "https://ipfs.konubinix.eu/p/zzk-label", "date": "2043-01-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzk-label-t",
                "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzkaratefest", "state": "todo"}   # by label, outside the event span
    by_event = {"cid": "https://ipfs.konubinix.eu/p/zzk-event", "date": "2043-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzk-event-t",
                "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzkevfest", "state": "todo"}       # no karate label — pulled in only by the event
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in (by_label, by_event): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzkaratefest; since:2043; until:2043")
        expect(tiles(page)).to_have_count(2)              # the tagged doc AND the doc taken during zzKaratefest
        search_for(page, "zzkevfest; since:2043; until:2043")   # the event doc's own label matches only it
        expect(tiles(page)).to_have_count(1)
    finally:
        for d in (by_label, by_event): gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: search matches events like labels")

That reach shows up while you type, too. A bare word offers not just the labels that start with it but any event whose name it matches, each handed over as a ready event: token — so kara surfaces the label karate beside event:Open Karaté, and the same few letters lead to both a tagged photo and the occasion another was shot during.

@testcase
def test_bare_word_completes_events(page):
    """A bare word in the search box offers a matching event, handed over as a ready event: token."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-karc", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
          "summary": "zzKar Open", "owner": "konubinix", "status": "confirmed"}
    during = {"cid": "https://ipfs.konubinix.eu/p/zzkc-ev", "date": "2020-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzkc-ev-t",
              "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzduringonly", "state": "todo"}  # gives the event a photo, so it completes
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": during["cid"]}); gql(CREATE, {"p": during})
    try:
        open_app(page); chip(page, "all").click()
        box = search_box(page)
        box.click(); box.press_sequentially("zzkar", delay=20)                    # a bare word, not an event: token
        ev_opt = options(page).filter(has_text="event:zzKar Open").first
        expect(ev_opt).to_be_visible()                                            # the event, offered as a token
        ev_opt.click()
        expect(box).to_have_value("event:zzKar Open; ")                           # picked, as a token
    finally:
        gql(DELETE, {"cid": during["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: bare word completes events")

The box completes event: as it does the other tokens — a prefix of the key offers it.

@testcase
def test_event_token_completes_key(page):
    """Typing a prefix of the key offers event: (like owner:/year: do)."""
    open_app(page)
    box = search_box(page)
    box.click(); box.press_sequentially("eve", delay=20)      # 2+ chars → the key is offered
    expect(options(page).filter(has_text="event:").first).to_be_visible()
    print("  PASS: event token completes key")

Once you’re inside event:, the box offers the calendar’s own vocabulary — the event summaries in use, matched as you type — the same dynamic completion labels get, but drawn from the calendar. Picking one fills the token with that summary.

@testcase
def test_event_value_completes(page):
    """Inside event:, the box offers calendar event summaries; picking one fills the token."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-complete", "starttime": "2020-10-01T00:00:00Z", "endtime": "2020-10-02T23:59:59Z",
          "summary": "zzPiscineComplete", "owner": "konubinix", "status": "confirmed"}
    photo = {"cid": "https://ipfs.konubinix.eu/p/zzpisc-p", "date": "2020-10-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzpisc-p-t",
             "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzpiscp", "state": "todo"}   # so it qualifies
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
    try:
        open_app(page)
        box = search_box(page)
        box.click(); box.press_sequentially("event:zzPisc", delay=20)   # inside the token, a summary prefix
        opt = options(page).filter(has_text="zzPiscineComplete").first
        expect(opt).to_be_visible()                                     # the calendar summary is offered
        opt.click()
        expect(box).to_have_value("event:zzPiscineComplete; ")          # a completed event token, then a ';' for the next
    finally:
        gql(DELETE, {"cid": photo["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event value completes")

The match isn’t one contiguous run. Type the words of a name in any order, separated by spaces, and each just has to appear — so eur par and par eur both reach Europa Parc, handy when you hold two pieces of a name but not their order. The box draws these from the shared event_completions.

@testcase
def test_event_value_completes_multiword(page):
    """Inside event:, space-separated words match a summary in any order — eur par and par eur both find it."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-europa", "starttime": "2020-08-01T00:00:00Z", "endtime": "2020-08-02T23:59:59Z",
          "summary": "zzEuropa Parc", "owner": "konubinix", "status": "confirmed"}
    photo = {"cid": "https://ipfs.konubinix.eu/p/zzeuropa-p", "date": "2020-08-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzeuropa-p-t",
             "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzeuropap", "state": "todo"}   # so it qualifies
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
    try:
        open_app(page)
        box = search_box(page)
        box.click(); box.press_sequentially("event:eur par", delay=20)      # two words, out of order
        expect(options(page).filter(has_text="zzEuropa Parc").first).to_be_visible()
        box.fill(""); box.press_sequentially("event:par eur", delay=20)      # the same two, reversed
        expect(options(page).filter(has_text="zzEuropa Parc").first).to_be_visible()
    finally:
        gql(DELETE, {"cid": photo["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event value completes multiword")

And the menu is scoped to the query’s own date range: with a since:=/=until: in play, event: offers only the events overlapping that window — the occasions you could actually be looking at — not the whole calendar’s history.

@testcase
def test_event_completion_within_window(page):
    """event: suggestions are scoped to the query's date window — an out-of-window event isn't offered."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    a = {"rowId": "zzev-win-in",  "starttime": "2019-06-01T00:00:00Z", "endtime": "2019-06-02T00:00:00Z",
         "summary": "zzWinIn",  "owner": "konubinix", "status": "confirmed"}
    b = {"rowId": "zzev-win-out", "starttime": "2021-06-01T00:00:00Z", "endtime": "2021-06-02T00:00:00Z",
         "summary": "zzWinOut", "owner": "konubinix", "status": "confirmed"}
    # each event gets a photo in its span, so the window (not has-photo) is what excludes zzWinOut
    pics = [{"cid": "https://ipfs.konubinix.eu/p/zzwin-in-p",  "date": "2019-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzwin-in-p-t",
             "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzwinp", "state": "todo"},
            {"cid": "https://ipfs.konubinix.eu/p/zzwin-out-p", "date": "2021-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzwin-out-p-t",
             "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzwinp", "state": "todo"}]
    for e in (a, b): gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    for p in pics: gql(DELETE, {"cid": p["cid"]}); gql(CREATE, {"p": p})
    try:
        open_app(page)
        box = search_box(page)
        box.click(); box.press_sequentially("since:2019; until:2019; event:zzWin", delay=15)
        expect(options(page).filter(has_text="zzWinIn").first).to_be_visible()   # in window, has a photo
        expect(options(page).filter(has_text="zzWinOut")).to_have_count(0)        # 2021, outside the window
    finally:
        for p in pics: gql(DELETE, {"cid": p["cid"]})
        for e in (a, b): gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: event completion within window")

That listing starts from the bare event:, before you’ve typed any name — the window’s events laid out to pick from — so you can browse the occasions as well as match one by typing.

@testcase
def test_event_lists_on_bare_key(page):
    """The bare event: (no name typed yet) already lists the window's events to pick from."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-bare", "starttime": "2033-06-01T00:00:00Z", "endtime": "2033-06-02T00:00:00Z",
          "summary": "zzBareEvent", "owner": "konubinix", "status": "confirmed"}   # alone in a 2033 window
    photo = {"cid": "https://ipfs.konubinix.eu/p/zzbare-p", "date": "2033-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-p-t",
             "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzbarep", "state": "todo"}   # so it qualifies
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
    try:
        open_app(page)
        box = search_box(page)
        box.click(); box.press_sequentially("since:2033; until:2033; event:", delay=15)   # a window, then the bare token
        expect(options(page).filter(has_text="zzBareEvent").first).to_be_visible()   # the window's events, already listed
    finally:
        gql(DELETE, {"cid": photo["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: event lists on bare key")

And the menu only offers events that would actually turn something up: an event whose span holds none of your photos is dropped, so every suggestion leads to a non-empty wall — no more picking an occasion and landing on nothing.

@testcase
def test_completion_omits_photoless_events(page):
    """event: offers only events that have a matching photo — a photoless event isn't suggested."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    has = {"rowId": "zzev-has", "starttime": "2034-06-01T00:00:00Z", "endtime": "2034-06-01T23:59:59Z",
           "summary": "zzHasPhoto", "owner": "konubinix", "status": "confirmed"}
    non = {"rowId": "zzev-non", "starttime": "2034-07-01T00:00:00Z", "endtime": "2034-07-01T23:59:59Z",
           "summary": "zzNoPhoto",  "owner": "konubinix", "status": "confirmed"}
    photo = {"cid": "https://ipfs.konubinix.eu/p/zzhasphoto", "date": "2034-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzhasphoto-t",
             "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzhasphoto", "state": "todo"}
    for e in (has, non): gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
    try:
        open_app(page)
        box = search_box(page)
        box.click(); box.press_sequentially("since:2034; until:2034; event:zz", delay=15)
        expect(options(page).filter(has_text="zzHasPhoto").first).to_be_visible()   # has a photo → offered
        expect(options(page).filter(has_text="zzNoPhoto")).to_have_count(0)          # no photo → dropped
    finally:
        gql(DELETE, {"cid": photo["cid"]})
        for e in (has, non): gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: completion omits photoless events")

The lightbox

One doc, full size — where you look closely and edit what the archive holds about it: its labels, its state, its date.

Opening a doc — the lightbox

The wall is for scanning; sometimes you need one doc up close — to actually watch a video, or to read and fix its labels.

Open one doc and the modal shows its full web_cid media (a <video controls> when the mimetype is video, else the image), its date, and its labels as removable chips; the or Escape closes it again.

@testcase
def test_lightbox_opens_and_closes(page):
    """Double-clicking a tile opens a modal with the media and labels; close dismisses it."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    expect(d).to_be_visible()
    expect(d.get_by_role("img")).to_have_count(1)            # the image is shown
    expect(d.locator(".lb-date")).to_have_text(page.evaluate(f"() => new Date('{FIXTURES[0]['date']}').toLocaleString('fr-FR')"))   # its date
    expect(d.get_by_text(FIXTURE_LABEL)).to_be_visible()     # its labels are shown
    d.get_by_role("button", name="close").click()
    expect(d).to_be_hidden()
    print("  PASS: lightbox opens and closes")

A click that lands on nothing that acts — the backdrop, the image, the empty margins — also closes it; only a control swallows the click and keeps it open.

@testcase
def test_lightbox_click_outside_content_closes(page):
    """A click on nothing that acts — the image — closes the modal; a control click keeps it."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    expect(d).to_be_visible()
    sel = d.get_by_role("button", name=re.compile("select"))
    sel.click()                                        # a control acts → the modal stays
    expect(sel).to_have_text(re.compile("selected"))   # the click reached the button, didn't leak to close
    expect(d).to_be_visible()
    d.get_by_role("img").click()                       # the image doesn't act → the modal closes
    expect(d).to_be_hidden()
    print("  PASS: lightbox click outside content closes")

The open gesture itself is a double-click: a single tap stays the triage select and a long-press arms a range, so opening one doc full-size has its own gesture and the three don’t collide.

@testcase
def test_double_click_opens_lightbox(page):
    """A double-click on a tile is the open gesture — the modal comes up on that doc."""
    open_fixtures(page)
    tiles(page).nth(0).dblclick()
    d = dialog(page)
    expect(d).to_be_visible()
    expect(d.get_by_role("img")).to_have_count(1)
    print("  PASS: double-click opens lightbox")

That long-press is also what a touchscreen reads as a request for its own save image callout. But the app has its own use for every press — this one starts a selection — so on a tile it swallows the native context menu.

@testcase
def test_tile_context_menu_suppressed(page):
    """A long-press starts a selection — an owned gesture — so the app swallows the native context menu on a tile."""
    open_fixtures(page)
    fired = tiles(page).nth(0).evaluate(
        "el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
    assert fired is False, "a tile press should preventDefault on contextmenu"
    print("  PASS: tile context menu suppressed")

It swallows that menu across all its own surfaces, not just tiles — the open lightbox’s media too.

@testcase
def test_context_menu_suppressed_app_wide(page):
    """The native context menu is swallowed across the app's own surfaces, not just tiles — here the lightbox media."""
    open_fixtures(page)
    open_doc(page)
    fired = dialog(page).get_by_role("img").evaluate(
        "el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
    assert fired is False, "the lightbox media should suppress the native context menu"
    print("  PASS: context menu suppressed app-wide")

It keeps the native menu only in the text fields, where paste-and-select still earns its place.

@testcase
def test_context_menu_allowed_in_text_field(page):
    """A text field keeps its native menu — paste and select still help there."""
    open_app(page)
    fired = search_box(page).evaluate(
        "el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
    assert fired is True, "the search box should keep its native context menu"
    print("  PASS: context menu allowed in text field")

The lightbox has a few read-only looks — none of them touches a doc — so they share one boot: each opens a doc, checks its one thing, and hands the wall back closed for the next. First, the device back button leaves the lightbox: opening a doc pushes a history entry, so Back pops it — landing back on the grid — while Esc and unwind that same entry, so explicit-close and Back stay balanced (just as they do for the frame).

# Back steps out of the lightbox to the grid, like ✕ does
open_doc(page, 0)
expect(dialog(page)).to_be_visible()
page.go_back()
expect(dialog(page)).to_be_hidden()
expect(grid(page)).to_be_visible()
print("  PASS: back button closes lightbox")

The modal media is the downscaled web_cid, so the lightbox also offers the original file — the doc’s cid is itself the original’s /ipfs/ address — opened in a new tab for a full-resolution look or a download. The metadata rows should leave the media every pixel they can, so the offer rides on the date row as a small glyph: an icon that reads as “open full” carries it without spending a line or a word. An icon has no text to name it, so the link labels itself original for a screen reader, and its hover title spells out the full-resolution, new-tab behaviour.

# the lightbox links to the original doc at its /ipfs/ cid (full-res, new tab)
open_doc(page)
link = dialog(page).get_by_role("link", name="original")
expect(link).to_be_visible()
expect(link).to_have_attribute("href", FIXTURES[0]["cid"])    # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
page.keyboard.press("Escape")                # hand the wall back for the next view
expect(dialog(page)).to_be_hidden()
print("  PASS: lightbox links to original")

Up close means big: the doc is what you came to see, so the dialog takes the whole band the system leaves it and the media — photo or video alike, they share the same .lb-media box — gets every pixel the metadata rows leave, scaled up as well as down. A cap-only sizing (max-width=/=max-height) would shrink an oversized media but leave the downscaled web_cid floating small in the overlay — on a tablet, most of the screen wasted. But it is shown whole, never cropped: object-fit:contain scales the doc to the largest size that fits the box, so a portrait photo on a landscape screen (or the reverse) keeps every edge — the bars on the spare axis are the price of seeing all of it, and cropping away the very thing you opened the doc to look at is the worse trade.

open_doc(page)
img = dialog(page).get_by_role("img")
box = img.bounding_box()
assert box["width"] >= VIEWPORT["width"] * 0.9, f"media width {box['width']} < 90% of viewport"
assert box["height"] >= VIEWPORT["height"] * 0.65, f"media height {box['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", "media crops instead of fitting whole"
page.keyboard.press("Escape")                # hand the wall back (last view, kept uniform)
expect(dialog(page)).to_be_hidden()
print("  PASS: lightbox media fills screen")

A doc’s labels show as chips, and a chip is also a shortcut: click one and the lightbox closes, the wall re-filtered to that label.

@testcase
def test_lightbox_chip_filters(page):
    """Clicking a label chip in the lightbox sets the search to that label."""
    make_fixtures()
    chip_doc = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-chip", "date": "2020-05-15T12:00:00Z",
                "mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-chip-t",
                "labels": "zzchiponly; " + FIXTURE_LABEL, "state": "todo"}
    gql(CREATE, {"p": chip_doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzchiponly")                  # narrow to just the chip doc
        expect(tiles(page)).to_have_count(1)
        open_doc(page)
        d = dialog(page)
        d.get_by_role("button", name=FIXTURE_LABEL, exact=True).click()  # click its other chip
        expect(d).to_be_hidden()                             # the lightbox closes
        expect(search_box(page)).to_have_value(FIXTURE_LABEL)  # the filter switched to that label
    finally:
        gql(DELETE, {"cid": chip_doc["cid"]})
    print("  PASS: lightbox chip filters")

The box takes several labels at once, ;-separated, and skips any the doc already carries.

box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20)   # one existing + two new, typed
box.press("Enter")
expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1)  # not duplicated
print("  PASS: lightbox adds several labels")

Completion targets the segment after the last ;, so a pick fills that one and keeps the earlier labels already typed.

@testcase
def test_lightbox_completes_last_segment(page):
    """Completion targets the segment after the last ';' (and a pick keeps the earlier ones)."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    box = d.get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("zztest;cos", delay=20)   # typed, as a user would
    opt = options(page).filter(has_text="cosmo").first   # the live segment 'cos' completes to a vocab word
    expect(opt).to_be_visible()
    opt.click()
    expect(box).to_have_value("zztest;cosmo; ")         # earlier segment kept, completed, auto-';
    box.press("Enter")                                  # commit the built list
    expect(d.get_by_role("button", name="zztest", exact=True)).to_be_visible()
    expect(d.get_by_role("button", name="cosmo", exact=True)).to_be_visible()
    print("  PASS: lightbox completes last segment")

It’s the same keyboard path as the search box: then Enter completes the segment (with the auto-;), and a plain Enter commits the built list.

@testcase
def test_lightbox_label_keyboard(page):
    """In the lightbox add-label box, ↓+Enter completes (with auto-';'), then Enter commits."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    box = d.get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("cos", delay=20)
    expect(options(page).first).to_be_visible()
    word = options(page).first.inner_text().strip()
    box.press("ArrowDown"); box.press("Enter")              # apply the highlight → fills "word; "
    expect(box).to_have_value(word + "; ")
    box.press("Enter")                                      # nothing highlighted now → commit
    expect(d.get_by_role("button", name=word, exact=True)).to_be_visible()   # chip added
    print("  PASS: lightbox label keyboard")

With nothing highlighted, Enter adds the typed labels and Shift+Enter removes them — the keyboard twins of add and drop.

box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible()    # added
box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0)   # removed
print("  PASS: lightbox enter adds, shift-enter removes")

This box — and the frame’s add-label box — is a combobox on the same terms as the search box: an aria-expanded that tracks its popover, and a close that is a state flip, not a timed guess.

@testcase
def test_lightbox_label_combobox_state(page):
    """The lightbox add-label box is a combobox too: aria-expanded tracks its list, no-timing close."""
    open_fixtures(page)
    open_doc(page)
    box = dialog(page).get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("cos")
    expect(box).to_have_attribute("aria-expanded", "true")
    box.blur()
    expect(box).to_have_attribute("aria-expanded", "false")
    assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
    print("  PASS: lightbox label combobox state")

And while the box has focus, ←=/=→ edit the text rather than stepping to another doc — inside the field the arrows belong to the text, as the shared highlight set out.

@testcase
def test_label_edit_keeps_arrows_in_text(page):
    """While the add-label box is focused, ← / → edit the text — they don't step to another doc."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    img = d.get_by_role("img")
    src0 = img.get_attribute("src")
    box = d.get_by_placeholder("add a label…")
    box.click(); box.fill("abc")
    box.press("ArrowRight")                                  # would step to the next doc if unguarded
    page.wait_for_timeout(200)
    expect(img).to_have_attribute("src", src0)               # same doc — the arrow stayed in the field
    print("  PASS: label edit keeps arrows in text")

Completion also won’t waste a row on a label the doc already wears: it drops the open doc’s current labels from its offers, so you only ever see words you could actually add.

@testcase
def test_lightbox_completion_skips_present(page):
    """The lightbox's completion never re-offers a label the open doc already has."""
    make_fixtures()
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzpresent", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzpresent-t", "labels": "cosmo; zzpresent", "state": "todo"}
    gql(CREATE, {"p": doc})
    try:
        open_app(page)
        search_for(page, "zzpresent")                          # narrow to just this doc
        expect(tiles(page)).to_have_count(1)
        open_doc(page)
        box = dialog(page).get_by_placeholder("add a label…")
        box.click(); box.press_sequentially("balade", delay=20)             # a label the doc lacks…
        expect(options(page).filter(has_text=re.compile(r"^balade$")).first).to_be_visible()  # …is offered
        box.fill(""); box.press_sequentially("cosmo", delay=20)             # one it already has…
        expect(options(page).filter(has_text=re.compile(r"^cosmo$"))).to_have_count(0)         # …is not
    finally:
        gql(DELETE, {"cid": doc["cid"]})
    print("  PASS: lightbox completion skips present")

One thing about that box is peculiar to the lightbox: it sits at the very foot of the modal, and nothing in the lightbox scrolls. Whatever ends up below that foot is not awkward but gone — a doc you opened in order to label it is a doc you cannot label. Which is why the modal ends where the navigation bar’s strip begins; and why the completion list opens upward, above the input, rather than dropping off the bottom of a short screen with the rows you want out of reach and the wall behind catching the scroll you would try to chase them with.

@testcase
def test_lightbox_completion_opens_upward(page):
    """On a short screen the lightbox label completion opens above the input, on screen — reachable."""
    open_fixtures(page)
    open_doc(page)
    page.set_viewport_size({"width": 420, "height": 470})              # short: a downward list would fall off the foot
    box = dialog(page).get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("cos", delay=20)              # a 2+-char prefix with vocabulary
    listbox = page.get_by_role("listbox", name="suggestions")
    expect(listbox).to_be_visible()
    lb = listbox.bounding_box(); ib = box.bounding_box(); vh = page.viewport_size["height"]
    assert lb["y"] + lb["height"] <= ib["y"] + 1, f"the completion must open above the input: list {lb} input {ib}"
    assert lb["y"] >= 0 and lb["y"] + lb["height"] <= vh + 1, f"the completion must sit on screen: {lb} vh={vh}"
    print("  PASS: lightbox completion opens upward")

And the video branch: a video-mimetype doc opens as a <video> pointed at its web_cid. (A throwaway video fixture — a fake cid — proves the element and source are right without needing real playback.)

@testcase
def test_lightbox_video(page):
    """A video doc opens as a <video> sourced from its web_cid."""
    make_fixtures()
    gql(CREATE, {"p": VIDEO_FIXTURE})
    try:
        open_app(page)
        chip(page, "all").click()
        search_for(page, VIDEO_LABEL)               # a label only the video carries
        expect(tiles(page)).to_have_count(1)
        open_doc(page)
        video = dialog(page).locator("video")            # no ARIA role exists for <video>
        expect(video).to_be_visible()
        assert VIDEO_FIXTURE["webCid"] in (video.get_attribute("src") or ""), "wrong video src"
    finally:
        gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
    print("  PASS: lightbox video")

Browsing without closing. Once a doc is open you move through the wall in place — the / buttons or the keyboard arrows — stepping to the previous/next doc in the current (filtered, dated) order, wrapping at the ends. Nothing here reads a drag across the media: the media is the one place you want the browser’s gestures intact, which the next section comes to.

@testcase
def test_lightbox_prev_next(page):
    """The arrows and the nav buttons step through the wall in the lightbox."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    open_doc(page)
    img = dialog(page).get_by_role("img")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    page.keyboard.press("ArrowRight")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    page.keyboard.press("ArrowLeft")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    dialog(page).get_by_role("button", name="next photo").click()
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: lightbox prev/next")

The lightbox also shows whether the current doc is selected and lets you toggle it, so you can build a selection while reviewing one by one; the change shows on the wall behind.

@testcase
def test_lightbox_select(page):
    """The lightbox shows selection state and toggles it; the wall reflects it."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    sel = d.get_by_role("button", name=re.compile("select", re.I))
    expect(sel).to_have_attribute("aria-pressed", "false")
    sel.click()
    expect(sel).to_have_attribute("aria-pressed", "true")    # now selected
    d.get_by_role("button", name="close").click()
    expect(checks(page)).to_have_count(1)                    # the wall shows it selected
    print("  PASS: lightbox select")

A Shift+wheel over the photo steps prev/next as well — plain scroll is left for the panel, so the two don’t fight.

@testcase
def test_lightbox_shift_scroll_nav(page):
    """Shift+wheel over the photo steps prev/next (plain scroll is left for the panel)."""
    open_fixtures(page)
    open_doc(page)
    img = dialog(page).get_by_role("img")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = img.bounding_box()
    page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    page.keyboard.down("Shift")
    page.mouse.wheel(0, 240)                          # shift-scroll down → next
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    page.wait_for_timeout(250)                        # clear the one-step cooldown
    page.mouse.wheel(0, -240)                         # shift-scroll up → prev
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    page.keyboard.up("Shift")
    print("  PASS: lightbox shift-scroll nav")

Browsing to the next doc is where last-label reuse pays off: the label you just applied is offered there for a single tap, no retype.

box.fill("zzreuse"); box.press("Enter")
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible()  # applied here
d.get_by_role("button", name="next photo").click()
reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
expect(reuse).to_be_visible()                                                # offered on the next
reuse.click()
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible()  # reused, no retype
print("  PASS: lightbox reuse last label")

A chip’s × is the other half of the box: the way to take a word off a photo without typing it again. And whatever the chips show, the words have to have actually gone to the archive — so the proof is not a chip on screen but a fresh search: the photo answering to the word just put on it, and no longer answering to the word just taken off. A removal that never landed looks, on screen, exactly like one that did.

box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
d.get_by_role("button", name="close").click()
search_for(page, "lbadded")                             # the word put on: the photo answers
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL)                         # the word taken off: it no longer does
expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
print("  PASS: lightbox edits labels")

Selection has a keyboard twin too: with no field focused, Enter toggles the open doc’s selection — the lightbox echo of a click on the wall — so you can build a batch while reviewing one by one, hands on the keys.

@testcase
def test_lightbox_enter_toggles_select(page):
    """In the lightbox, Enter toggles the open doc's selection (when no field is focused)."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    sel = d.get_by_role("button", name=re.compile("select", re.I))
    expect(sel).to_have_attribute("aria-pressed", "false")
    page.keyboard.press("Enter")
    expect(sel).to_have_attribute("aria-pressed", "true")    # selected
    page.keyboard.press("Enter")
    expect(sel).to_have_attribute("aria-pressed", "false")   # toggled back off
    print("  PASS: lightbox enter toggles select")

This is what earns the lightbox its name. The whole point of opening a doc full-size is to look closer, and closer than the screen means two fingers — to read a sign in the background, or to be sure whose face that is. Then, magnified, you have to move about: a zoom you cannot travel shows you the middle of the photo and nothing else. The browser does both of those perfectly on its own, so the app’s whole job on the media is to claim no gesture there and let them through.

@testcase
def test_lightbox_pinch_zooms_and_pans(page):
    """Two fingers magnify the open photo, and a finger then travels across it."""
    open_fixtures(page)
    open_doc(page)
    box = dialog(page).get_by_role("img").bounding_box()
    cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
    seen = lambda: page.evaluate("() => [visualViewport.scale, visualViewport.pageLeft]")
    assert seen()[0] <= 1.01, "started already magnified"
    pinch_in(page, cx, cy)
    wait_until(page, lambda: seen()[0] > 1.5, label="the pinch reached the browser's zoom",
               detail=lambda: f"scale is {seen()[0]}")
    at = seen()[1]
    drag_touch(page, cx + 150, cy, cx - 150, cy)      # one finger, straight across the photo
    wait_until(page, lambda: abs(seen()[1] - at) > 5, label="the magnified photo panned sideways",
               detail=lambda: f"pageLeft went {at}{seen()[1]}")
    print("  PASS: lightbox pinch zooms and pans")

A long video shouldn’t make you scrub with the tiny native bar from across the room. So while a video is playing, the arrows seek it — jumps 5s on, 5s back — instead of leaving the doc; only at the very end (or start) does an arrow give up and step to the next (or previous) doc, so the wall is still one key away. A paused video, or a photo, steps as before.

@testcase
def test_lightbox_video_arrows(page):
    """While a video plays, → seeks +5s; from the end, → rolls to the next doc."""
    drop_fixtures()
    vid = {"cid": "https://ipfs.konubinix.eu/p/zzvarrow", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzvarrow-t", "webCid": "https://ipfs.konubinix.eu/p/zzvarrow-web",
           "labels": "zzvarrow", "state": "todo"}
    nxt = {"cid": "https://ipfs.konubinix.eu/p/zzvarrow-next", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzvarrow-next-t", "labels": "zzvarrow", "state": "todo"}
    for d in (vid, nxt): gql(CREATE, {"p": d})
    page.route("**/ipfs/zzvarrow-web", lambda r: r.fulfill(
        status=200, body=CLIP_WEBM, content_type="video/webm",
        headers={"Accept-Ranges": "bytes"}))
    try:
        open_app(page); chip(page, "all").click(); search_for(page, "zzvarrow")
        expect(tiles(page)).to_have_count(2)
        open_doc(page, 0)                                      # date order → the video first
        v = dialog(page).locator("video")
        v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")   # headless blocks unmuted autoplay
        wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
        v.evaluate("el => el.currentTime = 0")
        page.keyboard.press("ArrowRight")                      # → seeks +5s
        wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
        v.evaluate("el => el.currentTime = el.duration - 0.05")   # park at the end
        page.keyboard.press("ArrowRight")                      # → now rolls to the next doc
        expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzvarrow-next-t")
        print("  PASS: lightbox video arrows")
    finally:
        page.unroute("**/ipfs/zzvarrow-web")
        for d in (vid, nxt):
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

SPC is the video’s play control in the lightbox: it toggles play/pause, and once the clip has run to its end SPC replays it from the start rather than sitting on a frozen last frame. Driving it from the keyboard means it works whether or not the native control bar has focus.

@testcase
def test_lightbox_video_spc(page):
    """SPC toggles a lightbox video's play/pause, and replays it from the end."""
    drop_fixtures()
    vid = {"cid": "https://ipfs.konubinix.eu/p/zzvspc", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzvspc-t", "webCid": "https://ipfs.konubinix.eu/p/zzvspc-web",
           "labels": "zzvspc", "state": "todo"}
    gql(CREATE, {"p": vid})
    page.route("**/ipfs/zzvspc-web", lambda r: r.fulfill(
        status=200, body=CLIP_WEBM, content_type="video/webm",
        headers={"Accept-Ranges": "bytes"}))
    try:
        open_app(page); chip(page, "all").click(); search_for(page, "zzvspc")
        expect(tiles(page)).to_have_count(1)                      # wait for the settled result — not a stale tile mid-read
        open_doc(page, 0)
        v = dialog(page).locator("video")
        v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
        wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
        page.keyboard.press(" ")                                  # SPC pauses
        wait_until(page, lambda: v.evaluate("el => el.paused"))
        page.keyboard.press(" ")                                  # SPC plays again
        wait_until(page, lambda: v.evaluate("el => !el.paused"))
        v.evaluate("el => el.currentTime = el.duration")          # run it to the end
        page.keyboard.press(" ")                                  # SPC replays from the start
        wait_until(page, lambda: v.evaluate("el => el.currentTime") < 1
                                 and not v.evaluate("el => el.paused"))
        print("  PASS: lightbox video SPC")
    finally:
        page.unroute("**/ipfs/zzvspc-web")
        try: gql(DELETE, {"cid": vid["cid"]})
        except Exception: pass

The lightbox turns on one opened signal — the doc in view, or null — with the label-box text and its focus beside it. A photo can fall back to its thumbnail when its web copy is missing, but a video has nothing to show without one. Opening pushes a history entry, so the Back button leaves the modal the way does.

const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();

Adding a label goes through the optimistic applyLabels the intro described, and remembers the last word applied so labelling the next doc is one tap away; its Shift-Enter twin removes instead of adds.

async function applyLabels(labels){
    const cid = opened().cid;
    setOpened({ ...opened(), labels });
    await gql(UPDATE_PHOTO, { cid, patch: { labels } });
    await refetch();
}
const lbAdd = input => {
    const words = splitWords(input); if(!words.length) return;
    setLastLabel(words[words.length - 1]);
    const cur = labelsOf(opened());
    for(const w of words) if(!cur.includes(w)) cur.push(w);
    setLbText('');
    return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
    setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };

The / buttons, the arrows and a Shift+wheel are three ways of asking for the same thing, so they meet in one step. A playing video takes the arrows first, though — seeking within the clip before it steps off it. The wheel reads whichever axis Shift maps it to, and is held to one step per burst so a fast fling doesn’t overshoot — plain scroll left free for the panel.

const step = delta => {
    const list = items(); if(!list.length || !opened()) return;
    const i = list.findIndex(p => p.cid === opened().cid);
    setOpened(list[((i < 0 ? 0 : i) + delta + list.length) % list.length]); setLbText('');
};
let lbVideo = null;
const seekOrStep = dir => {
    const v = lbVideo;
    if(v && isVideo(opened()) && !v.paused &&
       (dir > 0 ? v.currentTime < v.duration - 0.25 : v.currentTime > 0.25))
        v.currentTime = Math.max(0, Math.min(v.duration, v.currentTime + dir * 5));
    else step(dir);
};
let wheelAt = 0;
const onWheel = e => {
    if(!e.shiftKey) return;
    const d = e.deltaY || e.deltaX;
    const now = performance.now();
    if(Math.abs(d) < 1 || now - wheelAt < 200) return;
    wheelAt = now; step(d > 0 ? 1 : -1);
};

One window listener routes the keyboard while a doc is open, gathering the browsing and triage keys already met into one place. Its own rule is the guard: the whole set stands aside whenever a text field has focus, where those keys belong to the text and the label box’s suggestions.

onMount(() => {
    const onKey = e => {
        if(!opened()) return;
        const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
        if(e.key === 'Escape') dismissPhoto();
        else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); seekOrStep(1); }
        else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); seekOrStep(-1); }
        else if(!editing && e.key === 'Delete'){ e.preventDefault(); lbDelete(e.shiftKey); }
        else if(!editing && e.key === 'Enter'){ e.preventDefault(); toggle(opened().cid); }
        else if(!editing && e.key === ' ' && isVideo(opened()) && lbVideo){
            e.preventDefault(); const v = lbVideo;
            if(v.currentTime >= v.duration - 0.25){ v.currentTime = 0; v.play(); }
            else if(v.paused) v.play(); else v.pause();
        }
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

<${Show} when=${() => opened()}>
  <div class="lb" onClick=${e => {
         if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]')) dismissPhoto(); }}>
    <div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
      <button class="lb-close" aria-label="close" onClick=${dismissPhoto}></button>
      <button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
              onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid) ? '✓ selected' : 'select'}</button>
      <button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}> frame</button>
      <button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}></button>
      <button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}></button>
      <${Show} when=${() => hasMedia(opened())}
               fallback=${html`<div class="lb-media noimg">
                 <span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
                 <span class="mt">no preview · ${() => opened()?.filename || opened()?.mimetype || ''}</span></div>`}>
        <${Show} when=${() => isVideo(opened())}
                 fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
          <video class="lb-media" controls autoplay ref=${el => lbVideo = el} src=${() => IPFS + opened()?.webCid}></video>
        <//>
      <//>
      <div class="lb-meta">
        <${Show} when=${() => editingDate()}
                 fallback=${html`<button class="lb-date" aria-label="edit date"
                     onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
          <input class="lb-date-edit" type="datetime-local" aria-label="date"
                 ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
                 onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
                 onBlur=${e => commitDate(e.target.value)} />
        <//>
        <a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
           aria-label="original" title="original — full resolution, new tab"></a>
      </div>
      <div class="lb-events">
        <${For} each=${() => lbEvents() || []}>${e => html`
          <button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
        <//>
      </div>
      <div class="lb-states">
        <${For} each=${() => STATES}>${st => html`
          <button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
                  onClick=${() => lbSetState(st)}>${st}</button>`}
        <//>
      </div>
      <div class="lb-labels">
        <${For} each=${() => labelsOf(opened())}>${w => html`
          <span class="lb-chip"><button class="lb-chip-word"
                onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
                aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
        <//>
        <${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
          <button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}> ${() => lastLabel()}</button>
        <//>
        <div class="complete">
          <input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
                 aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
                 value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
                 onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
                 onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
                   sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
          <${Show} when=${() => lbFocus()}>
            <${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
                        onLoading=${setSugLoading} onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
          <//>
        </div>
      </div>
    </div>
  </div>
<//>

.lb{ position:fixed; inset:0; z-index:100; background:#000d; display:flex;
     padding: calc(16px + env(safe-area-inset-top))    calc(16px + env(safe-area-inset-right))
              calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); }
.lb-inner{ position:relative; flex:1; display:flex; flex-direction:column; gap:10px; }
.lb-media{ flex:1; min-height:0; width:100%; object-fit:contain; border-radius:6px; background:#000; }
.lb-media.noimg{ display:flex; flex-direction:column; align-items:center; justify-content:center;
                 gap:10px; color:#9aa; }
.lb-media.noimg .ph{ font-size:64px; opacity:.55; }
.lb-media.noimg .mt{ font-size:13px; }
.lb-close{ position:absolute; top:-6px; right:-6px; width:32px; height:32px; border-radius:50%;
           border:none; background:#262a40; color:var(--fg); font-size:16px; cursor:pointer; z-index:2; }
.lb-nav{ position:absolute; top:50%; transform:translateY(-50%); z-index:2; width:40px; height:64px;
         border:none; border-radius:8px; background:#262a40cc; color:var(--fg); font-size:28px; cursor:pointer; }
.lb-nav:hover{ background:#33395a; }
.lb-nav:active{ transform:translateY(-50%) scale(0.88); background:#3d4468; }
.lb-prev{ left:-6px; } .lb-next{ right:-6px; }
.lb-select{ position:absolute; top:-6px; left:-6px; z-index:2; padding:6px 10px; border:none;
            border-radius:8px; background:#262a40; color:var(--fg); font-size:12px; cursor:pointer; }
.lb-select[aria-pressed='true']{ background:#6cf; color:#08111e; font-weight:700; }
.lb-meta{ display:flex; align-items:center; gap:8px; }
.lb-date{ font-size:13px; color:#9aa; }
.lb-orig{ color:#6cf; text-decoration:none; font-size:17px; line-height:1; }
.lb-orig:hover{ color:#9df; }
.lb-states{ display:flex; gap:6px; flex-wrap:wrap; }
.lb-st{ padding:4px 10px; border:1px solid #3a3f5a; border-radius:999px; background:#262a40;
        color:var(--fg); font-size:12px; cursor:pointer; }
.lb-st[data-st]{ border-color:var(--st); color:var(--st); }
.lb-st[aria-pressed='true']{ background:var(--st,#6cf); color:#08111e; font-weight:700; }
.lb-labels{ display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.lb-labels .suggest{ top:auto; bottom:100%; margin:0 0 4px; }   /* open upward from the foot */
.lb-chip{ display:inline-flex; align-items:center; gap:4px; padding:4px 6px 4px 10px; font-size:13px;
          background:#262a40; border:1px solid #3a3f5a; border-radius:999px; }
.lb-chip-word{ border:none; background:none; color:inherit; font:inherit; cursor:pointer; padding:0; }
.lb-chip-word:hover{ text-decoration:underline; }
.lb-chip .x{ border:none; background:none; color:#9aa; font-size:16px; line-height:1; cursor:pointer; padding:0 2px; }
.lb-chip .x:hover{ color:#f88; }
/* one-tap reuse of the last-applied label — dashed accent so it reads as an offer, not a set tag */
.lb-reuse{ border:1px dashed #6cf; background:#16243b; color:#6cf; padding:4px 10px;
           border-radius:999px; font-size:13px; cursor:pointer; }
.lb-reuse:hover{ background:#1d2f4d; }

Seeing a doc’s events

A photo sits in time, and the calendar knows what was happening then — so the lightbox names it. The events the photo falls within — from the calendar’s eventsAt, keyed on the doc’s date and owner — ride under the date as small chips. The calendar is Google’s, managed elsewhere, so memories never edits the events themselves — it only surfaces the overlap they name.

@testcase
def test_lightbox_shows_events(page):
    """The lightbox names the calendar events the photo falls within (its date, its owner)."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-piscine", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
          "summary": "zzPiscine Beynost", "owner": "konubinix", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzevphoto", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzevphoto-t", "labels": "zzevphoto", "owner": "konubinix", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})       # the event the photo was taken during
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzevphoto")
        expect(tiles(page)).to_have_count(1)
        open_doc(page)
        expect(dialog(page).get_by_text("zzPiscine Beynost")).to_be_visible()   # the overlapping event, named
        start = page.evaluate("() => new Date('2020-06-01T00:00:00Z').toLocaleDateString('fr-FR')")
        end   = page.evaluate("() => new Date('2020-06-30T23:59:59Z').toLocaleDateString('fr-FR')")
        expect(dialog(page).get_by_text(start)).to_be_visible()   # the span's start, beside the name
        expect(dialog(page).get_by_text(end)).to_be_visible()     # …and its end
    finally:
        gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: lightbox shows events")

On the same open doc, the event’s start and end dates read off the pill (a month-long event here, so both bounds show); each is computed in-browser so the locale and timezone match what the pill rendered.

start = page.evaluate("() => new Date('2020-06-01T00:00:00Z').toLocaleDateString('fr-FR')")
end   = page.evaluate("() => new Date('2020-06-30T23:59:59Z').toLocaleDateString('fr-FR')")
expect(dialog(page).get_by_text(start)).to_be_visible()   # the span's start, beside the name
expect(dialog(page).get_by_text(end)).to_be_visible()     # …and its end

An occasion need not fill a day. When it is timed — here an afternoon, 09:00 to 18:00 — the hour is what places it, so its chip must show the clock, not the date alone.

@testcase
def test_lightbox_shows_timed_event_hours(page):
    """A timed occasion (not a whole day) shows its start and end hour on the pill, not just the date."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-timed", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzKarate Beynost", "owner": "konubinix", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zztimedphoto", "date": "2020-03-07T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zztimedphoto-t", "labels": "zztimedphoto", "owner": "konubinix", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zztimedphoto")
        expect(tiles(page)).to_have_count(1)
        open_doc(page)
        when = page.evaluate("""() => {
          const s = new Date('2020-03-07T09:00:00Z'), en = new Date('2020-03-07T18:00:00Z');
          const t = x => x.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
          return `${s.toLocaleDateString('fr-FR')} ${t(s)} – ${t(en)}`;
        }""")
        expect(dialog(page).get_by_text(when)).to_be_visible()   # date + clock, computed in-browser
    finally:
        gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: lightbox shows timed event hours")

Owner-scoping is not cosmetic: two people’s calendars can hold events on the very same day, and a photo belongs to just one of them. So the lightbox shows only that owner’s events — a different owner’s event spanning the same instant stays hidden.

@testcase
def test_lightbox_events_are_owner_scoped(page):
    """A photo shows only its own owner's events — a same-day event of another owner stays hidden."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    mine  = {"rowId": "zzev-mine",  "starttime": "2020-07-01T00:00:00Z", "endtime": "2020-07-31T23:59:59Z",
             "summary": "zzMine Konubinix", "owner": "konubinix", "status": "confirmed"}
    other = {"rowId": "zzev-other", "starttime": "2020-07-01T00:00:00Z", "endtime": "2020-07-31T23:59:59Z",
             "summary": "zzAyla Elsewhere", "owner": "aylapomme", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzevphoto-k", "date": "2020-07-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzevphoto-k-t", "labels": "zzevphotok", "owner": "konubinix", "state": "todo"}
    for e in (mine, other): gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzevphotok")
        expect(tiles(page)).to_have_count(1)
        open_doc(page)
        d = dialog(page)
        expect(d.get_by_text("zzMine Konubinix")).to_be_visible()   # its own owner's event shows
        expect(d.get_by_text("zzAyla Elsewhere")).to_have_count(0)   # another owner's, same day — hidden
    finally:
        gql(DELETE, {"cid": doc["cid"]})
        for e in (mine, other): gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: lightbox events are owner-scoped")

A pill is also a way in: the occasion it names is usually the next thing you want whole — every photo of it — so clicking one runs event:<name> and drops you back on the wall, now narrowed to that occasion. It is a jump, not an edit: the calendar stays untouched.

@testcase
def test_lightbox_event_pill_searches(page):
    """Clicking an event pill in the lightbox runs its event: search and lands on the filtered wall."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-pill", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
          "summary": "zzPillfest Beynost", "owner": "konubinix", "status": "confirmed"}
    inside  = {"cid": "https://ipfs.konubinix.eu/p/zzpill-in",  "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
               "thumbnailCid": "https://ipfs.konubinix.eu/p/zzpill-in-t",  "labels": "zzpill", "owner": "konubinix", "state": "todo"}
    outside = {"cid": "https://ipfs.konubinix.eu/p/zzpill-out", "date": "2020-09-15T12:00:00Z", "mimetype": "image/jpeg",
               "thumbnailCid": "https://ipfs.konubinix.eu/p/zzpill-out-t", "labels": "zzpill", "owner": "konubinix", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in (inside, outside): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzpill")
        expect(tiles(page)).to_have_count(2)                       # both docs, same label
        grid(page).get_by_role("listitem").filter(              # open the one inside the event
            has=page.get_by_role("img", name="2020-06-15")).dblclick()
        pill = dialog(page).get_by_role("button", name=re.compile("zzPillfest Beynost"))
        expect(pill).to_be_visible()
        pill.click()
        expect(search_box(page)).to_have_value("event:zzPillfest Beynost")   # the pill's search, committed
        expect(dialog(page)).to_be_hidden()                        # left the lightbox for the wall
        expect(tiles(page)).to_have_count(1)                       # narrowed to the occasion
        expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzpill-in-t")
    finally:
        for d in (inside, outside): gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: lightbox event pill searches")

The lookup rides on the open doc: a resource keyed on opened() runs fetchDocEvents, which asks eventsAt for a doc’s date and owner — so it follows whatever doc is shown. A doc with no owner has nothing to scope by, so the resource stays idle rather than asking. (The wall query carries each doc’s owner for exactly this.) fetchDocEvents is written against a doc rather than the lightbox, so any surface with a current doc can reuse it.

const EVENTS_FOR_DOC = `query($d:Datetime!,$o:OwnerType!){ eventsAt(d:$d, o:$o){ nodes{ summary starttime endtime } } }`;
const fetchDocEvents = async o => (await gql(EVENTS_FOR_DOC, { d: o.date, o: o.owner }))?.eventsAt?.nodes ?? [];
const dayBased = (s, en) => s.getUTCHours() === 0 && s.getUTCMinutes() === 0 && s.getUTCSeconds() === 0
                         && en.getUTCHours() === 23 && en.getUTCMinutes() === 59 && en.getUTCSeconds() === 59;
const hhmm = t => t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const eventWhen = e => {
    const s = new Date(e.starttime), en = new Date(e.endtime);
    const sd = s.toLocaleDateString('fr-FR'), ed = en.toLocaleDateString('fr-FR');
    if (dayBased(s, en)) return sd === ed ? sd : `${sd}${ed}`;
    return sd === ed ? `${sd} ${hhmm(s)}${hhmm(en)}` : `${sd} ${hhmm(s)}${ed} ${hhmm(en)}`;
};
// run the occasion's own event: search — the pill's jump into it
const searchEvent = summary => { setSearch('event:' + summary); commit(); };
const [lbEvents] = createResource(
    () => { const o = opened(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);

Each chip names the occasion and, beside the name, carries when it ran. A whole-day occasion shows only its dates — collapsed to one when it lasted a single day — since its clock would read 00:00 and add nothing; a timed occasion instead shows the start and end hour, which is what places it. Under the hood, whole-day is read off the span’s UTC bounds — 00:00:00 to 23:59:59, a day’s last second — the inclusive end the calendar spans carry, the same bound events_at matches on. That when is muted to a quiet aside (.ev-when, dimmed): a photo’s occasion is Piscine, juin 2020, and the name alone recurs, so the when disambiguates without shouting.

<div class="lb-events">
  <${For} each=${() => lbEvents() || []}>${e => html`
    <button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
  <//>
</div>

They are small rounded pills, muted against the dark panel and wrapping onto another line when there are several. Unlike a label chip they carry no × — there is no event to edit here — but the whole pill is a button: a pointer and a hover lift mark it as a jump into the occasion.

.lb-events{ display:flex; flex-wrap:wrap; gap:6px; }
.lb-event{ font:inherit; font-size:12px; color:#bcd; background:#20293f; border:1px solid #34406088;
           border-radius:999px; padding:3px 10px; cursor:pointer; }
.lb-event:hover{ background:#2a3450; border-color:#4a5680; }
/* the span reads as a quiet aside beside the name — the discreet part of "name + when" */
.ev-when{ margin-left:6px; opacity:.6; }

Change state from the lightbox

Triage shouldn’t need closing the photo: the lightbox carries the state buttons too, and tapping one saves it at once. But you are reading the run in order — one, then the next, then the next — and judging the doc in front of you drops it out of the filter you are working through, so it vanishes from the wall under you. The one you were going to read next is the one after it. Landing you instead on the one before — which you read a moment ago, and dealt with — costs you a step back and a step forward to get where you already were.

d.get_by_role("button", name="done", exact=True).click()   # leaves the todo filter
expect(d).to_be_visible()                             # still open…
expect(img).to_have_attribute("src", ahead)           # …on the doc that took its place
assert img.get_attribute("src") != behind, "the modal stepped backwards"
print("  PASS: judging one lands on the next, not the previous")

There is no doc without a next, because the run is a ring — the arrows already come round at its ends. So judging the one at the end is no special case at all: you carry on to the first, the same single step forward as anywhere else.

onto_last = thumb_imgs(page).nth(2).get_attribute("src")   # the last of the three left
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", onto_last)            # settled on it before judging it
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_visible()
expect(img).to_have_attribute("src", behind)          # round to the first, not back to the end
print("  PASS: judging the last comes round to the first")

Carry on and the run empties. Each judgement hands you the next as before, right down to the last of them — and when the one you just judged was the only one left, there is nothing to show at all, so the modal gets out of the way rather than sitting on an empty wall.

d.get_by_role("button", name="done", exact=True).click()
expect(img).to_have_attribute("src", ahead)           # round again, to the one still standing…
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_hidden()                              # …and with that one gone, nothing to show
expect(tiles(page)).to_have_count(0)
print("  PASS: the last one judged closes the run")

All of it falls out of one write and one re-anchor. The doc that was next around the ring is noted before the write, because after it that doc has moved up into the gap and its neighbour would answer instead.

async function lbPatch(patch){
    const list = items(), cur = opened(); if(!cur) return;
    const i = list.findIndex(p => p.cid === cur.cid);
    const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
    await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
    await refetch();
    requestAnimationFrame(() => {
        const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
        const stay = l2.find(p => p.cid === cur.cid);
        setOpened(stay || (nextCid && l2.find(p => p.cid === nextCid)) || l2[0]);
    });
}
const lbSetState = st => lbPatch({ state: st });

Marking for deletion with a key

Sweeping a stack down to the keepers means sending a lot of docs to delete, and reaching for the button each time is the slow part. So in the lightbox the Delete key marks the open doc for deletion, through the same state-setting a button press uses — so it re-anchors and closes just as one would. Because it is destructive it asks first; Shift+Delete is the trusting shortcut that skips the question. And while a label box has focus, Delete is just text editing — it obeys the same field-focus guard as the arrows.

Pressing Delete raises a confirm; accepting it moves the open doc to delete.

del_btn = d.get_by_role("button", name="delete", exact=True)
expect(del_btn).to_have_attribute("aria-pressed", "false")
asked = []
page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
page.keyboard.press("Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true")   # moved to delete
assert asked, "Delete should have asked to confirm"
print("  PASS: lightbox delete confirms")

Shift+Delete is the same move without the prompt — for a confident run down a stack. It has to be tried on a doc that is not already condemned, or a key that did nothing at all would look like a key that worked.

d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false")   # a fresh doc, not yet condemned
asked.clear()
page.keyboard.press("Shift+Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true")
assert not asked, "Shift+Delete should not ask to confirm"
print("  PASS: lightbox shift-delete skips confirm")

And where a key that condemns a photo would be worst — mid-word in the label box, reaching to rub out a typo — it must be nothing but text editing.

d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false")
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzoops", delay=10)
box.press("ArrowLeft"); box.press("Delete")                  # rubbing out the last letter
expect(box).to_have_value("zzoop")
expect(del_btn).to_have_attribute("aria-pressed", "false")   # the photo is untouched
print("  PASS: lightbox delete in a field is text editing")

The key routes the open doc through the lightbox’s state-setting, so it inherits the re-anchoring whole; Shift is what forces past the confirm.

const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };

Fixing a wrong date

A doc turns up wearing a date it never earned — a WhatsApp copy stamped to the day it was saved, a scan that carried no capture time — and the date-sorted wall files it years from where it belongs. The correction has to be right there in the moment you catch it, eyes on the photo and no detour to a form: the date on the lightbox’s meta row is already what you are reading, so it is what you edit. It saves through the very lbPatch a state button uses, so a doc that just changed date re-anchors just as one that changed state — it slides to its rightful place on the wall and the lightbox keeps its footing.

The edit is a small state machine, worth seeing whole before the pieces.

d.get_by_role("button", name="edit date").click()          # the date is a button — open the picker
box = d.get_by_label("date", exact=True)
box.fill("2020-12-15T12:00")                     # push it past the other two
box.press("Enter")                               # save
d.get_by_role("button", name="close").click()    # back to the wall
expect(tiles(page)).to_have_count(len(FIXTURES))
# the tile's alt is its day (set straight from the date, no lazy load), so the edited
# doc — now the latest — sits last on the date-sorted wall.
expect(thumb_imgs(page).nth(2)).to_have_attribute("alt", "2020-12-15")
print("  PASS: lightbox edit date re-orders")

The date on the meta row is a button; opening the picker seeds it with what the date held.

want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');"
                     " return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
                     FIXTURES[0]["date"])
expect(d.get_by_label("date", exact=True)).to_have_value(want)   # seeded with the stored instant, in local time
print("  PASS: date editor seeds current value")

<${Show} when=${() => editingDate()}
         fallback=${html`<button class="lb-date" aria-label="edit date"
             onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
  <input class="lb-date-edit" type="datetime-local" aria-label="date"
         ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
         onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
         onBlur=${e => commitDate(e.target.value)} />
<//>

Under the hood the picker is a datetime-local input, which speaks the browser’s local wall-clock while the stored date is a zoned instant — so a small formatter seeds it and toISOString() reads it back. Only the day was ever wrong, so the seed keeps the hours and minutes too, and a save that fixes just the year leaves the time of day standing.

const toLocalInput = iso => { if(!iso) return '';
    const d = new Date(iso), p = n => String(n).padStart(2, '0');
    return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
let cancelEdit = false;
const commitDate = v => { const skip = cancelEdit; cancelEdit = false; setEditingDate(false);
    if(!skip && v) lbPatch({ date: new Date(v).toISOString() }); };

Backing out has to stay inside the lightbox: Escape would otherwise reach the modal’s own key handler and close the whole photo, so the picker swallows the key at the input and trips the veto before it lets go.

else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); }

Whether the backing-out took has to be read off the wall behind, and not off the picker’s own row, because a date that saves can carry its photo clean out of the window the wall is showing — and the open doc then re-anchors to a neighbour. So after a save that should never have happened, the row is showing neither the old date nor the new one but a third photo’s altogether, and it cannot answer the question that was asked of it. The wall can: the photo is missing from it, and the dates it lists are the ones actually stored. And since the claim is that nothing happened, the reading waits out the span a save would have needed before believing it.

unmoved = tile_dates(page)                                            # the wall as it stands
box = d.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(d).to_be_visible()                                             # still open
page.wait_for_timeout(SAVE_GRACE_MS)                                  # room for a save to land
assert tile_dates(page) == unmoved, f"the backed-out date was written anyway: {tile_dates(page)}"
print("  PASS: date edit escape cancels")

Reaching the picker should cost no more than reaching the label box does: d — for date — opens it on the doc in view, the same reflex as l for labels.

page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_be_focused()
print("  PASS: date shortcut focuses editor")

It stands aside where it would be in the way: while a text field already holds the keys, d is a letter like any other.

d.get_by_placeholder("add a label…").click()                  # focus the label box
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_have_count(0)   # the picker stayed shut
print("  PASS: date shortcut stands aside in a field")

And on the wall, with nothing open, it has no doc to act on — nor may it lie in wait and spring on the next one opened.

page.keyboard.press("d")                                      # no doc open
open_doc(page, 0)
d = dialog(page)
expect(d.get_by_role("button", name="edit date")).to_be_visible()   # opens in display mode…
expect(d.get_by_label("date", exact=True)).to_have_count(0)         # …not the picker
print("  PASS: date shortcut ignored without a doc")

onMount(() => {
    const onKey = e => {
        if(e.key !== 'd' || !opened()) return;                     // only while a doc is open
        if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;     // a field already owns the key
        e.preventDefault(); setEditingDate(true);
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

.lb-date{ background:none; border:none; padding:0; font-family:inherit; cursor:pointer; }
.lb-date:hover{ color:#ccd; text-decoration:underline; }
.lb-date-edit{ font-size:13px; color:var(--fg); background:#262a40; border:1px solid #3a3f5a;
               border-radius:6px; padding:2px 6px; }

Customizable thumbnail size

Wall density is a taste call, so the tile size is adjustable — the − / + controls run the tiles from 80 to 320px in steps of forty, opening at 96 for a first visit.

page.set_viewport_size({"width": 1600, "height": 800})   # wide enough that every step re-columns the wall
open_fixtures(page)
t = tiles(page)
w0 = t.nth(0).bounding_box()["width"]
page.get_by_role("button", name="bigger thumbnails").click()
page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w0 + 8)

Density chosen once should stay chosen, so the size outlives the session — reopen the app and the wall comes back at the size you left it.

w1 = t.nth(0).bounding_box()["width"]
open_app(page)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)

A hand already on the wall shouldn’t have to travel to the controls, so Ctrl+scroll over the tiles does the same: scroll up to grow them, down to shrink.

w2 = t.nth(0).bounding_box()["width"]
box = grid(page).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + 10)
page.keyboard.down("Control")
page.mouse.wheel(0, -240)                            # ctrl-scroll up → bigger
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w2 + 8)

That gesture is not ours to borrow quietly — Ctrl+wheel is the browser’s own page zoom, and a handler that merely listened would leave the whole app scaling around the wall instead of the tiles in it. So the wall claims the event outright before it resizes anything.

A wheel also arrives as a burst of notches where a finger meant one movement, and counting them would rocket the wall through its range. So a burst buys a single step: notches less than 120 ms apart are the same gesture, and stepping back once returns the size the burst started from.

page.wait_for_timeout(200)                           # a fresh gesture, clear of the notch above
w3 = t.nth(0).bounding_box()["width"]
page.keyboard.down("Control")
page.mouse.wheel(0, -240)
page.mouse.wheel(0, -240)                            # a second notch inside the same burst
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w3 + 8)   # the burst moved the wall
page.get_by_role("button", name="smaller thumbnails").click()         # …by exactly one step
wait_until(page, lambda: abs(t.nth(0).bounding_box()["width"] - w3) < 2)

const SIZE_KEY = 'memories.thumbSize';
const [thumbSize, setThumbSize] = createSignal(+localStorage.getItem(SIZE_KEY) || 96);
createEffect(() => localStorage.setItem(SIZE_KEY, thumbSize()));
const bumpSize = d => setThumbSize(s => Math.max(80, Math.min(320, s + d * 40)));
let zoomAt = 0;
const onGridWheel = e => {
    if(!e.ctrlKey) return;
    e.preventDefault();
    const now = performance.now();
    if(now - zoomAt < 120) return;
    zoomAt = now; bumpSize(e.deltaY < 0 ? 1 : -1);
};

A tile at the top of that range shows a 256px thumbnail at 320 — a stretch of a quarter on a desktop, and several-fold on a phone, whose device pixels outnumber its CSS ones by two or three to one. On a dense wall that softness is worth what it saves, because sharpness here is priced per tile. Sampled across the archive — 126 photos from twelve evenly spaced points through all seventy-five thousand — a thumbnail runs 16–64 kB where the web_cid beside it runs 102–474 kB: the same photo, five to twelve times heavier in the second. At the default size a fold holds some seventy tiles, and the next search draws a fresh wall of them, so buying the heavier rendition for a contact sheet would run into hundreds of megabytes spent sharpening pictures nobody is looking at yet.

What changes as the tiles grow is not that price but the number of tiles paying it. Three columns of 330px fill the same fold with fewer than ten tiles where the default fits seventy, and that drop is a factor of twelve against the five to twelve the rendition costs: a grown fold comes in at or below what a dense one already spent. Nor is that a bargain of one screen — a fold holds area over cell squared tiles, so the area falls out, and a phone strikes the same trade as a desktop. What the same bytes buy, though, is incomparably better, because at that width the upscale has stopped being a softness you can overlook: the tile has become a picture you are looking at rather than a stamp you are picking out of a sheet. So past that width the wall refines: each tile on screen trades up to its web_cid, the same rendition the lightbox and the frame show — a wall grown that wide has more in common with those surfaces than with a contact sheet. A video’s web_cid is a film rather than a bigger poster, so a video tile keeps the poster it has.

Where the two curves cross is a judgement call, fixed at a drawn cell of 300px — to move by eye if the wall comes to feel either soft or heavy. Drawn, not chosen: the wall’s columns are auto-fill tracks running to 1fr, so they stretch into whatever width is left over, and a phone at the top of the slider draws a single column across its whole screen. The threshold has to read the pixels the picture is actually painted at, so it is taken from the resolved column and not from the setting.

At the default size, then, the wall buys thumbnails and nothing else — scroll it as far as you like.

page.set_viewport_size({"width": 1000, "height": 420})   # short, so the dense wall really does overflow
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
open_app(page)
search_for(page, "zzref")
expect(tiles(page)).to_have_count(80)
assert cell_px(page) <= 300, f"the default wall should be a contact sheet, drawn at {cell_px(page)}px"
page.mouse.move(500, 210)
for _ in range(10):                              # all the way down the wall
    page.mouse.wheel(0, 400)
    page.wait_for_timeout(100)
assert tiles(page).last.bounding_box()["y"] < 420, "the wall never overflowed — nothing was scrolled into reach"
assert not web(), f"the dense wall fetched {len(web())} web renditions"

Grow the tiles past the threshold and that same wall sharpens, the thumbnail staying underneath so that nothing blinks out while the fuller picture is on its way.

tiles(page).first.scroll_into_view_if_needed()
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()   # to the 320 ceiling
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
           detail=lambda: f"cell={cell_px(page)}px")
wait_until(page, lambda: any(u.endswith("zzref-web-1") for u in web()),
           label="the tile on screen trades up to its web rendition",
           detail=lambda: f"srcs={tile_srcs(page, 1)} web={web()}")
assert any("zzref-t-1" in (s or "") for s in tile_srcs(page, 1)), "the thumbnail left from under it"

Only what you are looking at is bought, and only if it is a photo: the tiles far below the fold keep their thumbnails, and the video keeps its poster instead of pulling a film onto the wall.

assert not [u for u in web() if u.endswith("web-79")], f"a tile far below the fold refined: {web()}"
assert not [u for u in web() if u.endswith("web-vid")], f"the video tile fetched its film: {web()}"

Refinement must never cost the wall its legibility. The thumbnail pass is what turns a screenful of blanks into pictures you can triage, and that moment is what the wall is for; the sharpening after it is a luxury. A web rendition is ten thumbnails on the wire, and issued alongside them it competes for the same bandwidth and pushes that moment back. So the two passes are ordered: nothing refines until every thumbnail the wall is waiting for has settled. Settled, not painted — an address the gateway cannot serve must not hold the whole wall soft for good.

page.route("**/ipfs/zzwait-t-0", lambda route: None)   # never answered: this one is still on its way
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
page.set_viewport_size({"width": 1000, "height": 700})
open_app(page)
search_for(page, "zzwait")
expect(tiles(page)).to_have_count(12)
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
           detail=lambda: f"cell={cell_px(page)}px")
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwait-t-0")   # asked for…
assert not [u for u in fetched() if u.endswith("zzwait-t-0")], "…and still unanswered"
wait_until(page, lambda: len([u for u in fetched() if "zzwait-t-" in u]) >= 2,
           label="its neighbours' thumbnails have settled",
           detail=lambda: f"settled={[u for u in fetched() if 'zzwait-t-' in u]}")
page.wait_for_timeout(600)                            # ample room for a refinement to fire
assert not web(), f"the wall refined with a thumbnail still in flight: {web()}"

Scrolling reopens the question: fresh tiles bring fresh thumbnails to wait for, and the wall is waiting again. A tile that has already traded up keeps what it has — handing a picture back because a neighbour arrived late would be a flicker bought with nothing — so the sharpening only ever moves forward, a screenful at a time.

page.route("**/ipfs/zzfwd-t-4", lambda route: None)   # doc 4's thumbnail never arrives
sharp = lambda i: any(f"zzfwd-web-{i}" in (s or "") for s in tile_srcs(page, i))
inreach = lambda i: any(f"zzfwd-t-{i}" in (s or "") for s in tile_srcs(page, i))
page.set_viewport_size({"width": 480, "height": 700})   # one tall column at the ceiling
open_app(page)
search_for(page, "zzfwd")
expect(tiles(page)).to_have_count(8)
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
           detail=lambda: f"cell={cell_px(page)}px")
wait_until(page, lambda: sharp(0), label="the tile on screen sharpens",
           detail=lambda: f"srcs={tile_srcs(page, 0)}")
tiles(page).nth(2).scroll_into_view_if_needed()
wait_until(page, lambda: sharp(3), label="the tiles scrolled into reach sharpen too",
           detail=lambda: f"srcs={tile_srcs(page, 3)}")
tiles(page).nth(4).scroll_into_view_if_needed()       # doc 4 comes into reach, and never lands
wait_until(page, lambda: inreach(5), label="doc 5 has come into reach behind it",
           detail=lambda: f"srcs={tile_srcs(page, 5)}")
page.wait_for_timeout(600)                           # ample room for doc 5 to settle and refine
assert inreach(4), f"doc 4 must be in reach to hold anything back: {tile_srcs(page, 4)}"
assert sharp(3), f"a tile handed its picture back: {tile_srcs(page, 3)}"
assert not sharp(5), f"a tile refined behind a thumbnail in flight: {tile_srcs(page, 5)}"

What a tile does give back is what it gave back before. Scrolled out of reach, it drops the fuller picture along with its thumbnail, so a wall you have left behind holds no more decoded pixels for having been sharpened.

wait_until(page, lambda: not inreach(0), label="doc 0 has been left behind",
           detail=lambda: f"srcs={tile_srcs(page, 0)}")
assert not sharp(0), f"the fuller picture outlived the thumbnail: {tile_srcs(page, 0)}"

A tile can also leave the wall altogether, when a search swaps the whole of it while a thumbnail is still on its way. The tally has to let go of whatever left with it: a wall still counting thumbnails that are no longer on it would never refine again.

search_for(page, "zzfwd2")                           # swapped out from under a thumbnail in flight
expect(tiles(page)).to_have_count(3)
wait_until(page, lambda: any("zzfwd2-web-0" in (s or "") for s in tile_srcs(page, 0)),
           label="the wall that replaced it sharpens too",
           detail=lambda: f"srcs={tile_srcs(page, 0)}")

In code the threshold is read off the grid itself, and it takes two triggers to keep that reading true: a ResizeObserver re-measures the resolved column whenever the wall’s own box changes, and an effect re-measures when the slider moves the columns inside a box that has not changed.

Under the hood, getComputedStyle is what makes the second reading current: it flushes the pending layout, so the effect reads the columns the new setting produces rather than the ones the last frame drew.

const REFINE_CELL = 300;                    // drawn px
const [cellPx, setCellPx] = createSignal(0);
const measureCell = () => gridEl && setCellPx(parseFloat(getComputedStyle(gridEl).gridTemplateColumns) || 0);
const watchCell = el => { const ro = new ResizeObserver(measureCell);
                          ro.observe(el); onCleanup(() => ro.disconnect()); };
createEffect(() => { thumbSize(); measureCell(); });   // the slider moves the columns, not the grid's box
const [thumbsInFlight, setThumbsInFlight] = createSignal(0);
const refining = () => cellPx() > REFINE_CELL && thumbsInFlight() === 0;

A tile’s part is to declare itself: it counts itself among what the wall is waiting for from the moment it comes into reach until its thumbnail settles, and once it has traded up it holds that claim until it leaves — whether it leaves the viewport or the wall.

const [thumbOn, setThumbOn] = createSignal(false);
const thumbSettled = () => setThumbOn(true);           // painted, or failed and never coming
createEffect(() => { near(); setThumbOn(false); });     // re-shown → its source is refetched → waiting again
createEffect(() => {                                   // one of the thumbnails the wall waits for…
    if(!(near() && photo.thumbnailCid && !thumbOn())) return;
    setThumbsInFlight(n => n + 1);
    onCleanup(() => setThumbsInFlight(n => n - 1));     // …until it settles or leaves
});
const [sharp, setSharp] = createSignal(false);
createEffect(() => { if(!near()) setSharp(false);       // gone → both layers go
                     else if(thumbOn() && refining()) setSharp(true); });
const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
const [webOn, setWebOn] = createSignal(false);
createEffect(() => { webSrc(); setWebOn(false); });     // a new source → fade the overlay in again

The picture is then two stacked images, as in the frame: the thumbnail carries the tile, and the fuller rendition fades in over it once it has painted. Both are the same photo, so only the layer beneath is named — the overlay’s alt is empty, which keeps it out of the accessibility tree.

<${Show} when=${webSrc}>
  <img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
       src=${webSrc} onLoad=${() => setWebOn(true)} />
<//>

.tile .thumb.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; }
.tile .thumb.web.shown{ opacity:1; }

Two layers is also two decoded images, and decoded pixels — not bytes on the wire — are what the reach window exists to bound. Whichever way a browser unpacks a rendition, that comes to at most about twice the decoded image a dense wall already held; the annex works it out.

Frame mode

Left to itself the app becomes a photo frame: a fullscreen slideshow over whatever the wall is showing.

The frame — a fullscreen slideshow

This folds the photo-frame in: no separate app. ▶ frame turns the current wall — whatever the query language has narrowed to — into a frame show. It is not the lightbox: it’s a full-screen horizontal filmstrip, each doc a viewport-wide slide laid side by side in a native scroll container, which gives it the feel of a hand-built slider: a fluid lateral swipe whose momentum coasts across several docs, easing onto whichever one it comes to rest nearest. It plays the wall in the order shown — chronological by default, or the myrandom draw under sort:random (ordering is an app-wide concern, not a frame one). The media fills the screen, videos don’t auto-play, and there’s no chrome — a small bar (pause, interval, exit) is a tap away.

Which slide it has come to rest on is a question the strip cannot answer directly: a filmstrip has no notion of a current item, only a scroll offset. In practice the answer is arithmetic — divide the offset by a slide’s width and round to the nearest. The width has to be the one the show itself places slides by, the strip’s own rather than the viewport’s, or the reading drifts from the thing it is measuring; the measure the show places by says why the two differ. The whole chapter asks the question in three shapes: the bare position along the strip, the picture sitting there, and which of the fixtures that picture belongs to.

SLIDE_W = "el => (el.scrollWidth / (el.children.length || 1)) || 1"

ON_SLIDE = f"el => Math.round(el.scrollLeft / ({SLIDE_W})(el))"

CENTERED = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
            " const im = el.children[i] && el.children[i].querySelector('img');"
            " return im && im.getAttribute('src'); }")

def centred_slide(strip):
    src = strip.evaluate(CENTERED) or ""
    return next((k for k, f in enumerate(FIXTURES) if f["thumbnailCid"] == src), None)

Left alone, the show auto-advances: a smooth scroll to the next slide every interval (default 60s, ?ms= overrides), reading the current scroll position each tick. It runs in date order, so the fixtures play thumb-0, thumb-1, thumb-2.

for src in ["thumb-0", "thumb-1", "thumb-2"]:                   # date order
    wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
print("  PASS: frame auto-advances")

Touch it and the show yields: any tap or swipe stops the auto-advance, which picks back up only after a span of quiet (?idleresume overrides) — so a slide you’ve stopped on to look at is never pulled out from under you.

opened = strip.evaluate(ON_SLIDE)                              # wherever the show resumed
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened,    # …and it is advancing from there
           label="the show is running before we touch it")
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)  # a centre tap: real interaction, no nav
page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS)                # let any in-flight step settle
held = strip.evaluate(ON_SLIDE)
page.wait_for_timeout(LIVE_RESUME_MS // 2)                     # several tempo ticks, still inside the resume span
assert strip.evaluate(ON_SLIDE) == held, f"interaction must stop the show; it drifted {held}{strip.evaluate(ON_SLIDE)}"
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held)      # quiet long enough → resumes on its own
print("  PASS: frame pauses on interaction")

The strip loops both ways: an arrow or swipe before the first slide lands on the last, and past the last on the first, so the show never dead-ends. A native scroller cannot wrap, so the strip is laid out with a copy of the last doc before the first and a copy of the first after the last: stepping off either end lands on a copy, and the settle silently jumps the scroll to the real doc it duplicates. That padding is why the first doc sits at offset one rather than zero, and why the last of N sits at N.

page.keyboard.press("ArrowLeft")                               # off the front → the last doc
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
           label="a step back off the first lands on the last",
           detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
page.keyboard.press("ArrowRight")                              # off the end → the first
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print("  PASS: frame wraps both ways")

The strip is a keyboard-focusable scroller, so an unguarded arrow would just scroll it natively. The frame claims the arrows itself, so they step the show even once focus has dropped to the page — after a centre tap, which only reveals the bar.

page.keyboard.press("ArrowRight")                              # focus is on the body, not a control
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.keyboard.press("ArrowLeft")                               # back where we started
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print("  PASS: frame arrow steps off control")

Esc, exit, or the device back button leaves the show rather than navigating away: entering pushes a history entry and exit unwinds it via history.back, so back and explicit-exit stay balanced (and the wake lock is released).

page.go_back()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible()                          # still on the app, not gone
print("  PASS: frame exits on back")

A video the viewer started is paused once it scrolls out of view — an IntersectionObserver over the strip stops any slide video that drops below half-visible, so sound doesn’t keep playing from a slide you’ve left.

@testcase
def test_frame_pauses_offscreen_video(page):
    """A video scrolled out of view in the frame is paused."""
    make_fixtures()
    gql(CREATE, {"p": VIDEO_FIXTURE})                              # dated earliest → the first slide
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, FIXTURE_LABEL)
        expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate("el => el.scrollLeft === el.clientWidth"))  # settled on slide 1 (the video)
        vid = strip.locator("video").first                        # the centered (first) slide
        vid.evaluate("v => { v.dataset.paused = '0';"
                     " const o = v.pause.bind(v); v.pause = () => { v.dataset.paused = '1'; return o(); }; }")
        page.keyboard.press("ArrowRight")                         # scroll the video off-screen
        wait_until(page, lambda: vid.get_attribute("data-paused") == "1")
    finally:
        gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
    print("  PASS: frame pauses offscreen video")

The swipe is the frame’s main gesture, and across the room it has to feel like the slider it grew out of: you fling the strip and it coasts a few docs on its own momentum. The browser’s own scroll-snap reaches for that feel but overshoots — a quick flick is flung clear across the wall, ten docs gone in one careless swipe, which is exactly what makes the cabinet frame unusable from the couch.

Both checks below ride one gesture. A mouse drag carries no momentum, so the behaviour only shows under a real touch fling, driven (as for the lightbox) through Chromium’s touch pipeline: the finger flies left across the glass and lets go.

Knowing when the fling is over is the awkward part, because the strip goes still twice. Momentum runs out first, then the settle waits out its own beat of quiet before easing onto the nearest doc — so a reading taken during that lull would measure a resting place the show is about to leave. The way past it is to insist on a stillness longer than the beat: only the rest that follows the ease can be that quiet, and any motion starts the count again. The ceiling on waiting has to clear the worst case — the fling, the beat, and the ease together — and is generous, since it costs nothing when the strip settles early.

SETTLE_BEAT_MS = 150            # the show's own wait for quiet before it eases
STILL_READS = 7                 # a stillness longer than that beat, in 100ms reads
SETTLE_CAP_MS = 6000            # fling + beat + ease, with room to spare

def hard_frame_flick(page, strip):
    """A hard, fast touch fling — the finger flies ~900px left in ten quick steps —
    returning once the strip (and any recentre ease) comes to rest."""
    box = strip.bounding_box()
    cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
    cdp = page.context.new_cdp_session(page)
    cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": cx, "y": cy}]})
    for k in range(1, 11):
        cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": cx - 90 * k, "y": cy}]})
        time.sleep(0.001)
    cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
    prev, stable, step = None, 0, 100
    for _ in range(SETTLE_CAP_MS // step):
        cur = strip.evaluate("el => el.scrollLeft")
        stable = stable + 1 if (prev is not None and abs(cur - prev) < 1) else 0
        if stable >= STILL_READS: break
        prev = cur; page.wait_for_timeout(step)

First, the fling must stay controlled: a hard, fast one advances a handful and comes to rest within the first few docs, never stampedes across the whole strip — the ceiling the suite can pin. The fuller feel the frame is for — a brisk flick carrying you across several docs at once — rides real-device momentum stronger than the headless touch pipeline’s, so it’s the cabinet tablet that has the final say on it, not the suite.

Where to put that ceiling was measured rather than chosen: on this machine the overshooting build landed four slides beyond where native momentum rests, repeatably, so a ceiling set at momentum’s own reach plus a slide of margin separates the two without sitting on either.

CONTROLLED_SLIDES = 4
before = strip.evaluate(ON_SLIDE)
hard_frame_flick(page, strip)
landed = strip.evaluate(ON_SLIDE)
assert landed - before <= CONTROLLED_SLIDES, \
    f"the swipe flung {landed - before} slides on — past the controlled range"
print("  PASS: frame swipe does not overshoot")

Coasting freely, momentum stops the strip wherever friction runs out — usually between two docs, leaving the show squinting at half of each. So a moment after the fling stops — a sixth of a second of quiet, enough to know it is fully spent — the strip eases onto whichever doc it came to rest nearest, settling centred on one, never straddling two; waiting for that quiet lets the fling die completely first, so the ease lands cleanly instead of wrestling momentum still trailing off.

Centred means landing on a slide boundary, and the ease lands on it exactly, so the tolerance only has to be tight enough to catch a strip that never eased at all — which rests hundreds of pixels out. A score of pixels is slack by that measure and unreachable by accident.

CENTRED_PX = 20
w = strip.evaluate(SLIDE_W); rest = strip.evaluate("el => el.scrollLeft")
off = min(rest % w, w - (rest % w))                           # distance to the nearest slide boundary
assert off < CENTRED_PX, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
print("  PASS: frame swipe settles centered")

Filling the glass is measurable — the frame is watched from across the room, so a web_cid smaller than the display must be grown to it, photo and video alike — but the doc is shown whole: object-fit:contain scales it to the largest size that fits without cropping, so a differently-shaped doc keeps all of itself, the black glass framing its spare axis:

@testcase
def test_frame_media_fills_screen(page):
    """A slide's media — video or photo — spans the whole viewport, on black glass."""
    make_fixtures()
    gql(CREATE, {"p": VIDEO_FIXTURE})                              # earliest → the first slide
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, FIXTURE_LABEL)
        expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        for media in [strip.locator("video").first, strip.locator("img").first]:
            box = media.bounding_box()
            assert box["width"] >= VIEWPORT["width"] * 0.95, f"media width {box['width']} < 95% of viewport"
            assert box["height"] >= VIEWPORT["height"] * 0.95, f"media height {box['height']} < 95% of viewport"
            assert media.evaluate("el => getComputedStyle(el).objectFit") == "contain", "slide media crops instead of fitting whole"
        assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", "the frame's glass must be black"
    finally:
        gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
    print("  PASS: frame media fills screen on black")

Showing the doc whole is no good if it shows up late: web_cid is heavy, so a slide is built in two layers. The thumbnail is the base — light, and already cached from the wall you came in through — so every slide within range paints something at once instead of sitting black. Over it, the full web_cid leads the way you’re heading: it is carried one slide behind the centre and three ahead, so each next step lands on a doc already whole; if it isn’t in hand yet, the thumbnail stands in (marked, below) until it arrives. The kept-thumbnail window leans the same way — six slides behind, fourteen ahead — and past it a doc is dropped to a blank, so a long wall never keeps thousands of decoded images alive at once. Turn around and the lean turns with you, so the loading always anticipates where you’re going next.

To read the bands the test jumps the strip to a chosen slide, locating it by arithmetic on scrollWidth — total width over slide count. Two things must hold for that jump to land true. First, the strip must be laid out: the slides reach their 100vw width a beat after they render, and the arithmetic divides by a scrollWidth that is only right once they have — so the test waits for scrollWidth to come within one slide of full width (a slide of slack, so a sub-pixel settle doesn’t hang the wait), not merely for the nodes to appear. Second, entering the frame starts its own auto-centre — an animation frame that scrolls to the opening slide; were the test to jump before that frame fires, the auto-centre would land afterward and undo the jump — so the test also waits until the strip has settled on that first slide. Only then does it scroll.

@testcase
def test_frame_preloads_web_window(page):
    """The load window leans toward travel: full-res reaches further ahead than behind, the
    kept-thumbnail band likewise, and the lean flips when you reverse direction."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzwin-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzwin-t-{i}",
             "webCid": f"https://ipfs.konubinix.eu/p/zzwin-web-{i}", "labels": "zzwin", "state": "todo"} for i in range(40)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, "zzwin")
        expect(tiles(page)).to_have_count(40)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        # laid out at full width, not merely populated (see the note just above)
        wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
                                                " && el.scrollWidth >= el.clientWidth * (n - 1)", len(docs) + 2),
                   label="strip laid out at full width (all slides + 2 clones)",
                   detail=lambda: f"children={strip.evaluate('el => el.children.length')}"
                                  f" scrollWidth={strip.evaluate('el => el.scrollWidth')}"
                                  f" clientWidth={strip.evaluate('el => el.clientWidth')}")
        # …and for the frame's own initial auto-centre to have landed (see the note above)
        wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1,
                   label="frame centred on its first slide before we jump",
                   detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')} scrollWidth={strip.evaluate('el => el.scrollWidth')}")
        # slot k holds doc k-1; read each slot's loaded image sources (thumbnail base + web overlay)
        srcs_at = "(el, k) => { const s = el.children[k]; return s ? Array.from(s.querySelectorAll('img')).map(im => im.getAttribute('src')) : []; }"
        srcs = lambda k: strip.evaluate(srcs_at, k)
        web = lambda k: any("zzwin-web-" in x for x in srcs(k))
        thumb = lambda k: any("zzwin-t-" in x for x in srcs(k))
        blank = lambda k: any(x and x.startswith("data:image/gif") for x in srcs(k))
        goto = lambda n: strip.evaluate("(el, n) => el.scrollLeft = n * (el.scrollWidth / el.children.length)", n)

        c = 20; goto(c)                                          # head FORWARD to a mid slide → direction is +1
        wait_until(page, lambda: web(c),                         # the centre arrives, full-res on it
                   label=f"web overlay reaches the centre (slot {c})",
                   detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
                                  f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{c}]={srcs(c)}")
        # full-res leads the way you're going: it reaches 3 ahead but only 1 behind
        assert web(c + 3) and not web(c + 4), f"web should reach 3 ahead, got {srcs(c + 3)} / {srcs(c + 4)}"
        assert web(c - 1) and not web(c - 2), f"web should reach only 1 behind, got {srcs(c - 1)} / {srcs(c - 2)}"
        # the kept-thumbnail band leans the same way: 14 ahead, 6 behind
        assert thumb(c + 14) and blank(c + 15), f"thumbnail kept 14 ahead, got {srcs(c + 14)} / {srcs(c + 15)}"
        assert thumb(c - 6) and blank(c - 7),  f"thumbnail kept 6 behind, got {srcs(c - 6)} / {srcs(c - 7)}"

        c2 = 15; goto(c2)                                        # reverse → head BACK → direction flips
        wait_until(page, lambda: web(c2 - 3),                    # full-res now leads the OTHER way
                   label=f"web overlay leads the reversed way (slot {c2 - 3})",
                   detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')} srcs[{c2 - 3}]={srcs(c2 - 3)} srcs[{c2}]={srcs(c2)}")
        assert web(c2 - 3) and not web(c2 + 3), f"after reversing, web should reach 3 the new way, got {srcs(c2 - 3)} / {srcs(c2 + 3)}"
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame preloads web window")

Those bands are only as honest as the centre they measure from, and a hard fling tests that centre. Were it moved only when the strip comes to rest, a fast swipe would land deep in the blank band — a black slide, held the sixth of a second until the settle caught up. So the centre is followed live, on every scroll and not just at rest; the slide you fling onto is already in band, and requested, by the time you reach it.

This test lands its fling by the same scrollWidth arithmetic as the window above, so it too waits for the strip to be laid out and centred before it scrolls.

@testcase
def test_frame_fling_loads_into_view(page):
    """A fast fling re-centres the bands live, so the slide you land on is requested — not the
    blank gif the pre-fling centre would leave it."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzfling-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfling-t-{i}",
             "webCid": f"https://ipfs.konubinix.eu/p/zzfling-web-{i}", "labels": "zzfling", "state": "todo"} for i in range(30)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, "zzfling")
        expect(tiles(page)).to_have_count(30)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
                                                " && el.scrollWidth >= el.clientWidth * (n - 1)", len(docs) + 2),
                   label="strip laid out at full width",
                   detail=lambda: f"scrollWidth={strip.evaluate('el => el.scrollWidth')} clientWidth={strip.evaluate('el => el.clientWidth')}")
        wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
        # fling deep into the strip, then read the landing slot two frames later — far under the
        # 0.15s settle, so we observe the bands MID-fling, before any settle re-centres them
        src = page.evaluate("""async (k) => {
            const el = document.querySelector('.strip');
            el.scrollLeft = k * (el.scrollWidth / el.children.length);
            await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
            const im = el.children[k] && el.children[k].querySelector('img');
            return im && im.getAttribute('src');
        }""", 20)
        assert src and not src.startswith("data:image/gif"), f"slide flung-to must be loaded, not blank: {src}"
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame fling loads into view")

In code each slide measures its signed distance from the centre in the travel direction — from the strip index frameCenterIdx and the last direction frameDir — and picks each layer’s source: the thumbnail base anywhere in the leaning window, the web_cid overlay only at its leading edge.

const WEB_BEHIND = 1, WEB_AHEAD = 3, KEEP_BEHIND = 6, KEEP_AHEAD = 14;
const inReach = (k, behind, ahead) => { const t = (k - frameCenterIdx()) * frameDir();  // signed steps, in the way you're heading
    return t >= -behind && t <= ahead; };
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD)              // base layer:
    ? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK;                   // thumbnail kept in the window, blank past it
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid)   // overlay:
    ? IPFS + p.webCid : '';                                                  // full-res leads the way you're going

A photo with nothing yet to show should say so rather than sit black — you need to read it as loading, not broken. So until the thumbnail base paints, the slide carries a 🖼 mark — the wall’s missing-thumbnail glyph — labelled loading so a screen reader announces it too; the instant the thumbnail’s load fires, the mark clears and the picture stands.

@testcase
def test_frame_shows_placeholder_while_loading(page):
    """A slide whose image hasn't painted shows a 'loading' mark; it clears once the image loads."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzph-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzph-t-{i}",
             "labels": "zzph", "state": "todo"} for i in range(2)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    # doc 0's thumbnail actually loads (a real 1×1 PNG); doc 1's never does
    png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
    page.route("**/ipfs/zzph-t-0", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, "zzph")
        expect(tiles(page)).to_have_count(2)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.clientWidth||1))") == 1)
        slides = strip.get_by_role("listitem")             # [clone, doc0(centre), doc1(neighbour), clone]
        # the neighbour's thumbnail never loads, so its loading mark stays up
        expect(slides.nth(2).get_by_label("loading")).to_be_visible()
        # the centred doc's thumbnail loads, so its mark clears (wait for the load event)
        wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0)
    finally:
        page.unroute("**/ipfs/zzph-t-0")
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame shows placeholder while loading")

Once the thumbnail stands, a near doc’s full web_cid may still be on its way. The big icon has no place there — the picture is already up, only sharper detail is pending — so a quieter mark takes its place: a small pulse tucked in the corner, labelled fetching full resolution, that reads as “this is the thumbnail; the full image is coming.” When the web_cid paints over the base, it clears.

@testcase
def test_frame_thumbnail_shows_upgrade_mark(page):
    """Once the thumbnail paints, the big mark gives way to a subtle 'fetching full resolution
    mark until the web_cid lands; when it lands, that mark clears too."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzup-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzup-t-{i}",
             "webCid": f"https://ipfs.konubinix.eu/p/zzup-web-{i}", "labels": "zzup", "state": "todo"} for i in range(2)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
    for c in ("zzup-t-0", "zzup-t-1", "zzup-web-1"):                # both thumbnails load; only doc1's web does
        page.route(f"**/ipfs/{c}", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, "zzup")
        expect(tiles(page)).to_have_count(2)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        slides = strip.get_by_role("listitem")                     # [clone, doc0(centre), doc1, clone]
        wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0)   # doc0's thumbnail painted → big mark gone
        expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible() # doc0's web 404s → subtle mark stays
        wait_until(page, lambda: slides.nth(2).get_by_label("fetching full resolution").count() == 0)  # doc1's web painted → subtle gone
    finally:
        for c in ("zzup-t-0", "zzup-t-1", "zzup-web-1"): page.unroute(f"**/ipfs/{c}")
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame thumbnail shows upgrade mark")

A slide is a reused box: after an edit re-anchors the show (a doc leaves the filter and the strip closes the gap), a box that held one doc comes to hold another. The mark has to follow the new doc, not linger from the old — a freshly-shown doc whose image is still arriving must wear the mark even though the box it landed in had finished loading something else.

@testcase
def test_frame_placeholder_after_edit_remaps_slot(page):
    """When an edit re-uses a slide box for a different doc, the loading mark follows the new doc."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzbstale-{i}", "date": f"2020-01-{i + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbstale-t-{i}",
             "labels": "zzbstale", "state": "todo"} for i in range(2)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
    page.route("**/ipfs/zzbstale-t-0", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
    try:
        open_app(page, "?ms=999999")
        search_for(page, "zzbstale")                                   # default chip = todo
        expect(tiles(page)).to_have_count(2)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        slides = strip.get_by_role("listitem")
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbstale-t-0")
        wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0)  # doc0's image loaded → mark cleared
        strip.click()                                                  # reveal the bar
        bar = page.get_by_role("toolbar", name="frame actions")
        bar.get_by_role("button", name="done", exact=True).click()     # doc0 leaves todo → slot 1 reused for doc1
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbstale-t-1")
        # doc1's image never loads, so its mark must be up — not inherited "loaded" from doc0
        expect(slides.nth(1).get_by_label("loading")).to_be_visible()
    finally:
        page.unroute("**/ipfs/zzbstale-t-0")
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame placeholder after edit remaps slot")

A slot swap is not the only way a slide’s base image changes under it. As the centre moves, a slide keeps its doc but its base layer is re-picked — a blank far out, the thumbnail in range. A slide flung in from the blank band carries an already-painted blank, so on its own it would read as loaded and sit black while the thumbnail arrives. So the big mark keys on the base layer’s current source: it returns whenever that source changes — a new doc dropped in the box, or the thumbnail re-picked for the doc already there — and clears once the thumbnail paints.

@testcase
def test_frame_placeholder_returns_on_band_flip(page):
    """When the band flips a far slide from the blank gif to a real image, the loading mark
    returns until it paints — the gif's finished-loading state must not leave you on black."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzbflip-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbflip-t-{i}",
             "webCid": f"https://ipfs.konubinix.eu/p/zzbflip-web-{i}", "labels": "zzbflip", "state": "todo"} for i in range(30)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, "zzbflip")
        expect(tiles(page)).to_have_count(30)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
        far = strip.get_by_role("listitem").nth(20).get_by_label("loading")
        expect(far).to_have_count(0)            # slot 20 starts in the blank band — its gif painted, no mark
        strip.evaluate("el => el.scrollLeft = 20 * (el.scrollWidth / el.children.length)")  # fling onto it
        expect(far).to_have_count(1)            # its source flips to a real image → the mark returns until it paints
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame placeholder returns on band flip")

Assembling the slide, then: two stacked images — the thumbnail base and, when close enough, the web_cid overlay above it — with the marks between them. Each layer re-arms its own mark when its source changes (a new doc, a band that swaps blank↔thumbnail, or one that adds or drops the overlay), so neither mark lingers from a source no longer shown:

const FrameSlide = (slide, k) => {
    const thumbSrc = createMemo(() => thumbBand(slide(), k));   // base layer's source
    const webSrc = createMemo(() => webBand(slide(), k));       // overlay's source ('' when far from centre)
    const [thumbOn, setThumbOn] = createSignal(false);          // the base thumbnail has painted
    const [webOn, setWebOn] = createSignal(false);              // the full-res overlay has painted
    createEffect(() => { thumbSrc(); setThumbOn(false); });     // each layer re-arms its mark on its own source change
    createEffect(() => { webSrc(); setWebOn(false); });
    return html`
    <div class="slide" role="listitem">
      <${Show} when=${() => hasMedia(slide())}
               fallback=${html`<div class="slide-media noimg">
                 <span class="ph">${() => isVideo(slide()) ? '🎬' : '🖼'}</span></div>`}>
        <${Show} when=${() => isVideo(slide())}
                 fallback=${html`<div class="slide-pic">
                   <${Show} when=${() => !thumbOn()}>
                     <span class="ph load-ph" aria-label="loading">🖼</span><//>
                   <img class="slide-media" loading="lazy"
                        src=${thumbSrc} onLoad=${() => setThumbOn(true)} />
                   <${Show} when=${() => webSrc()}>
                     <img class="slide-media web" classList=${() => ({ shown: webOn() })}
                          loading="lazy" src=${webSrc} onLoad=${() => setWebOn(true)} />
                     <${Show} when=${() => thumbOn() && !webOn()}>
                       <span class="upgrading" aria-label="fetching full resolution"></span><//>
                   <//></div>`}>
          <video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
        <//>
      <//>
    </div>`;
};

Triaging from the frame. A tap reveals the control bar, and it turns the slideshow into a review station: everything on it acts on the centred doc, so you can triage without leaving the show.

An edit often pushes that doc out of the current filter — mark a todo done while viewing todos. frameEdit handles it: if the doc leaves the set it re-anchors on the previous doc, so the next advance lands on whatever filled the gap instead of skipping it; if it stays, it keeps it centred.

@testcase
def test_frame_edit_reanchors(page):
    """Editing a frame doc out of the filter re-centers on the previous doc."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzedit-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzedit-t-{i}",
             "labels": "zzeditframe", "state": "todo"} for i in range(3)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, "?ms=999999")
        search_for(page, "zzeditframe")                            # default state chip = todo
        expect(tiles(page)).to_have_count(3)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-0")
        page.keyboard.press("ArrowRight")                          # centre the middle doc
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-1")
        strip.click()                                              # reveal the frame bar
        bar = page.get_by_role("toolbar", name="frame actions")
        bar.get_by_role("button", name="done", exact=True).click()  # → leaves the todo filter
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-0")   # back to previous
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame edit re-anchors")

The bar’s state buttons are each tinted their own hue — todo cyan, next amber, done green, delete red — so the one you want is easy to pick across the room.

strip.click()                                                  # a tap reveals the frame bar
expect(bar).to_be_visible()
colours = [bar.get_by_role("button", name=st, exact=True).evaluate("el => getComputedStyle(el).color")
           for st in ["todo", "next", "done", "delete"]]
assert len(set(colours)) == 4, f"each state pill should have its own colour, got {colours}"
print("  PASS: frame state pills are colour coded")

It also names the centred slide’s date, and the date follows the slide as the show advances.

def shown(k):     # the date the bar should be reading, as the browser renders it
    return page.evaluate("d => new Date(d).toLocaleString('fr-FR')", FIXTURES[k]["date"])
i = centred_slide(strip)                                   # whichever slide the show stopped on
expect(bar.locator(".frame-date")).to_have_text(shown(i))
page.keyboard.press("ArrowRight")                          # step to the next slide
j = (i + 1) % len(FIXTURES)
wait_until(page, lambda: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{j}")
expect(bar.locator(".frame-date")).to_have_text(shown(j))  # follows the slide
print("  PASS: frame shows date")

Having said its piece the bar should step back out of the way. So after a span of quiet it hides itself and the frame is clean glass again — twenty seconds by default, long enough to read the controls and act, short enough that a bar tapped up by accident doesn’t sit over the show. Under the hood the wait needs no clock of its own: the assertion polls, so the window simply elapses inside it.

expect(bar).to_be_visible()                       # still up from the tap that raised it
expect(bar).to_be_hidden()                        # and gone once the window has run out
print("  PASS: frame bar auto-hides when idle")

And “quiet” means quiet: any interaction while the bar is up — a tap, a swipe, a press on its own controls — restarts the count, so it never vanishes mid-use and leaves only once you have truly stopped. Pressing it repeatedly at less than a window’s interval therefore keeps it alive past the moment a bar that ignored the presses would already have gone; stop pressing, and it goes.

strip.click()                                     # bring the bar back up
expect(bar).to_be_visible()
pause = bar.get_by_role("button", name=re.compile("pause|play"))
for _ in range(3):
    pause.click(); page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)
page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)    # now well past a window since the first press
expect(bar).to_be_visible()                       # still up: each press restarted the count
expect(bar).to_be_hidden()                        # now left alone → it finally hides
print("  PASS: frame bar idle resets on use")

The centred slide’s date is editable in place — a click opens the picker, Enter or a click away saves it through frameEdit, the same path the lightbox uses.

@testcase
def test_frame_edits_date(page):
    """The frame's date is editable in place: set a new date and it saves through frameEdit."""
    strip = enter_frame(page, 999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    strip.click()                                             # reveal the bar
    bar = page.get_by_role("toolbar", name="frame actions")
    bar.get_by_role("button", name="edit date").click()      # the date is a button — open the picker
    box = bar.get_by_label("date", exact=True)
    box.fill("2020-12-15T12:00")                             # move the centred doc to December
    box.press("Enter")                                       # save
    want = page.evaluate("() => new Date('2020-12-15T12:00').toLocaleString('fr-FR')")
    expect(bar.get_by_role("button", name="edit date")).to_have_text(want)   # saved, and shown
    print("  PASS: frame edits date")

The picker opens seeded with the slide’s current date, time and all, so a small correction isn’t a re-entry of the whole stamp.

@testcase
def test_frame_date_seeds(page):
    """The frame's date picker opens seeded with the centred slide's date — time and all."""
    strip = enter_frame(page, 999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    strip.click()
    bar = page.get_by_role("toolbar", name="frame actions")
    bar.get_by_role("button", name="edit date").click()
    want = page.evaluate("() => { const t = new Date('2020-01-15T12:00:00Z'), p = n => String(n).padStart(2, '0');"
                         " return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }")
    expect(bar.get_by_label("date", exact=True)).to_have_value(want)
    print("  PASS: frame date seeds")

Escape backs out of the picker without saving, and leaves you in the frame rather than exiting the show.

@testcase
def test_frame_date_escape_cancels(page):
    """Escape backs out of the frame's date picker without saving, and stays in the frame."""
    strip = enter_frame(page, 999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    strip.click()
    bar = page.get_by_role("toolbar", name="frame actions")
    before = bar.get_by_role("button", name="edit date").text_content()
    bar.get_by_role("button", name="edit date").click()
    box = bar.get_by_label("date", exact=True)
    box.fill("1999-01-01T00:00")
    box.press("Escape")
    expect(strip).to_be_visible()                            # Escape didn't exit the frame
    expect(bar.get_by_role("button", name="edit date")).to_have_text(before)   # date unchanged
    print("  PASS: frame date escape cancels")

The red button asks before it acts: a delete is a soft, undoable mark, but across the room a stray tap shouldn’t bin a photo, so it waits for a yes — dismiss keeps the doc, accept marks it (and, leaving the filter, re-anchors like any other edit).

@testcase
def test_frame_delete_confirms(page):
    """Marking a doc for deletion in the frame asks first: dismiss keeps it, accept removes it."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdelcfm-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdelcfm-t-{i}",
             "labels": "zzdelcfm", "state": "todo"} for i in range(3)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, "?ms=999999")
        search_for(page, "zzdelcfm")                               # default chip = todo
        expect(tiles(page)).to_have_count(3)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-0")
        page.keyboard.press("ArrowRight")                          # centre the middle doc
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-1")
        strip.click()                                              # reveal the frame bar
        delete_btn = page.get_by_role("toolbar", name="frame actions").get_by_role("button", name="delete", exact=True)
        seen = []
        dismiss = lambda d: (seen.append(d.message), d.dismiss())
        page.on("dialog", dismiss)
        delete_btn.click()                                         # ask, then DISMISS
        wait_until(page, lambda: bool(seen), label="delete asks for confirmation")
        assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-1", "dismiss must keep the doc"
        page.remove_listener("dialog", dismiss)
        page.on("dialog", lambda d: d.accept())
        delete_btn.click()                                         # ask, then ACCEPT
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-0")  # left the filter, re-anchored
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame delete confirms")

The show is meant to run unattended, so it remembers where it was: on a reboot it resumes the slide it was last centred on, not the first. Two things make that hard to catch in the act. Coming back on the first slide is what a show with no memory does, so if it went down on the opening slide a resume and a cold start are the same picture. And a show that is playing will wander onto the right slide by itself within a few ticks, so an answer waited for is no answer at all — the slide it comes back on has to be read the moment it lands, before the first tick moves it.

In practice both ends of the reboot need pinning down. Going down, the slide is written a beat after the show settles on it, so waiting for merely something to be written would accept the slide before last. Coming back, the show is placed from what was written only once the wall’s docs are in hand, in an animation frame of its own; before they arrive it has nothing to go on and sits on the opening slide. So the reading waits for the write to name the slide we are on, and afterwards for the docs and that frame — never for the position to become the expected one, which is the very thing in question.

if centred_slide(strip) == 0:                              # park it away from the cold-start slide
    page.keyboard.press("ArrowRight")
    wait_until(page, lambda: centred_slide(strip) != 0,
               label="the show steps off the slide a cold start would pick")
i = centred_slide(strip)
was = strip.evaluate(CENTERED)                             # the slide it is showing when it goes down
remembered = lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')")
wait_until(page, lambda: remembered() == FIXTURES[i]["cid"],
           label="the slide it is on is the one written down",
           detail=lambda: f"written down: {remembered()}")
open_app(page, CABINET_QS)                                 # reboot: a plain relaunch
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
expect(tiles(page)).to_have_count(len(FIXTURES))           # the docs it places from are in
page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
came_back_on = strip.evaluate(CENTERED)                    # read at once, not waited for
assert came_back_on == was, f"resumed on {came_back_on}, not the {was} it went down on"
print("  PASS: frame resumes position")

Leaving, on the other hand, is meant to stick: exit the show deliberately and the next launch leaves you on the wall, however many times you relaunch.

page.keyboard.press("Escape")                              # leaving turns the memory off
expect(strip).to_be_hidden()
open_app(page, CABINET_QS)
expect(page.get_by_role("list", name="slideshow")).to_be_hidden()   # stays on the wall
print("  PASS: frame mode persists")

Set it going again and the memory comes back with it. The relaunch carries no instruction to enter the frame — only the cabinet’s tempo — yet the show opens itself over the query the wall had persisted, which is what makes an unattended tablet survive a power cut. That auto-entry fires once per launch, though: exiting it stays exited, so a tablet you have walked over to and switched off doesn’t snap straight back in.

expect(tiles(page)).to_have_count(len(FIXTURES))           # the persisted query brought the wall back
page.get_by_role("button", name=re.compile("frame", re.I)).click()   # set it going again → remembered
expect(page.get_by_role("list", name="slideshow")).to_be_visible()
open_app(page, CABINET_QS)                                 # relaunch: remembered → auto-enters, no click
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
page.keyboard.press("Escape")                              # exit within this launch
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS)              # long enough for a re-entry to fire
expect(strip).to_be_hidden()                               # once per launch — it doesn't
print("  PASS: frame autostart")

The bar carries an add-label box too, with the same vocabulary completion as everywhere else.

@testcase
def test_frame_label_completion(page):
    """The frame's add-label box completes on the existing vocabulary."""
    strip = enter_frame(page, 999999)
    strip.click()                                                  # reveal the frame bar
    box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("cos", delay=20)
    expect(options(page).first).to_be_visible()                    # vocabulary suggestions
    assert "cos" in options(page).first.inner_text().strip().lower()
    print("  PASS: frame label completion")

It’s a combobox on the same terms as the others — aria-expanded tracks its list, and it closes on a state flip, not a timer.

@testcase
def test_frame_label_combobox_state(page):
    """The frame add-label box is a combobox too: aria-expanded tracks its list, no-timing close."""
    strip = enter_frame(page, 999999)
    strip.click()                                                  # reveal the frame bar
    box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("cos")
    expect(box).to_have_attribute("aria-expanded", "true")
    box.blur()
    expect(box).to_have_attribute("aria-expanded", "false")
    assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
    print("  PASS: frame label combobox state")

And like the lightbox, its completion drops the centred doc’s own labels, so it never offers a word the doc already wears.

@testcase
def test_frame_completion_skips_present(page):
    """Like the lightbox, the frame's completion drops the centred doc's own labels."""
    make_fixtures()
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzpresentframe", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzpresentframe-t", "labels": "cosmo; zzpresentframe", "state": "todo"}
    gql(CREATE, {"p": doc})
    try:
        open_app(page, "?ms=999999")
        chip(page, "all").click()
        search_for(page, "zzpresentframe")                         # narrow to just this doc
        expect(tiles(page)).to_have_count(1)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        strip.click()                                              # reveal the frame bar
        box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
        box.click(); box.press_sequentially("balade", delay=20)    # a label the doc lacks…
        expect(options(page).filter(has_text=re.compile(r"^balade$")).first).to_be_visible()  # …is offered
        box.fill(""); box.press_sequentially("cosmo", delay=20)    # one the centred doc has…
        expect(options(page).filter(has_text=re.compile(r"^cosmo$"))).to_have_count(0)        # …is not
    finally:
        gql(DELETE, {"cid": doc["cid"]})
    print("  PASS: frame completion skips present")

The frame state and clock. The interval timer smooth-scrolls one slide on from wherever the strip currently sits, so manual swipes are respected and it wraps. A playing video holds it too: each tick checks the slides first and skips the advance while one is still rolling, so a clip you started isn’t scrolled off before it ends.

A pinch holds it hardest of all. The pinch itself is the browser’s own — spread two fingers and it magnifies the slide, no code of ours in the loop — but the frame has to notice, because a slide that auto-advances or snap-realigns out from under a magnified look is useless. The browser reports the pinch through visualViewport: its scale sits at 1 unzoomed and climbs past it the moment a pinch takes hold, so scale > 1 is our reading that one is on, watched off the viewport’s own resize and scroll.

One measure underlies all the positioning: a slide is located by its own geometry — each slide’s offsetLeft, and the true per-slide width (the strip’s scrollWidth over every slide) — never the viewport’s rounded clientWidth. A slide is a full viewport (100vw) wide, a length that can sit a fraction off the integer clientWidth; across a wall of thousands of slides that fraction compounds into whole slides adrift, so only the slides' real geometry keeps the show landing dead-centre.

@testcase
def test_frame_video_holds_the_show(page):
    """While a slide's video is playing, the show doesn't auto-advance; when it ends, it resumes."""
    drop_fixtures()
    vid = {"cid": "https://ipfs.konubinix.eu/p/zzfvid", "date": "2019-01-15T12:00:00Z", "mimetype": "video/webm",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvid-web",
           "labels": "zzfvid", "state": "todo"}
    # three stills after the video, so an un-held show marches visibly past it instead of
    # wrapping the short clone-cycle back onto the video within the test window
    stills = [{"cid": f"https://ipfs.konubinix.eu/p/zzfvid-{i}", "date": f"20{19 + i}-02-15T12:00:00Z", "mimetype": "image/jpeg",
               "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfvid-{i}-t", "labels": "zzfvid", "state": "todo"} for i in range(1, 4)]
    for d in (vid, *stills): gql(CREATE, {"p": d})
    page.route("**/ipfs/zzfvid-web", lambda r: r.fulfill(
        status=200, body=CLIP_WEBM, content_type="video/webm", headers={"Accept-Ranges": "bytes"}))
    try:
        open_app(page, "?ms=1000")
        chip(page, "all").click(); search_for(page, "zzfvid")
        expect(tiles(page)).to_have_count(4)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        on_slide = "el => Math.round(el.scrollLeft / (el.clientWidth || 1))"
        wait_until(page, lambda: strip.evaluate(on_slide) == 1)        # settled on the video (first real slide)
        v = strip.locator("video").first
        v.evaluate("el => { el.muted = true; el.currentTime = 0; el.play().catch(() => {}); }")
        wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
        page.wait_for_timeout(2500)                                    # several 1s ticks pass…
        assert strip.evaluate(on_slide) == 1, "a playing video must hold the show"
        v.evaluate("el => { el.currentTime = el.duration; }")          # let it play out
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzfvid-1-t")   # show resumes onto the first still
    finally:
        page.unroute("**/ipfs/zzfvid-web")
        for d in (vid, *stills):
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass
    print("  PASS: frame video holds the show")

So the auto-advance stands still while a pinch is on — lean in on a face and the show won’t step away from it, even once the after-touch idle span that also pauses it has lapsed.

@testcase
def test_frame_pinch_pauses_autoadvance(page):
    """A magnified slide (native pinch) holds the show even after the touch-idle span lapses."""
    make_fixtures()
    open_app(page, "?ms=500&idleresume=600")           # brisk tempo, short touch-idle so only the zoom can hold
    chip(page, "all").click()
    search_for(page, FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(len(FIXTURES))
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = strip.bounding_box()
    pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)   # the browser magnified
    page.wait_for_timeout(1600)                        # past the 600ms touch-idle AND several 500ms ticks
    assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0", "a magnified slide must not auto-advance"
    print("  PASS: frame pinch pauses auto-advance")

And the settle-snap stands off while a pinch is on, so a scroll nudged mid-slide isn’t yanked back to a slide edge and out from under the magnified view.

@testcase
def test_frame_pinch_freezes_snap(page):
    """While magnified, the settle-snap stands off — a mid-slide scroll fires no realign."""
    make_fixtures()
    open_app(page, "?ms=999999")                       # no auto-advance to muddy the scroll
    chip(page, "all").click()
    search_for(page, FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(len(FIXTURES))
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = strip.bounding_box()
    pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)
    page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))")   # let the app read the now-live scale
    # count the settle-snap's realign action; a mid-slide scroll would trigger it when not frozen
    strip.evaluate("el => { window.__snaps = 0; const o = el.scrollTo.bind(el); el.scrollTo = (...a) => { window.__snaps++; return o(...a); }; }")
    strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
        el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4);   // 40% into slide 1: well off any boundary
        el.dispatchEvent(new Event('scroll')); }""")
    page.wait_for_timeout(400)                         # well past the ~150ms settle-snap
    assert page.evaluate("() => window.__snaps") == 0, "a magnified slide's settle-snap must not realign"
    print("  PASS: frame pinch freezes snap")

Everything the frame does is a reaction to a small cluster of state: whether it is open and playing, which slide has settled — and at what strip index, so the load bands know where they are — and whether a recent touch or a live pinch should hold the auto-advance. We keep the signals together so every effect below reads one live picture.

const [frame, setFrame] = createSignal(false);
const [playing, setPlaying] = createSignal(true);
const [frameUI, setFrameUI] = createSignal(false);     // controls revealed on tap
const [frameLabel, setFrameLabel] = createSignal('');
const [frameLabelFocus, setFrameLabelFocus] = createSignal(false);
const [frameCenterCid, setFrameCenterCid] = createSignal(null);   // the settled slide
const frameDoc = () => items().find(p => p.cid === frameCenterCid());   // the centred doc
const [frameEditingDate, setFrameEditingDate] = createSignal(false);
const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);
const [frameDir, setFrameDir] = createSignal(1);                  // last travel direction (+1 forward)
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
const FRAME_IDLE_MS = Number(new URLSearchParams(location.search).get('idleresume')) || 60000;
const [pokes, setPokes] = createSignal(0);
const nudge = () => setPokes(n => n + 1);
const [interacting, setInteracting] = createSignal(false);
const [zoomed, setZoomed] = createSignal(false);

The frame plays the wall in the order shown — chronological, or the sort:random draw. To loop seamlessly it builds an infinite carousel: a clone of the last slide sits before the first and a clone of the first after the last, so real slide 0 lands at strip index 1. Positions and indices read the slides’ own geometry rather than a rounded clientWidth (which the settle would then have to correct), and a step is just a smooth scroll to the neighbour.

const frameSlides = () => { const o = items();
    return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
let stripEl, wakeLock = null;
const slideW = () => stripEl && stripEl.children.length ? stripEl.scrollWidth / stripEl.children.length : (stripEl?.clientWidth || 1);
const slideAt = () => Math.round((stripEl?.scrollLeft || 0) / slideW());      // nearest slide index
const slideLeft = i => { const k = stripEl && stripEl.children[i]; return k ? k.offsetLeft : i * slideW(); };
const frameGo = delta => { if(!stripEl) return;
    stripEl.scrollTo({ left: slideLeft(slideAt() + delta), behavior: 'smooth' }); };

A native scroll flows freely; the frame lets it, then tidies up once it stops. While the strip moves, the load bands ride the live position and lean the way it is going. About 150ms after the last scroll, the settle re-centres on the nearest real slide — swapping a clone for its twin at the edges — and remembers the doc it came to rest on, so a reboot resumes there. A pinch that has taken hold is left alone: a realign queued before it must stand off rather than yank the magnified view back to a slide edge.

const FRAME_CID_KEY = 'memories.frame.cid';
let snapT;
const onFrameScroll = () => {
    if(stripEl){ const i = slideAt(), c = frameCenterIdx();
        if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
    clearTimeout(snapT); snapT = setTimeout(() => {
    if(!stripEl) return;
    if(zoomed()) return;
    const n = items().length;
    let i = slideAt();
    if(i <= 0){ stripEl.scrollLeft = slideLeft(n); i = n; }       // leading clone(last) → real last
    else if(i >= n + 1){ stripEl.scrollLeft = slideLeft(1); i = 1; }  // trailing clone(first) → real first
    const target = slideLeft(i);
    if(Math.abs(stripEl.scrollLeft - target) > 1) stripEl.scrollTo({ left: target, behavior: 'smooth' });
    const doc = items()[i - 1];
    setFrameCenterIdx(i);
    if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
}, 150); };

Entering the frame is a small ceremony: mark it open and playing, remember it in localStorage so a PWA relaunch comes straight back, push a history entry so the back button leaves, and take a wake lock so the cabinet screen stays lit. Closing is the teardown — turn the auto-enter off and release the lock. Exit unwinds through the history entry when there is one, so the back button and an explicit exit both leave history balanced.

const FRAME_ON_KEY = 'memories.frame.on';
async function enterFrame(){
    if(!items().length) return;
    setFrame(true); setPlaying(true); setFrameUI(false);
    localStorage.setItem(FRAME_ON_KEY, '1');
    history.pushState({ frame: true }, '');
    try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
}
function closeFrame(){
    setFrame(false);
    localStorage.setItem(FRAME_ON_KEY, '0');
    try { wakeLock?.release(); } catch(e) {} wakeLock = null;
}
const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();

You can edit the centred doc without leaving the show — set its state, add or drop a label, fix its date. An edit can push the doc out of the current filter; when it does we re-anchor on the previous doc, so the next advance shows whatever filled the gap rather than skipping it, and when it stays we keep it centred. Each editor below is a thin wrapper over that one frameEdit.

const frameIndex = () => Math.max(0, Math.min(items().length - 1, slideAt() - 1));
async function frameEdit(patchFor){
    const list = items(); if(!list.length || !stripEl) return;
    const i = frameIndex(), cur = list[i], prevCid = i > 0 ? list[i - 1].cid : null;
    await gql(UPDATE_PHOTO, { cid: cur.cid, patch: patchFor(cur) });
    setFrameLabel('');
    await refetch();
    requestAnimationFrame(() => {
        const l2 = items(); if(!l2.length) { exitFrame(); return; }
        const stay = l2.findIndex(p => p.cid === cur.cid);
        let t = stay >= 0 ? stay : (prevCid ? l2.findIndex(p => p.cid === prevCid) : 0);
        stripEl.scrollLeft = slideLeft(Math.max(0, t) + 1);
    });
}
const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
    return frameEdit(() => ({ state: st })); };
const frameAddWord = input => { const words = splitWords(input); if(!words.length) return;
    return frameEdit(p => { const cur = splitLabels(p);
        for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; }); };
const frameAddLabel = () => frameAddWord(frameLabel());
const frameDropLabel = () => { const words = splitWords(frameLabel()); if(!words.length) return;
    return frameEdit(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') })); };
let frameCancelDate = false;
const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false; setFrameEditingDate(false);
    if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };

The cabinet tablet has no address bar: the frame launches from a PWA home-screen shortcut, which opens with no query string, so an auto-start can’t ride a URL param. Being in the frame is remembered instead — when the persisted wall loads and we were last in the frame, the show auto-starts, once only, so exiting doesn’t immediately re-enter. It opens on the slide we left off, resuming across a reboot, and falls back to the first real slide.

const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1';
let autoEntered = false;
createEffect(() => {
    if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
});
createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
    const saved = localStorage.getItem(FRAME_CID_KEY);
    const r = saved ? items().findIndex(p => p.cid === saved) : -1;
    stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1);   // +1 for the leading clone
    setFrameCenterIdx(r >= 0 ? r + 1 : 1);                             // seed the centre before any scroll
    setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
}); });

A video the viewer started should not keep playing once it has scrolled away — a soundtrack from a slide nobody can see, over a photo that has nothing to do with it.

@testcase
def test_frame_video_pauses_when_it_leaves(page):
    """A video playing in the frame stops once the show has moved off its slide."""
    drop_fixtures()
    vid = {"cid": "https://ipfs.konubinix.eu/p/zzfvid", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvid-web",
           "labels": "zzfvid", "state": "todo"}
    nxt = {"cid": "https://ipfs.konubinix.eu/p/zzfvid-next", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-next-t", "labels": "zzfvid", "state": "todo"}
    for d in (vid, nxt): gql(CREATE, {"p": d})
    page.route("**/ipfs/zzfvid-web", lambda r: r.fulfill(
        status=200, body=CLIP_WEBM, content_type="video/webm",
        headers={"Accept-Ranges": "bytes"}))
    try:
        open_app(page, "?ms=999999")                  # the show holds still; the test moves it
        search_for(page, "zzfvid")                    # default chip = todo
        expect(tiles(page)).to_have_count(2)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        v = strip.locator("video").first          # slide 1 — slide 0 is the wrap-around clone of the last
        v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")   # headless blocks unmuted autoplay
        wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
        strip.evaluate("el => el.scrollLeft = el.children[2].offsetLeft")     # onto the next slide
        wait_until(page, lambda: v.evaluate("el => el.paused"),
                   label="the video paused once its slide left the screen")
        print("  PASS: frame video pauses when it leaves")
    finally:
        page.unroute("**/ipfs/zzfvid-web")
        for d in (vid, nxt):
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

And it has to keep doing so after the strip has re-flowed under it. Triage from the frame removes docs from the filter, so the boxes shuffle: a box that held a photo comes to hold the video that used to sit after it. That video is a different element from the one the show opened with, and it must be watched just the same.

@testcase
def test_frame_video_pauses_after_reflow(page):
    """A video that only lands in its slide box after an edit re-flows the strip still pauses."""
    drop_fixtures()
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzfvr-a", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-a-t", "labels": "zzfvr", "state": "todo"},
            {"cid": "https://ipfs.konubinix.eu/p/zzfvr-v", "date": "2020-02-15T12:00:00Z", "mimetype": "video/webm",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-v-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvr-web",
             "labels": "zzfvr", "state": "todo"},
            {"cid": "https://ipfs.konubinix.eu/p/zzfvr-c", "date": "2020-03-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-c-t", "labels": "zzfvr", "state": "todo"}]
    for d in docs: gql(CREATE, {"p": d})
    page.route("**/ipfs/zzfvr-web", lambda r: r.fulfill(
        status=200, body=CLIP_WEBM, content_type="video/webm",
        headers={"Accept-Ranges": "bytes"}))
    try:
        open_app(page, "?ms=999999")
        search_for(page, "zzfvr")
        expect(tiles(page)).to_have_count(3)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        strip.click()                                          # reveal the bar, on the first photo
        bar = page.get_by_role("toolbar", name="frame actions")
        bar.get_by_role("button", name="done", exact=True).click()   # it leaves todo → the strip closes the gap
        expect(tiles(page)).to_have_count(2)
        v = strip.locator("video").first                       # the video now sits in the box the photo had
        v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
        wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
        strip.evaluate("el => el.scrollLeft = el.children[2].offsetLeft")
        wait_until(page, lambda: v.evaluate("el => el.paused"),
                   label="the re-flowed video paused once its slide left the screen")
        print("  PASS: frame video pauses after reflow")
    finally:
        page.unroute("**/ipfs/zzfvr-web")
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

An IntersectionObserver over the strip pauses any slide video that drops below half-visible. It can only watch the videos it was handed, though, and it is handed them by one sweep of the strip — so the sweep has to happen again every time the boxes are re-dealt, not just when the show opens. Hence the observer is thrown away and rebuilt on each of those two events, which is what on([items, frame]) says: rebuild when the docs change, and when the show opens.

createEffect(on([items, frame], () => {
    if(!frame() || !stripEl) return;
    const io = new IntersectionObserver(
        es => es.forEach(e => { if(e.intersectionRatio < 0.5) e.target.pause(); }),
        { root: stripEl, threshold: 0.5 });
    requestAnimationFrame(() => stripEl.querySelectorAll('video').forEach(v => io.observe(v)));
    onCleanup(() => io.disconnect());
}));

The auto-advance is a smooth step to the next slide every interval, but it must yield. The browser’s own pinch-zoom is watched off visualViewport, so a magnified slide holds the show; a playing video holds it too; and a recent touch holds it — each interaction marks the show busy, then quiet again after the idle span, and every tap or swipe on the strip bumps that timer. When nothing holds it, the show steps and wraps at the end.

createEffect(() => { if(!frame()) return; const vv = window.visualViewport; if(!vv) return;
    const read = () => setZoomed(vv.scale > 1);
    read(); vv.addEventListener('resize', read); vv.addEventListener('scroll', read);
    onCleanup(() => { vv.removeEventListener('resize', read); vv.removeEventListener('scroll', read); }); });
createEffect(() => {
    if(!frame() || !playing() || zoomed() || interacting()) return;
    const id = setInterval(() => {
        if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
        frameGo(1);
    }, intervalMs());
    onCleanup(() => clearInterval(id));
});
createEffect(() => { if(!pokes()) return;
    setInteracting(true);
    const id = setTimeout(() => setInteracting(false), FRAME_IDLE_MS); onCleanup(() => clearTimeout(id)); });
createEffect(() => { if(!frame() || !stripEl) return;
    stripEl.addEventListener('pointerdown', nudge);
    onCleanup(() => stripEl.removeEventListener('pointerdown', nudge)); });

A few listeners ride on the window for the frame’s whole life. Esc and the arrow keys work the show from a desktop keyboard — except while the label box holds focus, where the arrows must edit the text rather than step the slide. The back button unwinds the stack in order — out of the frame, then out of the lightbox it may have launched from, then to the wall. And when the tab returns to the foreground the wake lock, dropped while hidden, is taken again so the screen stays lit.

onMount(() => {
    const onKey = e => {
        if(!frame()) return;
        const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
        if(e.key === 'Escape') exitFrame();
        else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); frameGo(1); }
        else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); frameGo(-1); }
    };
    const onVis = async () => {
        if(frame() && document.visibilityState === 'visible' && !wakeLock)
            try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
    };
    const onPop = () => {
        if(frame()) closeFrame();
        const st = history.state || {};
        if(opened() && !st.lb) closePhoto();
        if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }
    };
    window.addEventListener('keydown', onKey);
    window.addEventListener('popstate', onPop);
    document.addEventListener('visibilitychange', onVis);
    onCleanup(() => { window.removeEventListener('keydown', onKey);
                      window.removeEventListener('popstate', onPop);
                      document.removeEventListener('visibilitychange', onVis); });
});

On a desktop keyboard, and step the show — the same one-slide move the side-taps make. The frame claims that keypress wholly, so frameGo alone drives the step: a modern browser hands keyboard focus to a scrollable region, and left to its own devices it would answer the arrow by scrolling the strip a notch itself — jerking the doc sideways and fighting the step the settle then has to undo. Taking the key keeps the arrow a clean step, the same animation as a side-tap.

Those arrows want a keyboard the cabinet tablet doesn’t have, and one-handed from across the room a dependable swipe wants two hands. So the screen itself carries the same step, and where the tap lands is the whole of it.

Two of the three zones go to travel, because stepping is what you do most and it has to be reachable without looking: the outer thirds move the show, left back and right forward.

page.mouse.click(box["x"] + box["width"] * 0.92, midY)         # right third → forward
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.mouse.click(box["x"] + box["width"] * 0.08, midY)         # left third → back
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print("  PASS: frame tap zones step the show")

The centre third is what is left, and it has to be the bar’s: put the controls behind an edge and every reach for them would step the show first.

page.mouse.click(box["x"] + box["width"] / 2, midY)            # centre third → the bar
expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
print("  PASS: frame centre tap reveals the bar")

In code the zones are the whole handler: an x against two thirds of the width, and nothing else to decide.

const onFrameTap = e => {
    const w = window.innerWidth || 1;
    if(e.clientX < w / 3) frameGo(-1);
    else if(e.clientX > w * 2 / 3) frameGo(1);
    else setFrameUI(v => !v);
};

A plain click can’t carry that step: a <video controls> in the slide swallows the tap before any click bubbles up. So we read the strip’s raw pointers instead, watching for a lone finger that presses and lifts in place — travelling no more than about ten pixels, past which it is a swipe rather than a tap. A second finger is not a tap at all: it is a pinch, and pinching now belongs to the browser’s own zoom, so the watch lets that gesture go and never steps the show.

const TAP_SLOP = 10;
let tapFrom = null;                                   // where a lone finger went down, while it could still be a tap
const tapPtrs = new Set();
const onTapDown = e => { tapPtrs.add(e.pointerId);
    tapFrom = tapPtrs.size === 1 ? { x: e.clientX, y: e.clientY } : null; };
const onTapMove = e => { if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null; };
const onTapUp = e => { tapPtrs.delete(e.pointerId);
    if(e.type === 'pointerup' && tapFrom) onFrameTap(e);
    if(!tapPtrs.size) tapFrom = null; };
createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
    const on = (t, h) => el.addEventListener(t, h), off = (t, h) => el.removeEventListener(t, h);
    on('pointerdown', onTapDown); on('pointermove', onTapMove); on('pointerup', onTapUp); on('pointercancel', onTapUp);
    onCleanup(() => { off('pointerdown', onTapDown); off('pointermove', onTapMove);
        off('pointerup', onTapUp); off('pointercancel', onTapUp); }); });

The second finger is the watch’s other half: two fingers planted in an outer third and lifted must leave the show exactly where it stood — only a lone finger carries the side-step.

x = box["x"] + box["width"] * 0.92                             # the right third — a lone tap here would step forward
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
         "touchPoints": [{"x": x - 20, "y": midY}, {"x": x + 20, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
page.wait_for_timeout(FRAME_STEP_GRACE_MS)                     # long enough for a step to show
assert strip.evaluate(ON_SLIDE) == first, "a two-finger gesture must not step the show"
print("  PASS: frame two-finger gesture does not step")

The window is its own knob — ?uiidle overrides the twenty seconds — and deliberately apart from the minute of quiet that lets the auto-advance resume: this timer tidies away a menu, that one guards a slide you are studying, so they pace different things and shouldn’t share a number. The count has to restart whenever you touch the show, so it rides the same pokes() signal the frame already tracks for every interaction.

const FRAME_UI_IDLE_MS = Number(new URLSearchParams(location.search).get('uiidle')) || 20000;
createEffect(() => { if(!frameUI()) return; pokes();
    const id = setTimeout(() => setFrameUI(false), FRAME_UI_IDLE_MS); onCleanup(() => clearTimeout(id)); });

That interaction signal is fed by the strip, but the bar lays over it as a sibling, so a press on the bar’s own controls never reaches it. The bar forwards its presses there, so working the menu counts as touching the show and keeps it up:

onPointerDown=${nudge}

That stack bottoms out at the grid; one more Back would leave the app entirely — easy to do by accident on the cabinet tablet. So a root history entry sits under the grid, and popping below it (nothing open) asks first: confirm and we leave, cancel and the root is re-pushed so the wall stays put.

@testcase
def test_back_at_grid_asks_before_exit(page):
    """At the grid, with nothing open, the back button asks before leaving the app."""
    open_fixtures(page)
    asked = []
    page.on("dialog", lambda d: (asked.append(d.message), d.dismiss()))   # cancel → stay
    page.go_back()
    wait_until(page, lambda: bool(asked))                                 # the dialog is delivered async
    expect(grid(page)).to_be_visible()                                    # cancelling kept us in the app
    print("  PASS: back at grid asks before exit")

The completion list under the search box is the one thing that can be showing without being a stacked overlay — yet the same reflex applies: with it up, Back should retract it, not ask whether to leave. Retracting the last thing that appeared is what the button is for, so a Back that finds the list open spends itself closing it, and only a Back with nothing showing reaches the leave prompt. And retracting the list must not spend the root guard itself: a further Back, now with nothing showing, still meets that prompt.

@testcase
def test_back_closes_completion(page):
    """With the completion list up, the back button retracts it and stays in the app."""
    open_app(page)
    search_box(page).click()                       # focus the empty box → the token menu drops down
    expect(options(page).first).to_be_visible()
    page.go_back()                                 # Back retracts the list — it must not leave the app
    expect(options(page)).to_have_count(0)         # the list is gone
    expect(search_box(page)).to_be_visible()       # and we're still in Memories
    print("  PASS: back closes completion")

@testcase
def test_back_after_completion_still_guards_exit(page):
    """After Back retracts the completion list, a further Back still asks before leaving."""
    open_app(page)
    search_box(page).click()                         # focus → the list drops down
    expect(options(page).first).to_be_visible()
    page.go_back()                                   # first Back: retract the list
    expect(options(page)).to_have_count(0)
    asked = []
    page.on("dialog", lambda d: (asked.append(d.message), d.dismiss()))
    page.go_back()                                   # second Back: must reach the leave guard
    wait_until(page, lambda: bool(asked))
    expect(search_box(page)).to_be_visible()         # cancelled → still in the app
    print("  PASS: back after completion still guards exit")

In practice the guard reads whether the list is up straight from the DOM — the presence of the rendered .suggest node — rather than from a state flag: a flag holding the last-offered items would keep its value after the box blurs and the list unmounts, and so would claim a list that is already gone.

onMount(() => {
    history.pushState({ app: true }, '');          // the root entry the back button stops on
    const onExit = () => {
        const st = history.state || {};
        if(!frame() && !opened() && !st.lb && !st.app){      // popped below the root with nothing open
            if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; }   // bail out of a frozen read; stay
            if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; }   // retract the list; keep the root beneath
            if(confirm('Leave Memories?')) history.back();   // really leave
            else history.pushState({ app: true }, '');       // stay — restore the root
        }
    };
    window.addEventListener('popstate', onExit);
    onCleanup(() => window.removeEventListener('popstate', onExit));
});

The filmstrip: a natively-scrolled row of viewport-wide slides (incl. the two clones). The control bar lays over it as a sibling — outside the strip that reads taps — so a press on the bar’s own buttons is never taken for a tap on the show.

<${Show} when=${() => frame()}>
  <div class="frame">
    <div class="strip" role="list" aria-label="slideshow"
         ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
      <${Index} each=${() => frameSlides()}>${(slide, k) => FrameSlide(slide, k)}<//>
    </div>
    <${Show} when=${() => frameUI()}>
      <div class="frame-bar" role="toolbar" aria-label="frame actions" onPointerDown=${nudge}>
        <button aria-label=${() => playing() ? 'pause' : 'play'}
                onClick=${() => setPlaying(p => !p)}>${() => playing() ? '⏸' : '▶'}</button>
        <label>every <input class="ivl" type="number" min="2" aria-label="seconds per photo"
               value=${() => Math.round(intervalMs() / 1000)}
               onChange=${e => setIntervalMs(Math.max(2, +e.target.value) * 1000)} />s</label>
        <${Show} when=${() => frameEditingDate()}
                 fallback=${html`<button class="frame-date" aria-label="edit date"
                     onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
                       return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
          <input class="frame-date-edit" type="datetime-local" aria-label="date"
                 ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
                 onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
                 onBlur=${e => frameCommitDate(e.target.value)} />
        <//>
        <span class="room-link" data-state=${roomLink}>${roomLink}</span>
        <${For} each=${() => frameEvents() || []}>${e => html`
          <button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
        <//>
        ${STATES.map(st => html`
          <button class="st" data-st=${st} onClick=${() => frameSetState(st)}>${st}</button>`)}
        <div class="complete">
          <input class="frame-label" role="combobox" placeholder="add a label…" aria-label="add a label in the frame"
                 aria-expanded=${() => frameLabelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
                 value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
                 onFocus=${() => setFrameLabelFocus(true)}
                 onBlur=${() => setFrameLabelFocus(false)}
                 onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
                   sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
          <${Show} when=${() => frameLabelFocus()}>
            <${Suggest} text=${frameLabel} present=${() => labelsOf(frameDoc())}
                        active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
                        onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
          <//>
        </div>
        <button aria-label="exit frame" onClick=${exitFrame}> exit</button>
      </div>
    <//>
  </div>
<//>

The date on the bar is the lightbox’s date editor moved here: a button showing the centred slide’s date that, pressed, becomes a datetime-local seeded with what it held. Saving routes through frameEdit, so fixing a wrong timestamp mid-show re-orders the strip and keeps the doc under you, exactly as a state change does.

<${Show} when=${() => frameEditingDate()}
         fallback=${html`<button class="frame-date" aria-label="edit date"
             onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
               return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
  <input class="frame-date-edit" type="datetime-local" aria-label="date"
         ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
         onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
         onBlur=${e => frameCommitDate(e.target.value)} />
<//>

Backing out must not leave the show: Escape otherwise exits the frame, so the picker swallows it at the input and marks the edit cancelled before it lets go.

else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); }

The frame carries the same overlap the lightbox does: beside the date, the centred slide’s events ride on the bar, so a slideshow tells you not just when a photo was taken but what was happening then — and stepping to the next slide brings its own.

@testcase
def test_frame_shows_events(page):
    """The frame bar names the calendar events the centred slide was taken during."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-frame", "starttime": "2020-11-01T00:00:00Z", "endtime": "2020-11-30T23:59:59Z",
          "summary": "zzFrameEvent", "owner": "konubinix", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzframephoto", "date": "2020-11-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzframephoto-t",
           "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzframeev", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzframeev")
        expect(tiles(page)).to_have_count(1)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()   # into the slideshow
        expect(page.get_by_role("list", name="slideshow")).to_be_visible()
        page.get_by_role("list", name="slideshow").click()                   # reveal the bar
        bar = page.get_by_role("toolbar", name="frame actions")
        expect(bar.get_by_text("zzFrameEvent")).to_be_visible()              # the centred slide's event
        start = page.evaluate("() => new Date('2020-11-01T00:00:00Z').toLocaleDateString('fr-FR')")
        end   = page.evaluate("() => new Date('2020-11-30T23:59:59Z').toLocaleDateString('fr-FR')")
        expect(bar.get_by_text(start)).to_be_visible()   # the span's start, beside the name
        expect(bar.get_by_text(end)).to_be_visible()     # …and its end
    finally:
        gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: frame shows events")

The bar’s pill carries the occasion’s when too — the same muted date aside as the lightbox, kept on the bar’s single line. On the revealed bar, the event’s start and end read off the pill.

start = page.evaluate("() => new Date('2020-11-01T00:00:00Z').toLocaleDateString('fr-FR')")
end   = page.evaluate("() => new Date('2020-11-30T23:59:59Z').toLocaleDateString('fr-FR')")
expect(bar.get_by_text(start)).to_be_visible()   # the span's start, beside the name
expect(bar.get_by_text(end)).to_be_visible()     # …and its end

The wiring is the lightbox’s with one substitution: the resource keys on the centred frameDoc() instead of opened(), reusing fetchDocEvents and its owner guard. The chips carry the same name + muted when as there; and because the bar is a single line, both the pill and its date aside must stay on it (nowrap).

const [frameEvents] = createResource(
    () => { const o = frameDoc(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);

<${For} each=${() => frameEvents() || []}>${e => html`
  <button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>

The bar’s pill jumps like the lightbox’s: clicking it runs the occasion’s event: search and steps out of the show — through exitFrame — onto the wall, now narrowed to that occasion.

@testcase
def test_frame_event_pill_searches(page):
    """Clicking an event pill on the frame bar runs its event: search and leaves the show for the wall."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-fpill", "starttime": "2020-11-01T00:00:00Z", "endtime": "2020-11-30T23:59:59Z",
          "summary": "zzFramePill", "owner": "konubinix", "status": "confirmed"}
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzfpill", "date": "2020-11-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfpill-t",
           "owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzframepill", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzframepill")
        expect(tiles(page)).to_have_count(1)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()   # into the slideshow
        expect(page.get_by_role("list", name="slideshow")).to_be_visible()
        page.get_by_role("list", name="slideshow").click()                   # reveal the bar
        bar = page.get_by_role("toolbar", name="frame actions")
        bar.get_by_role("button", name=re.compile("zzFramePill")).click()    # jump to the occasion
        expect(search_box(page)).to_have_value("event:zzFramePill")          # the pill's search, committed
        expect(page.get_by_role("list", name="slideshow")).to_have_count(0)  # stepped out of the show
        expect(tiles(page)).to_have_count(1)                                 # back on the filtered wall
    finally:
        gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: frame event pill searches")

.frame-event{ font:inherit; font-size:12px; color:#bcd; background:#20293f; border:1px solid #34406088;
              border-radius:999px; padding:3px 10px; white-space:nowrap; cursor:pointer; }
.frame-event:hover{ background:#2a3450; border-color:#4a5680; }

.frame{ position:fixed; inset:0; z-index:200; background:#000; }
.strip{ display:flex; width:100%; height:100%; overflow-x:auto; overflow-y:hidden;
        scrollbar-width:none; }
.strip::-webkit-scrollbar{ display:none; }
.slide{ flex:0 0 100%; width:100vw; height:100vh;
        display:flex; align-items:center; justify-content:center; }
.slide-media{ width:100vw; height:100vh; object-fit:contain; }
.slide-media.noimg{ display:flex; align-items:center; justify-content:center; color:#9aa; }
.slide-media.noimg .ph{ font-size:80px; opacity:.5; }
/* the loading mark sits centred over the slide until the image paints over it */
.slide-pic{ position:relative; width:100vw; height:100vh; display:flex; align-items:center; justify-content:center; }
.slide-pic .load-ph{ position:absolute; font-size:80px; opacity:.5; color:#9aa; }
.slide-pic .slide-media.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; }  /* full-res fades in over the thumbnail */
.slide-pic .slide-media.web.shown{ opacity:1; }
/* a quiet pulse, corner-tucked, while the full-res is still on its way */
.slide-pic .upgrading{ position:absolute; bottom:16px; right:16px; width:10px; height:10px;
    border-radius:50%; background:#cdd; animation:upgrading 1.3s ease-in-out infinite; }
@keyframes upgrading{ 0%,100%{ opacity:.15 } 50%{ opacity:.6 } }
.frame-bar{ position:fixed; bottom:calc(18px + env(safe-area-inset-bottom));
            left:50%; transform:translateX(-50%); z-index:3;
            display:flex; flex-wrap:wrap; gap:8px 12px; align-items:center; justify-content:center;
            max-width:92vw; background:#11131fdd; border:1px solid #3a3f5a;
            border-radius:10px; padding:8px 14px; font-size:14px; color:#cdd; }
.frame-bar button{ border:none; background:none; color:var(--fg); font-size:16px; cursor:pointer; }
.frame-bar .st{ font-size:12px; text-transform:uppercase; letter-spacing:.03em;
                border:1px solid #3a3f5a; border-radius:5px; padding:3px 8px; }
[data-st="todo"]{   --st:#6cf; }
[data-st="next"]{   --st:#fb7; }
[data-st="done"]{   --st:#7d7; }
[data-st="delete"]{ --st:#f77; }
button[data-st]:active{ transform:scale(0.9); filter:brightness(1.2); }
.frame-bar .st[data-st]{ border-color:var(--st); color:var(--st); }
.frame-bar .ivl{ width:48px; background:#262a40; color:var(--fg); border:1px solid #3a3f5a;
                 border-radius:4px; padding:3px 5px; }
.frame-bar .frame-date{ font-size:13px; color:#9aa; background:none; border:none; padding:0;
                        font-family:inherit; cursor:pointer; }
.frame-bar .frame-date:hover{ color:#ccd; text-decoration:underline; }
.frame-bar .frame-date-edit{ font-size:13px; color:var(--fg); background:#262a40; border:1px solid #3a3f5a;
                             border-radius:5px; padding:3px 6px; }
.frame-bar .frame-label{ background:#262a40; color:var(--fg); border:1px solid #3a3f5a;
                         border-radius:5px; padding:4px 8px; font-size:13px; }
.frame-bar .suggest{ top:auto; bottom:100%; margin:0 0 4px; }   /* open upward from the bar */

Letting go of a forgotten pinch

A pinch left untouched must not trap the frame forever — the cabinet plays across the room, and a slide someone magnified and wandered from should find its way back to the show. But the pinch is the browser’s, and the browser offers no way back: on the tablet’s Chromium a script cannot zoom out. Neither a maximum-scale flip on the viewport tag nor a plain reload clears it — both leave the magnification in place, Chromium carrying the remembered scale even across the reload. The one move that drops it is landing on an address the browser has never seen zoomed. So after five minutes with no touch (?zoomidle= overrides the span) the frame navigates to its own page with a fresh throwaway query and lands at 1:1; its persistence carries the show across the reboot — it auto-re-enters and resumes the slide it was on. Every touch re-arms the five minutes, so only a truly forgotten pinch ever triggers the reload.

@testcase
def test_frame_pinch_resets_after_idle(page):
    """Left magnified and untouched, the frame reloads itself back to 1:1 after the idle span."""
    make_fixtures()
    open_app(page, "?ms=999999&zoomidle=700")          # a short idle window for the test
    chip(page, "all").click()
    search_for(page, FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(len(FIXTURES))
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = strip.bounding_box()
    pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)
    page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))")   # register the zoom → arm the idle timer
    wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01, timeout=8000)   # reloaded → 1:1
    assert "z=" in page.url, "the reset lands on a fresh throwaway url"
    print("  PASS: frame pinch resets after idle")

The countdown rides the same pokes() interaction signal the rest of the frame uses, so every touch restarts it; only a stretch of pure quiet while zoomed reaches the navigation.

const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
createEffect(() => { if(!frame() || !zoomed()) return; pokes();   // any touch re-arms the countdown
    const id = setTimeout(() => { const u = new URL(location.href);
        u.searchParams.set('z', String(Date.now()));   // an address the browser hasn't seen zoomed → it lands at 1:1
        location.href = u.href; }, ZOOM_IDLE_MS);
    onCleanup(() => clearTimeout(id)); });

Into the frame, from the lightbox

The frame launches from the wall, over whatever the query narrowed to. But the moment you most want the big show is usually when you’re already lingering on one photo in the lightbox — so the lightbox carries a ▶ frame button too, and it opens the show on the doc you’re looking at rather than back at the first slide.

Landing on a chosen slide is something the frame already knows how to do: it opens on whichever slide matches the remembered cid, the way it resumes after a reboot. So launching from the lightbox is just seeding that memory with the open doc, dropping the modal, and entering — the strip settles on that very slide.

@testcase
def test_frame_from_lightbox(page):
    """▶ frame in the lightbox enters the slideshow centered on the open doc."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    open_doc(page, 1)                                # open the middle doc, not the first
    d = dialog(page)
    expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    d.get_by_role("button", name=re.compile("frame", re.I)).click()
    expect(d).to_be_hidden()                         # the lightbox gives way to the frame
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: frame from lightbox")

The lightbox’s history entry stays underneath the frame’s, so the back button unwinds the whole way down: leaving the frame reopens the doc it was launched from, and leaving that returns to the wall — frame → lightbox → grid.

@testcase
def test_back_from_frame_returns_to_lightbox(page):
    """A frame launched from a doc steps back to that doc's lightbox, then to the grid."""
    open_fixtures(page)
    open_doc(page, 1)                                          # lightbox on the middle doc
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    dialog(page).get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    page.go_back()                                             # out of the frame …
    expect(strip).to_be_hidden()
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")   # … back to its lightbox
    page.go_back()                                             # out of the lightbox …
    expect(dialog(page)).to_be_hidden()
    expect(grid(page)).to_be_visible()                         # … back to the wall
    print("  PASS: back from frame returns to lightbox")

The handler does exactly that: it writes the open doc’s cid where the frame looks for the slide to resume on, closes the modal, and enters.

const frameFromHere = () => { const cur = opened(); if(!cur) return;
    localStorage.setItem(FRAME_CID_KEY, cur.cid);    // the slide the frame will open on
    closePhoto();                                    // hide the modal but leave its history entry, so Back returns to it
    enterFrame(); };

The button sits at the top of the modal, between select and close.

<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}> frame</button>

.lb-frame{ position:absolute; top:-6px; left:50%; transform:translateX(-50%); z-index:2;
           padding:6px 12px; border:none; border-radius:8px; background:#262a40;
           color:var(--fg); font-size:12px; cursor:pointer; }
.lb-frame:hover{ background:#33395a; }

Handing the slide to a phone

The photo on the cabinet is often one you want to send to someone right then — and the conversation you would send it in is on your phone. The tablet has no way to hand a file to an app on another device, so the phone has to fetch it, and a small companion app is what does that: it holds whatever the frame is showing and passes it to the phone’s own share sheet. Everything memories owes it is a name for the photo.

That name lives in a shared document — a room on the house’s sync server, holding one entry that every frame writes and the companion reads. It is a heavier instrument than one value needs, and it is chosen for what surrounds the value rather than the value itself: the companion is a page with no server of its own, so nothing can hand it an update unless the update arrives over a connection it already holds.

Yjs holds such a document, and y-websocket carries it. Yjs has Solid’s one-instance requirement and meets it the same way — the socket library comes in marked ?external=yjs so that it and the map’s own yjs are the same copy. Two copies fail quietly in the worst way: the room connects, reports itself live, and never syncs anything.

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

Which room, and which server, a frame can work out for itself: the sync server answers on the same host the app was loaded from, so swapping the scheme is the whole of it. Both can be named at launch as well — every app here that joins a room takes its address that way, since the address is configuration and not a fact about where the page came from.

const frameParam = new URLSearchParams(location.search);
const SYNC_URL = frameParam.get('yws') || location.origin.replace(/^http/, 'ws') + '/ywebsocket';
const SYNC_ROOM = frameParam.get('room') || 'memories-nowshowing';
const [roomLink, setRoomLink] = createSignal('idle');

A frame names every slide it comes to rest on, unasked. Waiting for a gesture would defeat the point: you look up from the cabinet, take out your phone, and the photo has to be there already — a phone that needs you to go back and prod the tablet first is a phone you would not bother with.

Memories runs on the phones as much as on the cabinet, so several frames can be playing at once, all writing that same one entry, and the last to settle wins. There is no arbitration and no need for one: in practice the frame that keeps settling is the one that is on, which is the cabinet. Where two really are going at once, a touch breaks the tie.

A name is both of the doc’s addresses — the downscaled copy and the original, so the companion can offer you the choice the lightbox already draws — what kind of file it is, which the companion needs to build a file the share sheet will accept, and when the photo was taken, which is what the companion calls the file it hands over. Only the archive knows that date, and a frame that kept it to itself would leave the companion handing over a file it cannot name.

Under the hood the archive gives an instant back in its own offset rather than the Z it was handed, so the two read differently as text while naming the same moment; what is checked is therefore the instant, not its spelling.

named = watch_room(page.context, sync_url, room)           # the entry, watched as the phone will
open_app(page, f"?ms=999999&uiidle=1500&yws={sync_url}&room={room}")
search_for(page, "zzshare")                                # default state chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
# nothing is touched here: entering the show is the whole of it
wait_until(page, lambda: bool(named()), label="the frame names its slide unasked")
said = named()
assert said["cid"] == "https://ipfs.konubinix.eu/p/zzshare-0", f"named the wrong doc: {said}"
assert said["webCid"] == "https://ipfs.konubinix.eu/p/zzshare-web-0", f"no downscaled address: {said}"
assert said["mimetype"] == "image/jpeg", f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
                     [said.get("date"), SHARE_DOCS[0]["date"]]), \
    f"named a different instant than the doc's {SHARE_DOCS[0]['date']}: {said}"

A name rides the settle, not the scroll — the centred slide reads true long before the debounce that ends a step — so a step only counts as over once the show has come to rest, and that is when the next name goes out. Keep browsing and the name keeps up with you.

page.keyboard.press("ArrowRight")                          # a step, with nothing touched
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzshare-1",
           label="the name follows the slide", detail=lambda: str(named()))

Taking the room back is the one thing a touch is for. If another frame has spoken over you, touching yours puts your slide back without your having to move it.

speak_over(page.context, "https://ipfs.konubinix.eu/p/zzelsewhere")              # another frame claims the room
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzelsewhere")
strip.click()                                              # a touch, and the slide is not moved
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzshare-1",
           label="a touch takes the room back", detail=lambda: str(named()))
bar = page.get_by_role("toolbar", name="frame actions")
expect(bar.get_by_text("live", exact=True)).to_be_visible()   # the bar owns up to the link

The addresses go out exactly as the doc carries them, unresolved: the companion is served from elsewhere and reaches the archive by its own route, so it is the one that can turn an address into something it can fetch.

The socket opens on the first slide a frame names and lives as long as the page does — on a cabinet tablet, for days. Dropping it between shows would buy a little idle quiet and cost the next name a dial-up first, which is the wrong way round: the name is wanted the instant somebody looks up.

Naming a slide is a thing you cannot see working. The frame writes, says nothing, and looks exactly the same whether the name arrived or the socket is down — and if it is down, somebody on a sofa is looking at a phone that still shows the last photo the room heard about. The person standing at the frame is the only one who can notice, so the bar carries the link’s state in one word beside the date: idle until a slide has been named, connecting while the socket is being opened, then live or offline for as long as it holds or does not.

The word that has to be right is offline, because it is the only one that contradicts what the screen otherwise implies. A frame pointed somewhere nothing answers looks exactly like a frame whose every slide is landing. Pointing one nowhere takes a little care: the browser vetoes the low port numbers on its own, before any connection is attempted, so a frame aimed at one of those never learns anything about its link. A high port nobody is listening on is refused outright, which is the answer wanted.

@testcase
def test_the_frame_owns_up_to_a_dead_link(page):
    """A frame that cannot reach the room says so, instead of looking like it published."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdead-{i}", "date": f"2021-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdead-t-{i}",
             "webCid": f"https://ipfs.konubinix.eu/p/zzdead-web-{i}", "labels": "zzdead", "state": "todo"}
            for i in range(3)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page, "?ms=999999&uiidle=999999&yws=ws://127.0.0.1:45999&room=nowhere")
        search_for(page, "zzdead")
        expect(tiles(page)).to_have_count(3)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        strip.click()                                          # a touch, so the bar is up
        bar = page.get_by_role("toolbar", name="frame actions")
        expect(bar.get_by_text("offline", exact=True)).to_be_visible(timeout=20000)
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: the frame owns up to a dead link")

Whoever reads that word is standing back from a screen on a cabinet, and is not looking for it anyway — they are looking at the photo. So it has to catch the eye when it is bad news and otherwise stay out of the way. Only one of the four states is bad news: only offline takes a colour that interrupts, the two that are on their way somewhere share a muted one, and live stays quiet, being the answer you already assumed.

<span class="room-link" data-state=${roomLink}>${roomLink}</span>

.frame-bar .room-link{ font:10px/1.6 monospace; text-transform:uppercase; letter-spacing:.08em;
                       color:#6a7; }
.frame-bar .room-link[data-state="offline"]{ color:#d55; }
.frame-bar .room-link[data-state="connecting"],
.frame-bar .room-link[data-state="idle"]{ color:#aa6; }

let showingRoom = null;
const nowShowing = () => {
    if(!showingRoom){
        const shared = new Y.Doc();
        const provider = new WebsocketProvider(SYNC_URL, SYNC_ROOM, shared);
        setRoomLink('connecting');
        provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
        showingRoom = shared.getMap('showing');
    }
    return showingRoom;
};
createEffect(() => {
    if(!frame()) return;
    pokes();
    const d = frameDoc(); if(!d) return;
    nowShowing().set('doc', { cid: d.cid, webCid: d.webCid, mimetype: d.mimetype, date: d.date });
});

Under the hood, reading pokes() there is what makes a touch republish. An effect re-runs when anything it read has changed, so naming that counter alongside the doc buys the tie-breaker for one line: the settle path comes from the doc, and the claim path from the counter the show already bumps on every touch. Nothing has to notice which of the two happened.

Filtering by state

Triage needs a way to ask “show me only what’s still todo” — and since triage is the whole point of the app, that’s where a first-ever visit opens. Thereafter it opens on whatever you last chose, remembered like the search box (so the frame comes back to the view you were working). Each photo carries a workflow state (todo / next / done / delete); a row of chips under the search box switches an all / todo / next / done filter. The filter is a Solid signal — seeded from localStorage, defaulting to todo — folded into the resource key alongside search, so flipping a chip re-fetches and saves.

Crucially the filter is a server argument (states), applied inside photovideos_search before the ~2000 sample — an archive that’s almost entirely done would otherwise leave a “todo” view nearly empty (the sample is mostly done, so client-side filtering finds nothing). With the server filter, todo surfaces ~2000 todos spread across the whole span.

The test searches the fixtures (which span todo/next/done), confirms all three show under all, clicks the todo chip, and asserts only the todo tile remains.

@testcase
def test_state_filter(page):
    """The state chips narrow the wall to one workflow state, server-side."""
    open_fixtures(page)                               # starts on 'all' → all three states
    chip(page, "todo").click()
    expect(tiles(page)).to_have_count(1)              # only the todo fixture remains
    expect(grid(page).get_by_text("todo")).to_have_count(1)   # and its badge says so
    print("  PASS: state filter")

And the chosen chip is remembered: like the search box, the filter persists to localStorage, so a reload — or the frame’s reboot — comes back to the state you were last triaging, not always todo.

@testcase
def test_state_filter_persists(page):
    """The chosen state chip is saved locally, surviving a reload (and the frame's reboot)."""
    open_app(page)
    chip(page, "done").click()                                  # pick a non-default state
    expect(chip(page, "done")).to_have_attribute("aria-pressed", "true")
    page.reload(wait_until="commit")
    heading(page).wait_for(timeout=8000)
    expect(chip(page, "done")).to_have_attribute("aria-pressed", "true")   # remembered, not back to todo
    print("  PASS: state filter persists")

The chips: all plus one per state, in a labelled group so the filter buttons are unambiguous from the like-named batch buttons. The active one carries aria-pressed, which both styles it and names the active chip for a screen reader.

<div class="chips" role="group" aria-label="filter by state">
  ${['all', ...STATES].map(st => html`
    <button class="chip" data-st=${st} disabled=${() => photos.loading}
            aria-pressed=${() => stateFilter() === st ? 'true' : 'false'}
            onClick=${() => setStateFilter(st)}>${st}</button>`)}
  <button class="chip selall" aria-pressed=${() => allSelected() ? 'true' : 'false'}
          onClick=${toggleAll}>${() => allSelected() ? 'clear' : 'select all'}</button>
  <button class="chip frame-start" onClick=${enterFrame}> frame</button>
</div>

.chips{ display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
.chip{ padding:4px 12px; font-size:12px; text-transform:uppercase; letter-spacing:.04em;
       cursor:pointer; border-radius:999px; border:1px solid #3a3f5a;
       background:#262a40; color:#9aa; }
.chip[data-st]{ border-color:var(--st,#3a3f5a); color:var(--st,#9aa); }
.chip[aria-pressed='true']{ background:var(--st,#6cf); color:#08111e; border-color:var(--st,#6cf); font-weight:700; }
.selall{ margin-left:auto; }

Selecting and editing in bulk

Triage goes faster in bulk — pick a run of docs and edit them together.

Selecting tiles and batch-editing

Triage goes far faster in bulk: click tiles to build a selection, then apply one change to all of them at once — add a label (free-text, merged into each photo’s labels) or set a state (todo / next / done / delete, the photovideo workflow enum). Both ride the auto-generated updatePhotovideo(input:{cid,patch}) mutation, one call per selected cid; the batch runs under the mutating flag, so when it settles the wall re-reads the current query and reflects the change.

But the wall is only a ~2000 sample — to retag the thousands a filter may match, the toolbar’s all N matching toggle switches the same buttons to the server-side bulk functions (photovideosSetState / AddLabel / RemoveLabel), which update every row the current query matches in one statement. Touching the selection or the filter cancels the toggle, so you can’t bulk-edit a stale scope by accident.

Selection is a Solid Set signal — toggling replaces the set so fine-grained reactivity re-renders only the touched tiles’ outline/check. A sticky toolbar appears only while something is selected. A plain click toggles one tile and remembers it as the anchor, and a contiguous run from that anchor to a target tile (in the wall’s date order) can be selected several ways — all routed through the same extendTo, so they can’t drift apart.

A select all toggle sits at the end of the chips row (it has to live outside the selection toolbar, which is hidden when nothing is selected): one tap selects the whole shown wall, another clears it. Combined with the state filter it’s the fast path — e.g. filter next, select all, batch done.

@testcase
def test_select_all(page):
    """The select-all toggle selects the whole shown wall, then clears it."""
    open_fixtures(page)
    select_all(page).click()
    expect(checks(page)).to_have_count(len(FIXTURES))    # every tile shows its ✓
    select_all(page).click()
    expect(checks(page)).to_have_count(0)                # second tap clears
    print("  PASS: select all")

On desktop, shift-click the target tile and the whole run from the anchor to it is selected in one go.

@testcase
def test_range_select(page):
    """Shift-click selects the contiguous range between anchor and target."""
    open_fixtures(page)
    t = tiles(page)
    t.nth(0).click()                                  # anchor on the first tile
    t.nth(2).click(modifiers=["Shift"])              # extend the selection to the third
    expect(checks(page)).to_have_count(3)             # the middle tile is roped in too
    print("  PASS: range select")

Touch has no modifier key, so the toolbar’s ↔ range toggle stands in: arm it, tap the target, and the same run is roped in — the toggle disarming once it has.

@testcase
def test_range_select_touch(page):
    """The range toggle extends with plain taps — no shift key, for touch."""
    open_fixtures(page)
    t = tiles(page)
    t.nth(0).click()                                  # anchor + toolbar appears
    rng = toolbar(page).get_by_role("button", name="range")
    rng.click()                                       # arm range mode (the touch route)
    t.nth(2).click()                                  # a plain tap now extends the run
    expect(checks(page)).to_have_count(3)
    expect(rng).to_have_attribute("aria-pressed", "false")   # disarmed after extending
    print("  PASS: range select (touch)")

Or long-press a tile to arm range mode anchored there — the touch gesture for multi-selection, no trip to the toolbar; the next tap completes the run (a double-click stays the open gesture, so the two don’t collide).

@testcase
def test_long_press_arms_range(page):
    """A long-press on a tile arms range mode (it becomes the anchor); the next tap
    selects the run to it — the touch gesture for multi-selection, no trip to the toolbar."""
    open_fixtures(page)
    t = tiles(page)
    box = t.nth(0).bounding_box()
    page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    page.mouse.down(); page.wait_for_timeout(600); page.mouse.up()   # hold past the long-press threshold
    expect(dialog(page)).to_have_count(0)             # no lightbox — the long-press starts a selection
    expect(checks(page)).to_have_count(1)             # the pressed tile, now the anchor
    t.nth(2).click()                                  # a plain tap completes the range
    expect(checks(page)).to_have_count(len(FIXTURES))
    print("  PASS: long-press arms range")

When the window is too dense the wall shows only a spread of the matches (the “showing a spread” notice). That breaks the premise of all this: its tiles are an arbitrary handful, not a run — and a contiguous-batch selection over them can’t mean what it looks like it means. So the range gestures stand down: a shift-click ropes in only the tiles you touch, and the ↔ range toggle is disabled and says why. Single taps still pick tiles one by one.

@testcase
def test_spread_disables_multiselect(page):
    """On a spread the tiles are an arbitrary handful, not a run, so range selection
    stands down: a shift-click ropes in only the two tiles touched, and the ↔ range toggle
    is disabled and says why."""
    open_app(page); chip(page, "all").click()                 # the whole archive → a genuine spread
    expect(page.get_by_text(re.compile(r"showing a spread of \d+ from \d+"))).to_be_visible()
    t = tiles(page)
    t.nth(0).click()                                          # select the first (toolbar appears)
    t.nth(2).click(modifiers=["Shift"])                      # shift-click a later tile
    expect(checks(page)).to_have_count(2)                     # only the two touched — no run roped in
    rng = toolbar(page).get_by_role("button", name="range")
    expect(rng).to_be_disabled()                              # the range affordance stands down
    title = rng.get_attribute("title")
    assert title and ("spread" in title.lower() or "run" in title.lower()), \
        f"the disabled range toggle should say why: {title!r}"
    print("  PASS: spread disables multiselect")

Not every short wall is a spread, though. Ask for the earliest twenty and you are holding twenty in a row — fewer than match, but a run all the same, and a range across them means exactly what it looks like. What stands the gesture down is the spread, not the shortfall.

@testcase
def test_first_last_keep_range_select(page):
    """A first:/last: wall is short but contiguous, so range selection stays available."""
    open_app(page); chip(page, "all").click()
    search_for(page, "first:4")                                # the archive's earliest four
    expect(tiles(page)).to_have_count(4)
    expect(page.get_by_text(re.compile(r"showing the first 4 of \d+"))).to_be_visible()
    t = tiles(page)
    t.nth(0).click()                                          # select the first (toolbar appears)
    expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
    t.nth(2).click(modifiers=["Shift"])                       # a shift-click ropes in the run
    expect(checks(page)).to_have_count(3)
    print("  PASS: first/last keep range select")

Lifting the finger to hunt for the far tile is a wasted gesture, though — the finger is already on the wall. So the long-press need not end in a second tap: keep it down and slide, and the run follows it live, the tile under the finger becoming the run’s far end. It grows as the finger travels away from the anchor.

@testcase
def test_long_press_drag_extends_live(page):
    """Holding after the long-press and dragging the finger extends the run live to the
    tile under it — the range grows mid-drag, before the finger ever lifts."""
    open_fixtures(page)
    t = tiles(page)
    a = t.nth(0).bounding_box(); far = t.nth(2).bounding_box()
    page.mouse.move(a["x"] + a["width"] / 2, a["y"] + a["height"] / 2)
    page.mouse.down(); page.wait_for_timeout(600)     # hold past the threshold → arms range at the anchor
    expect(checks(page)).to_have_count(1)             # just the anchor so far
    page.mouse.move(far["x"] + far["width"] / 2, far["y"] + far["height"] / 2, steps=5)
    expect(checks(page)).to_have_count(len(FIXTURES)) # the whole run roped in mid-drag…
    expect(dialog(page)).to_have_count(0)             # …and no lightbox — this is a selection gesture
    page.mouse.up()
    print("  PASS: long-press drag extends live")

And it shrinks as the finger comes back toward the anchor, never stranding a tail. A drag can retreat as well as advance, where a tap only ever adds once — so it runs on the same grow-or-shrink engine as the keyboard’s Shift-arrow run (extendRun, which recomputes the run from the fixed anchor over the selection snapshotted when the drag began), not the one-shot extendTo the tap routes share.

@testcase
def test_long_press_drag_shrinks_on_return(page):
    """Dragging back toward the anchor shrinks the run — like the keyboard's Shift-arrow,
    it follows the finger both ways and never strands a tail."""
    open_fixtures(page)
    t = tiles(page)
    a = t.nth(0).bounding_box(); mid = t.nth(1).bounding_box(); far = t.nth(2).bounding_box()
    page.mouse.move(a["x"] + a["width"] / 2, a["y"] + a["height"] / 2)
    page.mouse.down(); page.wait_for_timeout(600)     # arm range at the anchor
    page.mouse.move(far["x"] + far["width"] / 2, far["y"] + far["height"] / 2, steps=5)
    expect(checks(page)).to_have_count(3)             # grew to the whole run…
    page.mouse.move(mid["x"] + mid["width"] / 2, mid["y"] + mid["height"] / 2, steps=5)
    expect(checks(page)).to_have_count(2)             # …then dragging back drops the far tile
    page.mouse.up()
    print("  PASS: long-press drag shrinks on return")

Under the hood, the run’s far end can’t be read from the move event’s target: once the press fires it captures the pointer, so every later move reports the pressed tile, not the one under the finger. So the far tile is hit-tested instead — document.elementFromPoint at the finger, mapped to its doc through the grid’s tile order.

A pointer drag over the wall competes with two things the browser would sooner do with it. On a desktop an <img> is draggable by default, so the browser tries to peel the thumbnail off as a drag-and-drop ghost and the drag never reaches the run; the tiles’ images are marked draggable“false”= to refuse it. On a touch screen the wall is a scroll surface, so the browser reads a moving finger as a pan and cancels the pointer out from under the run; while a press is armed the drag vetoes that scroll and keeps the gesture. Those two defences answer to different inputs — a mouse drag is no touch gesture at all, so it never triggers the touch-scroll the veto guards against and slips past untouched — so proving the touch side takes a test on Chromium’s real touch pipeline.

In practice the scroll veto is a touchmove listener that calls preventDefault while the press is live; it has to be a non-passive listener, because a passive one — the default the framework would attach — is forbidden from cancelling the scroll at all.

@testcase
def test_long_press_drag_touch(page):
    """A real finger (CDP touch, not a mouse) long-presses then drags, and the run
    follows — the gesture stays the app's instead of scrolling the wall."""
    n = 18
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdragt-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdragt-t-{i}",
             "labels": "zzdragt", "state": "todo"} for i in range(n)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 360, "height": 700})   # narrow so the wall overflows (a drag can pan), tall so row 0 clears the edge-scroll margin
        open_app(page); chip(page, "all").click(); search_for(page, "zzdragt")
        expect(tiles(page)).to_have_count(n)
        t = tiles(page)
        a = t.nth(0).bounding_box(); end = t.nth(2).bounding_box()   # first row, on screen, clear of both the toolbar and the edge margin
        x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
        x1, y1 = end["x"] + end["width"] / 2, end["y"] + end["height"] / 2
        cdp = page.context.new_cdp_session(page)
        cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
        cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
        page.wait_for_timeout(600)                          # hold past the long-press → arms range
        for f in (0.25, 0.5, 0.75, 1.0):                    # drag across the row to tile 2
            cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
                     "touchPoints": [{"x": x0 + (x1 - x0) * f, "y": y0 + (y1 - y0) * f}]})
        cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
        expect(checks(page)).to_have_count(3)               # the contiguous run 0..2, not one lonely anchor
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: long-press drag (touch)")

A run shouldn’t halt at the last visible tile when the wall runs on below the fold. So a finger that comes within a ~48px margin of the bottom edge drags the wall up under itself — a steady ~14px a frame — the run growing onto each row that rises into view, down to the last tile; the top margin pulls the other way. That margin is read against the same toolbar-lifted floor the keyboard reveal measures, so the finger lands on a tile and not on the bar pinned across the foot.

@testcase
def test_long_press_drag_autoscrolls(page):
    """Holding the drag against the bottom edge scrolls the wall, so the run keeps growing
    onto tiles that started below the fold — all the way to the last one."""
    n = 30
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdrags-{i}", "date": f"2021-03-{i + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdrags-t-{i}",
             "labels": "zzdrags", "state": "todo"} for i in range(n)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 360, "height": 480})   # the wall overflows well past the fold
        open_app(page); chip(page, "all").click(); search_for(page, "zzdrags")
        expect(tiles(page)).to_have_count(n)
        t = tiles(page)
        a = t.nth(0).bounding_box(); last = t.nth(n - 1).bounding_box()
        x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
        xl = last["x"] + last["width"] / 2                      # the last tile's column (its row is below the fold)
        cdp = page.context.new_cdp_session(page)
        cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
        cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
        page.wait_for_timeout(600)                              # arm range at the anchor
        for f in (0.5, 1.0):                                    # drag to the bottom edge, in the last column, and hold
            cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x0 + (xl - x0) * f, "y": 470}]})
        wait_until(page, lambda: checks(page).count() == n, timeout=6000,   # the wall scrolls the rest under the finger
                   label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{n} selected")
        cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
        expect(checks(page)).to_have_count(n)
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: long-press drag auto-scrolls")

On a desktop the fastest way to grab a block is to sweep a rectangle around it. A mouse drag that starts on empty grid space — never on a tile, so the tap/double-click/ long-press gestures stay untouched — rubber-bands a box, and every tile it covers joins the selection. We drag from the empty cells past the last fixture back across the row and expect all of them checked.

@testcase
def test_marquee_selects(page):
    """A rubber-band drag from empty grid space selects the tiles it covers."""
    open_fixtures(page)
    g = grid(page).bounding_box()
    y = g["y"] + 30
    page.mouse.move(g["x"] + g["width"] - 12, y)   # empty cells right of the row
    page.mouse.down()
    page.mouse.move(g["x"] + 12, y + 25, steps=10)  # sweep left across every tile
    page.mouse.up()
    expect(checks(page)).to_have_count(len(FIXTURES))
    print("  PASS: marquee selects")

Testing against prod, safely. These tests mutate rows, so they don’t touch real photos: make_fixtures inserts a few clearly-marked rows (sentinel label zzbatchfix, fake cids) before each batch test, and drop_fixtures deletes exactly those at the end of the run. Searching the sentinel yields only the fixtures, so assertions are exact.

Taking the first one is where the toolbar arrives, and it must not cost you your place: if the grid slid down to make room for it, the tile under your finger would move away as you reached for the next one. So the measurement has to be made across that first pick, while the toolbar is still absent — once it is up, its arrival can no longer disturb anything.

t = tiles(page)
expect(toolbar(page)).to_be_hidden()       # nothing picked yet, so the bar is not there
before = t.first.bounding_box()
t.nth(0).click()                           # the first pick — the bar arrives on this one
expect(toolbar(page)).to_be_visible()
after = t.first.bounding_box()
assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
print("  PASS: select doesn't shift grid")

From there the wall is a set you build up and pare back: each tile toggles, and the toolbar keeps the count so you know what you are about to act on.

t.nth(1).click()                           # a second one joins it
expect(checks(page)).to_have_count(2)
expect(toolbar(page).get_by_text("2 selected")).to_be_visible()
t.nth(0).click()                           # toggle one back off
expect(checks(page)).to_have_count(1)
print("  PASS: select toggles tiles")

One box serves both + label and - label, so the two have to agree about what they read out of it. Adding merges the typed words into every selected doc.

select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="add label").click()
expect(checks(page)).to_have_count(0)            # selection clears once applied
search_for(page, "addedbybatch")                # the fresh word now finds them all
expect(tiles(page)).to_have_count(n)
print("  PASS: batch add label")

- label strips the typed words from every selected doc instead. The wall is standing on the word being stripped, so when it is gone from all of them there is nothing left to show — which is the whole of the proof, and no chip has to be believed for it.

select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)
print("  PASS: batch remove label")

Hands stay on the keys for a run this size, so Enter stands in for +. In practice the box is a controlled input the toolbar tears down when the selection clears and rebuilds on the next select-all, so the box reached for after a selection settles is a new one.

search_for(page, FIXTURE_LABEL)                  # back to the run itself
expect(tiles(page)).to_have_count(n)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret")          # the word is in the live box
box.press("Enter")                               # + via RET
expect(checks(page)).to_have_count(0)            # applied → selection clears
search_for(page, "zzbatchret")
expect(tiles(page)).to_have_count(n)             # every selected photo got it
print("  PASS: batch enter adds")

And Shift+Enter for −, so a word put on the wrong run comes off it without the hands moving either.

select_all(page).click()
expect(checks(page)).to_have_count(n)            # selection settled
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret")          # the word is in the live box
box.press("Shift+Enter")                         # - via S-RET
expect(tiles(page)).to_have_count(0)             # the docs no longer carry the label → gone from the wall
print("  PASS: batch shift-enter removes")

Picking a word from the completion list rather than typing it out leaves a ; behind it, ready for the next one — so that trailing separator is the state the box is normally in, not an edge case. Removing has to read the box the way adding does and strip the word anyway.

search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ")   # as a picked suggestion leaves it
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)             # the trailing sep didn't defeat the match
print("  PASS: batch remove label trailing separator")

Setting a state applies it to every selected photo at once; once the edit settles and the wall re-reads, each tile’s badge shows the new value.

@testcase
def test_batch_set_state(page):
    """A state clicked on the selection is written to every selected photo."""
    open_fixtures(page)
    select_all(page).click()
    toolbar(page).get_by_role("button", name="done").click()
    # once the edit settles and the wall re-reads, every shown tile's badge reads done
    expect(grid(page).get_by_text("done")).to_have_count(len(FIXTURES))
    print("  PASS: batch set state")

Sometimes the whole filter is the target, not just the tiles you’ve picked — an all N matching button widens the scope, applying the state to every doc the search matches, selected or not.

@testcase
def test_batch_all_matching_state(page):
    """'all N matching' applies a state to the whole filter, not just the selection."""
    open_fixtures(page)
    tiles(page).nth(0).click()                                 # select just one, to raise the toolbar
    tb = toolbar(page)
    tb.get_by_role("button", name=re.compile("all .* matching")).click()   # widen the scope
    tb.get_by_role("button", name="done").click()
    # all three matching docs become done, though only one was selected
    expect(grid(page).get_by_text("done")).to_have_count(len(FIXTURES))
    print("  PASS: batch all-matching state")

That widening stays inside the active filter, though: an owner: scope spares every other owner’s photos, so “all matching” never reaches past what you’re actually looking at.

@testcase
def test_bulk_all_matching_respects_owner(page):
    """'all N matching' stays within the owner filter, sparing other owners' photos."""
    drop_fixtures()
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzownb-k", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzownb-k-t", "labels": "zzownb", "state": "todo", "owner": "konubinix"},
            {"cid": "https://ipfs.konubinix.eu/p/zzownb-a", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzownb-a-t", "labels": "zzownb", "state": "todo", "owner": "aylapomme"}]
    for d in docs: gql(CREATE, {"p": d})
    cnt = lambda owner, state: gql(
        "query($o:[OwnerType!],$s:[State!]){ photovideosCount(search:\"zzownb\","
        " since:\"2007-01-01\", until:\"2035-01-01\", owners:$o, states:$s) }",
        {"o": [owner], "s": [state]})["data"]["photovideosCount"]
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzownb; owner:konubinix")
        expect(tiles(page)).to_have_count(1)
        tiles(page).nth(0).click()                                 # raise the toolbar
        tb = toolbar(page)
        tb.get_by_role("button", name=re.compile("all .* matching")).click()
        tb.get_by_role("button", name="done").click()
        wait_until(page, lambda: cnt("konubinix", "done") == 1)    # the filtered owner flips
        assert cnt("aylapomme", "todo") == 1, "bulk leaked across owners"
        print("  PASS: bulk all-matching respects owner")
    finally:
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

The same wide scope adds a label across the whole filter, not only a state.

@testcase
def test_batch_all_matching_label(page):
    """'all N matching' adds a label to the whole filter, not just the selection."""
    open_fixtures(page)
    tiles(page).nth(0).click()
    tb = toolbar(page)
    tb.get_by_role("button", name=re.compile("all .* matching")).click()
    tb.get_by_placeholder("add a label…").fill("bulkall")
    tb.get_by_role("button", name="add label").click()
    search_for(page, "bulkall")                                # all three carry it now
    expect(tiles(page)).to_have_count(len(FIXTURES))
    # the fresh read shows the committed label captioned on every matching tile
    expect(grid(page).get_by_text("bulkall")).to_have_count(len(FIXTURES))
    print("  PASS: batch all-matching label")

Labels split on ;, not on commas, so a label with a comma inside it is a single token — removing it across the filter must drop the whole token, not the fragments around the comma.

@testcase
def test_all_matching_removes_comma_label(page):
    """'all N matching' strips a label that itself contains a comma. Labels split on
    ';', so the comma is part of one token — the server must drop the whole label,
    not the comma-fragments around it."""
    comma_label = "zzleft, zzright"                  # one ;-token, with a comma inside it
    open_fixtures(page)                              # 3 fixtures carry FIXTURE_LABEL
    select_all(page).click()
    tb = toolbar(page)
    tb.get_by_placeholder("add a label…").fill(comma_label)
    tb.get_by_role("button", name="add label").click()        # selection path ;-joins it on
    search_for(page, "zzleft")
    expect(tiles(page)).to_have_count(len(FIXTURES))          # sanity: the comma-label took
    select_all(page).click()                                  # raise the toolbar again
    # the add clears the box only after its awaited mutation resolves — a late clear that, if
    # it lands after the refill below, would blank it and make the remove a no-op. Waiting for
    # the empty box here proves that clear has already fired, so the refill sticks.
    box = tb.get_by_placeholder("add a label…")
    wait_until(page, lambda: box.input_value() == "",
               label="batch label box clears after the selection add",
               detail=lambda: f"box={box.input_value()!r} alert={page.get_by_role('alert').count()} tiles={tiles(page).count()}")
    tb.get_by_role("button", name=re.compile("all .* matching")).click()
    tb.get_by_placeholder("add a label…").fill(comma_label)
    tb.get_by_role("button", name="remove label").click()     # bulk path → server remove_label
    search_for(page, "zzleft")
    expect(tiles(page)).to_have_count(0)                      # the whole comma-label is gone
    print("  PASS: all-matching removes comma label")

Past triage you often just want the files, and there are two reasons to want them: a web copy (the downscaled web_cid) to hand to someone now, and the orig (the doc’s own /ipfs/ cid) to rework. The toolbar’s group saves the explicit selection — one file per doc — at whichever of the two you ask for, and hands over no other rendition.

tiles(page).nth(photo).click()                          # the one worth sending
expect(checks(page)).to_have_count(1)
tb, got = toolbar(page), {}
for res in ["web", "orig"]:
    with page.expect_download() as di:
        tb.get_by_role("button", name=res, exact=True).click()
    got[res] = (di.value.url, di.value.suggested_filename)
assert docs[photo]["webCid"] in got["web"][0], f"web → webCid: {got}"
assert docs[photo]["cid"] in got["orig"][0], f"orig → the doc's own cid: {got}"
print("  PASS: download selection")

A doc missing that rendition is passed over rather than served its original in the rendition’s place: a file named …-web.jpg holding several megabytes of full-resolution original is worse than no file, because nothing about it admits what it is. So the photo that was never downscaled joins the first in the selection, and asking the pair for web has to bring down one file, not two.

tiles(page).nth(never_downscaled).click()
expect(checks(page)).to_have_count(2)
saved = []                                              # armed here: only this click's files count
page.on("download", lambda d: saved.append(d.url))
toolbar(page).get_by_role("button", name="web", exact=True).click()
wait_until(page, lambda: len(saved) >= 1, label="the doc that has a web copy saves it")
page.wait_for_timeout(500)                              # room for a second, unwanted save to land
assert len(saved) == 1, f"expected the one web copy, got {len(saved)}: {saved}"
assert docs[photo]["webCid"] in saved[0], f"the wrong rendition came down: {saved[0]}"
print("  PASS: download skips a missing rendition")

Taking both forms of one photo — the copy to send and the original to rework — would put two files of the same name in the download folder, and the second would silently replace the first. Each saved file therefore carries its rendition in the name, holiday-orig.jpg beside holiday-web.jpg. In practice nothing needs downloading again to see this: both forms of the first photo are already in hand, and it is their names that are read.

names = {res: name for res, (_, name) in got.items()}   # the two the photo just yielded
assert len({*names.values()}) == len(names), f"same name → they collide in the folder: {names}"
for res in names:
    assert res in names[res], f"rendition missing from the name: {names}"
print("  PASS: download names by resolution")

A cid carries no extension and a missing object answers text/plain, so a name from either is unopenable. The right extension depends on the rendition: a video’s web copy is an MP4 (an image’s stays a JPEG), while the original keeps its true type from the DB mimetype. So a .mov original saves …-orig.mov and its web copy …-web.mp4. The photos are put back down and the clip taken up on its own, since it is the one doc whose two forms disagree about their type.

tiles(page).nth(photo).click()
tiles(page).nth(never_downscaled).click()
tiles(page).nth(video).click()
expect(checks(page)).to_have_count(1)
tb, ext = toolbar(page), {}
for res in ["orig", "web"]:
    with page.expect_download() as di:
        tb.get_by_role("button", name=res, exact=True).click()
    ext[res] = di.value.suggested_filename.rsplit(".", 1)[-1]
assert ext["orig"] == "mov", f"original keeps its real type: {ext}"
assert ext["web"] == "mp4", f"a video's web copy is mp4: {ext}"
print("  PASS: download extensions by rendition")

Selecting tiles and editing them in bulk is a cluster of small machines: the picked set and the gestures that grow it, the two ways an edit reaches the docs, and the download. We lay them out one at a time; the tangle reassembles them into the component.

At the heart is the picked set — a set of cids that persists, so an accidental refresh mid-triage doesn’t drop the run — beside the flags the gestures read: whether a range is armed for the next tap, and whether an action should reach the whole matching filter rather than only the shown sample. A range only makes sense over a contiguous run, so it stands down whenever the wall is a spread.

const STATES = ['todo', 'next', 'done', 'delete'];
const SEL_KEY = 'memories.selected';
const [selected, setSelected] = createSignal(new Set(JSON.parse(localStorage.getItem(SEL_KEY) || '[]')));
createEffect(() => localStorage.setItem(SEL_KEY, JSON.stringify([...selected()])));
const [labelText, setLabelText] = createSignal('');
const [labelFocus, setLabelFocus] = createSignal(false);
const [anchor, setAnchor] = createSignal(null);
const [rangeMode, setRangeMode] = createSignal(false);
const [allMatching, setAllMatching] = createSignal(false);
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
    const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };

Select-all lives outside the toolbar — which only appears once something is picked — so it toggles the whole shown wall on or off straight from the empty state.

const shownCids = () => items().map(p => p.cid);
const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));

A range can grow two ways. extendTo takes the whole contiguous run from the anchor to a tile in one shot — a shift-click, or an armed tap. extendRun is the continuous version the keyboard Shift-arrow and the held-finger drag share: because it can grow or shrink as the far end moves, it snapshots the selection once at the run’s start and recomputes from the fixed anchor each step.

const extendTo = cid => {
    const list = items().map(p => p.cid);
    const a = list.indexOf(anchor()), b = list.indexOf(cid);
    if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
    const [lo, hi] = a < b ? [a, b] : [b, a];
    setSelected(s => { const n = new Set(s);
        for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
};
const extendRun = cid => {
    if(!canRange()) return;
    const list = items();
    const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
    if(a < 0 || ni < 0) return;
    if(!extending){ extending = true; rangeBase = new Set(selected()); }
    const [lo, hi] = a < ni ? [a, ni] : [ni, a];
    setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
};

A plain click toggles one tile, re-anchors there, and plants the keyboard cursor so the arrows carry on from where you clicked. It becomes a range extension only when a modifier is present — shift-click on the desktop, or the toolbar’s armed range-mode then a tap on touch — and either way the tap clears range mode. Arming a range from a tile makes it the anchor and readies a fresh run for the first drag-move to snapshot.

const onTileClick = (e, cid) => {
    setCursor(cid);
    if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
        extendTo(cid); setRangeMode(false); return;
    }
    toggle(cid); setAnchor(cid);
};
const armRange = cid => {
    if(!canRange()) return;
    setSelected(s => { const n = new Set(s); n.add(cid); return n; });
    setAnchor(cid); setRangeMode(true); extending = false;
};

The held-finger drag has to know which tile the finger is over. tileCidAt hit-tests a screen point down to a tile and maps it through the grid’s own child order to a cid.

const tileCidAt = (x, y) => {
    const tile = document.elementFromPoint(x, y)?.closest('.tile');
    if(!tile || !gridEl) return null;
    const i = [...gridEl.children].indexOf(tile);
    return i < 0 ? null : items()[i]?.cid;
};

A long-press arms a range and then drives it live while the finger stays down. Its state is a bundle: the press timer, whether it has fired, the press origin, the captured pointer, whether the drag has moved, the last finger position, and the edge-scroll frame. Each move re-extends the run to the tile under the finger. And when the finger nears a top or bottom margin, an animation loop scrolls the wall a step (~14px) per frame and keeps extending, so a run reaches past the fold — its floor lifted clear of the pinned toolbar.

let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false, lpPos = null, lpRaf = 0;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
const EDGE = 48, SCROLL_STEP = 14;
const dragTick = () => {
    lpRaf = 0;
    if(!lpFired || !lpPos) return;
    const bar = document.querySelector('.toolbar');
    const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
    const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
    if(!dy) return;                                    // finger left the margin → stop the loop
    scrollBy(0, dy);
    dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1)));   // hit-test clear of the bar
    lpRaf = requestAnimationFrame(dragTick);
};

The press starts a 500ms timer; survive it without moving and it fires — capturing the pointer so the held drag’s moves route here, and arming the range. A move before it fires (past ~10px) abandons it; a move after drives the drag, entering the edge-scroll when the finger sits in a margin. Releasing a moved drag lands the run and disarms range mode, while a press that never moved leaves the range armed for a following tap. The press swallows its own trailing click, and while a drag is armed the wall’s scroll is blocked so the gesture and the scroll don’t fight.

const onTileDown = (e, photo) => {
    lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
    lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
    lpTimer = setTimeout(() => { lpFired = true; lpCancel();
        try { lpEl.setPointerCapture(lpId); } catch(_){}
        armRange(photo.cid); }, 500);
};
const onTileMove = e => {
    if(lpFired){
        lpPos = { x: e.clientX, y: e.clientY };
        dragExtendAt(e.clientX, e.clientY);
        if(!lpRaf) dragTick();
        return;
    }
    if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
};
const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; } dragStop(); lpCancel(); };
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
onMount(() => gridEl?.addEventListener('touchmove',
    e => { if(lpFired) e.preventDefault(); }, { passive: false }));

Now the two ways an edit reaches the docs. patchSelected is the per-doc path: it maps the shown photos by cid (so a label-merge can read each row’s existing labels), fires one updatePhotovideo per selected cid — skipping any the patch-maker declines, so a caller can leave some docs untouched (as moving onto an occasion leaves other owners’) — then clears the selection and re-reads the wall. Labels are a ;-delimited list, so one split parses both a doc’s stored labels and what’s typed into a box, and a; b adds two in one go.

async function patchSelected(patchFor){
    setMutating(m => m + 1);
    try {
        const byCid = new Map(items().map(p => [p.cid, p]));
        for(const cid of selected()){
            const patch = patchFor(byCid.get(cid));
            if(patch) await gql(UPDATE_PHOTO, { cid, patch });
        }
        clearSel();
        await refetch();
    } finally { setMutating(m => m - 1); }
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);

applyBulk is the all-matching counterpart: it edits every doc the filter matches server-side — beyond the ~2000-row sample — through the bulk functions, over the same query the wall is showing. Those return only a count of the rows touched, so where the per-cid path hands back each changed row for the cache to act on, the bulk path tags the Photovideo type instead.

const filterVars = () => ({ ...photoVars(parseQuery(search())),
                            states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
  photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
async function applyBulk(kind, v){
    setMutating(m => m + 1);
    try { await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); clearSel(); await refetch(); }
    finally { setMutating(m => m - 1); }
}

The three edits pick their path by the all matching flag: add-label, remove-label and set-state each route to the bulk function when the whole filter is the target, else to patchSelected over the ticked set. Add-label also remembers the last word applied, for one-tap reuse on the next doc.

const addLabel = async () => {
    const words = splitWords(labelText()); if(!words.length) return;
    setLastLabel(words[words.length - 1]);
    if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
    else await patchSelected(p => { const cur = splitLabels(p);
        for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
    setLabelText('');
};
const removeLabel = async () => {
    const words = splitWords(labelText()); if(!words.length) return;
    if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
    else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
    setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
                                        : patchSelected(() => ({ state: st }));

Finally the download: save the picked docs as files, at the resolution asked for. Like the move edit it acts on the explicit ticked selection (there is no client-side list of the whole filter to fetch), and the browser asks once before saving several.

const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
                   'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
                   'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
                   'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
    const byCid = new Map(items().map(p => [p.cid, p]));
    for(const cid of selected()){
        const p = byCid.get(cid); if(!p) continue;
        const path = res === 'web' ? p.webCid : p.cid;
        if(!path) continue;
        const isVid = (p.mimetype || '').startsWith('video');
        const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
        const raw = p.filename || p.cid.split('/').pop() || 'download';
        const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
        const a = document.createElement('a');
        a.href = IPFS + path;
        a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
        document.body.appendChild(a); a.click(); a.remove();
    }
}

The rubber-band feeds that same selection set. A drag counts as a sweep only when its pointerdown lands on the grid container itself — an empty cell — so a press that begins on a tile still belongs to that tile’s tap, double-click or long-press; touch is left out entirely (it has the long-press range gesture already). While the button is held, each move grows the box and re-selects every tile it intersects, tested in client coordinates against each tile’s rectangle. The sweep is additive — it extends the current selection rather than replacing it, matching how clicks accrue.

const [marquee, setMarquee] = createSignal(null);   // {x0,y0,x1,y1} in client coords, or null
let marqueeFrom = null;
const marqueeRect = m => ({ l: Math.min(m.x0, m.x1), r: Math.max(m.x0, m.x1),
                            t: Math.min(m.y0, m.y1), b: Math.max(m.y0, m.y1) });
const marqueeSelect = () => {
    const m = marquee(); if(!m) return;
    const r = marqueeRect(m), list = items(), kids = gridEl.children, next = new Set(selected());
    for(let i = 0; i < kids.length && i < list.length; i++){
        const b = kids[i].getBoundingClientRect();
        if(b.left < r.r && b.right > r.l && b.top < r.b && b.bottom > r.t) next.add(list[i].cid);
    }
    setAllMatching(false); setSelected(next);
};
const onGridDown = e => {
    if(e.pointerType === 'touch' || e.button !== 0 || e.target !== gridEl || !canRange()) return;
    marqueeFrom = { x: e.clientX, y: e.clientY };
    setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
    gridEl.setPointerCapture?.(e.pointerId);
};
const onGridMove = e => {
    if(!marqueeFrom) return;
    setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
    marqueeSelect();
};
const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };

The toolbar: only mounted while a selection exists, and clustered by purpose so the row of controls reads as groups rather than a wall of buttons — scope (the count, the all matching toggle, the ↔ range toggle), label (the input with +/- — Enter in the input is +, Shift+Enter is -), move (an event box with a → event button, for re-anchoring a stray onto its occasion), state (one button per state), download ( then orig=/=web), then clear — with thin dividers between the groups.

The label input is a combobox on the same terms as the search box: an aria-expanded that tracks its popover, and a close that is a state flip rather than a timed fade — so a caller waits on the state, never a clock.

@testcase
def test_batch_label_combobox_state(page):
    """The selection's add-label box is a combobox too: aria-expanded tracks its list, and
    it closes on a state flip, no timing."""
    open_fixtures(page)
    select_all(page).click()
    box = toolbar(page).get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("cos")
    expect(box).to_have_attribute("aria-expanded", "true")     # listing completions → true
    box.blur()
    expect(box).to_have_attribute("aria-expanded", "false")    # closed → false
    assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
    print("  PASS: batch label combobox state")

<${Show} when=${() => selCount() > 0}>
  <div class="toolbar" role="toolbar" aria-label="selection actions">
    <!-- scope: what the actions apply to -->
    <span class="count">${() => allMatching() ? `all ${total()} matching` : `${selCount()} selected`}</span>
    <button class="allmatch" aria-pressed=${() => allMatching() ? 'true' : 'false'}
            onClick=${() => setAllMatching(m => !m)}>all ${() => total()} matching</button>
    <button class="range" aria-pressed=${() => rangeMode() ? 'true' : 'false'}
            disabled=${() => !canRange()}
            title=${() => canRange() ? undefined : 'a spread is not a run — range select is off here'}
            onClick=${() => setRangeMode(m => !m)}> range</button>
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- label edit -->
    <div class="complete">
      <input class="batch-label" role="combobox" placeholder="add a label…" aria-label="label for the selection"
             aria-expanded=${() => labelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
             value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
             onFocus=${() => setLabelFocus(true)}
             onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
               sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
             onBlur=${() => setLabelFocus(false)} />
      <${Show} when=${() => labelFocus()}>
        <${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
                    onPick=${w => setLabelText(replaceSeg(labelText(), w) + '; ')} />
      <//>
    </div>
    <button aria-label="add label" onClick=${addLabel}> label</button>
    <button aria-label="remove label" onClick=${removeLabel}> label</button>
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- move the selection onto an occasion -->
    <div class="complete">
      <input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
             aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
             value=${() => moveText()}
             onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
             onFocus=${() => setMoveFocus(true)}
             onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
             onBlur=${() => setMoveFocus(false)} />
      <${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
        <ul class="suggest" role="listbox" aria-label="events">
          <${For} each=${() => moveCandidates()}>${(e, i) => html`
            <li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
                aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
                onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
              ${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
          <//>
        </ul>
      <//>
    </div>
    <button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
            onClick=${moveToEvent}> event</button>
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- stamp the selection with one instant -->
    <div class="batch-date">
      <button class="datebtn" aria-label="set date" title="set the selection's date"
              aria-expanded=${() => datingSel() ? 'true' : 'false'}
              onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>&#x1F553;</button>
      <${Show} when=${() => datingSel()}>
        <div class="date-pop">
          <input type="datetime-local" aria-label="date for the selection"
                 value=${() => selDate()} onInput=${e => setSelDate(e.target.value)}
                 onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); stampSelDate(); } }} />
          <button class="datestamp" aria-label="apply date" disabled=${() => !selDate()}
                  onClick=${stampSelDate}>&#x2192; date</button>
        </div>
      <//>
    </div>
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- state -->
    ${STATES.map(st => html`
      <button class="st" data-st=${st} onClick=${() => setStateFor(st)}>${st}</button>`)}
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- download the selection, one file per doc, at a chosen resolution -->
    <span class="dl-grp" aria-hidden="true">&#x2193;</span>
    <button class="dl" onClick=${() => downloadSelection('orig')}>orig</button>
    <button class="dl" onClick=${() => downloadSelection('web')}>web</button>
    <span class="tb-sep" aria-hidden="true"></span>
    <button class="clear" onClick=${clearSel}>clear</button>
  </div>
<//>

/* a fixed bottom action bar: appearing on first select must NOT reflow the wall and
   jump the tiles, so it overlays rather than taking flow space. */
.toolbar{ position:fixed; left:0; right:0; bottom:0; z-index:20; display:flex; flex-wrap:wrap;
          gap:6px; align-items:center; background:#11131f; border-top:1px solid #3a3f5a;
          padding: 8px calc(12px + env(safe-area-inset-right))
                   calc(8px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left)); }
.toolbar .suggest{ top:auto; bottom:100%; margin:0 0 2px; }   /* open upward from a bottom bar */
.toolbar .count{ font-size:13px; color:#9aa; margin-right:4px; }
/* thin dividers cluster the bar into scope | label | state | download | clear */
.toolbar .tb-sep{ width:1px; align-self:stretch; min-height:22px; background:#3a3f5a; }
.toolbar .clear{ margin-left:auto; }   /* push clear to the far end */
.toolbar .dl-grp{ font-size:13px; color:#9aa; }
.toolbar .dl{ font-size:11px; text-transform:uppercase; letter-spacing:.03em; }
.toolbar .complete{ flex:1 1 140px; min-width:120px; }
.batch-label{ width:100%; box-sizing:border-box; padding:5px 8px; font-size:14px;
              background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:5px; }
.toolbar button{ padding:5px 10px; font-size:13px; cursor:pointer; border-radius:5px;
                 border:1px solid #3a3f5a; background:#262a40; color:var(--fg); }
.toolbar .st{ text-transform:uppercase; letter-spacing:.03em; font-size:11px; }
.toolbar .st[data-st]{ border-color:var(--st); color:var(--st); }
.toolbar button:hover{ background:#33395a; }
.toolbar .range[aria-pressed='true'], .toolbar .allmatch[aria-pressed='true']{
    background:#6cf; color:#08111e; border-color:#6cf; font-weight:700; }
/* when range is armed, hint that the next tap picks the end of the run */
.toolbar .range[aria-pressed='true']::after{ content:' · tap end'; font-weight:400; }

Moving a stray photo onto its occasion

A WhatsApp copy carries the day it was saved, not the day it was taken — so it lands years from the occasion it belongs to, stranded clear of its colour band where a glance already catches it. The date is lost but the occasion is not: you still know these were the swim, the trip, the party. So, with the strays selected, you name that occasion and they take its date — sliding back into the band. Editing one date by hand already rescues a single stray, but a batch of them shares one occasion, and naming it once beats typing the same date into each.

The date they take is the occasion’s own first day. So: select the strays, name the occasion in the toolbar’s move box, pick it, and they land on its start.

@testcase
def test_move_selection_to_event(page):
    """A stray-dated selection, moved onto an occasion, takes that event's start date."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-move", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzMoveKarate", "owner": "konubinix", "status": "confirmed"}
    # two WhatsApp copies stamped with their save-date (2024), years off their real occasion
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzmv-1", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmv-1-t"},
            {"cid": "https://ipfs.konubinix.eu/p/zzmv-2", "date": "2024-11-02T13:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmv-2-t"}]
    for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzmove", "owner": "konubinix", "state": "todo"})
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzmove")
        expect(tiles(page)).to_have_count(2)
        select_all(page).click()
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        box.click(); box.press_sequentially("zzMoveK", delay=20)
        tb.get_by_role("option", name=re.compile("zzMoveKarate")).click()   # pick the occasion explicitly
        tb.get_by_role("button", name="move to event").click()
        expect(checks(page)).to_have_count(0)                               # applied → the selection clears
        # both now wear the event's start day — the wall re-anchors, and the tile's alt is that day
        expect(grid(page).get_by_role("img", name="2020-03-07")).to_have_count(2)
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: move selection to event")

The move box lives in the selection toolbar, beside the label box. It holds three things: the text you type, whether it has focus, and — once you pick — the occasion you armed.

const [moveText, setMoveText] = createSignal('');
const [moveFocus, setMoveFocus] = createSignal(false);
const [moveTarget, setMoveTarget] = createSignal(null);   // the armed occasion, or null → nothing to apply

The occasions to pick from are the whole calendar’s, not the wall’s window. A stray’s date sits nowhere near the event it belongs to, so a list bounded by what the wall happens to show could never reach it. We read them the moment the box opens, over an all-time span — capped like the wall’s own event read so the connection never truncates before we use it.

const [moveEvents] = createResource(moveFocus,
    f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);

When the name fits several occasions — a « Karaté » that recurs every week — the list should lead with the one nearest the strays. So we rank each occasion by the mean of the selection’s dates: how far that mean sits from the occasion’s span — zero when it falls inside, otherwise the gap to the nearer bound, ties going to the earlier occasion. The order only suggests — you still pick — so a misleading mean costs a glance, never a silent mis-move.

@testcase
def test_move_ranks_by_mean_closeness(page):
    """When several occasions match the name, the one nearest the selection's mean date leads."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    evs = [{"rowId": "zzev-rfar",  "starttime": "2010-01-01T00:00:00Z", "endtime": "2010-01-31T23:59:59Z",
            "summary": "zzRankFar",  "owner": "konubinix", "status": "confirmed"},
           {"rowId": "zzev-rnear", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
            "summary": "zzRankNear", "owner": "konubinix", "status": "confirmed"}]
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzrk-1", "date": "2020-06-10T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzrk-1-t"},
            {"cid": "https://ipfs.konubinix.eu/p/zzrk-2", "date": "2020-06-20T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzrk-2-t"}]  # mean ~2020-06-15
    for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzrankmv", "owner": "konubinix", "state": "todo"})
    for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzrankmv")
        expect(tiles(page)).to_have_count(2)
        select_all(page).click()
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        box.click(); box.press_sequentially("zzRank", delay=20)
        opts = tb.get_by_role("option")
        expect(opts).to_have_count(2)                       # both occasions match "zzRank"
        expect(opts.first).to_contain_text("zzRankNear")    # the one nearest the mean leads
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: move ranks by mean closeness")

That closeness rests on two readings of the selection: its mean date, and — for each occasion — the distance from that mean to the occasion’s span, folded to the single number the sort orders on.

const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
    return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
    return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
const eventDist = (e, mean) => { if(mean == null) return 0;
    const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
    return mean < s ? s - mean : mean > en ? mean - en : 0; };

The move is owner-scoped, on both sides — a calendar belongs to one person, two people can each keep a « Piscine ». Applying it touches only a doc whose owner holds the picked occasion, leaving anyone else’s where they are.

@testcase
def test_move_is_owner_scoped(page):
    """Moving onto an occasion touches only docs whose owner holds it; another owner's is left."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-ownmove", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzOwnMove", "owner": "konubinix", "status": "confirmed"}   # konubinix's occasion
    K = {"cid": "https://ipfs.konubinix.eu/p/zzomv-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzomv-k-t", "owner": "konubinix"}
    A = {"cid": "https://ipfs.konubinix.eu/p/zzomv-a", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzomv-a-t", "owner": "aylapomme"}
    for d in (K, A): d.update({"mimetype": "image/jpeg", "labels": "zzownmove", "state": "todo"})
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in (K, A): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzownmove")
        expect(tiles(page)).to_have_count(2)
        select_all(page).click()
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        box.click(); box.press_sequentially("zzOwnMove", delay=20)
        tb.get_by_role("option", name=re.compile("zzOwnMove")).click()
        tb.get_by_role("button", name="move to event").click()
        expect(checks(page)).to_have_count(0)
        # konubinix's photo took the occasion's day; aylapomme's kept its stray date
        expect(grid(page).get_by_role("img", name="2020-03-07")).to_have_count(1)
        expect(grid(page).get_by_role("img", name="2024-11-02")).to_have_count(1)
    finally:
        for d in (K, A): gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: move is owner-scoped")

And the list offers only the selection’s own owners’ occasions, so a namesake in someone else’s calendar — which could never apply — is never even shown, and a pick can’t land on a silent no-op.

@testcase
def test_move_offers_only_selection_owner_events(page):
    """The move box lists only occasions of the selection's own owners — a namesake owner's is not."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    evs = [{"rowId": "zzev-scmk", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
            "summary": "zzScopeKonu", "owner": "konubinix", "status": "confirmed"},
           {"rowId": "zzev-scma", "starttime": "2020-03-08T09:00:00Z", "endtime": "2020-03-08T18:00:00Z",
            "summary": "zzScopeAyla", "owner": "aylapomme", "status": "confirmed"}]
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzscm-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscm-k-t",
           "mimetype": "image/jpeg", "labels": "zzscopemove", "owner": "konubinix", "state": "todo"}
    for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzscopemove")
        expect(tiles(page)).to_have_count(1)
        select_all(page).click()                           # the one konubinix doc
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        box.click(); box.press_sequentially("zzScope", delay=20)
        expect(tb.get_by_role("option", name=re.compile("zzScopeKonu"))).to_be_visible()   # own owner's — offered
        expect(tb.get_by_role("option", name=re.compile("zzScopeAyla"))).to_have_count(0)   # another owner's — not
    finally:
        gql(DELETE, {"cid": doc["cid"]})
        for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
    print("  PASS: move offers only selection-owner events")

const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));

Now the list itself. As you type, the box narrows the calendar to the matching occasions: those whose summary contains the text, kept to the selection’s own owners, and ordered by the closeness above — the eight nearest shown, so a long calendar never floods the box.

const moveCandidates = () => { const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
    return (moveEvents() || []).filter(e => owners.has(e.owner))
        .filter(e => !q || (e.summary || '').toLowerCase().includes(q))
        .sort((a, b) => eventDist(a, mean) - eventDist(b, mean) || new Date(a.starttime) - new Date(b.starttime))
        .slice(0, 8); };

Picking one arms that exact occasion and closes the list; applying then gives every selected doc the occasion’s own start — its first day — through the same per-doc patch a batch label uses, so the wall re-anchors and the moved photos slide into the band.

const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
    await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
    setMoveText(''); setMoveTarget(null); };

In the toolbar the box is a combobox like the others, and its keyboard is theirs too: the one shared completion handler arrows through the rows and Enter picks the highlighted occasion, exactly as in the search and label boxes; with nothing highlighted Enter applies the armed pick instead, the same as the → event button beside it. For that shared handler to see them, the box hands its candidates up to the single keyboard-highlight the whole app keeps.

createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });

Each row names the occasion and, beside it, when it ran (the same when aside the lightbox reads off a pill), so a recurring occasion is told apart by its date; the highlighted row is marked as the shared index moves over it.

<div class="complete">
  <input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
         aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
         value=${() => moveText()}
         onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
         onFocus=${() => setMoveFocus(true)}
         onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
         onBlur=${() => setMoveFocus(false)} />
  <${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
    <ul class="suggest" role="listbox" aria-label="events">
      <${For} each=${() => moveCandidates()}>${(e, i) => html`
        <li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
            aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
            onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
          ${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
      <//>
    </ul>
  <//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
        onClick=${moveToEvent}> event</button>

A move fires only from an explicit pick. The → event button stays disabled until a pick arms it — so an unpicked box, however fully typed, can never fire it — and editing the name after picking disables it again, waiting for a fresh pick.

@testcase
def test_move_button_armed_only_by_a_pick(page):
    """The → event button is disabled until a pick arms it; typed text never enables it, and editing disables it again."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-arm", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzArmEvent", "owner": "konubinix", "status": "confirmed"}
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzarm-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzarm-{i}-t",
             "mimetype": "image/jpeg", "labels": "zzarm", "owner": "konubinix", "state": "todo"} for i in range(3)]
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzarm")
        expect(tiles(page)).to_have_count(3)
        select_all(page).click()
        expect(checks(page)).to_have_count(3)
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        move_btn = tb.get_by_role("button", name="move to event")
        expect(move_btn).to_be_disabled()                  # nothing picked yet
        box.click(); box.press_sequentially("zzArm", delay=20)
        expect(move_btn).to_be_disabled()                  # typed text is not a pick
        tb.get_by_role("option", name=re.compile("zzArmEvent")).click()
        expect(move_btn).to_be_enabled()                   # a pick arms it
        box.press("x")                                     # edit the name → the pick drops
        expect(move_btn).to_be_disabled()                  # disabled again, until a fresh pick
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: move button armed only by a pick")

A pick need not be a click. Arrowing down to an occasion and pressing Enter is a pick just the same — the keyboard route the box shares with every other completion box — so it arms the → event button no differently.

@testcase
def test_move_box_keyboard_picks(page):
    """Arrow keys highlight a move-box occasion and Enter picks it, arming the move — no mouse."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-kbd", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzKbdEvent", "owner": "konubinix", "status": "confirmed"}
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzkbd-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzkbd-{i}-t",
             "mimetype": "image/jpeg", "labels": "zzkbd", "owner": "konubinix", "state": "todo"} for i in range(3)]
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzkbd")
        expect(tiles(page)).to_have_count(3)
        select_all(page).click()
        expect(checks(page)).to_have_count(3)
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        move_btn = tb.get_by_role("button", name="move to event")
        box.click(); box.press_sequentially("zzKbd", delay=20)
        opt = tb.get_by_role("option", name=re.compile("zzKbdEvent"))
        expect(opt).to_be_visible()                              # the list is open
        expect(move_btn).to_be_disabled()                        # nothing picked yet
        box.press("ArrowDown")                                   # highlight the first candidate
        expect(opt).to_have_attribute("aria-selected", "true")   # the arrow moved the shared highlight onto it
        box.press("Enter")                                       # Enter picks the highlighted one → arms
        expect(move_btn).to_be_enabled()
        expect(box).to_have_value("zzKbdEvent")                  # the pick filled the box
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: move box keyboard picks")

A pick is also bound to the selection it was made against: touching the selection drops the armed pick — the box clears, ready for a fresh pick — so a stale pick can never move a set you have since changed.

@testcase
def test_move_target_resets_on_selection_change(page):
    """Changing the selection drops an armed pick — the box clears, so a stale pick can't move a new set."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-resetmove", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzResetMove", "owner": "konubinix", "status": "confirmed"}
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzrst-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzrst-{i}-t",
             "mimetype": "image/jpeg", "labels": "zzresetmv", "owner": "konubinix", "state": "todo"} for i in range(3)]
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzresetmv")
        expect(tiles(page)).to_have_count(3)
        select_all(page).click()
        expect(checks(page)).to_have_count(3)              # all three selected
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        box.click(); box.press_sequentially("zzReset", delay=20)   # a prefix, so a pick visibly completes it
        tb.get_by_role("option", name=re.compile("zzResetMove")).click()
        expect(box).to_have_value("zzResetMove")           # the pick registered — the occasion is armed
        tiles(page).nth(0).click()                         # drop one tile → the selection changes
        expect(box).to_have_value("")                      # the armed pick is dropped, the box cleared
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: move target resets on selection change")

createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });

The occasion you move onto usually has no photo of its own yet — assigning photos to it is the whole point — and a plain label search carries no date window for the wall to hang events on. Both are why the box reads the calendar itself rather than the wall’s window of events: even a photoless occasion, on a windowless search, is there to pick. (The search box’s event: completion, which offers only occasions that already hold a photo, would hide exactly this one.)

@testcase
def test_move_offers_photoless_event(page):
    """The move box offers an occasion with no photos yet, on a windowless search — the point is filling it."""
    CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
    CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
    ev = {"rowId": "zzev-empty", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
          "summary": "zzEmptyOcc", "owner": "konubinix", "status": "confirmed"}   # no photo falls in its span
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzem-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzem-k-t",
           "mimetype": "image/jpeg", "labels": "zzemptymove", "owner": "konubinix", "state": "todo"}
    gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzemptymove")                    # a bare label — no since:/until: window
        expect(tiles(page)).to_have_count(1)
        select_all(page).click()
        tb = toolbar(page)
        box = tb.get_by_placeholder("move to event…")
        box.click(); box.press_sequentially("zzEmptyOcc", delay=20)
        expect(tb.get_by_role("option", name=re.compile("zzEmptyOcc"))).to_be_visible()   # offered though photoless
    finally:
        gql(DELETE, {"cid": doc["cid"]})
        gql(CAL_DEL, {"id": ev["rowId"]})
    print("  PASS: move offers photoless event")

The box mirrors the batch label box’s styling; the row list reuses the shared completion look (.suggest / .sug) and the pill’s muted when aside, and the → event button dims while no pick is armed.

.batch-move{ width:100%; box-sizing:border-box; padding:5px 8px; font-size:14px;
             background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:5px; }
.movebtn:disabled{ opacity:.45; cursor:default; }

Stamping a selection with one time

Photos that arrive re-shared — a batch off WhatsApp — carry a junk timestamp: the moment they were forwarded, not the moment they were shot. So a pile of them scatters across the wall, each landing on a day that never happened. There is no true instant to recover, so the useful move is to pin the whole pile to one plausible moment — a given day, one hour — and let them sit together. The lightbox already re-dates one doc; this does the same to every ticked doc at once, through the same per-doc patchSelected. The picker seeds from the selection’s mean date, so you begin in the middle of what you’re fixing and nudge to the day you mean.

const [datingSel, setDatingSel] = createSignal(false);   // is the picker open?
const [selDate, setSelDate] = createSignal('');          // its datetime-local value
const openSelDate = () => { const m = selMean();
    setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
    setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
    await patchSelected(() => ({ date: new Date(v).toISOString() }));
    setDatingSel(false); };

The control cannot simply sit in the toolbar. The bar overlays the wall’s foot, and a double-tap opens a doc — its first tap selects, which is what mounts the bar. A permanently-shown datetime-local is wide enough to wrap the bar onto a second row on a phone, and that taller overlay swallows the double-tap’s second tap before it reaches the tile. So the picker hides behind one compact button and opens in a popover above the bar — out of the bar’s own flow — only when asked; closed, the bar keeps the single height the double-tap depends on.

<div class="batch-date">
  <button class="datebtn" aria-label="set date" title="set the selection's date"
          aria-expanded=${() => datingSel() ? 'true' : 'false'}
          onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>&#x1F553;</button>
  <${Show} when=${() => datingSel()}>
    <div class="date-pop">
      <input type="datetime-local" aria-label="date for the selection"
             value=${() => selDate()} onInput=${e => setSelDate(e.target.value)}
             onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); stampSelDate(); } }} />
      <button class="datestamp" aria-label="apply date" disabled=${() => !selDate()}
              onClick=${stampSelDate}>&#x2192; date</button>
    </div>
  <//>
</div>

We need the stamp to reach every ticked doc, whatever day each started on, and — going through patchSelected — to clear the selection as it lands, so the bar dismisses itself. Two shots dated years apart, ticked together and stamped to one midday (noon, so no timezone drags the instant onto an adjacent day), then drop out of a search for their old year and turn up under the stamped day.

@testcase
def test_batch_date_stamps_selection(page):
    """One chosen instant lands on every ticked doc — for a pile of junk-timestamped shots."""
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbd-a", "date": "2019-03-01T08:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbd-a-t"},
            {"cid": "https://ipfs.konubinix.eu/p/zzbd-b", "date": "2019-11-22T19:30:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbd-b-t"}]  # two junk dates, far apart
    for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzbatchdate", "owner": "konubinix", "state": "todo"})
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzbatchdate")
        expect(tiles(page)).to_have_count(2)
        select_all(page).click()
        tb = toolbar(page)
        tb.get_by_role("button", name="set date").click()
        tb.get_by_label("date for the selection").fill("2020-06-15T12:00")
        tb.get_by_role("button", name="apply date").click()
        expect(toolbar(page)).to_be_hidden()                                 # selection cleared → the bar dismisses itself
        search_for(page, "zzbatchdate; date:2019")                           # dropped out of the old year: 2 → 0
        expect(tiles(page)).to_have_count(0)
        search_for(page, "zzbatchdate; date:2020-06-15")                     # and turns up under the stamped day: 0 → 2
        expect(tiles(page)).to_have_count(2)
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: batch date stamps the selection")

The picker mirrors the bar’s other controls; the popover reuses the upward-opening shape the label and move completions use, and the &#x2192; date button dims until a moment is set.

.batch-date{ position:relative; }
.date-pop{ position:absolute; bottom:100%; left:0; margin:0 0 4px; z-index:26;
           display:flex; gap:6px; padding:6px; background:#11131f;
           border:1px solid #3a3f5a; border-radius:6px; }
.date-pop input{ padding:5px 8px; font-size:14px; background:#262a40; color:var(--fg);
                 border:1px solid #3a3f5a; border-radius:5px; }
.datestamp:disabled{ opacity:.45; cursor:default; }

Across the app

A few concerns run through every surface at once: the room the phone’s own bars leave, colour, the keyboard, where the view comes to rest, and quick reach for the label box.

Room left by the phone’s bars

Standing back from the strips is not a courtesy one surface pays: a control drawn under a bar is a control no thumb reaches, whichever surface put it there. So take a phone showing both of them and walk the app. A desktop Chromium reports no insets at all, so the phone has to be asked for: Emulation.setSafeAreaInsetsOverride is what makes env() answer with a status bar and a navigation bar, and the band between them is what everything below has to stay inside.

page.set_viewport_size({"width": 360, "height": 640})
TOP, BOT = 28, 48                            # a status bar, and a three-button navigation bar
page.context.new_cdp_session(page).send(
    "Emulation.setSafeAreaInsetsOverride",
    {"insets": {"top": TOP, "left": 0, "right": 0, "bottom": BOT}})
floor = page.viewport_size["height"] - BOT

The wall comes first, and its title is what sits nearest the top edge — what it clears, the status bar gave back.

title = heading(page).bounding_box()
assert title["y"] >= TOP, f"the title sits under the status bar: {title}"

At the other edge the selection toolbar is pinned to the very foot, and it is a wide, wrapping bar — so it is not enough for the one control you thought of to clear the strip; the download buttons and the clear at its far end have to as well, and they are the ones that sit lowest.

tiles(page).nth(0).click()                                    # one tap selects → the wall's toolbar
all_clear("the selection toolbar", toolbar(page))
tiles(page).nth(0).click()                                    # drop the selection again

Then the open doc — where the stakes are highest, since nothing there scrolls past a bar.

open_doc(page)
all_clear("the open doc", dialog(page))
page.keyboard.press("Escape")

And the frame, whose bar floats just above that same foot.

page.get_by_role("button", name=re.compile("frame", re.I)).click()
page.get_by_role("list", name="slideshow").click()            # a tap brings the bar up
all_clear("the frame bar", page.get_by_role("toolbar", name="frame actions"))
print("  PASS: controls clear the system bars")

A colour per state, everywhere it shows

A doc’s state earns its colour wherever it appears, not only in the frame: one source defines the four state hues and every surface reads it, so the eye learns the code once — on the wall’s chips, the batch toolbar, and the open doc alike.

On the wall, the filter chips wear those same hues, so the state you can switch to reads at a glance (the neutral all chip keeps its plain look).

make_fixtures()
open_app(page)
expect(tiles(page).first).to_be_visible()
hue = lambda loc: loc.evaluate("el => getComputedStyle(el).color")
pill_hues = lambda scope: [hue(scope.get_by_role("button", name=st, exact=True))
                           for st in ("todo", "next", "done", "delete")]
chip_hues = pill_hues(filters(page))
assert len(set(chip_hues)) == 4, f"chip state pills want distinct colours, got {chip_hues}"

The batch toolbar’s state buttons carry the same hues, so setting a selection’s state speaks the same colour vocabulary.

select_all(page).click()                                   # the selection toolbar appears
expect(toolbar(page)).to_be_visible()
tb_hues = pill_hues(toolbar(page))
assert len(set(tb_hues)) == 4, f"toolbar state pills want distinct colours, got {tb_hues}"
select_all(page).click()                                   # clear, so the next leg opens a doc cleanly

And the open doc’s own state buttons, in the lightbox.

open_doc(page, 0)
expect(dialog(page)).to_be_visible()
lb_hues = pill_hues(dialog(page))
assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"

@testcase
def test_state_pills_colour_coded(page):
    make_fixtures()
    open_app(page)
    expect(tiles(page).first).to_be_visible()
    hue = lambda loc: loc.evaluate("el => getComputedStyle(el).color")
    pill_hues = lambda scope: [hue(scope.get_by_role("button", name=st, exact=True))
                               for st in ("todo", "next", "done", "delete")]
    chip_hues = pill_hues(filters(page))
    assert len(set(chip_hues)) == 4, f"chip state pills want distinct colours, got {chip_hues}"
    select_all(page).click()                                   # the selection toolbar appears
    expect(toolbar(page)).to_be_visible()
    tb_hues = pill_hues(toolbar(page))
    assert len(set(tb_hues)) == 4, f"toolbar state pills want distinct colours, got {tb_hues}"
    select_all(page).click()                                   # clear, so the next leg opens a doc cleanly
    open_doc(page, 0)
    expect(dialog(page)).to_be_visible()
    lb_hues = pill_hues(dialog(page))
    assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"
    print("  PASS: state pills colour coded")

Colour tells you what a button is; a press tells you it took. On a touchscreen a tap that changes state runs a round-trip, and with nothing to show for the tap the finger tends to go again — so every state button, and the lightbox’s / , depresses the instant it is pressed, a quick scale felt before the result lands. It rides on :active, so no button is wired for it by hand.

@testcase
def test_buttons_acknowledge_a_press(page):
    """Press-and-hold a nav button and a state button; each control's transform changes under :active."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    def held_vs_rest(btn):
        bb = btn.bounding_box()
        page.mouse.move(bb["x"] + bb["width"] / 2, bb["y"] + bb["height"] / 2)
        rest = btn.evaluate("el => getComputedStyle(el).transform")
        page.mouse.down()
        held = btn.evaluate("el => getComputedStyle(el).transform")   # :active while the pointer is held
        page.mouse.up()
        return rest, held
    rest, held = held_vs_rest(d.get_by_role("button", name="next photo"))         # the › nav
    assert held != rest, f"the nav gives no press feedback: rest={rest} held={held}"
    rest, held = held_vs_rest(d.get_by_role("button", name="done", exact=True))   # a state button
    assert held != rest, f"the state button gives no press feedback: rest={rest} held={held}"
    print("  PASS: buttons acknowledge a press")

Driving the wall from the keyboard

Triage is a two-handed rhythm — glance, judge, move on — and reaching for the mouse between every photo breaks it. The wall already answers a click and a hold, but a reviewer running down a folder of photos wants what every file manager gives: a focused tile the eye can follow, moved with the arrow keys. So the grid grows a cursor.

The arrows walk it across the wall — left and right by one tile, down and up by a whole row — and Enter opens the focused doc in the lightbox (Escape leaves it, as it already does).

page.keyboard.press("ArrowRight")               # the first arrow focuses the first tile
page.keyboard.press("ArrowRight")               # → the second
page.keyboard.press("Enter")                    # Enter opens the focused doc
d = dialog(page)
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
print("  PASS: grid cursor opens focused doc")

And Space toggles the focused tile’s selection — the keyboard twin of a click.

expect(checks(page)).to_have_count(0)           # nothing picked by walking about
page.keyboard.press(" ")                        # Space picks the focused one
expect(checks(page)).to_have_count(1)
page.keyboard.press(" ")                        # Space again unpicks it
expect(checks(page)).to_have_count(0)
print("  PASS: grid cursor space toggles selection")

A click plants the cursor on the tile it touched, so the arrows carry on from there rather than snapping back to the wall’s first tile.

tiles(page).nth(5).click()                       # reach for the mouse for one tile
page.keyboard.press("ArrowRight")               # the arrows carry on from there
page.keyboard.press("Enter")                     # open the now-focused tile
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(6))
page.keyboard.press("Escape")
toolbar(page).get_by_role("button", name="clear").click()   # drop what the click picked up
expect(checks(page)).to_have_count(0)
print("  PASS: cursor starts at last clicked tile")

Holding Shift while arrowing selects: the selection becomes the contiguous run from the anchor to the cursor, so arrowing away grows it and arrowing back toward the anchor shrinks it again — a folder’s Shift-selection, built without the mouse. Whatever was selected before the Shift-run is kept underneath.

page.keyboard.press("ArrowRight")               # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(0)           # and selects nothing
page.keyboard.press("Shift+ArrowRight")         # reach on…
page.keyboard.press("Shift+ArrowRight")         # …and on again
expect(checks(page)).to_have_count(3)           # the whole run from the anchor
print("  PASS: grid cursor shift extends selection")

And arrowing back toward the anchor shrinks the run — the selection follows the cursor in both directions, never leaving a stranded tail.

page.keyboard.press("Shift+ArrowLeft")          # back one → the far end drops off
expect(checks(page)).to_have_count(2)
page.keyboard.press("Shift+ArrowLeft")          # back onto the anchor → only it remains
expect(checks(page)).to_have_count(1)
toolbar(page).get_by_role("button", name="clear").click()
expect(checks(page)).to_have_count(0)
print("  PASS: grid cursor shift reduces on return")

Down and up move by a row, not a tile, so the cursor travels the grid in two dimensions — and because the wall is responsive, a row is however many columns it is showing at that size.

tiles(page).nth(0).click()                      # plant the cursor at the top-left
toolbar(page).get_by_role("button", name="clear").click()
cols = grid(page).evaluate("el => getComputedStyle(el).gridTemplateColumns.split(' ').length")
page.keyboard.press("ArrowDown")                # → one row down, i.e. index `cols`
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(cols))
page.keyboard.press("Escape")
print("  PASS: grid cursor down steps a row")

And Ctrl+A (⌘A on a Mac) grabs the whole shown wall at once — the keyboard twin of the select-all toggle — so a reviewer can scoop up everything for a bulk edit without leaving the home row.

page.keyboard.press("Control+a")
expect(checks(page)).to_have_count(KEY_N)       # the whole wall at once
print("  PASS: ctrl+a selects whole wall")

The cursor is the keyboard’s focus on the wall: the arrows walk it a tile across or a whole row up and down, clamped at the ends — a row being however many columns the responsive grid is showing at the time. Each move brings the focused tile fully into view, so the eye follows the cursor past the fold. A plain move drops the anchor where it lands and ends any Shift-run; the first Shift move snapshots the current selection, and every Shift move then sets the selection to that snapshot plus the run from the anchor to the cursor — recomputed each step from the fixed anchor, so reversing direction shrinks the run rather than stranding it.

const CURSOR_KEY = 'memories.cursor';
const [cursor, setCursor] = createSignal(localStorage.getItem(CURSOR_KEY));
createEffect(() => { const c = cursor(); c ? localStorage.setItem(CURSOR_KEY, c) : localStorage.removeItem(CURSOR_KEY); });
let gridEl, rangeBase = new Set(), extending = false;
const gridCols = () => gridEl ? getComputedStyle(gridEl).gridTemplateColumns.split(' ').length : 1;
const moveCursor = (delta, extend) => {
    const list = items(); if(!list.length) return;
    const at = list.findIndex(p => p.cid === cursor());
    const ni = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + delta));
    setCursor(list[ni].cid);
    if(extend && anchor() !== null){ extendRun(list[ni].cid); }   // grow-or-shrink the run to the cursor
    else { extending = false; setAnchor(list[ni].cid); }   // a plain move re-anchors and ends the run
    requestAnimationFrame(scrollCursorIntoView);
};

Bringing it fully into view is more than nudging it past the edge, because the selection toolbar is pinned over the foot of the wall and floats above it. That pin causes two separate troubles, and a Shift-run driving the cursor downward — toolbar up, cursor heading for the bottom — walks straight into both. A run dragged to the foot of a tall wall must still leave its last tile whole, and above the bar.

tiles(page).nth(0).click()                               # start the run at the top-left
for _ in range(KEY_N): page.keyboard.press("Shift+ArrowDown")   # drag it down past the fold
last = tiles(page).nth(KEY_N - 1).bounding_box()
bar = toolbar(page).bounding_box()                       # the run raised the toolbar
assert last["y"] >= 0, f"tile head scrolled above the fold: {last}"
assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
toolbar(page).get_by_role("button", name="clear").click()
print("  PASS: grid cursor reveals tile above toolbar")

The first trouble is position: a tile scrolled flush to the bottom sits half-hidden behind the bar. So the reveal measures the focused tile against a viewport whose bottom is lifted by the toolbar’s own height — zero until a selection summons it — and scrolls only as far as it takes to clear whichever edge the tile overruns.

Which cuts both ways, and the way back up is the one easily forgotten: a wall walked to its foot has to be walkable back to its head, and a cursor climbing out of view is as lost as one that never came into it.

for _ in range(KEY_N): page.keyboard.press("ArrowUp")    # climb back out, plain moves
first = tiles(page).nth(0).bounding_box()
assert first["y"] >= 0, f"the cursor climbed above the fold and stayed there: {first}"
print("  PASS: grid cursor reveals a tile climbed back to")

const scrollCursorIntoView = () => {
    const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
    const r = tile.getBoundingClientRect();
    const bar = document.querySelector('.toolbar');
    const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
    if(r.top < 0) scrollBy(0, r.top);                          // head above the fold → pull it down
    else if(r.bottom > floor) scrollBy(0, r.bottom - floor);   // foot under the bar → push it up
};

The second is room: the page ends where the last row does, so the bottom tiles have nowhere to scroll to — no nudge lifts them past a bar the document is too short to outrun. So while a selection holds the toolbar up, we reserve a strip beneath the wall as tall as the bar, giving those rows somewhere to rise into. The strip is measured a frame after the toolbar mounts, since its wrapped height isn’t known until it has laid out.

createEffect(() => {
    const shown = selCount() > 0;   // tracked synchronously; the toolbar mounts on this turn
    requestAnimationFrame(() => {
        const bar = shown && document.querySelector('.toolbar');
        document.body.style.paddingBottom = bar ? bar.getBoundingClientRect().height + 'px' : '';
    });
});

The keys live on a window listener that stands aside whenever another surface owns them — the lightbox or the frame while either is up, or any text field while it has focus — so the cursor only ever drives the bare wall. Space toggles the focused tile’s selection and Enter opens it in the lightbox; the Shift flag is what tells an arrow to reach rather than step; and Ctrl=/=⌘+A selects the whole shown wall.

onMount(() => {
    const onKey = e => {
        if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
        const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
        if(e.key === 'ArrowRight') step(1);
        else if(e.key === 'ArrowLeft') step(-1);
        else if(e.key === 'ArrowDown') step(gridCols());
        else if(e.key === 'ArrowUp') step(-gridCols());
        else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
        else if(e.key === 'Enter' && cursor()){ e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
        else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){ e.preventDefault(); setSelected(new Set(shownCids())); }
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

The focus ring is amber, set apart from selection’s blue outline so a tile that is both focused and selected shows both at once.

.tile[data-cursor='1']{ box-shadow:0 0 0 3px #f9a826, 0 0 10px #f9a826aa; }

A triaging session can run long, building up a selection and a cursor position that would be tedious to rebuild — and a refresh is easy to hit by accident. So the cursor and the run you’ve picked persist: each is written to localStorage as it changes (the same way the search box and the state chip already do) and read back on load, so an accidental reload comes back to the tile you were on with the selection intact.

@testcase
def test_cursor_and_selection_survive_reload(page):
    """The picked run and the keyboard cursor outlast a reload, like the search does."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    tiles(page).nth(0).click()                       # pick tile 0 — the click plants the cursor there
    page.keyboard.press("Shift+ArrowRight")         # extend the run to tile 1; the cursor lands on it
    expect(checks(page)).to_have_count(2)
    page.reload(wait_until="commit")
    heading(page).wait_for(timeout=8000)
    expect(tiles(page)).to_have_count(3)            # the saved search brings the same wall back
    expect(checks(page)).to_have_count(2)           # the picked run survived
    page.keyboard.press("Enter")                     # the cursor survived on tile 1 → opens it
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: cursor and selection survive reload")

The cursor follows what you’re viewing

The wall’s keyboard cursor marks where you are. But dive into a doc in the lightbox — or step into the frame — and move through the wall in place, and the cursor is left behind on the tile you started from: come back and the arrows resume there, not on the doc you actually stopped on. So the cursor rides along, tracking whatever doc you are looking at, and leaving either surface lands you back on it.

@testcase
def test_lightbox_nav_follows_cursor(page):
    """Stepping through the lightbox carries the wall cursor along: close it and the
    cursor rests on the doc you stopped on, not the one you opened."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    open_doc(page, 0)                                # double-click opens the first
    d = dialog(page)
    d.get_by_role("button", name="next photo").click()   # step to the second doc
    expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    page.keyboard.press("Escape")
    expect(d).to_be_hidden()
    page.keyboard.press("Enter")                     # the bare wall reopens the doc the cursor rests on
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: lightbox nav follows cursor")

The frame is the same story: run the slideshow on a bit, leave it, and the cursor is on the slide you stopped on — ready for the arrows to carry on.

@testcase
def test_frame_nav_follows_cursor(page):
    """Moving through the frame carries the wall cursor: exit and the cursor rests on
    the slide you stopped on."""
    strip = enter_frame(page, 999999)                # 3 fixtures, frame opens on thumb-0 (date order)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    page.keyboard.press("ArrowRight")                # advance to the second slide
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    wait_until(page, lambda: page.evaluate(          # the slide has settled (its cid committed) — as when you pause on it
        "() => localStorage.getItem('memories.frame.cid')") == "https://ipfs.konubinix.eu/p/zzbatchfix-1")
    page.keyboard.press("Escape")                    # leave the frame → back to the wall
    expect(strip).to_be_hidden()
    page.keyboard.press("Enter")                     # the bare wall reopens the doc the cursor rests on
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: frame nav follows cursor")

A cursor riding off-screen would be no help — you’d leave the surface and land on a wall scrolled to where you started, hunting for the tile you actually stopped on. So the wall scrolls to keep that cursor tile in view: navigate deep into a spread from the lightbox, and leaving it lands the wall on your tile, not at the top.

IN_VIEW = "el => { const r = el.getBoundingClientRect(); return r.top < window.innerHeight && r.bottom > 0; }"

@testcase
def test_wall_scrolls_to_lightbox_cursor(page):
    """Stepping the lightbox scrolls the wall to the cursor; leaving lands on that tile, in view."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}", "date": f"2020-02-{i + 1:02d}T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}-t", "webCid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}-web",
             "labels": "zzlbscroll", "state": "todo"} for i in range(18)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 360, "height": 480})   # narrow + short: the 18-tile wall overflows
        open_app(page); chip(page, "all").click()
        search_for(page, "zzlbscroll")
        expect(tiles(page)).to_have_count(18)
        assert not tiles(page).nth(17).evaluate(IN_VIEW), "setup: the last tile must start below the fold"
        open_doc(page, 0)                                       # open the first (wall at top)
        expect(dialog(page)).to_be_visible()                    # wait for the lightbox before driving it
        page.keyboard.press("ArrowLeft")                       # wrap to the last doc, far down the wall
        page.keyboard.press("Escape")
        expect(dialog(page)).to_be_hidden()
        wait_until(page, lambda: tiles(page).nth(17).evaluate(IN_VIEW),
                   label="the wall scrolled the cursor tile into view")
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: wall scrolls to lightbox cursor")

The frame scrolls the wall the same way: step the show far along, leave it, and the wall lands on the slide you stopped on — in view.

@testcase
def test_wall_scrolls_to_frame_cursor(page):
    """The frame does the same, through the same follow: stepping the show scrolls the wall to the slide."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzfrscroll-{i:02d}", "date": f"2020-03-{i + 1:02d}T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfrscroll-{i:02d}-t", "labels": "zzfrscroll", "state": "todo"} for i in range(18)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 360, "height": 480})
        open_app(page); chip(page, "all").click()
        search_for(page, "zzfrscroll")
        expect(tiles(page)).to_have_count(18)
        assert not tiles(page).nth(17).evaluate(IN_VIEW), "setup: the last tile must start below the fold"
        page.get_by_role("button", name=re.compile("frame", re.I)).click()   # into the slideshow
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: bool(page.evaluate("() => localStorage.getItem('memories.frame.cid')")),
                   label="the show settled on its opening slide")
        init = page.evaluate("() => localStorage.getItem('memories.frame.cid')")
        page.keyboard.press("ArrowLeft")                       # wrap to the last slide
        wait_until(page, lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')") != init,
                   label="the show settled on the far slide")
        page.keyboard.press("Escape")                          # leave the show
        expect(strip).to_be_hidden()
        wait_until(page, lambda: tiles(page).nth(17).evaluate(IN_VIEW),
                   label="the wall scrolled to the slide we stopped on")
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: wall scrolls to frame cursor")

The doc you are looking at is opened() while the lightbox is up, and otherwise the frame’s centred slide. Every move on either surface updates one of those — the ‹/› buttons, the arrows, a swipe, a slideshow step — so a single effect mirroring whichever is live onto the cursor — and scrolling that cursor’s tile into view on the wall behind — covers them all. With neither surface open it has nothing to mirror and leaves the cursor untouched, right where you last left it. One thing would silently undo that scroll: leaving either surface rides on history.back(), and the browser’s own scroll restoration then snaps the window back to where it stood when the surface opened. So the app turns restoration off and owns the wall’s scroll itself — the follow is the authority on where the wall stands.

history.scrollRestoration = 'manual';   // Back must not undo the follow (see prose)
createEffect(() => { const c = opened()?.cid || (frame() ? frameCenterCid() : null); if(!c) return;
    setCursor(c);
    gridEl?.children[items().findIndex(p => p.cid === c)]?.scrollIntoView({ block: 'nearest' }); });

Jumping to the label box

Selecting a run of photos and then dragging the mouse all the way to the label field breaks the keyboard flow the cursor just built. So l — for label — jumps straight to the add-label box: the lightbox’s when a doc is open, or the selection toolbar’s when a batch is waiting on the wall. Hands stay on the keys — select with Shift+→, press l, type the word; or press . to drop in the label you used last and just hit Enter.

With a doc open, l focuses its add-label box.

@testcase
def test_label_shortcut_focuses_lightbox_box(page):
    """In the lightbox, pressing l jumps focus to the add-label box, ready to type."""
    open_fixtures(page)
    open_doc(page, 0)
    box = dialog(page).get_by_placeholder("add a label…")
    expect(box).not_to_be_focused()
    page.keyboard.press("l")
    expect(box).to_be_focused()
    expect(box).to_have_value("")                # l opened the box; it didn't type into it
    print("  PASS: label shortcut focuses lightbox box")

On the wall, with a selection up, l focuses the toolbar’s label box instead.

@testcase
def test_label_shortcut_focuses_batch_box(page):
    """On the wall, with a selection up, l jumps focus to the toolbar's label box."""
    open_fixtures(page)
    tiles(page).nth(0).click()                   # select one → the toolbar appears
    box = toolbar(page).get_by_placeholder("add a label…")
    page.keyboard.press("l")
    expect(box).to_be_focused()
    expect(box).to_have_value("")
    print("  PASS: label shortcut focuses batch box")

. — repeat — goes one further: it fills that same box with the label applied last and focuses it, so a run of photos can take the same tag without retyping. With a doc open, . refills the lightbox’s box.

@testcase
def test_label_repeat_fills_lightbox_box(page):
    """In the lightbox, '.' refills the add-label box with the label applied last."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    box = d.get_by_placeholder("add a label…")
    box.click(); box.fill("zzrep"); box.press("Enter")          # apply → remembered as last
    expect(d.get_by_role("button", name="zzrep", exact=True)).to_be_visible()
    box.blur()                                                  # leave the field so '.' is a shortcut
    page.keyboard.press(".")
    expect(box).to_be_focused()
    expect(box).to_have_value("zzrep")                          # refilled, ready to commit again
    print("  PASS: label repeat fills lightbox box")

And on the wall, with a selection up, . refills the toolbar’s box the same way.

@testcase
def test_label_repeat_fills_batch_box(page):
    """On the wall, '.' refills the toolbar's label box with the label applied last."""
    open_fixtures(page)
    tiles(page).nth(0).click()
    box = toolbar(page).get_by_placeholder("add a label…")
    box.click(); box.fill("zzrep"); box.press("Enter")          # apply to one → remembered, selection clears
    tiles(page).nth(1).click()                                  # select another → toolbar back
    page.keyboard.press(".")
    box2 = toolbar(page).get_by_placeholder("add a label…")
    expect(box2).to_be_focused()
    expect(box2).to_have_value("zzrep")
    print("  PASS: label repeat fills batch box")

A small window listener routes l and . to whichever box is live — the open lightbox’s, else the selection toolbar’s — and stands aside while a field already has focus or the frame is up. l just focuses; . first drops in lastLabel, the word any earlier apply (here or in a batch) left behind. The preventDefault keeps that keystroke from landing in the box it just opened.

onMount(() => {
    const onKey = e => {
        if((e.key !== 'l' && e.key !== '.') || frame()) return;    // the frame has its own bar
        if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;    // a field already owns the key
        const box = opened() ? document.querySelector('.lb .batch-label')
                  : selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
        if(!box) return;
        e.preventDefault();
        if(e.key === '.' && lastLabel())                           // '.' re-drops the label applied last
            (opened() ? setLbText : setLabelText)(lastLabel());
        box.focus();
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

Installable, fullscreen (PWA)

On a phone the wall wants the whole screen. A web-app manifest (display:fullscreen, an SVG icon, the app’s dark theme_color) plus a service worker make it installable to the home screen and launchable chrome-free. The worker earns its keep beyond that: it makes launches instant and keeps the app current after a deploy — without the hard-reload a phone makes painful. It caches only its own shell; the live data (/graphql) and the archive’s media (/ipfs/) stay on the network.

The test can’t drive a real install, so it asserts the observable scaffolding: the manifest is linked and declares fullscreen, and the service worker reaches ready.

@testcase
def test_pwa_installable(page):
    """The app ships a fullscreen manifest and a registered service worker."""
    open_app(page)
    assert page.locator("link[rel='manifest']").get_attribute("href"), "no manifest linked"
    man = page.evaluate("() => fetch('manifest.json').then(r => r.json())")
    assert man["display"] == "fullscreen", f"display is {man.get('display')!r}"
    assert man["icons"], "manifest has no icons"
    ready = page.evaluate("""() => Promise.race([
        navigator.serviceWorker.ready.then(r => !!r.active),
        new Promise(res => setTimeout(() => res(false), 6000))])""")
    assert ready, "service worker did not become ready"
    print("  PASS: pwa installable")

Its cache is keyed to the build, and on activate it drops caches left by any other build — so after a deploy the next open finds the stale cache gone and re-fetches the fresh files. That re-fetch reaches past the browser’s own HTTP cache (the very thing a hard-reload exists to bypass), so the new build lands with an ordinary open — no hard-reload.

@testcase
def test_sw_drops_stale_version_cache(page):
    """On activate the worker deletes caches from other builds, so a new build isn't served stale."""
    open_app(page)
    page.evaluate("() => navigator.serviceWorker.ready")
    # seed a cache from a different build, then unregister so the next load re-installs fresh
    page.evaluate("""async () => {
        await (await caches.open('memories-stale')).put('/x', new Response('x'));
        for (const r of await navigator.serviceWorker.getRegistrations()) await r.unregister();
    }""")
    open_app(page)                                # re-register → fresh install/activate purges other builds
    page.evaluate("() => navigator.serviceWorker.ready")
    wait_until(page, lambda: "memories-stale" not in page.evaluate("() => caches.keys()"),
               label="stale-version cache purged on activate")
    print("  PASS: sw drops stale version cache")

Within a build it serves the shell cache-first — an instant launch that survives a flaky link.

@testcase
def test_sw_serves_from_cache(page):
    """Within a build the worker serves the shell from its cache, not the network."""
    open_app(page)
    page.evaluate("() => navigator.serviceWorker.ready")
    ver = "memories-" + (page.locator(".build-tag").text_content() or "").strip()   # the SW's cache name
    page.evaluate(f"""() => caches.open({ver!r}).then(c => c.put('manifest.json',
        new Response('{{\\"display\\":\\"CACHED\\"}}', {{headers:{{'content-type':'application/json'}}}})))""")
    display = page.evaluate("() => fetch('manifest.json').then(r => r.json()).then(m => m.display)")
    assert display == "CACHED", f"served network instead of cache: {display!r}"
    print("  PASS: sw serves from cache")

{
  "name": "Memories",
  "short_name": "Memories",
  "start_url": ".",
  "scope": ".",
  "display": "fullscreen",
  "background_color": "#1b1d2e",
  "theme_color": "#1b1d2e",
  "icons": [
    { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
  ]
}

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  <rect width="512" height="512" fill="#1b1d2e"/>
  <g fill="#6cf">
    <rect x="120" y="120" width="120" height="120" rx="14"/>
    <rect x="272" y="120" width="120" height="120" rx="14"/>
    <rect x="120" y="272" width="120" height="120" rx="14"/>
    <rect x="272" y="272" width="120" height="120" rx="14"/>
  </g>
</svg>

const CACHE = 'memories-nil';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil((async () => {
    for(const k of await caches.keys()) if(k !== CACHE) await caches.delete(k);
    await self.clients.claim();
})()));
self.addEventListener('fetch', e => {
    const req = e.request, url = new URL(req.url);
    if(req.method !== 'GET' || url.origin !== location.origin
       || url.pathname.includes('/graphql') || url.pathname.includes('/ipfs/')) return;
    e.respondWith((async () => {
        const cache = await caches.open(CACHE);
        const hit = await cache.match(req);
        if(hit) return hit;
        const res = await fetch(req.url, { cache: 'reload' });
        if(res.ok) cache.put(req, res.clone());
        return res;
    })());
});

Annexe

What a sharpened wall holds

The refined wall draws two pictures per tile, and a picture unpacked for drawing costs four bytes a pixel however little it weighed compressed. Measured on the same sample as the byte figures, a thumbnail is 0.045 Mpx — 256px on its long side — and a rendition 0.79 Mpx, cut to fit 1024px: some eighteen times the pixels, where it is only five to twelve times the bytes.

How many of those pixels a browser actually keeps is not settled here. It may unpack a rendition at the size the file was written, or at the size the tile draws it, and the two lead to opposite readings of what happens as the cells grow. Both, though, land on the same ceiling.

Unpacking at the file’s size, a fold of cells c wide holds area-over-c-squared tiles of 0.18 + 3.15 MB against the dense wall’s tiles of 0.18 MB at 96px, so the ratio is (96/c)² × 18.5 — 1.9 at the 300px threshold, level by 400px, and falling after that. The threshold is the worst of it, and the wall gets lighter the further past it you go.

Unpacking at the drawn size instead, the arithmetic collapses: tiles times cell area is just the fold’s area, so a fold holds its own area in pixels once per layer, whatever the cells measure. Two layers is then twice one, at every size, and growing the tiles changes nothing.

Neither reading is a bound on the other — at cells past 256px the thumbnail layer is upscaled, so drawn-size unpacking holds more for it than its file would suggest, while for the dense wall it holds less. What survives both is a ceiling: about twice what the dense wall already held. The reach window was drawn around a single fold of thumbnails, so this is that bound doubled — bounded rather than free, and what a device actually holds at that ceiling is a reading still to be taken.

Notes linking here