A Photos/Videos Organiser With Solid
FleetingWhy 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.
- Why this note
- Use cases
- Sharing a few photos with someone
- The cabinet plays on its own
- Somebody stops to look
- Fixing a date that came out wrong
- Putting words on a photo
- Saying no, quickly
- Judging a run down to nothing
- Doing one thing to forty photos
- Hunting for a photo you half-remember
- Letting the box do the typing
- Working the wall without the mouse
- Asking for more than the archive can hand over
- Setting Memories up on a phone that never had it
- Getting on screen
- The wall
- Searching and filtering
- The lightbox
- Customizable thumbnail size
- Frame mode
- Filtering by state
- Selecting and editing in bulk
- Across the app
- Installable, fullscreen (PWA)
- Annexe
- Notes linking here
- Permalink
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.
Then the same three photos are handed over the other way, which is not to hand them over at all: put them up on the frame and let the phone in somebody’s pocket take what it wants. That is the frame naming what it is showing, and it needs a room of this run’s own to name it into, plus somebody listening in it the way the phone would — and, at the end, the same frame pointed somewhere nothing answers.
Both of the show’s knobs are wound past the end of the session rather than to any particular span. Nothing here is watching the show move: what is read is what the room was told and what one word on the bar says, so the slide must not step under either reading, and the bar, once a touch has brought it up, must not tidy itself away again before the reading that wants it. The address that answers nothing is not picked freely either: which port it names is a choice with its own reason.
NEVER_MS = 999999 # past the end of this session, both times
SHARE_QS = f"?ms={NEVER_MS}&uiidle={NEVER_MS}"
DEAD_PORT = 45999
@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 — then put the same three up on the frame instead, where a
phone can help itself, and read off the bar whether anybody is hearing them."""
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")
# …and the same three put up on the frame, for a phone to take from
sync_url, room = require_sync(), "memories-test-" + uuid.uuid4().hex[:8]
named = watch_room(page.context, sync_url, room) # the entry, listened to as the phone will
open_app(page, SHARE_QS + f"&yws={sync_url}&room={room}")
expect(tiles(page)).to_have_count(len(docs)) # still the afternoon you searched for
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")
# 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, shown = named(), docs[photo]
assert said["cid"] == shown["cid"], f"named the wrong doc: {said}"
assert said["webCid"] == shown["webCid"], f"no downscaled address: {said}"
assert said["mimetype"] == shown["mimetype"], f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
[said.get("date"), shown["date"]]), \
f"named a different instant than the doc's {shown['date']}: {said}"
print(" PASS: frame publishes what is showing")
page.keyboard.press("ArrowRight") # a step, with nothing touched
wait_until(page, lambda: (named() or {}).get("cid") == docs[never_downscaled]["cid"],
label="the name follows the slide", detail=lambda: str(named()))
print(" PASS: the name follows the slide")
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") == docs[never_downscaled]["cid"],
label="a touch takes the room back", detail=lambda: str(named()))
print(" PASS: a touch takes the room back")
wait_until(page, lambda: bar.get_by_text("live", exact=True).count() == 1,
label="the bar says the link is live",
detail=lambda: f"the bar is up: {bar.is_visible()}; it reads "
f"{bar.text_content() if bar.count() else '—'!r}")
print(" PASS: the bar says the link is live")
# and the same frame pointed where nothing answers
open_app(page, SHARE_QS + f"&yws=ws://127.0.0.1:{DEAD_PORT}&room=nowhere")
expect(strip).to_be_visible()
strip.click() # a touch, so the bar is up
expect(bar.get_by_text("offline", exact=True)).to_be_visible()
print(" PASS: the frame owns up to a dead link")
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 screen, 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. One occasion is laid over all three of them, so that whichever slide the show happens to be resting on has something to say about itself.
CABINET_EVENT = "zzVacancesCabinet"
CABINET_FROM, CABINET_TO = "2020-01-01T00:00:00Z", "2020-03-31T23:59:59Z"
CABINET_EVENTS = [{"rowId": "zzcab-ev", "summary": CABINET_EVENT, "owner": "konubinix",
"status": "confirmed", "starttime": CABINET_FROM, "endtime": CABINET_TO}]
The passer-by who stops also gets an add-label box, and what it offers has to come from somewhere. The archive’s own vocabulary is somebody’s real life, renamed and pruned by people who never heard of this session, so the cabinet brings two words and takes them away after: one that answers a prefix and is nowhere near these slides, and one laid on every slide before the show starts, so that whichever one it is resting on already wears it. Both answer to the same stem, which is what a prefix has to reach.
CABINET_STEM = "zzcab"
CABINET_FREE, CABINET_WORN = "zzcabfree", "zzcabworn"
CABINET_WORDS = [CABINET_FREE, CABINET_WORN]
Nobody is standing at the cabinet to tell a picture that is still arriving from one that never will, so the show says which it is, and saying so is a promise worth its own run of slides. It wants one doc in each of the three states there is anything to say about: one whose thumbnail arrives and whose full-res never does, so the picture is up with something sharper still pending; one where nothing arrives at all; and one where both arrive, so that there is a slide with nothing left to announce. The bytes they answer with are the smallest picture that is still a picture — a single pixel, which loads like any other and is quicker to hand over than to describe.
MARK_LABEL = "zzmark"
MARK_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzmark-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzmark-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzmark-web-{i}", "labels": MARK_LABEL, "state": "todo"}
for i in range(3)]
MARK_ARRIVES = ("zzmark-t-0", # the picture, but never anything sharper
"zzmark-t-2", "zzmark-web-2") # both — and nothing at all for the one between them
ONE_PIXEL = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
@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 — including a box that completes on the archive's own words — comes back
where it was after a relaunch, and says which of its pictures are still on the way."""
make_fixtures()
for f in FIXTURES: # the worn word, on every slide
gql(UPDATE, {"cid": f["cid"], "patch": {"labels": f"{FIXTURE_LABEL}; {CABINET_WORN}"}})
seed_vocab(CABINET_WORDS)
seed_events(CABINET_EVENTS)
for d in MARK_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
for c in MARK_ARRIVES:
page.route(f"**/ipfs/{c}", lambda r: r.fulfill(status=200, content_type="image/png", body=ONE_PIXEL))
try:
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.get_by_text(CABINET_EVENT)).to_be_visible() # whatever slide it rests on, it is that
day = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(bar.get_by_text(day(CABINET_FROM))).to_be_visible() # the span's start, beside the name
expect(bar.get_by_text(day(CABINET_TO))).to_be_visible() # …and its end
print(" PASS: frame shows events")
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(tiles(page)).to_have_count(len(FIXTURES)) # the docs an auto-entry would place from
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired
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
chip(page, "todo").click() # …and the wall reads again, as it will
expect(tiles(page)).to_have_count(len(FIXTURES)) # fresh docs — the chance a re-entry would take
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired
expect(strip).to_be_hidden() # once per launch — it doesn't
print(" PASS: frame autostart")
# somebody sets it going once more, and this time follows where it leads
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
strip.click()
expect(bar).to_be_visible()
box = bar.get_by_placeholder("add a label…")
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20)
expect(options(page).first).to_be_visible() # vocabulary suggestions
assert CABINET_STEM in options(page).first.inner_text().strip().lower()
print(" PASS: frame label completion")
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20)
expect(options(page).filter(has_text=re.compile(f"^{CABINET_FREE}$")).first).to_be_visible()
offered = [t.strip() for t in options(page).all_inner_texts()]
assert CABINET_FREE in offered, f"the list went before it could be read: {offered}"
assert CABINET_WORN not in offered, f"a word every slide already wears was offered: {offered}"
print(" PASS: frame completion skips present")
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM)
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")
hold_completions(page)
expanded_while_loading(page, bar.get_by_placeholder("add a label…"))
print(" PASS: frame label expanded while loading") # last: a held query stays held
bar.get_by_role("button", name=re.compile(CABINET_EVENT)).click() # follow the occasion
expect(search_box(page)).to_have_value(f"event:{CABINET_EVENT}") # the pill's search, committed
expect(strip).to_have_count(0) # and out of the show with it
print(" PASS: frame event pill searches")
# later, on a run whose pictures are still coming in
open_app(page, CABINET_QS)
expect(strip).to_be_hidden() # the pill search left the show behind
search_for(page, MARK_LABEL)
expect(tiles(page)).to_have_count(len(MARK_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
slides = strip.get_by_role("listitem") # [clone, doc0 (centre), doc1, doc2, clone]
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzmark-t-0"),
label="the show opens on the doc with a picture but nothing sharper coming")
expect(slides.nth(2).get_by_label("loading")).to_be_visible() # its thumbnail never arrives: still loading
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0,
label="the mark clears on the slide whose thumbnail arrived")
print(" PASS: frame shows placeholder while loading")
expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible() # picture up, full-res still out
wait_until(page, lambda: slides.nth(3).get_by_label("loading").count() == 0,
label="the picture is up on the slide where both arrive")
expect(slides.nth(3).get_by_label("fetching full resolution")).to_have_count(0) # …and nothing sharper is pending
print(" PASS: frame thumbnail shows upgrade mark")
strip.click() # the bar, on the doc the show opened on
bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → its box goes to the one that never arrives
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzmark-t-1"),
label="the box that had finished loading now holds the doc that never will")
expect(slides.nth(1).get_by_label("loading")).to_be_visible() # the mark came with the new doc
print(" PASS: frame placeholder after edit remaps slot")
finally:
for c in MARK_ARRIVES: page.unroute(f"**/ipfs/{c}")
for d in MARK_DOCS: gql(DELETE, {"cid": d["cid"]})
drop_events(CABINET_EVENTS)
drop_vocab(CABINET_WORDS)
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.
Looking is also when you notice things. A date that is plainly wrong, a frame worth binning, one you have finally dealt with — and the show is where you are, so it is where they get put right. The bar a tap reveals acts on the centred photo, which is the one you were looking at when you noticed, and an edit that pushes that photo out of what you are watching has to leave you somewhere sensible rather than skipping whatever filled the gap.
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 apiece, enough that a hard fling has somewhere to travel. A strip that loops carries a copy of its last 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 one 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.
The leaning-in runs at that same brisk tempo, but wants two of the frame’s own spans wound down to reach. Its after-touch patience is set short, so that when the show stands still it is the magnified view holding it and not a patience yet to run down. Its give-up-and-reload span is set short too, but not that short: it has to outlast everything measured under the pinch, or it would end the looking mid-reading.
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
PINCH_HOLD_MS = 1600 # past a 600ms touch-idle, and several ticks
SNAP_GRACE_MS = 400
ZOOM_IDLE_MS = 3000 # outlasts both, so only the leaving-it reaches it
PINCH_QS = f"?ms={LIVE_MS}&idleresume=600&zoomidle={ZOOM_IDLE_MS}"
The clips are a second, shorter set, because a clip costs bytes to serve and a photo does not, and because they want an arrangement the photos have no use for: a photo ahead of the clip, so that retiring it re-deals the boxes and the clip comes to sit in one that held a picture, and two behind it, so the show has somewhere to march to while the clip is holding it back.
Their tempo is the third the session opens: slow enough that the clip comes round without a wait worth watching, brisk enough that a show which failed to hold would have stepped twice off the clip by the time the reading is taken — and once is already enough to fail it.
CLIP_LABEL = "zzclip"
CLIP_DOCS = ([{"cid": "https://ipfs.konubinix.eu/p/zzclip-0", "date": "2018-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzclip-t-0", "labels": CLIP_LABEL, "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzclip-v", "date": "2018-02-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzclip-t-v", "webCid": "https://ipfs.konubinix.eu/p/zzclip-web",
"labels": CLIP_LABEL, "state": "todo"}]
+ [{"cid": f"https://ipfs.konubinix.eu/p/zzclip-{i}", "date": f"2018-0{i + 2}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzclip-t-{i}",
"labels": CLIP_LABEL, "state": "todo"} for i in (1, 2)])
CLIP_MS = 1000
CLIP_QS = f"?ms={CLIP_MS}"
CLIP_HOLD_MS = 2500 # two of those ticks, and half of a third
And a third set, long, because what it is for cannot be seen on a short one. The show keeps a band of slides in hand around wherever you are and lets the rest go; a run has to be longer than that band for there to be anything outside it to look at. The band reaches fourteen ahead, so the run is forty — enough that a slide can sit twenty along and still have both a loaded edge and a let-go one on either side of it, which is what the readings below are of. They carry a full-resolution rendition as well as a thumbnail, because the two are kept to different distances and the whole point is which reaches further.
The one slide those readings return to is twenty along: far enough from the opening slide to be outside every band at the start, and central enough that fourteen ahead and six behind both still land on real slides rather than off the end of the run.
BAND_LABEL = "zzband"
BAND_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzband-{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/zzband-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzband-web-{i}", "labels": BAND_LABEL, "state": "todo"}
for i in range(40)]
FAR = 20 # the slot every band reading is taken around
@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 putting right what you noticed while looking,
giving it back to run on, leaning in on one until the show waits for you, coming
back to a run with a clip in it, which holds the show and stops when it leaves the
screen, and to one long enough that the show must let go of most of it."""
for d in SWIPE_DOCS + CLIP_DOCS + BAND_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
page.route("**/ipfs/zzclip-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm", headers={"Accept-Ranges": "bytes"}))
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")
page.keyboard.press("Shift+ArrowRight")
page.keyboard.press("Shift+ArrowRight")
expect(checks(page)).to_have_count(0) # nothing chosen behind the slideshow
print(" PASS: the frame owns the keys")
CONTROLLED_SLIDES = 4
w = strip.evaluate(SLIDE_W)
for _ in range(FLING_TRIES):
before = strip.evaluate(ON_SLIDE)
coasted = 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"
strayed = min(coasted % w, w - (coasted % w))
if strayed > CENTRED_PX: break # this one has something to ease
print(" PASS: frame swipe does not overshoot")
rest = strip.evaluate("el => el.scrollLeft")
assert strayed > CENTRED_PX, \
f"{FLING_TRIES} flings all coasted within {strayed:.0f}px of a boundary — none could show an ease"
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
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
strip.click() # a tap to bring the bar up
page.get_by_role("button", name="exit frame").click()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible()
print(" PASS: frame exits on back")
# back in, because one of them is wrong and the show is the place you noticed it
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").startswith("https://ipfs.konubinix.eu/p/zzswipe-t-"))
strip.click() # the bar, and the doc it acts on
bar = page.get_by_role("toolbar", name="frame actions")
before = bar.get_by_role("button", name="edit date").text_content()
on = int(strip.evaluate(CENTERED).rsplit("-", 1)[1]) # whichever one is in front of you
bar.get_by_role("button", name="edit date").click()
want = page.evaluate("(iso) => { const t = new Date(iso), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
f"20{10 + on}-01-15T12:00:00Z") # the day it carries
expect(bar.get_by_label("date", exact=True)).to_have_value(want)
print(" PASS: frame date seeds")
box = bar.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(strip).to_be_visible() # Escape backed out of the picker, not the show
page.wait_for_timeout(SAVE_GRACE_MS) # long enough that a save would have shown
expect(bar.get_by_role("button", name="edit date")).to_have_text(before) # and none did
print(" PASS: frame date escape cancels")
bar.get_by_role("button", name="edit date").click()
box = bar.get_by_label("date", exact=True)
box.fill("2019-06-15T12:00") # the summer it was really taken
box.press("Enter")
want = page.evaluate("() => new Date('2019-06-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")
bar.get_by_role("button", name="done", exact=True).click() # → out of the todo filter
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9") # the one just before it
print(" PASS: frame edit re-anchors")
delete_btn = bar.get_by_role("button", name="delete", exact=True)
asked = []
dismiss = lambda d: (asked.append(d.message), d.dismiss())
page.on("dialog", dismiss)
delete_btn.click() # ask, and say no
wait_until(page, lambda: bool(asked), label="delete asks first")
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9", "saying no must keep the photo"
page.remove_listener("dialog", dismiss)
page.on("dialog", lambda d: d.accept())
delete_btn.click() # ask, and say yes
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-8") # gone, and re-anchored
print(" PASS: frame delete confirms")
page.go_back() # hand the show back
expect(strip).to_be_hidden()
# …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) - 2) # two of them are dealt with now
page.get_by_role("button", name=re.compile("frame", re.I)).click()
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")
# and the other thing a finger says: two of them, spreading — let me look closer
open_app(page, PINCH_QS)
expect(strip).to_be_visible() # it picks the show back up by itself
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").startswith("https://ipfs.konubinix.eu/p/zzswipe-t-"))
held = strip.evaluate(CENTERED)
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,
label="the browser magnifies")
page.wait_for_timeout(PINCH_HOLD_MS) # past the touch-idle, and several ticks
assert strip.evaluate(CENTERED) == held, "a magnified slide must not auto-advance"
print(" PASS: frame pinch pauses auto-advance")
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # the app reads the live scale
nudged = strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4); // 40% in: well off any boundary
el.dispatchEvent(new Event('scroll')); return el.scrollLeft; }""")
page.wait_for_timeout(SNAP_GRACE_MS) # well past the settle-snap's own beat
assert abs(strip.evaluate("el => el.scrollLeft") - nudged) <= 1, \
"a magnified slide must be left where it was nudged, not pulled to a slide edge"
print(" PASS: frame pinch freezes snap")
page.wait_for_url(re.compile(r"[?&]z=")) # left alone, it gives up and reloads itself…
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01,
label="…onto an address the browser has never seen zoomed, so it lands at 1:1")
print(" PASS: frame pinch resets after idle")
# come back another day, to a run with a clip in it
open_app(page, STILL_QS)
expect(strip).to_be_visible() # back in the show, where the pinch left it
page.keyboard.press("Escape") # out of it, to point it somewhere else
expect(strip).to_be_hidden()
search_for(page, CLIP_LABEL)
expect(tiles(page)).to_have_count(len(CLIP_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
on_clip = lambda: strip.evaluate(CENTERED_IS_CLIP)
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-0"),
label="the show opens on the photo ahead of the clip")
for kind in ("photo", "clip"):
m = strip.evaluate(CENTERED_MEDIA)
assert m, f"the centred {kind} shows nothing at all"
assert m["width"] == VIEWPORT["width"], \
f"the {kind} spans {m['width']}px of a {VIEWPORT['width']}px screen"
assert m["height"] == VIEWPORT["height"], \
f"the {kind} stands {m['height']}px in a {VIEWPORT['height']}px screen"
assert m["fit"] == "contain", f"the {kind} is cropped to fill rather than shown whole"
if kind == "photo":
page.keyboard.press("ArrowRight") # on to the other kind
wait_until(page, on_clip, label="the clip is centred")
assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", \
"the screen framing them must be black"
print(" PASS: frame media fills screen on black")
v = strip.locator("video").first
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"))
page.keyboard.press("ArrowRight") # step the show off it
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the clip stopped once its slide left the screen")
print(" PASS: frame video pauses when it leaves")
page.keyboard.press("ArrowLeft") # back onto the clip
wait_until(page, on_clip, label="back on the clip")
cdp = page.context.new_cdp_session(page) # a real touch tap: no click reaches a <video>
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": box["x"] + box["width"] * 0.92, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-1"),
label="the side-tap stepped the show off the clip")
print(" PASS: frame tap steps over video")
page.keyboard.press("ArrowLeft") # back over the clip…
wait_until(page, on_clip, label="back on the clip")
page.keyboard.press("ArrowLeft") # …to the photo ahead of it
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-0"),
label="back on the photo ahead of the clip")
strip.click() # the bar, on that photo
frame_bar = page.get_by_role("toolbar", name="frame actions")
frame_bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → the boxes close up
expect(tiles(page)).to_have_count(len(CLIP_DOCS) - 1)
wait_until(page, on_clip, label="the clip has taken the retired photo's box")
v = strip.locator("video").first # a different element from the one just watched
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press("ArrowRight")
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the re-dealt clip stopped once its slide left the screen")
print(" PASS: frame video pauses after reflow")
# …and set it going, to see which of the two gives way
open_app(page, CLIP_QS)
expect(strip).to_be_visible()
wait_until(page, on_clip, label="the clip comes round")
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(CLIP_HOLD_MS) # two ticks, and half of a third
assert on_clip(), "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) or "").endswith("zzclip-t-1"),
label="the show moves on once the clip has finished")
print(" PASS: frame video holds the show")
# and once more on a run far longer than the show can hold at once
open_app(page, STILL_QS)
expect(strip).to_be_visible()
page.keyboard.press("Escape")
expect(strip).to_be_hidden()
search_for(page, BAND_LABEL)
expect(tiles(page)).to_have_count(len(BAND_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(BAND_DOCS) + 2),
label="the strip is laid out at full width, every slide and both 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')}")
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == 1,
label="the show has settled on its opening slide, so a jump will stick",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}")
srcs = lambda k: strip.evaluate(SLOT_SRCS, k) # slot k holds doc k-1: base, then overlay
is_blank = lambda ss: any(x and x.startswith("data:image/gif") for x in ss)
web = lambda k: any("zzband-web-" in x for x in srcs(k))
thumb = lambda k: any("zzband-t-" in x for x in srcs(k))
blank = lambda k: is_blank(srcs(k))
goto = lambda n: strip.evaluate(SLIDE_JUMP, n)
back_to_the_start = lambda: (goto(1), wait_until(page, lambda: strip.evaluate(ON_SLIDE) == 1,
label="back at the opening slide"))
far_mark = strip.get_by_role("listitem").nth(FAR).get_by_label("loading")
expect(far_mark).to_have_count(0) # let go of, and its blank painted: nothing to announce
goto(FAR) # fling onto it
expect(far_mark).to_have_count(1) # its source turns real → the mark is back until that paints
print(" PASS: frame placeholder returns on band flip")
back_to_the_start()
goto(FAR) # forward to a mid slide → heading is +1
wait_until(page, lambda: web(FAR), # the centre arrives, full-res on it
label=f"the full-res reaches the centre (slot {FAR})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{FAR}]={srcs(FAR)}")
# full-res leads the way you're going: it reaches 3 ahead but only 1 behind
assert web(FAR + 3) and not web(FAR + 4), f"full-res should reach 3 ahead, got {srcs(FAR + 3)} / {srcs(FAR + 4)}"
assert web(FAR - 1) and not web(FAR - 2), f"full-res should reach only 1 behind, got {srcs(FAR - 1)} / {srcs(FAR - 2)}"
# the kept-thumbnail band leans the same way: 14 ahead, 6 behind
assert thumb(FAR + 14) and blank(FAR + 15), f"thumbnail kept 14 ahead, got {srcs(FAR + 14)} / {srcs(FAR + 15)}"
assert thumb(FAR - 6) and blank(FAR - 7), f"thumbnail kept 6 behind, got {srcs(FAR - 6)} / {srcs(FAR - 7)}"
back = FAR - 5; goto(back) # turn round → the lean must turn with you
wait_until(page, lambda: web(back - 3),
label=f"the full-res leads the reversed way (slot {back - 3})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" srcs[{back - 3}]={srcs(back - 3)} srcs[{back}]={srcs(back)}")
assert not web(back + 3), f"after turning, full-res should not still reach the old way, got {srcs(back + 3)}"
print(" PASS: frame preloads web window")
back_to_the_start()
wait_until(page, lambda: blank(FAR), # the bands are re-picked a beat after the show moves
label="the slide to be flung onto has been let go of, so the fling has something to prove",
detail=lambda: f"srcs[{FAR}]={srcs(FAR)}")
mid_fling = strip.evaluate(f"""async (el, k) => {{
({SLIDE_JUMP})(el, k);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return ({SLOT_SRCS})(el, k);
}}""", FAR)
assert mid_fling and not is_blank(mid_fling), \
f"the slide flung onto must be asked for, not blank: {mid_fling}"
print(" PASS: frame fling loads into view")
finally:
page.unroute("**/ipfs/zzclip-web")
for d in SWIPE_DOCS + CLIP_DOCS + BAND_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.
Nothing about the picture says so, though, and the wall is sorted by date, so a strayed photo sits quietly among strangers looking exactly as settled as they do. What gives it away is the calendar. The wall marks each photo with the occasion it was taken during, and since the wall runs in date order, one occasion’s photos land together as a run of a single colour. A photo carried weeks off by a bad timestamp lands outside that run, wearing no occasion at all, and the gap in the band is the thing the eye catches.
From there it is a photo at a time: open the one with the gap, see what it says, correct it, and read back from its occasions that it now sits where it belongs. That 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.
So the set is a run with one hole in it, and the hole has to be the only one — a second bare tile and the gap stops being a signal. Two occasions a fortnight apart, three photos inside the first and two inside the second, and one photo dated a month past both: the stray, and the only photo here without an occasion. A third occasion sits inside the first, two days of a longer week, so one photo is caught by two at once. Two more cover a single later day on which Ayla and I each took a photo — one occasion hers, one mine — since an occasion colours its owner’s photos and nobody else’s, and a day with two photographers on it is where that either holds or does not. A sixth is an afternoon rather than a day, laid over a photo already inside the second, because an occasion with hours in it has to say them and one filling a whole day has none to say.
Those dates are years ahead of today, in a stretch of calendar nobody has been to yet. The occasions on the wall are read from the real calendar over the window the wall shows, so a window over a year somebody has lived would bring their own occasions in alongside these and the colours would stop meaning what they are said to mean here.
One span below 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.
Closing an open photo is likewise not instant, and the wall underneath is not usable until the photo is gone rather than merely invisible — a sheet still lying over it takes the click meant for a tile. So going back to the wall is something to wait for, and the thing to wait for is the photo’s absence.
Typing the day in is only bearable for one photo. The dates that really go wrong go wrong by the dozen — an afternoon that came back through a messaging app, every copy stamped with the day it was saved — and there is no day to type, because the whole point is that nobody remembers which day it was. What is remembered is the occasion: the hike, the swim. So the second half of this session is a second pile with its own label, named onto an occasion rather than dated. Its own label, because the wall above rests on the stray being the only photo wearing no occasion, and four more bare tiles would have taken that signal away.
One of the pile is somebody else’s, a day apart from the rest so the wall can tell them apart, since a calendar belongs to one person and a move must leave everyone else alone. And two occasions join the six: a namesake of the hike months further off, so the list has two rows to put in order, and one with no photo at all, a year past the window the wall is showing — the kind you are most likely to be moving onto, and the kind a list built from the wall could never offer.
SAVE_GRACE_MS = 800
FIX_LABEL = "zzfixdate"
FIX_YEAR = datetime.date.today().year + 8
RANDO, SOMMET, PISCINE = "zzRando", "zzSommet", "zzPiscine"
AYLAS, MINE = "zzChezAyla", "zzChezMoi" # the same day, one each
GOUTER = "zzGouter" # an afternoon, not a day
RANDO_FAR = "zzRandoLoin" # a namesake of the hike, months further off
VIDE = "zzVide" # no photo, and a year past the wall's window
FIX_EVENTS = [
{"rowId": "zzfix-ev-rando", "summary": RANDO, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-01T00:00:00Z", "endtime": f"{FIX_YEAR}-06-10T23:59:59Z"},
{"rowId": "zzfix-ev-sommet", "summary": SOMMET, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-05T00:00:00Z", "endtime": f"{FIX_YEAR}-06-06T23:59:59Z"},
{"rowId": "zzfix-ev-piscine", "summary": PISCINE, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-20T00:00:00Z", "endtime": f"{FIX_YEAR}-06-30T23:59:59Z"},
{"rowId": "zzfix-ev-ayla", "summary": AYLAS, "owner": "aylapomme", "status": "confirmed",
"starttime": f"{FIX_YEAR}-08-01T00:00:00Z", "endtime": f"{FIX_YEAR}-08-02T23:59:59Z"},
{"rowId": "zzfix-ev-mine", "summary": MINE, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-08-01T00:00:00Z", "endtime": f"{FIX_YEAR}-08-02T23:59:59Z"},
{"rowId": "zzfix-ev-gouter", "summary": GOUTER, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-22T09:00:00Z", "endtime": f"{FIX_YEAR}-06-22T17:00:00Z"},
{"rowId": "zzfix-ev-randofar", "summary": RANDO_FAR, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-01-05T00:00:00Z", "endtime": f"{FIX_YEAR}-01-06T23:59:59Z"},
{"rowId": "zzfix-ev-vide", "summary": VIDE, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR + 1}-03-01T00:00:00Z", "endtime": f"{FIX_YEAR + 1}-03-02T23:59:59Z"},
]
STRAY_DAY = f"{FIX_YEAR}-07-15" # a month past every occasion — the one to put right
FIX_DOCS = [("06-02", "konubinix"), ("06-05", "konubinix"), ("06-08", "konubinix"),
("06-22", "konubinix"), ("06-25", "konubinix"),
("07-15", "konubinix"), # the stray
("08-01", "konubinix"), ("08-01", "aylapomme")] # the same day, two owners
def fix_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzfix-{i}", "date": f"{FIX_YEAR}-{md}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfix-{i}-t",
"labels": FIX_LABEL, "state": "todo", "owner": owner}
for i, (md, owner) in enumerate(FIX_DOCS)]
MOVE_LABEL = "zzfixmove"
MOVE_DOCS = [("11-20", "konubinix"), ("11-20", "konubinix"), ("11-20", "konubinix"),
("11-21", "aylapomme")]
def move_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzfixmv-{i}", "date": f"{FIX_YEAR}-{md}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfixmv-{i}-t",
"labels": MOVE_LABEL, "state": "todo", "owner": owner}
for i, (md, owner) in enumerate(MOVE_DOCS)]
def tile_of(page, day):
"""The tile of the photo taken on that day."""
return tiles(page).filter(has=page.get_by_alt_text(day, exact=True))
def pills_on(page, day):
"""The occasions written across the tile of the photo taken on that day."""
return tile_of(page, day).locator(".ev-pill")
def back_on_the_wall(page):
expect(dialog(page)).to_be_hidden()
@testcase
def test_fixing_a_date(page):
"""Spotting a photo whose date has drifted — it alone wears no occasion — then opening
it, reaching for the picker and backing out, putting the date right, and reading back
off the occasions it has rejoined that it now sits where it belongs. Then the same
trouble at a scale nobody types their way out of: a pile of copies all stamped the day
they were saved, named onto the occasion they came from instead."""
docs = fix_docs() + move_docs()
for d_ in docs: gql(DELETE, {"cid": d_["cid"]}); gql(CREATE, {"p": d_})
seed_events(FIX_EVENTS)
try:
open_app(page); chip(page, "all").click()
search_for(page, f"{FIX_LABEL}; since:{FIX_YEAR}; until:{FIX_YEAR}")
expect(tiles(page)).to_have_count(len(FIX_DOCS))
expect(grid(page).get_by_text(FIX_LABEL).first).to_be_visible()
print(" PASS: tiles show labels")
expect(pills_on(page, f"{FIX_YEAR}-06-02")).to_have_text([RANDO]) # the occasion, on the tile
print(" PASS: tiles show event pill")
BG = "el => getComputedStyle(el).backgroundColor"
hue = lambda day, name: pills_on(page, day).filter(has_text=name).evaluate(BG)
first = hue(f"{FIX_YEAR}-06-02", RANDO)
assert hue(f"{FIX_YEAR}-06-08", RANDO) == first, "one occasion should read as one band"
assert hue(f"{FIX_YEAR}-06-22", PISCINE) != first, "the next occasion should break it"
print(" PASS: event pills coloured per event")
at = lambda day: tile_of(page, f"{FIX_YEAR}-{day}")
lbl = at("06-02").get_by_text(FIX_LABEL).bounding_box()
pill = at("06-02").locator(".ev-pill").bounding_box()
assert pill["y"] > lbl["y"], f"the pill should come after the label: pill={pill['y']} label={lbl['y']}"
other = at("06-08").locator(".ev-pill").bounding_box() # the same occasion, further along
assert abs(other["y"] - pill["y"]) < 1, f"the band should be level: {pill['y']} vs {other['y']}"
print(" PASS: event pill sits below label")
both = pills_on(page, f"{FIX_YEAR}-06-05")
expect(both).to_have_text([RANDO, SOMMET]) # the week, and the two days within it
assert both.nth(0).evaluate(BG) != both.nth(1).evaluate(BG), "the two should be told apart"
print(" PASS: overlapping events show two pills")
shared = tile_of(page, f"{FIX_YEAR}-08-01")
expect(shared).to_have_count(2) # the same day, one photo each
expect(shared.nth(0).locator(".ev-pill")).to_have_count(1) # one occasion apiece…
expect(shared.nth(1).locator(".ev-pill")).to_have_count(1)
expect(grid(page).get_by_text(AYLAS, exact=True)).to_have_count(1) # …and it is their own
expect(grid(page).get_by_text(MINE, exact=True)).to_have_count(1)
print(" PASS: event pill owner scoped")
expect(pills_on(page, STRAY_DAY)).to_have_count(0) # the gap in the band: nothing was on that day
print(" PASS: out-of-event doc has no pill")
page.keyboard.press("d") # no doc open
tiles(page).filter(has_not=page.locator(".ev-pill")).click(click_count=2) # the one with the gap
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())}`; }",
f"{STRAY_DAY}T12:00:00Z")
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(f"{FIX_YEAR}-06-06T12:00") # the day it was really taken
box.press("Enter") # save
expect(d.get_by_text(RANDO)).to_be_visible() # the week it was taken during…
expect(d.get_by_text(SOMMET)).to_be_visible() # …and the two days within it
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
print(" PASS: lightbox shows events")
d.get_by_role("button", name="close").click() # back to the wall, for what comes next
back_on_the_wall(page)
print(" PASS: lightbox edit date rejoins its occasions")
tile_of(page, f"{FIX_YEAR}-06-22").click(click_count=2)
d = dialog(page)
when = page.evaluate("""([a, b]) => {
const s = new Date(a), en = new Date(b);
const t = x => x.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
return `${s.toLocaleDateString('fr-FR')} ${t(s)} – ${t(en)}`;
}""", [f"{FIX_YEAR}-06-22T09:00:00Z", f"{FIX_YEAR}-06-22T17:00:00Z"])
expect(d.get_by_text(when)).to_be_visible() # the day, and the hours of it
d.get_by_role("button", name="close").click()
back_on_the_wall(page)
print(" PASS: lightbox shows timed event hours")
mine = tile_of(page, f"{FIX_YEAR}-08-01").filter(has=page.locator(f'.ev-pill:text-is("{MINE}")'))
mine.click(click_count=2) # of the two that day, the one that is mine
d = dialog(page)
expect(d.get_by_text(MINE)).to_be_visible() # my account of that afternoon…
expect(d.get_by_text(AYLAS)).to_have_count(0) # …and not Ayla's, of the same one
print(" PASS: lightbox events are owner-scoped")
d.get_by_role("button", name=re.compile(MINE)).click() # the occasion in front of you
expect(search_box(page)).to_have_value(f"event:{MINE}") # the search typed out for you…
back_on_the_wall(page) # …and the photo stood aside for it
print(" PASS: lightbox event pill searches")
search_for(page, f"{MOVE_LABEL}; since:{FIX_YEAR}; until:{FIX_YEAR}")
expect(tiles(page)).to_have_count(len(MOVE_DOCS))
t = tiles(page)
t.nth(0).click(); t.nth(1).click(); t.nth(2).click() # the three that are mine
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.fill(""); box.press_sequentially(VIDE, delay=20)
expect(tb.get_by_role("option", name=re.compile(VIDE))).to_be_visible()
print(" PASS: move offers a photoless event")
box.fill(""); box.press_sequentially("zzChez", delay=20)
expect(tb.get_by_role("option", name=re.compile(MINE))).to_be_visible() # mine — offered
expect(tb.get_by_role("option", name=re.compile(AYLAS))).to_have_count(0) # hers — never shown
print(" PASS: move offers only selection-owner events")
box.fill(""); box.press_sequentially(RANDO, delay=20)
opts = tb.get_by_role("option")
expect(opts).to_have_count(2) # the hike and its namesake both answer to it
expect(opts.first).not_to_contain_text(RANDO_FAR) # the nearer of the two leads
print(" PASS: move ranks by mean closeness")
box.fill(""); box.press_sequentially(RANDO, delay=20)
expect(move_btn).to_be_disabled() # typed text is not a pick
tb.get_by_role("option").first.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
print(" PASS: move button armed only by a pick")
box.fill(""); box.press_sequentially(RANDO, delay=20)
opt = tb.get_by_role("option").first
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 shared highlight landed on it
box.press("Enter") # Enter picks the highlighted one → arms
expect(move_btn).to_be_enabled()
expect(box).to_have_value(RANDO) # the pick filled the box
print(" PASS: move box keyboard picks")
t.nth(0).click() # drop one tile → the selection changes
expect(box).to_have_value("") # the armed pick is dropped, the box cleared
expect(move_btn).to_be_disabled()
print(" PASS: move target resets on selection change")
select_all(page).click()
box.click(); box.press_sequentially(RANDO, delay=20)
tb.get_by_role("option").first.click() # the hike itself: the nearer of the two
move_btn.click()
expect(checks(page)).to_have_count(0) # applied → the selection clears
# they wear the occasion's start day now — the wall re-anchors, and a tile's alt is its day
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-01")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers stayed where it was saved
print(" PASS: move selection to event")
t.nth(0).click() # one of the three that are mine
tb.get_by_role("button", name=re.compile("all .* matching")).click()
box.click(); box.press_sequentially(PISCINE, delay=20)
tb.get_by_role("option").first.click()
move_btn.click()
expect(checks(page)).to_have_count(0)
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-20")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers, still where it was
print(" PASS: move is owner-scoped when widened")
finally:
drop_events(FIX_EVENTS)
for d_ in docs:
try: gql(DELETE, {"cid": d_["cid"]})
except Exception: pass
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.
And a run is worked without ever leaving it: once one photo is up, reaching the next must not cost a trip back to the wall to find it. The photo in front of you is what steps. And some of them are worth stopping on for their own sake — to read a sign in the background, or be sure whose face that is — so a photo takes two fingers as readily as any picture anywhere else.
One of them is sometimes worth more than a stop: worth putting up properly, which is what the frame is for. That is a tap from the photo already open rather than a trip back to the wall to start the show over, and coming out of it lands back on the photo you left, not at the beginning of everything.
Half of what makes the box bearable is what it offers, and the offers come from the vocabulary of an archive that is somebody’s real life — words that get renamed and rows that get pruned by people who have never heard of this session. So it brings its own three and takes them away after: one to complete onto the photo, one that must leave the list once the photo wears it, and one to take with the keyboard.
LABEL_WORDS = ["zzcalanque", "zzcigale", "zzcousin"]
@testcase
def test_putting_words_on_a_photo(page):
"""Working a run photo by photo: opening one, stepping to the next without leaving it,
handing one to the big show and stepping back out onto the photo, labelling — several
words at once, the keyboard for add and remove, the last word reused on the next, and
all of it written down — and getting close enough to read what is in one. Throughout,
the browser's own menu stays out of the way, except where the words are typed."""
seed_vocab(LABEL_WORDS)
try:
open_fixtures(page)
assert native_menu(page, tiles(page).nth(0)) is False, "a press on a tile should raise no menu"
print(" PASS: tile context menu suppressed")
assert native_menu(page, search_box(page)) is True, "the search box should keep its own menu"
print(" PASS: context menu allowed in text field")
tiles(page).nth(0).dblclick()
expect(dialog(page)).to_be_visible()
expect(dialog(page).get_by_role("img")).to_have_count(1) # the doc, full size
print(" PASS: double-click opens lightbox")
d = dialog(page)
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")
open_doc(page, 0)
img = d.get_by_role("img")
shown = img.bounding_box()
assert shown["width"] >= VIEWPORT["width"] * 0.9, f"media width {shown['width']} < 90% of viewport"
assert shown["height"] >= VIEWPORT["height"] * 0.65, f"media height {shown['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", "media crops instead of fitting whole"
print(" PASS: lightbox media fills screen")
link = d.locator(".lb-meta").get_by_role("link", name="original") # on the date row
expect(link).to_have_text("⤢")
expect(link).to_have_attribute("title", re.compile("full resolution.*new tab"))
expect(link).to_have_attribute("href", FIXTURES[0]["cid"]) # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
print(" PASS: lightbox links to original")
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")
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
d.get_by_role("button", name="previous photo").click() # back where we started
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
d.get_by_role("button", name="previous photo").click() # and off the front
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2") # → the last, not a dead stop
d.get_by_role("button", name="next photo").click() # off the end again
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
print(" PASS: lightbox prev/next")
over = img.bounding_box()
page.mouse.move(over["x"] + over["width"] / 2, over["y"] + over["height"] / 2)
page.keyboard.down("Shift")
page.mouse.wheel(0, 240) # one flick down…
page.mouse.wheel(0, 240) # …and its tail, still inside the deaf window
page.wait_for_timeout(250)
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") # one photo, not two
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")
page.wait_for_timeout(250)
page.mouse.wheel(0, 240) # no Shift → the panel's scroll, not ours
page.wait_for_timeout(250)
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
print(" PASS: lightbox shift-scroll nav")
asked = []
def note_leave(ask): asked.append(ask.message); ask.dismiss()
page.on("dialog", note_leave)
page.go_back()
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible()
page.remove_listener("dialog", note_leave)
assert not asked, f"closing a photo with Back asked to leave the app: {asked}"
print(" PASS: back button closes lightbox")
middle = FIXTURES[1]
open_doc(page, 1) # not the first, so where it opens says something
expect(img).to_have_attribute("src", middle["thumbnailCid"])
d.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(d).to_be_hidden() # the lightbox gives way to the show
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == middle["thumbnailCid"],
label="the show opens on the photo you were looking at",
detail=lambda: f"it opened on {strip.evaluate(CENTERED)!r}")
print(" PASS: frame from lightbox")
page.go_back() # out of the show…
expect(strip).to_be_hidden()
expect(d.get_by_role("img")).to_have_attribute("src", middle["thumbnailCid"]) # …onto the photo it came from
page.go_back() # out of the lightbox…
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible() # …back to the wall
print(" PASS: back from frame returns to lightbox")
open_doc(page, 0)
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")
open_doc(page, 0)
expect(d).to_be_visible()
box = d.get_by_placeholder("add a label…")
hold_completions(page) # the vocabulary, left unanswered
expanded_while_loading(page, box)
print(" PASS: lightbox label expanded while loading")
page.unroute("**/graphql") # the vocabulary answers again
box.press_sequentially("zzc")
expect(box).to_have_attribute("aria-expanded", "true")
box.press("Tab") # out of the box, the ordinary way
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "the popover outlived the focus"
print(" PASS: lightbox label combobox state")
box.fill(""); 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.fill(""); box.press_sequentially("zztest;zzcal", delay=20) # typed, as a user would
opt = options(page).filter(has_text=re.compile(r"^zzcalanque$")).first
expect(opt).to_be_visible()
opt.click()
expect(box).to_have_value("zztest;zzcalanque; ") # 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="zzcalanque", exact=True)).to_be_visible()
print(" PASS: lightbox completes last segment")
box.press_sequentially("zzcal", delay=20)
expect(options(page).filter(has_text=re.compile(r"^zzcalanque$"))).to_have_count(0)
box.fill(""); box.press_sequentially("zzcig", delay=20)
expect(options(page).filter(has_text=re.compile(r"^zzcigale$")).first).to_be_visible()
print(" PASS: lightbox completion skips present")
box.fill(""); box.press_sequentially("zzcou", delay=20)
expect(options(page).first).to_be_visible()
box.press("ArrowDown"); box.press("Enter") # apply the highlight → fills "zzcousin; "
expect(box).to_have_value("zzcousin; ")
box.press("Enter") # nothing highlighted now → commit
expect(d.get_by_role("button", name="zzcousin", exact=True)).to_be_visible() # chip added
print(" PASS: lightbox label keyboard")
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")
src0 = img.get_attribute("src")
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")
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")
open_doc(page, 0)
d.get_by_role("button", name="zzcalanque", exact=True).click()
expect(d).to_be_hidden() # the lightbox closes
expect(search_box(page)).to_have_value("zzcalanque") # the filter switched to that label
expect(tiles(page)).to_have_count(1) # the one photo wearing it
print(" PASS: lightbox chip filters")
open_doc(page, 0)
box.press_sequentially("zzcig", delay=20)
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
lb = listbox.bounding_box(); ib = box.bounding_box()
assert lb["y"] + lb["height"] <= ib["y"] + 1, f"the completion must open above the input: list {lb} input {ib}"
print(" PASS: lightbox completion opens upward")
area = img.bounding_box()
cx, cy = area["x"] + area["width"] / 2, area["y"] + area["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")
finally:
drop_vocab(LABEL_WORDS)
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.
A card dump is not all photographs, and a clip is the one thing on the wall that cannot be judged from a still: you have to watch some of it. So the run opens on one, and the keys that would step you along the row have to serve the clip first — space to start and stop it, the arrows to jump through it — right up until it has nothing left to show, at which point an arrow gives up and hands you the next doc. That is the only way a clip costs no more than a photo to say no to.
@testcase
def test_saying_no_quickly(page):
"""Running down a stack marking rejects: spotting the clip at the head of it from the
wall, watching it, then asking the first time, taking Shift for the rest, and staying
out of the way while you are typing."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE})
page.route("**" + VIDEO_FIXTURE["webCid"], 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, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
expect(grid(page).get_by_title("video")).to_have_count(1) # one of them, and only one, is badged
print(" PASS: video tiles are marked")
open_doc(page, 0) # the clip: earliest in the run
d = dialog(page)
v = d.locator("video") # no ARIA role exists for <video>
expect(v).to_be_visible()
assert VIDEO_FIXTURE["webCid"] in (v.get_attribute("src") or ""), "wrong video src"
print(" PASS: lightbox video")
v.evaluate("el => { el.muted = true; el.playbackRate = 0.1; 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 - 0.1") # running still, and all but over
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")
v.evaluate("el => el.currentTime = 0")
page.keyboard.press("ArrowRight") # → jumps 5s in
wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
page.keyboard.press("ArrowLeft") # ← jumps back to the head of it
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 0.25)
page.keyboard.press("ArrowLeft") # nothing behind it now → out of the clip
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[2]["thumbnailCid"]) # the run wraps back
page.keyboard.press("ArrowRight") # forward again, onto the clip…
page.keyboard.press("ArrowRight") # …and straight off it, since it is not running
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[0]["thumbnailCid"])
print(" PASS: lightbox video arrows")
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")
finally:
page.unroute("**" + VIDEO_FIXTURE["webCid"])
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
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.
RUN_N = len(FIXTURES) + 1 # the usual three, and one more to wrap on
@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, emptying the run closes it — and the photos are all still
there, under the chip you sent them to, which is where you come back to."""
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(RUN_N) # 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")
# the run is empty because they moved, not because they went
expect(tiles(page)).to_have_count(0) # the todo chip has nothing left
chip(page, "all").click()
expect(tiles(page)).to_have_count(RUN_N) # they are all still there
chip(page, "done").click()
expect(tiles(page)).to_have_count(RUN_N) # under the verdict you gave them
expect(grid(page).get_by_text("done")).to_have_count(RUN_N) # and their badges say so
print(" PASS: state filter")
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
expect(tiles(page)).to_have_count(RUN_N) # and the wall it was showing
print(" PASS: state filter persists")
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.
Before any of that, though, the wall has to be at a size you can work at, and which size that is depends on the job: picking a run out of a card-dump wants small tiles and a lot of them on screen, while telling two nearly-identical frames apart wants the opposite. So the size is yours to set, from the controls or from the hand already on the wall, and it stays set — a density chosen every time you open the app is a density you would stop choosing. Nor is the size the only thing that survives coming back: the run itself is still on the wall afterwards, so reopening costs you nothing you had set up, and the work carries on from where the size was settled.
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. Nor is the wall the only place a run gets picked up — half of it comes together while you are looking at the photos one at a time, so the open photo carries the same toggle, under the mouse and under the keys, and what it picks is the same set the wall is holding. 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.
A word is not the only thing a run wants in one go. A verdict and a date are the other two,
and all three face the same question of reach: sometimes what you ticked, sometimes every
photo the search matches, which on a card-dump is the difference between ticking forty tiles
and not. So the bar carries a switch between the two, and the words, the verdicts and the
date all obey it — a control that says all 400 matching and then edits the four you happened
to tick is worse than one that never offered the scope at all.
A run this size is also where the house’s wifi picks its moment, so the batch box is met
three times before it is used in anger. Once with a line that has gone dead, where the wall
says it is working in both the ways it has, then gives up and says the edit did not go
through, then takes that back when the write turns up after all. Once with a line that is
merely slow, where the edit is waited out to the end. And once with an archive that answers
and refuses. It is opened on ?editms=1000, so the
giving-up comes after a second rather than fifteen — a session that has to sit out the real
wait is one nobody would run.
The last of it belongs on a phone for two different reasons. The held drag has the browser’s own scroll to fight, and that fight only starts under a finger. And the run only has a fold to grow past on a wall taller than the screen. Neither is true of the wide window the rest of the session works at, or of three photos. So it goes to a narrow window and a wall of sixty, wound down to a contact sheet: small tiles put several in a row, so the drag can cross one without wandering into the margin where the wall starts scrolling under it, and sixty of them still run several screens deep for the drag that means to. The finger it uses is Chromium’s own rather than a mouse pretending: the veto that keeps the drag from scrolling answers to touch and would let a mouse straight through, so a mouse would prove nothing about it.
DRAG_LABEL = "zzdrag"
DRAG_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzdrag-{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/zzdrag-t-{i}",
"labels": DRAG_LABEL, "state": "todo"} for i in range(60)]
DRAG_VIEWPORT = {"width": 360, "height": 700}
The refining wants a third wall again, and a wide short one: wide so a contact sheet has somewhere to be dense, short so it overflows and there are tiles below the fold to leave alone. Eighty of them, because the reading is about what is not bought — the far end of the wall and the video among them, which keeps its poster rather than pulling a film onto a contact sheet.
The slider runs from eighty to three hundred and twenty in steps of forty, so six presses cross it either way. The session arrives here having sized the wall for its own reasons, so it winds all the way down before it starts rather than assuming where it was left.
REFINE_LABEL = "zzref"
REFINE_DOCS = ([{"cid": "https://ipfs.konubinix.eu/p/zzref-vid", "date": "2020-12-31T12:00:00Z", "mimetype": "video/mp4",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzref-t-vid", "webCid": "https://ipfs.konubinix.eu/p/zzref-web-vid",
"labels": REFINE_LABEL, "state": "todo"}]
+ [{"cid": f"https://ipfs.konubinix.eu/p/zzref-{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/zzref-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzref-web-{i}", "labels": REFINE_LABEL, "state": "todo"}
for i in range(1, 80)])
REFINE_VIEWPORT = {"width": 1000, "height": 420}
SIZE_STEPS = 6 # 80 to 320 in forties: the whole slider
REFINE_GRACE_MS = 600 # ample room for a refinement to have fired
def grow_the_wall(page):
"""Take the slider all the way up, and wait for the columns to be drawn past 300px."""
for _ in range(SIZE_STEPS): 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")
The ordering between the two passes wants a wall with one thumbnail that never lands, and room on screen for the others to settle around it — so a dozen on a window tall enough to hold a grown screenful of them.
WAIT_LABEL = "zzwait"
WAIT_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzwait-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzwait-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzwait-web-{i}", "labels": WAIT_LABEL, "state": "todo"} for i in range(12)]
WAIT_HELD = "zzwait-t-0" # the one the gateway never answers for
WAIT_VIEWPORT = {"width": 1000, "height": 700}
And the moving-forward wants the opposite shape: a narrow window, so grown tiles fall into one tall column and scrolling brings them into reach one at a time, with the stuck one far enough down the column that several have already sharpened above it. A second, smaller wall waits under another word, for the search that swaps the first out from under it.
FWD_LABEL, FWD_LABEL_AFTER = "zzfwd", "zzfwd2"
FWD_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzfwd-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfwd-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzfwd-web-{i}", "labels": FWD_LABEL, "state": "todo"} for i in range(8)]
FWD_DOCS_AFTER = [{"cid": f"https://ipfs.konubinix.eu/p/zzfwd2-{i}", "date": f"2021-02-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfwd2-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzfwd2-web-{i}", "labels": FWD_LABEL_AFTER, "state": "todo"}
for i in range(3)]
FWD_HELD = "zzfwd-t-4"
FWD_VIEWPORT = {"width": 480, "height": 700} # one tall column once the tiles are grown
@testcase
def test_doing_one_thing_to_forty_photos(page):
"""Taking a run in hand: set the wall to a size you can work at, pick the run up without
the wall moving — off the wall and from inside an open photo — watch one edit die on a
bad connection — given up on, then taken back — one waited out on a slow one and one
refused outright, then put a word on all of it and take it off again, by button and by
key — and a verdict, and a day, each across the ticked set and across the whole
search, which stops where the search does when the wall is shared — then the same
run on a screen so short the box's list comes down over the bar, and a run of sixty
picked up on a phone, by a finger that drags and a
finger that drags past the fold, and a wall of eighty at both ends of the size
slider, buying nothing while it is a contact sheet and only what you can see once
it is not — and two more walls with a thumbnail stuck on the wire, to see the
sharpening wait for it and then go on without ever going back."""
page.set_viewport_size({"width": 1600, "height": 800}) # wide enough that every step re-columns
launch = "?editms=1000"
open_fixtures(page, launch)
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)
w1 = t.nth(0).bounding_box()["width"]
open_app(page, launch)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # and the run is still the run
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)
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)
print(" PASS: thumbnail size adjustable, persisted, ctrl-scrollable one step per burst")
expect(tiles(page)).to_have_count(len(FIXTURES)) # the run is still the run
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")
# the run gets picked up whole, six ways, each starting from nothing picked
clear_sel = toolbar(page).get_by_role("button", name="clear", exact=True)
clear_sel.click()
select_all(page).click()
expect(checks(page)).to_have_count(n) # every tile shows its ✓
select_all(page).click()
expect(checks(page)).to_have_count(0) # second tap clears
print(" PASS: select all")
t.nth(0).click() # anchor on the first tile
t.nth(n - 1).click(modifiers=["Shift"]) # extend the selection to the last
expect(checks(page)).to_have_count(n) # the ones between are roped in too
print(" PASS: range select")
clear_sel.click()
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(n - 1).click() # a plain tap now extends the run
expect(checks(page)).to_have_count(n)
expect(rng).to_have_attribute("aria-pressed", "false") # disarmed after extending
print(" PASS: range select (touch)")
clear_sel.click()
press_and_hold(page, t.nth(0)); page.mouse.up() # held past the threshold, then let go
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(n - 1).click() # a plain tap completes the range
expect(checks(page)).to_have_count(n)
print(" PASS: long-press arms range")
clear_sel.click()
press_and_hold(page, t.nth(0)) # held past the threshold → armed at the anchor
expect(checks(page)).to_have_count(1) # just the anchor so far
drag_onto(page, t.nth(n - 1))
expect(checks(page)).to_have_count(n) # 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")
clear_sel.click()
press_and_hold(page, t.nth(0)) # armed at the anchor again
drag_onto(page, t.nth(n - 1))
expect(checks(page)).to_have_count(n) # grew to the whole run…
drag_onto(page, t.nth(n - 2))
expect(checks(page)).to_have_count(n - 1) # …then coming back drops the far tile
page.mouse.up()
print(" PASS: long-press drag shrinks on return")
clear_sel.click()
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(n)
print(" PASS: marquee selects")
clear_sel.click()
t.nth(1).click() # back to the one pick the run carries on from
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")
sel.click()
expect(sel).to_have_attribute("aria-pressed", "true") # now selected
expect(checks(page)).to_have_count(2) # and the wall says so under the modal
d.get_by_role("button", name="close").click()
expect(checks(page)).to_have_count(2) # the set the wall goes on holding
print(" PASS: lightbox select")
tb = toolbar(page)
dropped, dead = [], [True]
def drop_writes(route):
if dead[0] and "updatePhotovideo" in (route.request.post_data or ""): # the house's wifi
dropped.append(route); return
route.fallback()
page.route("**/graphql", drop_writes)
tb.get_by_placeholder("add a label…").fill("zzdropped")
tb.get_by_role("button", name="add label").click()
expect(grid(page)).to_have_attribute("aria-busy", "true") # busy during the WRITE phase
print(" PASS: wall busy during edit")
BG = "el => getComputedStyle(el).backgroundColor"
working = page.get_by_text("updating…")
expect(working).to_be_visible()
pending = working.evaluate(BG) # the colour of a wait, to tell a failure from
print(" PASS: wall shows updating feedback")
failed = page.get_by_role("status")
expect(failed).to_have_text("the edit did not go through — try again")
expect(page.get_by_text("updating…")).to_have_count(0) # it stopped claiming to work…
expect(grid(page)).to_have_attribute("aria-busy", "false") # …to everyone, not just the eye
assert failed.evaluate(BG) != pending, "a failed edit wears the colour of one still going"
print(" PASS: a write that never answers is given up on")
dead[0] = False # the connection comes back
for r in dropped: r.fallback() # the write we had written off goes out after all
expect(page.get_by_role("status")).to_have_count(0) # the app stops saying it failed…
expect(checks(page)).to_have_count(0) # …because it did not: the edit landed
expect(grid(page)).to_have_attribute("aria-busy", "false")
print(" PASS: a write that lands late takes the notice back")
# and once more, on a line that is merely slow
stalled = []
def stall_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""): stalled.append(route)
else: route.fallback()
page.route("**/graphql", stall_writes)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("zzpatient")
tb.get_by_role("button", name="add label").click()
for i in range(len(FIXTURES)):
wait_until(page, lambda: len(stalled) > i, label="the next write went out")
page.wait_for_timeout(700) # late, but inside the second
expect(page.get_by_text("the edit did not go through — try again")).to_have_count(0)
stalled[i].fallback() # …and it answers
expect(checks(page)).to_have_count(0) # the whole edit landed: the selection cleared…
expect(grid(page)).to_have_attribute("aria-busy", "false") # …and the wall has read it back
print(" PASS: a slow edit is waited out")
page.unroute("**/graphql", stall_writes)
# and once more, on an archive that answers and refuses
def refuse_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""):
route.fulfill(status=500, body="no", content_type="text/plain")
else: route.fallback()
page.route("**/graphql", refuse_writes)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("zzrefused")
tb.get_by_role("button", name="add label").click()
expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again")
expect(page.get_by_text("updating…")).to_have_count(0)
expect(grid(page)).to_have_attribute("aria-busy", "false")
expect(tb.get_by_placeholder("add a label…")).to_have_value("zzrefused") # ready to try again
print(" PASS: a refused edit says so")
page.unroute("**/graphql", refuse_writes)
select_all(page).click() # a refused run is still in hand: drop it
select_all(page).click()
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)
t.nth(0).click() # just one, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click() # widen the scope
tb.get_by_role("button", name="done").click()
expect(grid(page).get_by_text("done")).to_have_count(n) # all of them, off one tick
print(" PASS: batch all-matching state")
t.nth(0).click(); t.nth(1).click() # two of the three
tb.get_by_role("button", name="next", exact=True).click()
expect(grid(page).get_by_text("next")).to_have_count(2) # the ticked ones move…
expect(grid(page).get_by_text("done")).to_have_count(1) # …and the third keeps what it had
print(" PASS: batch set state")
t.nth(0).click()
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill("zzbulkall")
tb.get_by_role("button", name="add label").click()
search_for(page, "zzbulkall") # all of them carry it now
expect(tiles(page)).to_have_count(n)
expect(grid(page).get_by_text("zzbulkall")).to_have_count(n) # and the wall reads it back
print(" PASS: batch all-matching label")
COMMA = "zzleft, zzright" # one ;-token, with a comma inside it
search_for(page, FIXTURE_LABEL)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(COMMA)
tb.get_by_role("button", name="add label").click() # the ticked path ;-joins it on
expect(checks(page)).to_have_count(0) # applied, so the box has been emptied
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(n) # it went on as one word
select_all(page).click()
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill(COMMA)
tb.get_by_role("button", name="remove label").click() # the wide path, through the archive
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(0) # the whole word came off, not its halves
print(" PASS: all-matching removes comma label")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n) # three days months apart
t.nth(0).click(); t.nth(1).click() # two of them
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2018-06-15T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the selection cleared, so the bar goes
search_for(page, FIXTURE_LABEL + "; date:2018-06-15") # the ticked ones land together…
expect(tiles(page)).to_have_count(2)
search_for(page, FIXTURE_LABEL + "; date:2020") # …and the third keeps the day it had
expect(tiles(page)).to_have_count(1)
print(" PASS: batch date stamps the selection")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
t.nth(0).click() # one tick, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2016-03-04T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the scope let go, so the bar goes
search_for(page, FIXTURE_LABEL + "; date:2016-03-04") # every one of them, off one tick
expect(tiles(page)).to_have_count(n)
print(" PASS: batch date all-matching")
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")
search_for(page, "zzbulkall") # the run, by the word that reached all of it
seed_vocab(COMBO_WORDS)
try:
select_all(page).click()
box = tb.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzcombo")
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")
finally:
drop_vocab(COMBO_WORDS)
clear_sel.click() # nothing edited, so put the run down by hand
select_all(page).click()
hold_completions(page)
expanded_while_loading(page, tb.get_by_placeholder("add a label…"))
print(" PASS: batch label expanded while loading")
# …and the same widening on a wall two people share, where it must stop at his
page.unroute("**/graphql") # the vocabulary answers again
for d in OWN_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, f"{OWN_LABEL}; owner:konubinix")
expect(tiles(page)).to_have_count(1) # his, of the two
tiles(page).nth(0).click() # one pick, to bring the bar up
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="done").click()
wait_until(page, lambda: own_count("konubinix", "done") == 1,
label="his photo took the verdict")
assert own_count("aylapomme", "todo") == 1, "the widening reached past the owner filter"
print(" PASS: bulk all-matching respects owner")
finally:
for d in OWN_DOCS:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
# …and the same run on a screen too short to hold both the bar and the box's list
seed_vocab(OVERLAP_WORDS)
try:
page.set_viewport_size(OVERLAP_VIEWPORT)
open_fixtures(page)
tiles(page).nth(0).click() # one pick → the bar comes up
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20)
expect(options(page).first).to_be_visible() # the list is up
expect(tb).to_be_visible() # and the pick still holds the bar up under it
bar = tb.bounding_box()
sug = page.get_by_role("listbox", name="suggestions").bounding_box()
lo, hi = max(bar["y"], sug["y"]), min(bar["y"] + bar["height"], sug["y"] + sug["height"])
assert hi > lo, f"setup: bar {bar} and list {sug} do not meet — there is nothing to see"
hit = page.evaluate( # what a finger at the overlap would actually land on
"([x,y]) => { const el = document.elementFromPoint(x,y);"
" return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }",
[bar["x"] + bar["width"] / 2, (lo + hi) / 2])
assert hit["list"] and not hit["bar"], f"the bar is in front of the list where they meet: {hit}"
print(" PASS: search completion sits above the selection toolbar")
finally:
drop_vocab(OVERLAP_WORDS)
# …and a run of sixty, on the phone these two gestures were designed for
for d in DRAG_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size(DRAG_VIEWPORT)
page.unroute("**/graphql") # the vocabulary answers again
open_app(page)
for _ in range(SIZE_STEPS): # down to a contact sheet: several tiles to a row
page.get_by_role("button", name="smaller thumbnails").click()
chip(page, "all").click(); search_for(page, DRAG_LABEL)
expect(tiles(page)).to_have_count(len(DRAG_DOCS))
t = tiles(page)
a = t.nth(0).bounding_box(); end = t.nth(1).bounding_box() # its neighbour, wherever the columns fall
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(LONG_PRESS_MS) # hold past the long-press → arms range
for f in (0.25, 0.5, 0.75, 1.0): # drag off the anchor onto the next tile
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(2) # the run followed the finger, not one lonely anchor
print(" PASS: long-press drag (touch)")
toolbar(page).get_by_role("button", name="clear", exact=True).click()
m = len(DRAG_DOCS)
last = t.nth(m - 1).bounding_box()
xl = last["x"] + last["width"] / 2 # the last tile's column; its row is below the fold
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(LONG_PRESS_MS) # 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": DRAG_VIEWPORT["height"] - 10}]})
wait_until(page, lambda: checks(page).count() == m, # the wall scrolls the rest under the finger
label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{m} selected")
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(m)
print(" PASS: long-press drag auto-scrolls")
finally:
for d in DRAG_DOCS: gql(DELETE, {"cid": d["cid"]})
# …and a wall of eighty, to see what the size slider costs at either end of itself
for d in REFINE_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size(REFINE_VIEWPORT)
open_app(page)
for _ in range(SIZE_STEPS): # wind it down to a contact sheet
page.get_by_role("button", name="smaller thumbnails").click()
fetched = watch_fetches(page) # from here on, everything the wall asks for
web = lambda: [u for u in fetched() if "-web-" in u]
search_for(page, REFINE_LABEL)
expect(tiles(page)).to_have_count(len(REFINE_DOCS))
assert cell_px(page) <= 300, f"a contact sheet should be drawn under 300px, and this is {cell_px(page)}px"
page.mouse.move(REFINE_VIEWPORT["width"] / 2, REFINE_VIEWPORT["height"] / 2)
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"] < REFINE_VIEWPORT["height"], \
"the wall never overflowed — nothing was scrolled into reach"
assert not web(), f"the dense wall fetched {len(web())} web renditions"
print(" PASS: a dense wall buys thumbnails and nothing else")
tiles(page).first.scroll_into_view_if_needed()
grow_the_wall(page)
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"
print(" PASS: a grown wall trades up, the thumbnail staying under it")
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()}"
print(" PASS: a grown wall buys narrowly")
finally:
for d in REFINE_DOCS: gql(DELETE, {"cid": d["cid"]})
# …and a wall with one thumbnail the gateway never answers for
for d in WAIT_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
page.route(f"**/ipfs/{WAIT_HELD}", lambda route: None) # left hanging, never answered
try:
page.set_viewport_size(WAIT_VIEWPORT)
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
open_app(page)
search_for(page, WAIT_LABEL)
expect(tiles(page)).to_have_count(len(WAIT_DOCS))
grow_the_wall(page)
expect(thumb_imgs(page).first).to_have_attribute("src", f"/ipfs/{WAIT_HELD}") # asked for…
assert not [u for u in fetched() if u.endswith(WAIT_HELD)], "…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(REFINE_GRACE_MS)
assert not web(), f"the wall refined with a thumbnail still in flight: {web()}"
print(" PASS: the wall waits for its thumbnails before refining")
finally:
page.unroute(f"**/ipfs/{WAIT_HELD}")
for d in WAIT_DOCS: gql(DELETE, {"cid": d["cid"]})
# …and the same trouble down a column, where some of it has already sharpened
for d in FWD_DOCS + FWD_DOCS_AFTER: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
page.route(f"**/ipfs/{FWD_HELD}", lambda route: None)
try:
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(FWD_VIEWPORT)
open_app(page)
search_for(page, FWD_LABEL)
expect(tiles(page)).to_have_count(len(FWD_DOCS))
grow_the_wall(page)
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(REFINE_GRACE_MS)
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)}"
print(" PASS: a late thumbnail holds back what arrives behind it")
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)}"
print(" PASS: a tile left behind drops both its pictures")
search_for(page, FWD_LABEL_AFTER) # swapped out from under a thumbnail in flight
expect(tiles(page)).to_have_count(len(FWD_DOCS_AFTER))
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)}")
print(" PASS: a wall swapped out from under a thumbnail refines again")
finally:
page.unroute(f"**/ipfs/{FWD_HELD}")
for d in FWD_DOCS + FWD_DOCS_AFTER: gql(DELETE, {"cid": d["cid"]})
Hunting for a photo you half-remember
You know the photo exists and you know almost nothing that would find it. It was summer, at the Fête de la musique. It was one of mine, not Ayla’s. Five or six years back, and it might have been a video. Somebody typed a word on it at the time and you could not swear which. That is the whole of what you have.
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 — a word half-typed on it, the occasion it was, whose it was, what kind of thing, what month, what year, what day — and they combine, so a hunt is a funnel rather than a single lucky query. Some of them are not on the photo at all: an occasion is the calendar’s, and the calendar remembers when things were even where nobody wrote it down.
The set below is built so each guess in turn has something to cut away: six of them, five in June and spread over three years, one taken in March, one of the Junes somebody else’s, and one of them a video rather than a photo. Those years are counted back from today rather than written down, because “five or six years back” is the memory being worked from, and a memory pinned to a year on the page stops being that memory the year after it is written.
Each also carries a word somebody typed onto it at the time — zzsummer or zzholiday
for the June ones, zzsnow for the March — since half of hunting is not remembering
which word you used.
The calendar holds three occasions over these photos. The first is real and mine, running from the 18th of that June to the exact instant of the photo on the 25th, so it covers a photo of mine and one of Ayla’s and ends on a boundary rather than safely past it; it is spelt with accents, because that is how a calendar spells things and not how anybody searches. The second was called off, and sits over the June of the year after, where it covers a photo of mine that ought therefore to be beyond its reach. The third is over the March one and has nothing to fold, which is what lets it stand for the occasions a bare word can reach.
All of that is a hunt where the year is one of the things remembered. The harder shape is the one where it is not: before the 14th of July, around this time of year. Both name a position in the year and leave the year itself open, so both need photos the hunt above cannot supply — one pair straddling the last 14th of July, and three sharing a few days of the calendar across three widely separated years. One more is taken today, for the bound that is never typed. They carry labels of their own, so the counts the hunt has been narrowing stay what they were.
HUNT_LABEL = "zzhunt"
HUNT_BACK = 5 # "five or six years back", from today
HUNT_YEAR = datetime.date.today().year - HUNT_BACK
HUNT_DOCS = [
# (year, month-day, owner, the word it got, what it is, its draw) — June across three
# years, a March, one that is not mine, and one that is not a photo at all. The lowest
# draw goes to the latest date, so the shuffle opens on the photo the dates put last.
(HUNT_YEAR - 1, "06-15", "konubinix", "zzsummer", "image/jpeg", 0.9),
(HUNT_YEAR, "06-15", "konubinix", "zzholiday", "image/jpeg", 0.8),
(HUNT_YEAR + 1, "06-10", "konubinix", "zzsummer", "image/jpeg", 0.1),
(HUNT_YEAR, "03-20", "konubinix", "zzsnow", "image/jpeg", 0.6),
(HUNT_YEAR, "06-20", "aylapomme", "zzsummer", "image/jpeg", 0.5),
(HUNT_YEAR, "06-25", "konubinix", "zzholiday", "video/mp4", 0.4),
]
def hunt_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}", "date": f"{y}-{md}T12:00:00Z", "mimetype": kind,
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}-t", "labels": f"{HUNT_LABEL} {word}",
"state": "todo", "owner": owner, "myrandom": draw}
for i, (y, md, owner, word, kind, draw) in enumerate(HUNT_DOCS)]
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
HUNT_EVENT = "zzFêteDeLaMusique" # as the calendar spells it
HUNT_EVENT_TYPED = "zzfetedelamusique" # as somebody in a hurry types it
PLAIN_EVENT = "zzBrocante" # an occasion with nothing to fold
HUNT_EVENTS = [
# the occasion, ending at the very instant of the last photo of mine inside it
{"rowId": "zzhunt-ev", "summary": HUNT_EVENT, "owner": "konubinix", "status": "confirmed",
"starttime": f"{HUNT_YEAR}-06-18T00:00:00Z", "endtime": f"{HUNT_YEAR}-06-25T12:00:00Z"},
# and one that was called off, over the June of the year after
{"rowId": "zzhunt-ev-off", "summary": "zzAnnulé", "owner": "konubinix", "status": "cancelled",
"starttime": f"{HUNT_YEAR + 1}-06-09T00:00:00Z", "endtime": f"{HUNT_YEAR + 1}-06-11T23:59:59Z"},
# a third, plainly spelt, over the March one
{"rowId": "zzhunt-ev-plain", "summary": PLAIN_EVENT, "owner": "konubinix", "status": "confirmed",
"starttime": f"{HUNT_YEAR}-03-19T00:00:00Z", "endtime": f"{HUNT_YEAR}-03-21T23:59:59Z"},
]
# the guesses that carry no year at all, each with a set of its own
WHEN_JULY = "zzhuntjuly" # two photos either side of one 14th of July
WHEN_LAST = "zzhuntlast" # either side of the 31st of December last gone
WHEN_ANNIV = "zzhuntanniv" # the same few days of the year, three years apart
WHEN_TODAY = "zzhunttoday" # one taken today, for the bound nobody ever types
def july_year():
t = datetime.date.today() # the 14th of July last gone: this year's if it has been
return t.year - (0 if (t.month, t.day) >= (7, 14) else 1)
def when_docs():
t, y = datetime.date.today(), july_year()
out = [{"cid": "https://ipfs.konubinix.eu/p/zzwhen-before", "date": f"{y}-07-10T12:00:00Z", "labels": WHEN_JULY},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-after", "date": f"{y}-07-20T12:00:00Z", "labels": WHEN_JULY},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-yearago", "date": f"{t.year - 1}-12-30T12:00:00Z", "labels": WHEN_LAST},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-sincethen", "date": f"{t.year}-01-05T12:00:00Z", "labels": WHEN_LAST},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-today", "date": t.isoformat() + "T12:00:00Z", "labels": WHEN_TODAY}]
for i, (off, yr) in enumerate([(0, 2010), (1, 2015), (5, 2018)]):
when = (t + datetime.timedelta(days=off)).replace(year=yr)
out.append({"cid": f"https://ipfs.konubinix.eu/p/zzwhen-anniv-{i}", "date": when.isoformat() + "T12:00:00Z",
"labels": WHEN_ANNIV})
for d in out:
d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
"state": "todo", "owner": "konubinix"})
return out
def seed_events(events):
for e in events: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
def drop_events(events):
for e in events:
try: gql(CAL_DEL, {"id": e["rowId"]})
except Exception: pass
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 hunt_query(*tokens):
return "; ".join([HUNT_LABEL, *tokens])
def narrow(page, *tokens):
search_for(page, hunt_query(*tokens))
@testcase
def test_hunting_for_a_photo(page):
"""Finding a photo from what memory kept: nothing at all, so the wall is shuffled; then
a word, an occasion, whose it was, what kind of thing, what month, what year, what day
— up to and including today — each guess cutting the wall down, and one taken back
giving its share straight back."""
docs = hunt_docs() + when_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
seed_events(HUNT_EVENTS)
try:
open_app(page); chip(page, "all").click()
narrow(page)
expect(tiles(page)).to_have_count(len(HUNT_DOCS)) # nothing guessed yet
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-0-t") # by date, oldest first
search_box(page).fill(hunt_query("sort:random"))
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-2-t") # by the draw: the latest date
print(" PASS: sort random")
narrow(page, "zzsummer zzholiday")
expect(tiles(page)).to_have_count(0) # both words at once: nothing carries both
narrow(page, "zzsummer")
expect(tiles(page)).to_have_count(3) # only what got that word
narrow(page, "zzsummer or zzholiday")
expect(tiles(page)).to_have_count(5) # either word will do — every June
narrow(page, "-zzholiday")
expect(tiles(page)).to_have_count(4) # whatever it was, it was not that
print(" PASS: a word half-remembered, or ruled out")
narrow(page, f"event:{HUNT_EVENT_TYPED}") # typed flat; the calendar's is accented
expect(tiles(page)).to_have_count(1) # mine inside it — not Ayla's, standing right there
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-25") # the one on the last instant
narrow(page, f"-event:{HUNT_EVENT_TYPED}") # everything but that occasion
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1)
narrow(page, "event:zzannule")
expect(tiles(page)).to_have_count(0) # called off, so nothing was taken during it
print(" PASS: filter by occasion")
search_for(page, PLAIN_EVENT.lower()) # no token, no label beside it, just the name
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-03-20") # never labelled with it
print(" PASS: a bare word reaches the calendar")
narrow(page, "type:video")
expect(tiles(page)).to_have_count(1) # the one that moves
narrow(page, "type:image")
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1) # and everything that doesn't
print(" PASS: filter by kind")
narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5) # everyone else's set aside
expect(page.get_by_text("showing all 5")).to_be_visible() # …and the tally set them aside too
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")
for recent in ["1 week ago", "1 month ago", "7 days ago", "6 months ago",
"1 year ago", "3 weeks ago", "lastyear", "1 day ago"]:
narrow(page, "owner:konubinix", f"since:{recent}")
expect(tiles(page)).to_have_count(0) # nothing of mine is that recent
narrow(page, "owner:konubinix", f"since:{HUNT_BACK * 4} years ago")
expect(tiles(page)).to_have_count(5) # far enough back to reach every one of mine
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_BACK} years ago")
kept = thumb_imgs(page).evaluate_all("els => els.map(e => e.alt)")
assert f"{HUNT_YEAR + 1}-06-10" in kept, f"the June after the reach should have survived it: {kept}"
assert f"{HUNT_YEAR - 1}-06-15" not in kept, f"the June before the reach should not have: {kept}"
print(" PASS: a distance back stands in for a date")
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(3) # the June before that year is gone
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}", f"until:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(2) # and so is the one after it
print(" PASS: bounded from both ends")
NO_READ_MS = 500 # every read in this set is back well inside it
asked = len(SETTLED_READS)
search_box(page).fill(hunt_query("owner:konubinix", "month:6", f"year:{HUNT_YEAR}"))
page.get_by_role("button", name="run search").click() # asked, with no answer to wait for
page.wait_for_timeout(NO_READ_MS)
assert len(SETTLED_READS) == asked, "year: went and asked for the answer already on screen"
expect(tiles(page)).to_have_count(2) # and the pair's Junes never moved
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", f"date:{HUNT_YEAR}-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", f"date:{HUNT_YEAR}-06-15") # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-15") # the one asked for
print(" PASS: filter by date")
search_for(page, WHEN_TODAY) # no until: token, so the default bound decides
expect(tiles(page)).to_have_count(1) # this morning's photo is inside it
print(" PASS: today photos shown by default")
for spelling in ("07-14", "july-14"):
search_for(page, WHEN_JULY)
expect(tiles(page)).to_have_count(2) # both, with nothing said about when
search_for(page, f"{WHEN_JULY}; until:{spelling}")
expect(tiles(page)).to_have_count(1) # only the 10th is inside the bound
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-before-t")
search_for(page, f"{WHEN_JULY}; since:july") # the whole of that July, both back
expect(tiles(page)).to_have_count(2)
search_for(page, WHEN_LAST)
expect(tiles(page)).to_have_count(2)
search_for(page, f"{WHEN_LAST}; until:12-31") # the one last gone, not the one to come
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-yearago-t")
print(" PASS: bare month and day bounds")
search_for(page, f"{WHEN_ANNIV}; onthisday")
expect(tiles(page)).to_have_count(2) # today's day and the one after it
search_for(page, f"{WHEN_ANNIV}; onthisday:5")
expect(tiles(page)).to_have_count(3) # and the one five days out
print(" PASS: onthisday")
finally:
drop_events(HUNT_EVENTS)
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
Letting the box do the typing
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.
The same holds for everything else that can go in the box, for a different reason: the language’s own keys, the handful of values a key will take, the days of a year. Those you could recall perfectly well and still not want to type, because typing them exactly is work and getting them wrong is silent. Either way the box knows what belongs there better than the hand does, so it offers rather than waits — and what it offers goes in as written down, whether it was half-remembered or merely tedious.
There is one more vocabulary, and it is not the box’s at all: the names of the occasions in the calendar, which nobody ever chose for the archive and which are therefore the least likely of anything here to be recalled exactly. They complete like the rest.
The half-memory case is the demanding one, since 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.
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.
Two of these vocabularies need setting up, and the keys and closed values and days of a year do not — the box carries those with it or works them out.
Eight labels, covering four ways a label can be hard to reach and two properties the list itself has to have. Two are the shapes named above, a word with its distinctive part buried in the middle and a word spelt with a capital and an accent. A third is several words long, because a label is allowed spaces and half of one is a likelier memory than the whole. A fourth carries the beginning of one of the language’s own keys inside it, so a label and a key really do compete for the same fragment. The last four are there for the list: 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",
"zzparc des oiseaux", "zzsinge"]
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
And four occasions, which take more arranging. The list of them is drawn from the calendar rather than from anything this note owns, and a real calendar is full — so these are put years ahead of today, in a stretch nobody has been to yet, where the only occasions are the ones placed here. That is also why every guess at an occasion below begins by naming the window: the list is scoped to the stretch of time the query is already about, and it comes back eight rows at most — so a window over a real year of somebody’s life fills all eight with their own occasions and leaves no room for the ones placed here.
Three of the four have a photo taken during them, since an occasion is only worth offering if picking it would show you something, and the fourth deliberately has none. One of the three is named in several words, for the same reason a label is. The last sits two years further out with a photo of its own, so that what leaves it out of a list is the window and not the want of a photo.
EV_YEAR = datetime.date.today().year + 7 # far enough ahead that the calendar is empty there
EV_FAR = EV_YEAR + 2 # and outside any window naming EV_YEAR
EV_WINDOW = f"since:{EV_YEAR}; until:{EV_YEAR}; " # the stretch every guess below is about
EV_KERMESSE, EV_BROCANTE = "zzKermesse", "zzBrocante du Parc"
EV_PHOTOLESS, EV_ELSEWHERE = "zzSansPhoto", "zzHorsFenetre"
def _ev(row, summary, y, mo, has_photo=True):
e = {"rowId": row, "summary": summary, "owner": "konubinix", "status": "confirmed",
"starttime": f"{y}-{mo}-01T00:00:00Z", "endtime": f"{y}-{mo}-02T23:59:59Z"}
p = has_photo and {"cid": f"/ipfs/{row}-p", "date": f"{y}-{mo}-01T12:00:00Z",
"thumbnailCid": f"/ipfs/{row}-p-t", "owner": "konubinix",
"mimetype": "image/jpeg", "labels": "zzevp", "state": "todo"}
return e, p
OCCASIONS = [_ev("zzev-kerm", EV_KERMESSE, EV_YEAR, "06"),
_ev("zzev-broc", EV_BROCANTE, EV_YEAR, "07"),
_ev("zzev-none", EV_PHOTOLESS, EV_YEAR, "08", has_photo=False),
_ev("zzev-far", EV_ELSEWHERE, EV_FAR, "06")]
def seed_occasions():
seed_events([e for e, _ in OCCASIONS])
for _, p in OCCASIONS:
if p: gql(DELETE, {"cid": p["cid"]}); gql(CREATE, {"p": p})
def drop_occasions():
drop_events([e for e, _ in OCCASIONS])
for _, p in OCCASIONS:
if p:
try: gql(DELETE, {"cid": p["cid"]})
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.
In practice a guess begins by waiting for the box to be live. While a committed read is out
the query controls are locked, the box among them; and a locked box still takes the letters
but drops the arrows and the Enter that follow, which reads afterwards as a completion
that refused to apply for no reason at all. Nothing in this session commits a search, but
whatever ran before it might have, so the wait belongs at the start of every guess rather
than at the end of something else.
def type_into(page, fragment):
"""Start the term over and type it in, keystroke by keystroke as a hand would."""
sb = search_box(page)
expect(sb).to_be_enabled()
sb.fill(""); sb.click()
sb.press_sequentially(fragment, delay=20)
def guess(page, fragment):
type_into(page, fragment)
expect(options(page).first).to_be_visible()
@testcase
def test_letting_the_box_do_the_typing(page):
"""Building a query out of what the box knows better than the hand: a word remembered
only in the middle, one whose spelling is a guess, the language's own keys, a date
nobody wants to spell, and the name of an occasion nobody here chose — with the list
staying alongside throughout, reachable by the keys however long it runs, and saying so
even while it is still thinking — and then backing out of it all, one rung at a time,
the last rung asking before it lets go, and letting go when it is told to."""
seed_vocab(HALF_KNOWN)
seed_occasions()
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
wait_until(page, lambda: page.evaluate(HIGHLIGHTED) == "zzupb",
label="ArrowUp from none highlights the last suggestion",
detail=lambda: f"offered {options(page).all_inner_texts()}, "
f"highlighted {page.evaluate(HIGHLIGHTED)!r}, box {sb.input_value()!r}")
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, "parc des oise") # part of a label that has spaces
opt = options(page).filter(has_text="oiseaux").first
expect(opt).to_be_visible()
whole = opt.inner_text().strip() # the label as it is written down
opt.click()
expect(sb).to_have_value(whole + "; ") # all of it, and not twice over
print(" PASS: a label with spaces completes as one word")
year = datetime.date.today().year
sb.fill(f"until:; since:{year}") # two date terms, the later one filled in
sb.click(); sb.press("Home")
for _ in range(len("until:")): sb.press("ArrowRight") # back into the first, just past its colon
expect(options(page).get_by_text(f"until:{year}", exact=True)).to_be_visible()
expect(options(page).get_by_text(f"since:{year}", exact=True)).to_have_count(0)
print(" PASS: the list follows the caret, not the line")
guess(page, "sin") # begins "since:", and sits inside a label
expect(options(page)).to_have_text(["since:"]) # the key, alone
options(page).first.click()
expect(sb).to_have_value("since:") # a key still wants its value
print(" PASS: a key prefix offers the key and nothing else")
guess(page, "type:")
expect(options(page)).to_have_text(["type:image", "type:video"])
print(" PASS: a two-value token shows both")
year = datetime.date.today().year - 1 # inside the span the picker offers
guess(page, "since:")
pick = lambda text: options(page).get_by_text(text, exact=True)
expect(pick(f"since:{year}")).to_be_visible() # the years
pick(f"since:{year}").click()
expect(sb).to_have_value(f"since:{year}") # a year has further to go — still open
expect(pick(f"since:{year}-06")).to_be_visible() # …and what it goes to is months
pick(f"since:{year}-06").click()
expect(pick(f"since:{year}-06-15")).to_be_visible() # then days
pick(f"since:{year}-06-15").click()
expect(sb).to_have_value(f"since:{year}-06-15; ") # a whole date is done, like any finished word
expect(options(page)).to_have_count(0) # and the list has nothing left to say
print(" PASS: a date is tapped, not typed")
for possible, impossible in [(f"since:{year}-1", f"since:{year}-0"), # no 0th month
(f"since:{year}-12", f"since:{year}-13"), # nor a 13th
(f"since:{year}-06-1", f"since:{year}-06-0"), # no 0th day
(f"since:{year}-06-30", f"since:{year}-06-31")]: # nor a 31st of June
guess(page, possible) # the list fills for the one that exists…
type_into(page, impossible)
expect(options(page)).to_have_count(0) # …and stays away for the one that doesn't
print(" PASS: the picker offers only dates that exist")
guess(page, "date") # 2+ chars → the key is offered
expect(options(page).filter(has_text="date:").first).to_be_visible()
sb.press_sequentially(f":{year}", delay=20)
expect(options(page).get_by_text(f"date:{year}-06", exact=True)).to_be_visible() # drilling, as before
print(" PASS: date: drills the same picker")
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")
guess(page, "since:last") # a bound can be a word rather than a date
expect(options(page)).to_have_text(["since:lastweek", "since:lastmonth", "since:lastyear"])
options(page).last.click()
expect(sb).to_have_value("since:lastyear; ") # a whole bound in one word — done
print(" PASS: a rolling bound completes from its prefix")
guess(page, EV_WINDOW + "event:zzkerm") # a prefix of an occasion's name
opt = options(page).filter(has_text=EV_KERMESSE).first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # the calendar's spelling, and done
print(" PASS: an occasion completes from the calendar")
for pieces in ("brocante parc", "parc brocante"): # either order finds it
guess(page, EV_WINDOW + f"event:{pieces}")
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: an occasion's name matches in pieces, any order")
guess(page, EV_WINDOW + "zzkerm") # no token typed, and none needed
opt = options(page).filter(has_text=f"event:{EV_KERMESSE}").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # handed back as the token
print(" PASS: a bare word offers an occasion as a token")
guess(page, EV_WINDOW + "event:zz")
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_ELSEWHERE)).to_have_count(0) # has a photo; wrong window
expect(options(page).filter(has_text=EV_PHOTOLESS)).to_have_count(0) # right window; no photo
print(" PASS: only occasions worth picking are offered")
guess(page, EV_WINDOW + "event:") # the bare key, nothing typed
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: the bare key lists the window's occasions")
guess(page, "year") # 2+ chars → the key is offered
expect(options(page).filter(has_text="year:").first).to_be_visible()
sb.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")
sb.fill(""); sb.click() # empty box, just focused — no typing
expect(options(page)).to_have_count(14) # the whole menu, at once
got = sorted(x.strip() for x 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")
sb.fill(""); sb.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"), \
"the menu must overflow the popover for scrolling to matter"
for _ in range(options(page).count()): sb.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")
expect(sb).to_have_attribute("role", "combobox") # the box says what it is…
sb.fill(""); sb.click(); sb.press_sequentially(HALF_KNOWN[0][:4])
expect(sb).to_have_attribute("aria-expanded", "true") # …and 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")
hold_completions(page) # the vocabulary query, left unanswered
sb.fill(""); sb.click(); sb.press_sequentially("au", delay=20)
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: completion shows in-flight hint")
expect(sb).to_have_attribute("aria-expanded", "true")
print(" PASS: search expanded while loading")
# and the way out, which is a ladder rather than a door
page.unroute("**/graphql") # the vocabulary answers again
asked, leaving = [], [] # answered no until the last reading says otherwise
page.on("dialog", lambda dlg: (asked.append(dlg.message),
dlg.accept() if leaving else dlg.dismiss()))
sb.fill(""); sb.blur() # nothing typed, nothing showing
expect(options(page)).to_have_count(0)
page.go_back()
wait_until(page, lambda: len(asked) == 1, label="going back with nothing up asks first")
expect(grid(page)).to_be_visible() # cancelling kept us in the app
print(" PASS: back at grid asks before exit")
sb.click() # the empty box drops its menu again
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(sb).to_be_visible() # and we're still in Memories
assert len(asked) == 1, f"retracting the list should have asked nothing: {asked}"
print(" PASS: back closes completion")
page.go_back() # a further Back: must reach the leave guard
wait_until(page, lambda: len(asked) == 2, label="the guard was still there to meet")
expect(sb).to_be_visible() # cancelled → still in the app
print(" PASS: back after completion still guards exit")
leaving.append(True) # this time the prompt is answered yes
page.go_back()
wait_until(page, lambda: heading(page).count() == 0,
label="the app let go of the tab",
detail=lambda: f"still showing {page.url}")
print(" PASS: back confirmed leaves")
finally:
drop_occasions()
drop_vocab(HALF_KNOWN)
Working the wall without the mouse
A card-dump gets worked in one sitting with both hands on the keys: walk the wall, open one to be sure, pick it or leave it, sweep a run of them together, walk back over what you passed, take the lot — and then narrow the pile again and start over, because a sitting is several passes and the search box is where each one begins.
Which is a set of gestures the mouse already has, so the two have to agree rather than merely coexist. They are not alternatives used by different people; they are used by the same person seconds apart — 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. Dragging a run down to that corner and climbing back out of it is where the hardest promises live — that a row is however many columns are on show, and that wherever the cursor lands it 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}"
FIRST_WORD, SECOND_WORD = "zzkeyfirst", "zzkeysecond"
The last of the session leaves the wall for the two surfaces that can be worked from inside a doc, and comes back to it each time — so it needs to put the wall back where it started before each trip, with the far tile out of sight again, or a wall that never moved would read the same as one that followed.
Leaving the show is the other thing that has to be timed, because a slide arrives under the eye before the show has decided it has arrived: the picture is centred while the step is still settling, and only the settling hands the slide on to anything outside the strip. It calls a step over 150ms after the last scroll, so a quarter of a second of stillness is past it — and stillness is read from the strip’s own scroll position, held rather than merely sampled, so that how often anything looks at it does not come into the answer.
def back_to_the_top(page):
"""Send the wall back to the top, and wait until its far tile is out of sight again."""
page.evaluate("() => scrollTo(0, 0)")
wait_until(page, lambda: not tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the far tile is below the fold, so a scroll to it would show")
STILL_MS = 250 # past the show's own settle, whoever is watching
def show_at_rest(page, strip):
"""Wait until the strip has held one position for longer than the show's settle."""
held = [None, 0.0] # where it is, and when it first got there
def resting():
at, now = strip.evaluate("el => Math.round(el.scrollLeft)"), time.time()
if at != held[0]: held[0], held[1] = at, now
return (now - held[1]) * 1000 >= STILL_MS
wait_until(page, resting,
label="the show came to rest on the slide it stepped to",
detail=lambda: f"it was last at {held[0]}")
@testcase
def test_working_the_wall_without_the_mouse(page):
"""A pass over a card-dump from the keys: walk the wall, open one, pick one, drag a
run to the foot of it, climb back out, take the lot, reach a label box and the last
word twice over, turn back to the search box, carry the cursor out to the lightbox
and the show and back with the wall following it both ways — and lose the tab on all
of it, to find the run and the cursor where you left them."""
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)
d = dialog(page)
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
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(" ") # one picked already, before any run
expect(checks(page)).to_have_count(1)
page.keyboard.press("ArrowRight") # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(1) # and selects nothing of its own
page.keyboard.press("Shift+ArrowRight") # reach on…
page.keyboard.press("Shift+ArrowRight") # …and on again
expect(checks(page)).to_have_count(4) # the run from the anchor, over the one kept
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(3)
page.keyboard.press("Shift+ArrowLeft") # back onto the anchor → the run is just it
expect(checks(page)).to_have_count(2) # and the one kept underneath is still there
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"); page.keyboard.press("ArrowDown") # two rows down
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(2 * cols))
page.keyboard.press("Escape")
page.keyboard.press("ArrowUp") # back up by a row
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 steps a row, both ways")
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"] + 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")
open_doc(page, 0)
page.keyboard.press("Shift+ArrowRight") # a wall gesture, aimed at the open doc
page.keyboard.press("Escape")
expect(checks(page)).to_have_count(0) # nothing was gathered behind it
print(" PASS: an open doc owns the keys")
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 two keys that reach a label box without the hand leaving the keys
toolbar(page).get_by_role("button", name="clear").click()
tiles(page).nth(0).click() # one picked, so the bar is up
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).not_to_be_focused()
page.keyboard.press("l")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value("")
print(" PASS: label shortcut focuses batch box")
tb_box.fill(FIRST_WORD); tb_box.press("Enter") # applied to the one picked → remembered, selection clears
tiles(page).nth(1).click() # pick another, so the bar is back
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).to_have_value("") # the bar lets the word go once the write lands
tb_box.blur() # leave the field, so '.' is a shortcut again
page.keyboard.press(".")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value(FIRST_WORD)
print(" PASS: label repeat fills batch box")
toolbar(page).get_by_role("button", name="clear").click()
open_doc(page, 0)
lb_box = d.get_by_placeholder("add a label…")
expect(lb_box).not_to_be_focused()
page.keyboard.press("l")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value("") # l opened the box; it didn't type into it
print(" PASS: label shortcut focuses lightbox box")
lb_box.fill(SECOND_WORD); lb_box.press("Enter") # applied → the newest word remembered
expect(d.get_by_role("button", name=SECOND_WORD, exact=True)).to_be_visible()
lb_box.blur() # leave the field, so '.' is a shortcut again
page.keyboard.press(".")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value(SECOND_WORD) # the newest, not the one before it
print(" PASS: label repeat fills lightbox box")
page.keyboard.press("Escape")
expect(d).to_be_hidden()
on = lambda: grid(page).locator("[data-cursor='1'] img").get_attribute("src")
was = on()
search_box(page).click() # the box takes the keys
page.keyboard.press("ArrowRight")
page.keyboard.press("ArrowDown")
assert on() == was, f"the wall cursor moved while the search box had focus: {was} → {on()}"
print(" PASS: grid cursor stands aside for a field")
# the cursor is one thing across the three surfaces, and the wall keeps up with it
back_to_the_top(page)
open_doc(page, 0)
expect(d).to_be_visible()
page.keyboard.press("ArrowLeft") # wrap round to the last, far down the wall
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled the cursor's tile into view")
print(" PASS: wall scrolls to lightbox cursor")
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
print(" PASS: lightbox nav follows cursor")
# and the show carries it the same way
back_to_the_top(page)
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()
show_at_rest(page, strip) # its own opening centre has landed
page.keyboard.press("ArrowLeft") # wrap round to the last slide
wait_until(page, lambda: strip.evaluate(CENTERED) == key_thumb(KEY_N - 1),
label="the show came round to the last slide",
detail=lambda: f"it came round to {strip.evaluate(CENTERED)!r}")
show_at_rest(page, strip)
page.keyboard.press("Escape")
expect(strip).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled to the slide we stopped on")
print(" PASS: wall scrolls to frame cursor")
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
print(" PASS: frame nav follows cursor")
# and none of it is lost if the tab reloads under you
tiles(page).nth(0).click() # pick the first — the click plants the cursor there
page.keyboard.press("Shift+ArrowRight") # extend the run to the next; 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(KEY_N) # 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 the second → opens it
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
print(" PASS: cursor and selection survive reload")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
Asking for more than the archive can hand over
An archive of sixty thousand photos can be asked for more than it can hand over, and it happens two ways. A bare word against all of them, a date range covering a decade: the answer takes long enough that you are left waiting on it. Or the answer arrives and is simply too big for a wall — tens of thousands of tiles nobody will ever scroll — so what comes back is a part of it.
Both are the same failure to be honest if the app says nothing. A wait it does not name looks like an app that has stopped; a slice it does not name looks like the whole archive, and a wall that quietly shows you a fraction of what matched is a wall you will draw wrong conclusions from for as long as you use it. So it says which it is doing, and where it is waiting, it lets you change your mind.
Take the slice first, since it is what the archive does with almost every broad question. The wall says how much of the match it is holding and how it chose — every match, one end of it, or a spread across the whole — and the count it names is the true one, not the number of tiles it happens to be showing.
That count is a promise about the tiles, so the tiles have to be able to keep it. Two thousand of them is more than one screen and more than one machine’s memory, and both have to be answered without shrinking what is on offer: a tile out of sight lets go of its picture and takes it back on return, and a row whose picture does not exist yet is still a tile. Neither costs the wall a photo — which is the whole point, since a wall that quietly kept fewer than it counted would be lying in the same way as one that quietly showed fewer than matched.
Which slice it took also changes what you can do with the tiles. An end is a run and a spread is not, and a range gesture across a spread would rope in whatever happened to fall between two scattered tiles and call it a run — the same lie the notice exists to prevent, only told by the person to themselves. So the wall lets the gesture through on one and stands it down on the other.
Then the wait. The box does not chase the keyboard — it is a language, and half a token is not a question worth asking — and it says so while it holds something unrun. Once committed, the wall names the query it is fetching, freezes the controls so what you see cannot drift from what is coming, and offers three ways out of a wait you did not mean to start. The one it cannot offer is a way out of the very first read, where there is no earlier answer to fall back to; that one just has to let go.
Bailing out means going back to the last answer, so there has to be one: a query already asked and settled, sitting behind whatever gets tried next.
None of it can be watched while it happens unless the waiting is made to last. A read drags for reasons the app never learns — the network, the archive, the sheer size of the answer — so the hold is put on from outside, not asked for by the query. One word is agreed with the network, and a query carrying it is never answered; where there is no query to carry a word, as on the first read, or where the query is not the point, the hold is simply laid over whatever read comes next. Either way the app is left standing in the middle of it for as long as we care to look.
SLOW = "zzslow"
def hold_a_read(page, query):
"""Commit a query the network will never answer, and wait until it has taken hold."""
search_box(page).fill(query)
page.get_by_role("button", name="run search").click()
expect(search_box(page)).to_be_disabled()
@testcase
def test_asking_for_more_than_can_be_handed_over(page):
"""Asking for too much, three ways: a match too big to show, which the wall says it has
sliced and how — and which of those slices you can still take a run in hand from; a
wall too long to hold, which lets go of what you are not looking at
and still counts what has no picture yet; and a read that drags, which the wall names,
locks the query behind, and lets go of three different ways — including on the very
first one, where there is nothing to go back to."""
make_fixtures()
pick = pick_docs()
for d in pick: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
seen, aborted = [], []
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.on("requestfailed", lambda r: "/graphql" in r.url and aborted.append(1))
drag_them_all, slow_state = [True], [None] # the two holds put on from outside
def hold(r):
body = r.request.post_data or ""
if SLOW in body or (slow_state[0] and f'"states":["{slow_state[0]}"]' in body): return
if drag_them_all[0] and "photovideosSample" in body: return
r.fallback()
page.route("**/graphql", hold)
open_app(page)
expect(page.get_by_text("searching «everything»")).to_be_visible()
print(" PASS: wall cue names empty query everything")
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")
drag_them_all[0] = False # reads answer again from here
chip(page, "all").click() # every state, so the run is all three
wait_until(page, lambda: tiles(page).count() > 10, timeout=15000) # filled, not still the old wall
wait_until(page, lambda: "/ipfs/" in (thumb_imgs(page).first.get_attribute("src") or ""))
print(" PASS: shows thumbnails")
assert thumb_imgs(page).first.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
"thumbnail crops instead of fitting whole"
print(" PASS: thumbnail shows whole image")
far = thumb_imgs(page).nth(200) # hundreds of rows down
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # so it carries nothing
far.scroll_into_view_if_needed()
expect(far).to_have_attribute("src", re.compile(r"/ipfs/")) # brought into view → it takes one
thumb_imgs(page).first.scroll_into_view_if_needed()
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # left again → it gives it back
print(" PASS: thumbs load in view and unload when they leave")
expect(page.get_by_text(re.compile(r"showing a spread of 2000 from \d+"))).to_be_visible()
print(" PASS: sampling notice")
t = tiles(page)
t.nth(0).click() # pick the first (the 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")
toolbar(page).get_by_role("button", name="clear", exact=True).click()
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
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)}"
t = tiles(page)
t.nth(0).click() # pick the first (the toolbar appears)
expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
t.nth(1).click(modifiers=["Shift"]) # a shift-click ropes in the run
expect(checks(page)).to_have_count(2)
toolbar(page).get_by_role("button", name="clear", exact=True).click()
print(" PASS: first/last keep range select")
search_for(page, "zzpick; last:2")
expect(page.get_by_text(re.compile(r"showing the last 2 of \d+$"))).to_be_visible()
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)}"
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()
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}"
print(" PASS: first/last/sample pick which rows")
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(FIXTURE_LABEL, delay=0)
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 — this one is not held
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")
expect(tiles(page)).to_have_count(len(FIXTURES)) # settled on a query worth going back to
base = len(seen)
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzslow-typed", delay=0)
expect(sb).to_have_value("zzslow-typed") # 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
print(" PASS: wall reads only on commit")
expect(page.get_by_text(re.compile(rf"^showing all {len(FIXTURES)}$"))).to_be_visible()
print(" PASS: count always shown")
sb.press("Enter") # commit — nothing highlighted
expect(page.get_by_text("searching «zzslow-typed»")).to_be_visible() # the wall read what was typed
assert seen[base:] == ["zzslow-typed"], f"commit fired the wrong reads: {seen[base:]}"
print(" PASS: enter commits search")
expect(grid(page)).to_have_attribute("aria-busy", "true") # a read is out — the region is working
print(" PASS: wall reports busy")
expect(search_box(page)).to_be_disabled() # search frozen while the read is out
expect(page.get_by_role("button", name="run search")).to_be_disabled() # …the run button
expect(chip(page, "todo")).to_be_disabled() # …and the state chips
print(" PASS: query controls freeze while reading")
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")
hold_a_read(page, "zzslow-pill")
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))
print(" PASS: pill tap cancels frozen read")
hold_a_read(page, "zzslow-back")
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")
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzsecond-thoughts", delay=0)
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied"))
slow_state[0] = "next" # this filter is the slow one, and stays slow
chip(page, "next").click()
assert seen[-1] == FIXTURE_LABEL, f"the chip put {seen[-1]!r} on the wire, not the committed query"
expect(sb).to_be_disabled()
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # dirty and locked at once
page.keyboard.press("Escape")
expect(chip(page, "all")).to_have_attribute("aria-pressed", "true") # the filter came back with the query
expect(tiles(page)).to_have_count(len(FIXTURES)) # the wall answered, and stayed answered
expect(sb).to_be_enabled()
expect(sb).to_have_value(FIXTURE_LABEL) # the typed half-thought is gone…
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # …and so is its mark
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "the amber tint outlived the bail-out"
print(" PASS: bailing out clears the stale cue")
for d in nomedia_docs(): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
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)
d.get_by_role("button", name="close").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
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
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
for d in nomedia_docs():
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
print(" PASS: placeholders for docs with no media")
for d in pick:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
Setting Memories up on a phone that never had it
The other twelve sessions all start with the app already there. This one is the first five minutes on a device that has never run it: putting it on the home screen, opening it, and finding out whether it is any use before there is a single photo to look at.
Some of what that turns on is not about photos at all. Whether the app names the build it is running, whether it comes up without the network, whether its controls sit where a thumb can reach them past the phone’s own bars, whether a tap looks like it landed — none of it is a feature anybody asks for, and all of it is what the first five minutes are made of. And the two ways a new device is turned away, having no grant and finding the archive down, are the likeliest thing to happen on a phone that has never connected.
Every one of those last readings is a failure being said out loud instead of swallowed, which is a thing the harness has to be able to do before the app can be asked to. The fixture PostGraphile the whole suite talks to is a throwaway, and a throwaway that hid a server-side error would turn every failure below into an unexplained timeout somewhere else. So the session opens by making the archive refuse something — the same photo created twice — and reading the refusal back in full.
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpgerr", "date": "2020-01-01T00:00:00Z", "mimetype": "image/jpeg",
"labels": "zzpgerr", "state": "todo"}
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
dup = gql(CREATE, {"p": doc}) # same cid again → a Postgres unique violation
msg = ((dup.get("errors") or [{}])[0]).get("message", "")
assert "duplicate key" in msg.lower(), f"fixture masked the PG error instead of surfacing it: {msg!r}"
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: fixture surfaces real pg error")
That one reading is the only promise in the note no mutant can break: what it guards is the harness, and the harness is what would be running the broken copy. Everything the mutation pass can reach — the app, the query language, the shell, the worker — is downstream of it.
In practice the order of the rest is not the order of a story but the order the page
allows. The two refusals are put on by routing /graphql, which cannot be taken back
cleanly, so they come last. The cache leg leaves a doctored manifest behind in the
build’s cache, so it comes after everything that reads a real one. And the phone’s bars,
once asked for, stay for the rest of the run — which is why the wall is walked at a
desk’s size first and a phone’s size after.
@testcase
def test_setting_up_on_a_new_phone(page):
"""The first five minutes on a device that never had Memories: the archive shown to
report a refusal at all, then the app coming up titled, naming its build, installing
itself, launching from its own cache and dropping the last build's — speaking its
colours and answering a press, keeping clear of the phone's own bars — and then the
two ways a new device is turned away, with no grant and with the archive down."""
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpgerr", "date": "2020-01-01T00:00:00Z", "mimetype": "image/jpeg",
"labels": "zzpgerr", "state": "todo"}
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
dup = gql(CREATE, {"p": doc}) # same cid again → a Postgres unique violation
msg = ((dup.get("errors") or [{}])[0]).get("message", "")
assert "duplicate key" in msg.lower(), f"fixture masked the PG error instead of surfacing it: {msg!r}"
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: fixture surfaces real pg error")
open_app(page)
expect(heading(page)).to_have_text("Memories")
print(" PASS: boots into titled shell")
tag = page.locator(".build-tag")
assert tag.count() == 1, "no build tag"
h = (tag.text_content() or "").strip()
assert re.fullmatch(r"[0-9a-f]{7}", h), f"build tag isn't a 7-hex hash: {h!r}"
print(" PASS: build tag shows hash")
href = page.locator("link[rel='manifest']").get_attribute("href")
assert href, "no manifest linked"
man = page.evaluate("h => fetch(h).then(r => r.json())", href) # the one the page declares
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")
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")
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")
open_fixtures(page) # a wall to walk, from here on
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)
d = dialog(page)
expect(d).to_be_visible()
lb_hues = pill_hues(d)
assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"
print(" PASS: state pills colour coded")
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="done", exact=True)) # a state button
assert held != rest, f"the state button gives no press feedback: rest={rest} held={held}"
expect(grid(page).get_by_text("done")).to_have_count(2) # …and the press was a press: the verdict landed
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}"
print(" PASS: buttons acknowledge a press")
page.keyboard.press("Escape")
expect(d).to_have_count(0) # the doc is shut before the phone's bars come on
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
def all_clear(what, surface):
caught = surface.evaluate(
"""(root, [top, floor]) => [...root.querySelectorAll('button, input, a')]
.map(e => [e, e.getBoundingClientRect()])
.filter(([_, r]) => r.width && r.height && (r.top < top || r.bottom > floor))
.map(([e, r]) => `${e.getAttribute('aria-label') || e.placeholder
|| e.textContent.trim().slice(0, 18)}`
+ ` @${r.top.toFixed(0)}..${r.bottom.toFixed(0)}`)""",
[TOP, floor])
assert not caught, f"{what} leaves controls in a bar's strip (usable {TOP}..{floor}): {caught}"
title = heading(page).bounding_box()
assert title["y"] >= TOP, f"the title sits under the status bar: {title}"
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
open_doc(page)
all_clear("the open doc", dialog(page))
page.keyboard.press("Escape")
expect(dialog(page)).to_have_count(0)
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")
# …and the two ways a device that has never connected is turned away
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")
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))
expect(page.get_by_text("No photos.", exact=True)).to_have_count(0)
print(" PASS: load failure — banner up, empty-state down")
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).
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 screen. 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
A device without a grant cannot be produced on demand, so the refusal is put on from
outside: /graphql is routed to a 401, which overrides the fixture forward since a page
route wins over the context one, and the app is opened on it.
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)
print(" PASS: load failure — banner up, empty-state down")
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.
Under the hood, asking for the whole archive does not empty the wall first: the tiles already up stay where they are until the new answer lands, which is why a wall asked that question is read once it has filled rather than the instant the chip is clicked. Read too early and what you have is the old wall, or a half-drawn new one.
wait_until(page, lambda: tiles(page).count() > 10, timeout=15000) # filled, not still the old wall
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.
assert thumb_imgs(page).first.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, and that is what lets the wall and its count agree: the tally over the
photos is the same question as the wall, asked for a number instead of a page. They are
worth reading together at least once, because a token that reached only the wall would show
five photos and say six, and a hunt run on a count that has not narrowed with the wall is a
hunt being told it has further to go than it does.
narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5) # everyone else's set aside
expect(page.get_by_text("showing all 5")).to_be_visible() # …and the tally set them aside too
print(" PASS: filter by owner")
Not every guess is a token. Anything in the query that isn’t one is a word guess, handed
to Postgres as written, and words are where memory is least reliable of all: you know one
was typed on and not which. So the free text is a boolean search — terms side by side must
all match, or takes either, and a leading - rules one out. Naming both words of a pair
side by side asks for a photo carrying both and finds none; guessing one costs you the
photos labelled with the other; offering either takes them all; and ruling one out is a
guess about what the photo is not, which is often the surer memory.
narrow(page, "zzsummer zzholiday")
expect(tiles(page)).to_have_count(0) # both words at once: nothing carries both
narrow(page, "zzsummer")
expect(tiles(page)).to_have_count(3) # only what got that word
narrow(page, "zzsummer or zzholiday")
expect(tiles(page)).to_have_count(5) # either word will do — every June
narrow(page, "-zzholiday")
expect(tiles(page)).to_have_count(4) # whatever it was, it was not that
print(" PASS: a word half-remembered, or ruled out")
The occasion is the guess that costs nothing to make, since the calendar already knows when
the Fête de la musique was. event:NAME keeps the photos taken inside the span of an
occasion so named, which turns a memory nobody ever typed onto a photo into a query. Four
things have to hold for that to be trustworthy rather than merely impressive. It is the
photo’s own owner’s calendar that is consulted, so Ayla standing beside me all afternoon
does not put her photos among mine. The span includes its final instant, since an occasion
that ends at six and a photo taken at six are the same afternoon. An occasion that was
called off never happened, so its span covers nothing. And the name is matched with the
accents taken off both sides, because a calendar writes Fête and a hunt types fete.
Negating it works as it does on a word, and for the same reason: what the photo is not is often the surer memory. Whatever else that afternoon was, it was not the one everybody else was photographing.
narrow(page, f"event:{HUNT_EVENT_TYPED}") # typed flat; the calendar's is accented
expect(tiles(page)).to_have_count(1) # mine inside it — not Ayla's, standing right there
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-25") # the one on the last instant
narrow(page, f"-event:{HUNT_EVENT_TYPED}") # everything but that occasion
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1)
narrow(page, "event:zzannule")
expect(tiles(page)).to_have_count(0) # called off, so nothing was taken during it
print(" PASS: filter by occasion")
The reach goes further than the token. A plain word is run against the calendar as well as against the labels, so the name of an occasion finds the photos taken during it even where nobody thought to type it on — which is the whole point, since the ones you would have labelled are the ones you could already find.
Two limits come with it, and both change how the guess has to be made. The free text is put to the labels as one question and to the calendar as another, so a photo answering half of each answers neither: the name goes in alone, without the scoping word this hunt has leant on throughout. And the accents are folded on the token’s path but not on this one, so a name spelt with any is not found this way at all — the occasion asked for here is spelt plainly for that reason, and a name that is not is one to reach for the token for.
search_for(page, PLAIN_EVENT.lower()) # no token, no label beside it, just the name
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-03-20") # never labelled with it
print(" PASS: a bare word reaches the calendar")
One thing about it you will not have misremembered is whether it moved. type:video and
type:image split the archive on that, server-side and before the sample, so the count
agrees with the wall like every other token. It is the cheapest guess there is: it cannot
be half-right, and between them the two of them account for everything.
narrow(page, "type:video")
expect(tiles(page)).to_have_count(1) # the one that moves
narrow(page, "type:image")
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1) # and everything that doesn't
print(" PASS: filter by kind")
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")
Memory does not hand over a year, though; it hands over a distance — five or six years
back — and the box takes a distance as readily as a date, though only as one end of the
window: since:N years ago says the earliest the photo could be and leaves the near end
open, so what comes back is everything from there to today. The units are days, weeks,
months and years, one or many of them, and there are named distances too — lastweek,
lastmonth, lastyear — for the ones common enough to have a word. All of it resolves
against today, so a rolling window costs no arithmetic and no rewriting as the years pass.
A distance shorter than the one being remembered reaches back nowhere near these photos and
leaves nothing; a distance longer than any of them reaches past every one of mine.
Several of these say the same thing — a year back is a year back, however it is spelt — and the wall will not read twice for one bound: a query that resolves to the one already in force is the query already on screen. So the spellings are tried in an order where no two neighbours mean the same distance.
Where a distance lands can be read off the Junes it keeps. Reaching back the remembered distance itself falls partway through the middle year — before that June or after it, depending on the time of year the hunt happens — so the middle year is the one thing this cannot ask about. The Junes either side of it are unambiguous, and they are the ones checked: the later one is inside that reach whenever the question is put, the earlier one outside it.
for recent in ["1 week ago", "1 month ago", "7 days ago", "6 months ago",
"1 year ago", "3 weeks ago", "lastyear", "1 day ago"]:
narrow(page, "owner:konubinix", f"since:{recent}")
expect(tiles(page)).to_have_count(0) # nothing of mine is that recent
narrow(page, "owner:konubinix", f"since:{HUNT_BACK * 4} years ago")
expect(tiles(page)).to_have_count(5) # far enough back to reach every one of mine
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_BACK} years ago")
kept = thumb_imgs(page).evaluate_all("els => els.map(e => e.alt)")
assert f"{HUNT_YEAR + 1}-06-10" in kept, f"the June after the reach should have survived it: {kept}"
assert f"{HUNT_YEAR - 1}-06-15" not in kept, f"the June before the reach should not have: {kept}"
print(" PASS: a distance back stands in for a date")
The far end closes with until:, which is independent of since: and names the latest the
photo could be. A guess at both is a year named without ever saying its number: laid over
the Junes, the lower bound drops the earliest of them and the upper drops the latest.
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(3) # the June before that year is gone
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}", f"until:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(2) # and so is the one after it
print(" PASS: bounded from both ends")
A whole year, being the commonest guess of all, has a token of its own: year: is that
pair said once. Not merely agreeing with it on what to leave on the wall — the same query.
The wall proves it by doing nothing: swap the pair for the token and the archive is not
asked again, because what would be asked is what was asked a moment ago. A token that only
happened to agree would still have had to go and find out.
NO_READ_MS = 500 # every read in this set is back well inside it
asked = len(SETTLED_READS)
search_box(page).fill(hunt_query("owner:konubinix", "month:6", f"year:{HUNT_YEAR}"))
page.get_by_role("button", name="run search").click() # asked, with no answer to wait for
page.wait_for_timeout(NO_READ_MS)
assert len(SETTLED_READS) == asked, "year: went and asked for the answer already on screen"
expect(tiles(page)).to_have_count(2) # and the pair's Junes never moved
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")
A whole year is the smallest period that token can name, though, and a hunt does not stop
there: a month, or a day, still means spelling the pair out twice with the same date in
both. date: is the pair in one token at any granularity — a year, a year and a month, a
year and a month and 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", f"date:{HUNT_YEAR}-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", f"date:{HUNT_YEAR}-06-15") # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-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
const [editStalled, setEditStalled] = createSignal(false);
const EDIT_SILENCE_MS = Number(new URLSearchParams(location.search).get('editms')) || 15000;
async function editing(run){
setMutating(m => m + 1); setEditStalled(false);
let counted = true, timer = null;
const giveUp = () => { if(!counted) return;
counted = false; setMutating(m => m - 1); setEditStalled(true); };
const tick = () => { clearTimeout(timer); timer = setTimeout(giveUp, EDIT_SILENCE_MS); };
tick();
try { await run(tick); setEditStalled(false); }
catch(e){ setEditStalled(true); throw e; }
finally { clearTimeout(timer); if(counted){ counted = false; setMutating(m => m - 1); } }
}
// 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);
setStateFilter(JSON.parse(s.key).state); 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){
return editing(async tick => {
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 }); tick(); }
}
clearSel();
await refetch();
});
}
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_ARG = { SetState: ['State', 'toState'], SetDate: ['Datetime', 'toWhen'],
AddLabel: ['String', 'label'], RemoveLabel: ['String', 'label'] };
const BULK = k => { const [ty, arg] = BULK_ARG[k];
return `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${ty}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${arg}:$v}){ result } }`; };
async function applyBulk(kind, v, over){
return editing(async tick => {
await gql(BULK(kind), { ...filterVars(), ...over, v }, PV_CTX);
tick(); clearSel(); await refetch();
});
}
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;
if(allMatching()) await applyBulk('SetDate', ev.starttime, { owners: [ev.owner] });
else 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;
const when = new Date(v).toISOString();
if(allMatching()) await applyBulk('SetDate', when);
else await patchSelected(() => ({ date: when }));
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()}>🕓</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}>→ 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">↓</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>
<//>
<//>
<${Show} when=${() => editStalled()}>
<div class="wall-busy wall-stalled" role="status">the edit did not go through — try again</div>
<//>
<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); commit(); 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; }
/* an edit given up on is not a state to wait through — say so in the colour too */
.wall-stalled{ background:#5a3340; color:#ffdfe6; }
.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 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. That is what lets the sampling cap sit where it does, at a wall of two
thousand, rather than at whatever a browser can hold all at once.
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. The wall this is read from is the one the whole archive draws — two thousand tiles, which is the case the holding-on would actually have to be paid for.
far = thumb_imgs(page).nth(200) # hundreds of rows down
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # so it carries nothing
far.scroll_into_view_if_needed()
expect(far).to_have_attribute("src", re.compile(r"/ipfs/")) # brought into view → it takes one
thumb_imgs(page).first.scroll_into_view_if_needed()
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # left again → it gives it back
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.
The N it names is the cap on the nose, and that is worth pinning rather than reading as roughly-two-thousand: a sampler that hands back a few short of what it was asked for is a different sampler, and this line is where the difference shows.
expect(page.get_by_text(re.compile(r"showing a spread of 2000 from \d+"))).to_be_visible()
print(" PASS: sampling notice")
Under the cap there is nothing to confess, and the line says so rather than going quiet: a wall that only speaks up when it is hiding something teaches you to read silence as completeness, which is exactly the reading it would then have to be right about every time.
expect(page.get_by_text(re.compile(rf"^showing all {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 |
Each of the three is one gesture — a query typed and committed — and everything the wall then has to say about it is said at once: which slice it is holding, and which photos. So the asking happens here, once per row of that table, and what came back is read from the same wall further on rather than asked for again.
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. Going quiet again matters as much as
speaking up: a region that never stops saying it is working leaves somebody waiting on an
answer that arrived a minute ago — the same dishonesty as a wall showing a fraction of the
match and not saying so, and worse for being invisible to everyone who can see the tiles.
expect(grid(page)).to_have_attribute("aria-busy", "true") # a read is out — the region is working
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-busy — photos.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.
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.
BG = "el => getComputedStyle(el).backgroundColor"
working = page.get_by_text("updating…")
expect(working).to_be_visible()
pending = working.evaluate(BG) # the colour of a wait, to tell a failure from
print(" PASS: wall shows updating feedback")
A write can also simply never come back — the connection drops between the phone and the house,
and nothing answers. Left alone the counter would never come down and the wall would go on
saying updating… for the rest of the session, over a wall you have long since moved on from:
a claim to be working that is not only false but unfalsifiable, which is the worst thing this
cue could become. So an edit is only credited as working while its writes keep answering, and
fifteen seconds of silence (?editms= overrides the span) is taken as the answer that is not
coming. The wall goes quiet, and says so:
the edit did not go through, in the colour of something wrong rather than something pending, so
the next move is a retry and not a longer wait.
failed = page.get_by_role("status")
expect(failed).to_have_text("the edit did not go through — try again")
expect(page.get_by_text("updating…")).to_have_count(0) # it stopped claiming to work…
expect(grid(page)).to_have_attribute("aria-busy", "false") # …to everyone, not just the eye
assert failed.evaluate(BG) != pending, "a failed edit wears the colour of one still going"
print(" PASS: a write that never answers is given up on")
Giving up is a guess, though, and the wifi does come back. A write we had written off can land minutes later, and then the notice is standing over a wall that shows the edit applied — the same false claim this whole business exists to prevent, only inverted, and this time the person has been told to do the work again. So the notice is not a verdict: it is taken back the moment the edit it was about finishes after all.
for r in dropped: r.fallback() # the write we had written off goes out after all
expect(page.get_by_role("status")).to_have_count(0) # the app stops saying it failed…
expect(checks(page)).to_have_count(0) # …because it did not: the edit landed
expect(grid(page)).to_have_attribute("aria-busy", "false")
print(" PASS: a write that lands late takes the notice back")
const [editStalled, setEditStalled] = createSignal(false);
const EDIT_SILENCE_MS = Number(new URLSearchParams(location.search).get('editms')) || 15000;
async function editing(run){
setMutating(m => m + 1); setEditStalled(false);
let counted = true, timer = null;
const giveUp = () => { if(!counted) return;
counted = false; setMutating(m => m - 1); setEditStalled(true); };
const tick = () => { clearTimeout(timer); timer = setTimeout(giveUp, EDIT_SILENCE_MS); };
tick();
try { await run(tick); setEditStalled(false); }
catch(e){ setEditStalled(true); throw e; }
finally { clearTimeout(timer); if(counted){ counted = false; setMutating(m => m - 1); } }
}
Slow is not the same as dead, and a run of forty is slow by construction. So the clock is re-armed by every write that lands, not by the edit as a whole: what is being waited on is one answer, and an edit whose writes each come back inside the fifteen seconds runs as long as it likes. The way to tell the two apart is a connection that answers late but always — every gap under the limit, the whole edit far over it.
for i in range(len(FIXTURES)):
wait_until(page, lambda: len(stalled) > i, label="the next write went out")
page.wait_for_timeout(700) # late, but inside the second
expect(page.get_by_text("the edit did not go through — try again")).to_have_count(0)
stalled[i].fallback() # …and it answers
expect(checks(page)).to_have_count(0) # the whole edit landed: the selection cleared…
expect(grid(page)).to_have_attribute("aria-busy", "false") # …and the wall has read it back
print(" PASS: a slow edit is waited out")
The third way is the plainest: the archive answers, and the answer is no. A refusal is an answer, so there is nothing left to wait for and the wall goes quiet at once — but it is not the edit going through, so it wears the same notice as a silence given up on. Which is the other half of taking the notice back: only a write that worked retracts it, and an error arriving after the clock has run out changes nothing about whether the edit happened. The refusal also stops the edit where it stands, rather than carrying on down the run — the word stays in the box, because the notice says to try again and typing it out afresh is not that.
expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again")
expect(page.get_by_text("updating…")).to_have_count(0)
expect(grid(page)).to_have_attribute("aria-busy", "false")
expect(tb.get_by_placeholder("add a label…")).to_have_value("zzrefused") # ready to try again
print(" PASS: a refused edit says so")
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.)
Whether the wall waits is not a question about how quickly it reacts — it is about whether a request left at all.
base = len(seen)
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzslow-typed", delay=0)
expect(sb).to_have_value("zzslow-typed") # 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
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.)
sb.press("Enter") # commit — nothing highlighted
expect(page.get_by_text("searching «zzslow-typed»")).to_be_visible() # the wall read what was typed
assert seen[base:] == ["zzslow-typed"], f"commit fired the wrong reads: {seen[base:]}"
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.
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(FIXTURE_LABEL, delay=0)
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 — this one is not held
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.
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, the run button and
the state chips all report themselves disabled, so the query cannot drift from what is in
flight.
expect(search_box(page)).to_be_disabled() # search frozen while the read is out
expect(page.get_by_role("button", name="run search")).to_be_disabled() # …the run button
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.
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.
hold_a_read(page, "zzslow-back")
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")
Bailing out puts the box back, and that means the marks on it too. A state chip is not a commit — it re-reads whatever query was last committed — so it can start a slow read while the box is still holding something typed and unrun. Whoever bails out of that read did not ask to keep the half-thought that was sitting in the box, so it goes with the rest — and so does the chip, since the filter is part of the query. Leave it pressed and the wall would settle for an instant and set off again after the very read just abandoned, which is no escape at all.
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzsecond-thoughts", delay=0)
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied"))
slow_state[0] = "next" # this filter is the slow one, and stays slow
chip(page, "next").click()
assert seen[-1] == FIXTURE_LABEL, f"the chip put {seen[-1]!r} on the wire, not the committed query"
expect(sb).to_be_disabled()
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # dirty and locked at once
page.keyboard.press("Escape")
expect(chip(page, "all")).to_have_attribute("aria-pressed", "true") # the filter came back with the query
expect(tiles(page)).to_have_count(len(FIXTURES)) # the wall answered, and stayed answered
expect(sb).to_be_enabled()
expect(sb).to_have_value(FIXTURE_LABEL) # the typed half-thought is gone…
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # …and so is its mark
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "the amber tint outlived the bail-out"
print(" PASS: bailing out clears the stale cue")
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.
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.
hold_a_read(page, "zzslow-pill")
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))
print(" PASS: pill tap cancels frozen read")
Placeholders for docs with no media
Not every row has its renditions. Rows arrive before their pictures do — an import lands, and the thumbnails are still being made — so at any moment some of what matches a query has nothing to show for itself. Dropping those would make the wall a liar in the one way that matters most, since the line above it counts what is shown against what matched: a match quietly withheld would be a photo the archive says it has and the wall says it does not. So it still gets a tile — a placeholder with an icon — reachable rather than silently dropped, and it raises no sampling notice, because nothing was sampled away.
Rows that land while the wall is up do not push themselves onto it: the wall holds the
answer it was given until it is asked something else. So the new arrival is met the way
any is, by asking after it — and opening it, when there is no web_cid either, shows a
“no preview” placeholder rather than a broken image.
for d in nomedia_docs(): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
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)
Back on the wall, among the run it landed in, it is one of them: counted, tiled, and wearing the icon that says why it has no picture.
d.get_by_role("button", name="close").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
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.
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,
which is a difference you can read straight off the line that names the slice: ask the whole
archive and the number there is the cap itself.
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.
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)}"
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.
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.
The wall this is read from is the run somebody is putting a date right in, every photo of which was found by the one word they all carry — so that word is on the wall by construction, and the question is only whether the tiles say so.
expect(grid(page).get_by_text(FIX_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.
expect(pills_on(page, f"{FIX_YEAR}-06-02")).to_have_text([RANDO]) # the occasion, on the tile
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.
BG = "el => getComputedStyle(el).backgroundColor"
hue = lambda day, name: pills_on(page, day).filter(has_text=name).evaluate(BG)
first = hue(f"{FIX_YEAR}-06-02", RANDO)
assert hue(f"{FIX_YEAR}-06-08", RANDO) == first, "one occasion should read as one band"
assert hue(f"{FIX_YEAR}-06-22", PISCINE) != first, "the next occasion should break it"
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; });
The band is what the reading rests on, and a band is a property of a row of tiles rather than of any one of them. It needs two things. Within a tile the pill comes last, after the label, so that it lands on the tile’s bottom edge whatever the label above it turned out to be. And across tiles those edges have to agree: two photos of one occasion, side by side, must carry their strips at the same height, or the band is a staircase and the eye stops reading it as one thing. Each strip is filled with its occasion’s hue, dark on the light pastel so the name still reads.
at = lambda day: tile_of(page, f"{FIX_YEAR}-{day}")
lbl = at("06-02").get_by_text(FIX_LABEL).bounding_box()
pill = at("06-02").locator(".ev-pill").bounding_box()
assert pill["y"] > lbl["y"], f"the pill should come after the label: pill={pill['y']} label={lbl['y']}"
other = at("06-08").locator(".ev-pill").bounding_box() # the same occasion, further along
assert abs(other["y"] - pill["y"]) < 1, f"the band should be level: {pill['y']} vs {other['y']}"
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.
both = pills_on(page, f"{FIX_YEAR}-06-05")
expect(both).to_have_text([RANDO, SOMMET]) # the week, and the two days within it
assert both.nth(0).evaluate(BG) != both.nth(1).evaluate(BG), "the two should be told apart"
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.
expect(pills_on(page, STRAY_DAY)).to_have_count(0) # the gap in the band: nothing was on that day
print(" PASS: out-of-event doc has no pill")
An occasion is its owner’s alone, and a day belongs to everybody: two people out with cameras on the same afternoon each have their own account of it in their own calendar. So a photo takes only its owner’s occasions, and the test of that is a day where both accounts exist — each photo wearing one, rather than each wearing both.
shared = tile_of(page, f"{FIX_YEAR}-08-01")
expect(shared).to_have_count(2) # the same day, one photo each
expect(shared.nth(0).locator(".ev-pill")).to_have_count(1) # one occasion apiece…
expect(shared.nth(1).locator(".ev-pill")).to_have_count(1)
expect(grid(page).get_by_text(AYLAS, exact=True)).to_have_count(1) # …and it is their own
expect(grid(page).get_by_text(MINE, exact=True)).to_have_count(1)
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.
So on a wall of stills holding a single clip, exactly one tile carries the badge.
expect(grid(page).get_by_title("video")).to_have_count(1) # one of them, and only one, is badged
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.
Every guess in a hunt is that one act repeated, so the narrowing is read there rather
than again here.
The query itself outlives the session it was typed in: the box is saved locally, so reopening the app — or the frame rebooting itself overnight — comes back to the same question, and therefore the same show. That is read where the wall is reopened, beside the size it also keeps.
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")
A label is allowed spaces, and a spaced one is the most half-remembered kind there is — the distinctive word is the last, and the rest is the part you would not have thought to type. So the fragment offered is matched against the whole label, and what goes into the box is the whole label too, replacing what was typed rather than being appended to it. It is one word as far as the box is concerned, and finishes like one.
guess(page, "parc des oise") # part of a label that has spaces
opt = options(page).filter(has_text="oiseaux").first
expect(opt).to_be_visible()
whole = opt.inner_text().strip() # the label as it is written down
opt.click()
expect(sb).to_have_value(whole + "; ") # all of it, and not twice over
print(" PASS: a label with spaces completes as one word")
A query built over several guesses is a query with earlier guesses in it, and the one you want to reconsider is rarely the last. So the list follows the caret rather than the end of the line: put it back inside a term already typed and that is the term being completed, and the one it replaces. Otherwise going back to fix a term would mean deleting everything after it first, which is to say the box would only ever be good for the guess in hand.
year = datetime.date.today().year
sb.fill(f"until:; since:{year}") # two date terms, the later one filled in
sb.click(); sb.press("Home")
for _ in range(len("until:")): sb.press("ArrowRight") # back into the first, just past its colon
expect(options(page).get_by_text(f"until:{year}", exact=True)).to_be_visible()
expect(options(page).get_by_text(f"since:{year}", exact=True)).to_have_count(0)
print(" PASS: the list follows the caret, not the line")
The box knows its own language as well as your vocabulary, and where a fragment could be either it does not offer both. A fragment that begins one of the language’s keys is that key and nothing else, even when a label would have matched it too — because the key is something the box is certain of, and an uncertain guess offered beside a certain one only makes the certain one harder to see. The word carrying that collision is in the vocabulary above, so the offer being a list of one is a decision here and not an absence of candidates.
guess(page, "sin") # begins "since:", and sits inside a label
expect(options(page)).to_have_text(["since:"]) # the key, alone
options(page).first.click()
expect(sb).to_have_value("since:") # a key still wants its value
print(" PASS: a key prefix offers the key and nothing else")
Where a closed vocabulary is small enough to show whole, the box shows it whole rather than
waiting to be narrowed: type: has two values and there is nothing to half-know about a
choice of two.
guess(page, "type:")
expect(options(page)).to_have_text(["type:image", "type:video"])
print(" PASS: a two-value token shows both")
The other kind of bound is a date, and a date is the tedious case rather than the uncertain
one: ten characters in a format that has to be exact, and no help if it is not. So an open
since: offers the years, a chosen year offers its months, a
chosen month its days, and a range becomes a few taps. What makes that a drill rather than
a list is that the box can tell a part of a date from a whole one: a year has somewhere
further to go, so picking it leaves the term open; a day has not, so picking it closes the
term and the list stands empty, waiting for whatever comes next.
year = datetime.date.today().year - 1 # inside the span the picker offers
guess(page, "since:")
pick = lambda text: options(page).get_by_text(text, exact=True)
expect(pick(f"since:{year}")).to_be_visible() # the years
pick(f"since:{year}").click()
expect(sb).to_have_value(f"since:{year}") # a year has further to go — still open
expect(pick(f"since:{year}-06")).to_be_visible() # …and what it goes to is months
pick(f"since:{year}-06").click()
expect(pick(f"since:{year}-06-15")).to_be_visible() # then days
pick(f"since:{year}-06-15").click()
expect(sb).to_have_value(f"since:{year}-06-15; ") # a whole date is done, like any finished word
expect(options(page)).to_have_count(0) # and the list has nothing left to say
print(" PASS: a date is tapped, not typed")
A generated list can propose things that do not exist, which is worse than proposing nothing: a picker offering a thirteenth month is a picker you have to check. It only ever drills a date that could be tapped — a month is one to twelve, a day one to that month’s last — so a figure naming none of them leaves it silent rather than inventive.
Silence is the answer here, and silence is also what an empty box looks like, and what a box the list has not caught up with looks like. So each impossible figure is put in next to a possible one that differs from it by a digit: the same term, the same typing, the list filling for one and not the other. Otherwise the check would be satisfied by a list that never came at all.
for possible, impossible in [(f"since:{year}-1", f"since:{year}-0"), # no 0th month
(f"since:{year}-12", f"since:{year}-13"), # nor a 13th
(f"since:{year}-06-1", f"since:{year}-06-0"), # no 0th day
(f"since:{year}-06-30", f"since:{year}-06-31")]: # nor a 31st of June
guess(page, possible) # the list fills for the one that exists…
type_into(page, impossible)
expect(options(page)).to_have_count(0) # …and stays away for the one that doesn't
print(" PASS: the picker offers only dates that exist")
date: is the same picker reached by a different word, since a period named at one end and
a period named at both are the same act of saying when.
guess(page, "date") # 2+ chars → the key is offered
expect(options(page).filter(has_text="date:").first).to_be_visible()
sb.press_sequentially(f":{year}", delay=20)
expect(options(page).get_by_text(f"date:{year}-06", exact=True)).to_be_visible() # drilling, as before
print(" PASS: date: drills the same picker")
A date bound has the same shape as a word you half-know, and the box treats it as one. The
rolling bounds are named — lastweek, lastmonth, lastyear — and naming them means they
can be half-remembered too: the part they share is enough to bring all three up, and which
of them you meant is a thing to recognise rather than recall. One of them is a whole bound
on its own, with nothing further to drill into, so picking it closes the token like any
other finished value.
guess(page, "since:last") # a bound can be a word rather than a date
expect(options(page)).to_have_text(["since:lastweek", "since:lastmonth", "since:lastyear"])
options(page).last.click()
expect(sb).to_have_value("since:lastyear; ") # a whole bound in one word — done
print(" PASS: a rolling bound completes from its prefix")
The occasions are a vocabulary like the labels, except that nobody typed them here — they
are the calendar’s, written by whoever set the meeting up, and so are the one set of words
in the box that were never anybody’s choice of wording. All the more reason to offer them:
a name you did not pick is a name you will not reproduce. Inside event: they behave as
any finished value does, and picking one closes the term.
guess(page, EV_WINDOW + "event:zzkerm") # a prefix of an occasion's name
opt = options(page).filter(has_text=EV_KERMESSE).first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # the calendar's spelling, and done
print(" PASS: an occasion completes from the calendar")
A name nobody chose is also a name you may hold only in pieces, and in no particular order — it was the brocante, it was at the parc, and which word came first is exactly the sort of thing that goes. So the pieces are matched separately and anywhere in the name, rather than as one run from its start. That splitting is the archive’s too — the app sends the segment as typed, and would look the same however the words were matched at the other end, so the behaviour above is the occasion query’s to keep or lose.
for pieces in ("brocante parc", "parc brocante"): # either order finds it
guess(page, EV_WINDOW + f"event:{pieces}")
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: an occasion's name matches in pieces, any order")
The offer does not wait for the token, either. A bare word is put to the calendar as well
as to the labels, and an occasion it matches comes back already wearing event: — so the
fragment you had leads to the token you would have needed, and you never have to know the
token exists to use it.
guess(page, EV_WINDOW + "zzkerm") # no token typed, and none needed
opt = options(page).filter(has_text=f"event:{EV_KERMESSE}").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # handed back as the token
print(" PASS: a bare word offers an occasion as a token")
Being the calendar’s, this vocabulary is far larger than the archive and mostly about afternoons no camera came to. So the list is cut to the occasions worth picking: those overlapping the window the query already names, and those with a photo taken during them. The first keeps the list about the stretch of time being looked at; the second means no suggestion leads to an empty wall — an occasion you can pick and land on nothing is worse than one that was never offered.
guess(page, EV_WINDOW + "event:zz")
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_ELSEWHERE)).to_have_count(0) # has a photo; wrong window
expect(options(page).filter(has_text=EV_PHOTOLESS)).to_have_count(0) # right window; no photo
print(" PASS: only occasions worth picking are offered")
With nothing typed after the key, that same list is the whole of what the window holds — so the token is a way of browsing the occasions and not only of matching one, which matters when what you have is a stretch of time rather than a name.
guess(page, EV_WINDOW + "event:") # the bare key, nothing typed
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: the bare key lists the window's occasions")
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.
In practice the highlight is read out of the page in one go. A completion answering for letters typed a moment ago re-renders the list, so a row asked first for its count and then for its text can be gone by the second question; and the wait carries what it wanted and what it found, because a red that shows only under a whole suite leaves nothing else to go on.
HIGHLIGHTED = """() => { const o = document.querySelector(
'[role=listbox][aria-label=suggestions] [role=option][aria-selected=true]');
return o ? o.textContent.trim() : null; }"""
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
wait_until(page, lambda: page.evaluate(HIGHLIGHTED) == "zzupb",
label="ArrowUp from none highlights the last suggestion",
detail=lambda: f"offered {options(page).all_inner_texts()}, "
f"highlighted {page.evaluate(HIGHLIGHTED)!r}, box {sb.input_value()!r}")
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.
sb.fill(""); sb.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"), \
"the menu must overflow the popover for scrolling to matter"
for _ in range(options(page).count()): sb.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. Where
that reach is decided is worth knowing: the app hands the fragment over whole and
the vocabulary query is what says a match may begin anywhere. So this is a promise the
archive keeps, and no change to the code here can take it away — the way to break it is to
narrow that query, which lives in another note.
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.
hold_completions(page) # the vocabulary query, left unanswered
sb.fill(""); sb.click(); sb.press_sequentially("au", delay=20)
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: completion shows in-flight hint")
A popover that is only thinking is a popover that is up, so the box says so while it thinks —
otherwise the one place that reports whether the list is showing would announce collapsed
over a list plainly on screen, and a screen reader would be told the opposite of what a sighted
reader sees. That row is still on screen from just above, which is what makes this readable at
all: the query has not answered and will not.
expect(sb).to_have_attribute("aria-expanded", "true")
print(" PASS: search expanded while loading")
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. A dozen words sharing a prefix is what makes the list long enough to get down there on a screen that short.
OVERLAP_WORDS = [f"zzov{c}" for c in "abcdefghijkl"]
OVERLAP_VIEWPORT = {"width": 360, "height": 300}
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20)
expect(options(page).first).to_be_visible() # the list is up
expect(tb).to_be_visible() # and the pick still holds the bar up under it
bar = tb.bounding_box()
sug = page.get_by_role("listbox", name="suggestions").bounding_box()
lo, hi = max(bar["y"], sug["y"]), min(bar["y"] + bar["height"], sug["y"] + sug["height"])
assert hi > lo, f"setup: bar {bar} and list {sug} do not meet — there is nothing to see"
hit = page.evaluate( # what a finger at the overlap would actually land on
"([x,y]) => { const el = document.elementFromPoint(x,y);"
" return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }",
[bar["x"] + bar["width"] / 2, (lo + hi) / 2])
assert hit["list"] and not hit["bar"], f"the bar is in front of the list where they meet: {hit}"
print(" PASS: search completion sits above the selection toolbar")
The year: token completes like the others — typing it offers the key, then its years.
guess(page, "year") # 2+ chars → the key is offered
expect(options(page).filter(has_text="year:").first).to_be_visible()
sb.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")
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.
sb.fill(""); sb.click() # empty box, just focused — no typing
expect(options(page)).to_have_count(14) # the whole menu, at once
got = sorted(x.strip() for x 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")
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. Closing it is a state
flip, not a timed fade — blur the box and the list is gone at once, nothing to wait out, which
is what lets anything watching wait on the state rather than on a clock.
expect(sb).to_have_attribute("role", "combobox") # the box says what it is…
sb.fill(""); sb.click(); sb.press_sequentially(HALF_KNOWN[0][:4])
expect(sb).to_have_attribute("aria-expanded", "true") # …and 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")
Saying so while it thinks is not the search box’s own arrangement either. The three label boxes — the lightbox’s, the frame’s and the selection toolbar’s — each open a popover on a query that has not answered, and each has to report itself open while it does. Reading that means catching a box on its first word: after one list has arrived, the one underneath stays put while the next is fetched, so a box that had already offered something never has nothing to show. The lightbox’s is read where somebody is labelling a photo, with the vocabulary held quiet under the first thing they type.
hold_completions(page) # the vocabulary, left unanswered
expanded_while_loading(page, box)
print(" PASS: lightbox label expanded while loading")
The frame’s is read on the tablet, where the bar has just been woken by a passer-by.
hold_completions(page)
expanded_while_loading(page, bar.get_by_placeholder("add a label…"))
print(" PASS: frame label expanded while loading")
The selection’s is read where somebody is labelling a run.
select_all(page).click()
hold_completions(page)
expanded_while_loading(page, tb.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, so its operators are Postgres’s and
arrive without our having to write any of them.
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.
search_for(page, WHEN_TODAY) # no until: token, so the default bound decides
expect(tiles(page)).to_have_count(1) # this morning's photo is inside it
print(" PASS: today photos shown by default")
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. Two photos 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. Which year the token reaches for is its own claim, and a July shows it only for the
part of the year that July has already been: the 31st of December never has, whatever day you
ask on, so a second pair straddling the last one is where that half is read.
for spelling in ("07-14", "july-14"):
search_for(page, WHEN_JULY)
expect(tiles(page)).to_have_count(2) # both, with nothing said about when
search_for(page, f"{WHEN_JULY}; until:{spelling}")
expect(tiles(page)).to_have_count(1) # only the 10th is inside the bound
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-before-t")
search_for(page, f"{WHEN_JULY}; since:july") # the whole of that July, both back
expect(tiles(page)).to_have_count(2)
search_for(page, WHEN_LAST)
expect(tiles(page)).to_have_count(2)
search_for(page, f"{WHEN_LAST}; until:12-31") # the one last gone, not the one to come
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-yearago-t")
print(" PASS: bare month and day bounds")
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.
search_for(page, f"{WHEN_ANNIV}; onthisday")
expect(tiles(page)).to_have_count(2) # today's day and the one after it
search_for(page, f"{WHEN_ANNIV}; onthisday:5")
expect(tiles(page)).to_have_count(3) # and the one five days out
print(" PASS: onthisday")
An order is a guess of a sort too. sort:date is what you get without asking, oldest
first; sort:random hands the wall to the archive’s own myrandom draw, which is how you
look at photos you have no word for at all. The hunt’s photos carry draws of their own, the
lowest of them on the latest date, so asking for the shuffle brings up the photo the dates
had put last.
In practice the shuffle takes hold the moment it is typed, without being run: the order is the app’s own doing over photos it already holds, not a question the archive is asked, so it is the only token here that needs no commit — and the only one whose block therefore types into the box rather than committing through it.
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-0-t") # by date, oldest first
search_box(page).fill(hunt_query("sort:random"))
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-2-t") # by the draw: the latest date
print(" PASS: sort random")
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.
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.
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.
tiles(page).nth(0).dblclick()
expect(dialog(page)).to_be_visible()
expect(dialog(page).get_by_role("img")).to_have_count(1) # the doc, full size
print(" PASS: double-click opens lightbox")
That long-press is also how a touchscreen asks for its own save image callout, and the
right button asks for the same thing on a desk. But the app has its own use for every
press — this one starts a selection — so on a tile it turns that request down.
In practice, no browser will tell a test whether it went on to draw the menu; what can be read is whether the request was refused, so a listener sitting behind the app’s own reports what the app did with it. The gesture is the right button, the one a headless browser can actually make; the finger takes the same road once it has been read as a request.
def native_menu(page, target):
"""Ask for the native menu on something, and say whether the app let it through."""
page.evaluate("() => { window.__menu = null; addEventListener('contextmenu',"
" e => { window.__menu = !e.defaultPrevented; }, { once: true }); }")
target.click(button="right")
answer = page.evaluate("() => window.__menu")
assert answer is not None, "no menu was ever asked for, so nothing was answered"
return answer
assert native_menu(page, tiles(page).nth(0)) is False, "a press on a tile should raise no menu"
print(" PASS: tile context menu suppressed")
The refusal is one listener on the window, so it covers every surface the app draws — the open lightbox’s media as much as a tile. It keeps the native menu only in the text fields, where paste-and-select still earns its place.
assert native_menu(page, search_box(page)) is True, "the search box should keep its own menu"
print(" PASS: context menu allowed in text field")
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). A photo that pushed
nothing would still close on Back — by spending the app’s own root entry instead, which is
the entry that stands between a stray thumb and leaving Memories. Closing a photo would then
drag up the leaving-the-app question, over and over, all through a run. Closing a photo
never raises it.
asked = []
def note_leave(ask): asked.append(ask.message); ask.dismiss()
page.on("dialog", note_leave)
page.go_back()
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible()
page.remove_listener("dialog", note_leave)
assert not asked, f"closing a photo with Back asked to leave the app: {asked}"
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.
link = d.locator(".lb-meta").get_by_role("link", name="original") # on the date row
expect(link).to_have_text("⤢")
expect(link).to_have_attribute("title", re.compile("full resolution.*new tab"))
expect(link).to_have_attribute("href", FIXTURES[0]["cid"]) # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
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. Those rows spend
height and no width at all. The date, the states, the chips and the box are held to about a
third of the height between them; the media gets the rest, and anything less reads as
cramped. 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.
shown = img.bounding_box()
assert shown["width"] >= VIEWPORT["width"] * 0.9, f"media width {shown['width']} < 90% of viewport"
assert shown["height"] >= VIEWPORT["height"] * 0.65, f"media height {shown['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", "media crops instead of fitting whole"
print(" PASS: lightbox media fills screen")
A doc’s labels show as chips, and a chip is also the shortest way back: click one and the lightbox closes with the wall re-filtered to that label — the word you just put on a photo is, from that moment, how you reach it again.
d.get_by_role("button", name="zzcalanque", exact=True).click()
expect(d).to_be_hidden() # the lightbox closes
expect(search_box(page)).to_have_value("zzcalanque") # the filter switched to that label
expect(tiles(page)).to_have_count(1) # the one photo wearing it
print(" PASS: lightbox chip filters")
The box takes several labels at once, ;-separated, and skips any the doc already
carries.
box.fill(""); 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.
box.fill(""); box.press_sequentially("zztest;zzcal", delay=20) # typed, as a user would
opt = options(page).filter(has_text=re.compile(r"^zzcalanque$")).first
expect(opt).to_be_visible()
opt.click()
expect(box).to_have_value("zztest;zzcalanque; ") # 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="zzcalanque", exact=True)).to_be_visible()
print(" PASS: lightbox completes last segment")
Completion 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. The photo
has just been given zzcalanque, so that is the word the list has to have dropped, while
zzcigale, which it does not carry, has to still be there.
box.press_sequentially("zzcal", delay=20)
expect(options(page).filter(has_text=re.compile(r"^zzcalanque$"))).to_have_count(0)
box.fill(""); box.press_sequentially("zzcig", delay=20)
expect(options(page).filter(has_text=re.compile(r"^zzcigale$")).first).to_be_visible()
print(" PASS: lightbox completion skips present")
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.
box.fill(""); box.press_sequentially("zzcou", delay=20)
expect(options(page).first).to_be_visible()
box.press("ArrowDown"); box.press("Enter") # apply the highlight → fills "zzcousin; "
expect(box).to_have_value("zzcousin; ")
box.press("Enter") # nothing highlighted now → commit
expect(d.get_by_role("button", name="zzcousin", 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.
box.press_sequentially("zzc")
expect(box).to_have_attribute("aria-expanded", "true")
box.press("Tab") # out of the box, the ordinary way
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "the popover outlived the focus"
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.
src0 = img.get_attribute("src")
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")
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.
box.press_sequentially("zzcig", delay=20)
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
lb = listbox.bounding_box(); ib = box.bounding_box()
assert lb["y"] + lb["height"] <= ib["y"] + 1, f"the completion must open above the input: list {lb} input {ib}"
print(" PASS: lightbox completion opens upward")
And the video branch: a video-mimetype doc opens as a <video> pointed at its web_cid.
Not at its cid: the original of a clip is the whole file off the camera, and asking a
browser to stream that to decide whether to keep it is the wrong trade.
expect(v).to_be_visible()
assert VIDEO_FIXTURE["webCid"] in (v.get_attribute("src") or ""), "wrong video src"
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 order the
wall is showing, wrapping at the ends so neither end of a run is a dead stop.
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")
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
d.get_by_role("button", name="previous photo").click() # back where we started
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
d.get_by_role("button", name="previous photo").click() # and off the front
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2") # → the last, not a dead stop
d.get_by_role("button", name="next photo").click() # off the end again
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
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 — and the wall, still visible around the modal, takes the change as it happens rather than being handed a tally at the end, and goes on holding it once the photo is put away. One photo is already in the set when this one is opened, so what the wall has to show is the two of them together.
sel.click()
expect(sel).to_have_attribute("aria-pressed", "true") # now selected
expect(checks(page)).to_have_count(2) # and the wall says so under the modal
d.get_by_role("button", name="close").click()
expect(checks(page)).to_have_count(2) # the set the wall goes on holding
print(" PASS: lightbox select")
A wheel over the photo steps prev/next too, but only with Shift held: the app takes the modified flick and leaves the bare one alone, so it never eats a scroll that was meant for something else on the page. And a flick moves one photo, not a run: after a step the wheel goes deaf for a fifth of a second, so the tail of one flick cannot carry you past the photo you meant.
over = img.bounding_box()
page.mouse.move(over["x"] + over["width"] / 2, over["y"] + over["height"] / 2)
page.keyboard.down("Shift")
page.mouse.wheel(0, 240) # one flick down…
page.mouse.wheel(0, 240) # …and its tail, still inside the deaf window
page.wait_for_timeout(250)
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") # one photo, not two
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")
page.wait_for_timeout(250)
page.mouse.wheel(0, 240) # no Shift → the panel's scroll, not ours
page.wait_for_timeout(250)
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
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.
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 puts no pinch and no drag of its own on the media: two fingers magnify, and one then travels, exactly as they would on any page.
area = img.bounding_box()
cx, cy = area["x"] + area["width"] / 2, area["y"] + area["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")
SPC is the video’s play control in the lightbox: it toggles play/pause, and on a clip with
nothing left to show it replays from the start rather than leaving you on a frozen last
frame. That last quarter-second is treated as the end whether or not the element has got
round to saying so — a clip that is all but over and one that has just stopped are the same
thing to the hand on the key, and reading paused alone would make them behave differently
depending on how far the playhead happened to have crawled. Driving it from the keyboard
means it works whether or not the native control bar has focus.
That edge is a quarter of a second wide, and a clip running at speed crosses it between a key being sent and the app seeing it. So this one is played at a tenth speed and with the sound off — the second because no browser starts a clip aloud that nobody asked for, the first because at that rate a quarter-second is long enough to press a key inside. It stays slowed for as long as it is the clip on screen; stepping away and back mounts a fresh one, at ordinary speed.
v.evaluate("el => { el.muted = true; el.playbackRate = 0.1; 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 - 0.1") # running still, and all but over
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")
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) — the same quarter-second — 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 — and a clip you have stepped away from and come back to is a paused clip, since
nothing starts it but the hand, so returning to one and pressing on leaves it at once.
v.evaluate("el => el.currentTime = 0")
page.keyboard.press("ArrowRight") # → jumps 5s in
wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
page.keyboard.press("ArrowLeft") # ← jumps back to the head of it
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 0.25)
page.keyboard.press("ArrowLeft") # nothing behind it now → out of the clip
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[2]["thumbnailCid"]) # the run wraps back
page.keyboard.press("ArrowRight") # forward again, onto the clip…
page.keyboard.press("ArrowRight") # …and straight off it, since it is not running
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[0]["thumbnailCid"])
print(" PASS: lightbox video arrows")
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); commit(); 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.
Which makes the chips the first thing that answers a date you have just changed: correct the date and they are recomputed for where the photo now sits, without leaving the photo. A date is only ever wrong relative to something, and these are that something, so this is where a correction is confirmed or found to be wrong again.
expect(d.get_by_text(RANDO)).to_be_visible() # the week it was taken during…
expect(d.get_by_text(SOMMET)).to_be_visible() # …and the two days within it
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
print(" PASS: lightbox shows events")
The name alone would not settle it: knowing a photo falls inside zzRando says nothing about whether that is a week or an afternoon, and a date is only wrong relative to a span. So each chip carries the occasion’s own bounds beside its name. Both are read back through the browser, since the chip renders them in the reader’s locale and timezone and a date formatted any other way would be comparing two different things.
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
An occasion need not fill a day. When it fills only part of one, the hour is what places it, so its chip has to show the clock and not the date alone — and the clock it shows is the reader’s, since the bounds are stored as instants and rendered where they are read.
tile_of(page, f"{FIX_YEAR}-06-22").click(click_count=2)
d = dialog(page)
when = page.evaluate("""([a, b]) => {
const s = new Date(a), en = new Date(b);
const t = x => x.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
return `${s.toLocaleDateString('fr-FR')} ${t(s)} – ${t(en)}`;
}""", [f"{FIX_YEAR}-06-22T09:00:00Z", f"{FIX_YEAR}-06-22T17:00:00Z"])
expect(d.get_by_text(when)).to_be_visible() # the day, and the hours of it
d.get_by_role("button", name="close").click()
back_on_the_wall(page)
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. The scoping itself is done where the events are read, not here: the app passes the doc’s own owner and gets back what belongs to it, so getting that argument wrong returns nothing rather than somebody else’s. Showing too much would have to come from the archive, which puts this promise beyond anything the app alone can break.
mine = tile_of(page, f"{FIX_YEAR}-08-01").filter(has=page.locator(f'.ev-pill:text-is("{MINE}")'))
mine.click(click_count=2) # of the two that day, the one that is mine
d = dialog(page)
expect(d.get_by_text(MINE)).to_be_visible() # my account of that afternoon…
expect(d.get_by_text(AYLAS)).to_have_count(0) # …and not Ayla's, of the same one
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 asks for exactly that and gets out of the way. What it
asks is the event: search, written out and committed; where that leads is the search’s
business and is measured with the rest of the query language. What is the pill’s own is
that one click stands for typing it, and that having asked, the photo you were looking at
is no longer in front of the answer. It is a jump, not an edit: the calendar is untouched.
The query it writes is the whole query, not an addition to it — any window you had narrowed to is spent. That is the right call for a jump you make to see an occasion whole.
d.get_by_role("button", name=re.compile(MINE)).click() # the occasion in front of you
expect(search_box(page)).to_have_value(f"event:{MINE}") # the search typed out for you…
back_on_the_wall(page) # …and the photo stood aside for it
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 — and since the wall and the open photo are reading the same list, it takes
its new place in both at once while the lightbox keeps its footing.
What tells you the correction took is therefore the photo itself: still open, it now wears the occasions of the day you gave it. Going back to the wall is how the story carries on from there, not a second look at the same fact.
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(f"{FIX_YEAR}-06-06T12:00") # the day it was really taken
box.press("Enter") # save
expect(d.get_by_text(RANDO)).to_be_visible() # the week it was taken during…
expect(d.get_by_text(SOMMET)).to_be_visible() # …and the two days within it
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
print(" PASS: lightbox shows events")
d.get_by_role("button", name="close").click() # back to the wall, for what comes next
back_on_the_wall(page)
print(" PASS: lightbox edit date rejoins its occasions")
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())}`; }",
f"{STRAY_DAY}T12:00:00Z")
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
tiles(page).filter(has_not=page.locator(".ev-pill")).click(click_count=2) # the one with the gap
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.
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. So does the question that put those photos there: the query is kept alongside the density, and a run interrupted is a run you can walk back into rather than one you have to set up again.
w1 = t.nth(0).bounding_box()["width"]
open_app(page, launch)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # and the run is still the run
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. Page zoom happens in the browser’s own furniture rather than in the page, though, so nothing inside the document can report whether it was headed off. It wants a real hand on a real trackpad.
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.
Below that width, then, the wall buys thumbnails and nothing else — scroll it as far as you like.
assert cell_px(page) <= 300, f"a contact sheet should be drawn under 300px, and this is {cell_px(page)}px"
page.mouse.move(REFINE_VIEWPORT["width"] / 2, REFINE_VIEWPORT["height"] / 2)
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"] < REFINE_VIEWPORT["height"], \
"the wall never overflowed — nothing was scrolled into reach"
assert not web(), f"the dense wall fetched {len(web())} web renditions"
print(" PASS: a dense wall buys thumbnails and nothing else")
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()
grow_the_wall(page)
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"
print(" PASS: a grown wall trades up, the thumbnail staying 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()}"
print(" PASS: a grown wall buys narrowly")
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.
expect(thumb_imgs(page).first).to_have_attribute("src", f"/ipfs/{WAIT_HELD}") # asked for…
assert not [u for u in fetched() if u.endswith(WAIT_HELD)], "…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(REFINE_GRACE_MS)
assert not web(), f"the wall refined with a thumbnail still in flight: {web()}"
print(" PASS: the wall waits for its thumbnails before refining")
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.
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(REFINE_GRACE_MS)
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)}"
print(" PASS: a late thumbnail holds back what arrives behind it")
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)}"
print(" PASS: a tile left behind drops both its pictures")
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.
Nothing here can pin that one on its own. The tally is let go of in one place, and it is the same place a thumbnail settling releases it, so a break that strands the departed strands the settled too — and then nothing refines anywhere, which is caught by the reading that a grown wall trades up, long before this one. What this block adds is the case the release is hardest in: everything on the wall leaving at once, while one of them was still owed an answer.
search_for(page, FWD_LABEL_AFTER) # swapped out from under a thumbnail in flight
expect(tiles(page)).to_have_count(len(FWD_DOCS_AFTER))
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)}")
print(" PASS: a wall swapped out from under a thumbnail refines again")
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 five shapes: the bare position along the strip, the picture sitting there, which of the fixtures that picture belongs to, whether what has come to rest is a clip at all — a clip’s slide holds no picture, so the shape that reads a picture’s address has nothing to say about one — and how much of the screen whatever is there covers, which is a photo and a clip answering the same question and so asks after neither by name.
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'); }")
CENTERED_IS_CLIP = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" return !!(el.children[i] && el.children[i].querySelector('video')); }")
CENTERED_MEDIA = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" const m = el.children[i] && el.children[i].querySelector('video, img');"
" if(!m) return null; const r = m.getBoundingClientRect();"
" return { width: r.width, height: r.height,"
" fit: getComputedStyle(m).objectFit }; }")
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")
Leaving the show is not leaving the app. The device back button and the bar’s ✕ exit both put the wall back, where anything treating the show as a place you navigated to would carry you off to whatever page preceded Memories.
page.go_back()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible() # still on the app, not gone
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
strip.click() # a tap to bring the bar up
page.get_by_role("button", name="exit frame").click()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible()
print(" PASS: frame exits on back")
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 screen 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.
That first lull is worth catching rather than merely stepping over, because it is where momentum alone left the strip — the resting place the ease exists to correct, and the only evidence that there was anything to correct. It cannot be caught by asking from outside: each question costs a round trip, and two of them together are already about as long as the beat, so the answer that comes back is as likely to describe the strip after the ease as before it. The lull has to be found where it happens. The strip reports every scroll it makes, timestamped, and the beat is legible in that record as the one gap wide enough to be the show waiting: whatever the strip read just before that gap is where momentum left it, and everything after is the ease. What counts as wide enough is pinned on both sides. Too wide and it reaches past the beat into the ease, which reports its own scrolls a frame apart; too narrow and an ordinary stutter in the fling passes for the show’s wait. A frame’s grace under the beat clears both: longer than any gap momentum makes, shorter than the one gap it is looking for.
SETTLE_BEAT_MS = 150 # the show's own wait for quiet before it eases
FRAME_MS = 30 # the grace under it: wider than any gap momentum makes
STILL_READS = 7 # a stillness longer than that beat, in 100ms reads
SETTLE_CAP_MS = 6000 # fling + beat + ease, with room to spare
CENTRED_PX = 20 # slack against a boundary: tight, and unreachable by accident
FLING_TRIES = 4 # flings, until one coasts to rest off a boundary
# Record every scroll the strip makes, then read the beat back out of the record.
TRACE_SCROLL = """el => { const s = []; el.__scrolls = s;
el.addEventListener('scroll', () => s.push([performance.now(), el.scrollLeft])); }"""
COASTED_AT = """(el, beat) => { const s = el.__scrolls || [];
for(let i = 1; i < s.length; i++) if(s[i][0] - s[i - 1][0] > beat) return s[i - 1][1];
return s.length ? s[s.length - 1][1] : null; }"""
def hard_frame_flick(page, strip):
"""A hard, fast touch fling — the finger flies ~900px left in ten quick steps —
returning where momentum alone left the strip, and once the ease has come to rest."""
box = strip.bounding_box()
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
strip.evaluate(TRACE_SCROLL)
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)
return strip.evaluate(COASTED_AT, SETTLE_BEAT_MS - FRAME_MS)
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.
The ceiling guards the browser’s momentum rather than anything written here: no change to this app’s own code makes the fling overshoot without first breaking the wrap, which the story reads a moment earlier. So it rests on the measurement above and on nothing this app could do differently — a guard kept for the day a browser changes its mind about how far a flick carries.
CONTROLLED_SLIDES = 4
w = strip.evaluate(SLIDE_W)
for _ in range(FLING_TRIES):
before = strip.evaluate(ON_SLIDE)
coasted = 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"
strayed = min(coasted % w, w - (coasted % w))
if strayed > CENTRED_PX: break # this one has something to ease
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. Where friction stops the strip is not ours to choose, though, and now and then it stops near a boundary of its own accord — a fling with nothing to ease, which an eased strip and an un-eased one both satisfy. So the fling reports where momentum left it as well as where it ended, and one that coasted to rest already centred is flung again rather than counted. Each attempt is a whole gesture and answers the ceiling above on its own account: it is the same swipe, thrown again. Four of them is where the patience runs out — enough that all four landing centred is no longer bad luck but a strip that is snapping on its own, which would make the ease unmeasurable rather than absent, and is worth being told about rather than passed over.
rest = strip.evaluate("el => el.scrollLeft")
assert strayed > CENTRED_PX, \
f"{FLING_TRIES} flings all coasted within {strayed:.0f}px of a boundary — none could show an ease"
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 screen 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 of the screen framing its
spare axis.
In practice the reading is taken of whatever is centred, and taken twice: on the photo the show opens on, then on the clip a step along. A slide waiting off to the side is the full width of a viewport it is nowhere near, so the first one in the strip would answer yes without ever having been about the screen.
for kind in ("photo", "clip"):
m = strip.evaluate(CENTERED_MEDIA)
assert m, f"the centred {kind} shows nothing at all"
assert m["width"] == VIEWPORT["width"], \
f"the {kind} spans {m['width']}px of a {VIEWPORT['width']}px screen"
assert m["height"] == VIEWPORT["height"], \
f"the {kind} stands {m['height']}px in a {VIEWPORT['height']}px screen"
assert m["fit"] == "contain", f"the {kind} is cropped to fill rather than shown whole"
if kind == "photo":
page.keyboard.press("ArrowRight") # on to the other kind
wait_until(page, on_clip, label="the clip is centred")
assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", \
"the screen framing them must be black"
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.
In practice reading a band means putting the show on a chosen slide, and the strip is
reached 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 wait is for scrollWidth to come within one slide of full
width (a slide of slack, so a sub-pixel settle doesn’t hang it), 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; jumping before that frame fires would let the auto-centre land
afterward and undo the jump — so the wait also runs until the strip has settled on that first
slide. Only then does anything scroll.
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(BAND_DOCS) + 2),
label="the strip is laid out at full width, every slide and both 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')}")
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == 1,
label="the show has settled on its opening slide, so a jump will stick",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}")
Two things are asked of the strip by slot number, and both are written once, because one of the readings below has to do them from inside the page and the rest do them from outside: put the show on a slot, and read the image addresses sitting in a slot’s box — the thumbnail base, and the full-resolution overlay when the slide is close enough to carry one.
SLIDE_JUMP = "(el, k) => { el.scrollLeft = k * (el.scrollWidth / el.children.length); }"
SLOT_SRCS = ("(el, k) => { const s = el.children[k];"
" return s ? Array.from(s.querySelectorAll('img')).map(im => im.getAttribute('src')) : []; }")
goto(FAR) # forward to a mid slide → heading is +1
wait_until(page, lambda: web(FAR), # the centre arrives, full-res on it
label=f"the full-res reaches the centre (slot {FAR})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{FAR}]={srcs(FAR)}")
# full-res leads the way you're going: it reaches 3 ahead but only 1 behind
assert web(FAR + 3) and not web(FAR + 4), f"full-res should reach 3 ahead, got {srcs(FAR + 3)} / {srcs(FAR + 4)}"
assert web(FAR - 1) and not web(FAR - 2), f"full-res should reach only 1 behind, got {srcs(FAR - 1)} / {srcs(FAR - 2)}"
# the kept-thumbnail band leans the same way: 14 ahead, 6 behind
assert thumb(FAR + 14) and blank(FAR + 15), f"thumbnail kept 14 ahead, got {srcs(FAR + 14)} / {srcs(FAR + 15)}"
assert thumb(FAR - 6) and blank(FAR - 7), f"thumbnail kept 6 behind, got {srcs(FAR - 6)} / {srcs(FAR - 7)}"
back = FAR - 5; goto(back) # turn round → the lean must turn with you
wait_until(page, lambda: web(back - 3),
label=f"the full-res leads the reversed way (slot {back - 3})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" srcs[{back - 3}]={srcs(back - 3)} srcs[{back}]={srcs(back)}")
assert not web(back + 3), f"after turning, full-res should not still reach the old way, got {srcs(back + 3)}"
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.
In practice the reading has to be taken during the fling, so it is taken from inside the page: the scroll and the look happen two animation frames apart, far under the sixth of a second the settle waits, and a reading taken from outside could not be sure of arriving inside that window.
wait_until(page, lambda: blank(FAR), # the bands are re-picked a beat after the show moves
label="the slide to be flung onto has been let go of, so the fling has something to prove",
detail=lambda: f"srcs[{FAR}]={srcs(FAR)}")
mid_fling = strip.evaluate(f"""async (el, k) => {{
({SLIDE_JUMP})(el, k);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return ({SLOT_SRCS})(el, k);
}}""", FAR)
assert mid_fling and not is_blank(mid_fling), \
f"the slide flung onto must be asked for, not blank: {mid_fling}"
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.
expect(slides.nth(2).get_by_label("loading")).to_be_visible() # its thumbnail never arrives: still loading
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0,
label="the mark clears on the slide whose thumbnail arrived")
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.
In practice the clearing is read on the slide where both arrive, and read in two steps, because the absence of a mark means nothing on its own — a slide with no picture yet has none either. So the picture is established first; from there the quiet mark is up unless the full-res has landed, and its absence is the landing.
expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible() # picture up, full-res still out
wait_until(page, lambda: slides.nth(3).get_by_label("loading").count() == 0,
label="the picture is up on the slide where both arrive")
expect(slides.nth(3).get_by_label("fetching full resolution")).to_have_count(0) # …and nothing sharper is pending
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.
strip.click() # the bar, on the doc the show opened on
bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → its box goes to the one that never arrives
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzmark-t-1"),
label="the box that had finished loading now holds the doc that never will")
expect(slides.nth(1).get_by_label("loading")).to_be_visible() # the mark came with the new doc
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.
far_mark = strip.get_by_role("listitem").nth(FAR).get_by_label("loading")
expect(far_mark).to_have_count(0) # let go of, and its blank painted: nothing to announce
goto(FAR) # fling onto it
expect(far_mark).to_have_count(1) # its source turns real → the mark is back until that paints
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.
bar.get_by_role("button", name="done", exact=True).click() # → out of the todo filter
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9") # the one just before it
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 a clean screen 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.
bar.get_by_role("button", name="edit date").click()
box = bar.get_by_label("date", exact=True)
box.fill("2019-06-15T12:00") # the summer it was really taken
box.press("Enter")
want = page.evaluate("() => new Date('2019-06-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.
on = int(strip.evaluate(CENTERED).rsplit("-", 1)[1]) # whichever one is in front of you
bar.get_by_role("button", name="edit date").click()
want = page.evaluate("(iso) => { const t = new Date(iso), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
f"20{10 + on}-01-15T12:00:00Z") # the day it carries
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. Nothing having happened is the hard thing to see: the date on the bar reads
the same an instant after Escape whether the correction was dropped or is still travelling to
the archive, so the way to tell them apart is to wait out the journey and look again.
box = bar.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(strip).to_be_visible() # Escape backed out of the picker, not the show
page.wait_for_timeout(SAVE_GRACE_MS) # long enough that a save would have shown
expect(bar.get_by_role("button", name="edit date")).to_have_text(before) # and none did
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).
delete_btn = bar.get_by_role("button", name="delete", exact=True)
asked = []
dismiss = lambda d: (asked.append(d.message), d.dismiss())
page.on("dialog", dismiss)
delete_btn.click() # ask, and say no
wait_until(page, lambda: bool(asked), label="delete asks first")
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9", "saying no must keep the photo"
page.remove_listener("dialog", dismiss)
page.on("dialog", lambda d: d.accept())
delete_btn.click() # ask, and say yes
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-8") # gone, and re-anchored
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. Staying on the wall is a thing that fails to happen, and nothing that fails to happen can be seen the instant a page opens — an empty page and a page whose show has yet to appear look the same. So the relaunch is given what it would need to start the show, the wall’s docs, and then the time it would have taken.
page.keyboard.press("Escape") # leaving turns the memory off
expect(strip).to_be_hidden()
open_app(page, CABINET_QS)
expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs an auto-entry would place from
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired
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. Standing still is not what keeps it out: auto-entry waits on the wall having docs to show, so a wall that never reads again never gives it a second chance, and it never gets asked the question. A tablet’s wall reads again on its own, as the archive it is pointed at fills up; here the reading is provoked by hand, with a chip, because what has to hold is about the docs and not about who caused them — a fresh set of docs is not a fresh launch.
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
chip(page, "todo").click() # …and the wall reads again, as it will
expect(tiles(page)).to_have_count(len(FIXTURES)) # fresh docs — the chance a re-entry would take
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired
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.
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20)
expect(options(page).first).to_be_visible() # vocabulary suggestions
assert CABINET_STEM 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.
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM)
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. Both of the cabinet’s words answer the one stem, so one settled list answers both halves — and it is read in a single snapshot rather than asked twice, because the bar this list hangs under takes itself away when nothing has touched it, and a question asked twice can be answered the second time by an empty room.
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20)
expect(options(page).filter(has_text=re.compile(f"^{CABINET_FREE}$")).first).to_be_visible()
offered = [t.strip() for t in options(page).all_inner_texts()]
assert CABINET_FREE in offered, f"the list went before it could be read: {offered}"
assert CABINET_WORN not in offered, f"a word every slide already wears was offered: {offered}"
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.
wait_until(page, on_clip, label="the clip comes round")
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(CLIP_HOLD_MS) # two ticks, and half of a third
assert on_clip(), "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) or "").endswith("zzclip-t-1"),
label="the show moves on once the clip has finished")
print(" PASS: frame video holds the show")
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.
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.
page.wait_for_timeout(PINCH_HOLD_MS) # past the touch-idle, and several ticks
assert strip.evaluate(CENTERED) == held, "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.
nudged = strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4); // 40% in: well off any boundary
el.dispatchEvent(new Event('scroll')); return el.scrollLeft; }""")
page.wait_for_timeout(SNAP_GRACE_MS) # well past the settle-snap's own beat
assert abs(strip.evaluate("el => el.scrollLeft") - nudged) <= 1, \
"a magnified slide must be left where it was nudged, not pulled to a slide edge"
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.
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"))
page.keyboard.press("ArrowRight") # step the show off it
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the clip stopped once its slide left the screen")
print(" PASS: frame video pauses when it leaves")
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.
page.keyboard.press("ArrowLeft") # back over the clip…
wait_until(page, on_clip, label="back on the clip")
page.keyboard.press("ArrowLeft") # …to the photo ahead of it
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-0"),
label="back on the photo ahead of the clip")
strip.click() # the bar, on that photo
frame_bar = page.get_by_role("toolbar", name="frame actions")
frame_bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → the boxes close up
expect(tiles(page)).to_have_count(len(CLIP_DOCS) - 1)
wait_until(page, on_clip, label="the clip has taken the retired photo's box")
v = strip.locator("video").first # a different element from the one just watched
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press("ArrowRight")
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the re-dealt clip stopped once its slide left the screen")
print(" PASS: frame video pauses after reflow")
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); }); });
So the clip is where that reading is confirmed. Nothing this note can break will pin it
there, and the attempt was made: rewiring the watch onto click does break it, but the
same rewiring breaks a tap on a photo, and the photo tap is measured several movements
earlier — so the red lands there and never reaches here. What this one adds is not a
failure of its own but the case the pointer path exists for; the day somebody reaches for
onClick because it is shorter, this is the block that says which slide paid for it.
page.keyboard.press("ArrowLeft") # back onto the clip
wait_until(page, on_clip, label="back on the clip")
cdp = page.context.new_cdp_session(page) # a real touch tap: no click reaches a <video>
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": box["x"] + box["width"] * 0.92, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-1"),
label="the side-tap stepped the show off the clip")
print(" PASS: frame tap steps over video")
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 wall behind the show is still listening to the same keyboard, and it has uses of its
own for these keys. The two do not even read them the same way: the show takes an arrow
as an arrow and steps, holding Shift or not, while to the wall that same press is a
reach — an instruction to start picking photos out. So standing aside cannot be left to the
keys themselves to sort out: while the show is up it has them. A slideshow you walked away
from should not have quietly chosen anything.
page.keyboard.press("Shift+ArrowRight")
page.keyboard.press("Shift+ArrowRight")
expect(checks(page)).to_have_count(0) # nothing chosen behind the slideshow
print(" PASS: the frame owns the keys")
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.
page.go_back()
wait_until(page, lambda: len(asked) == 1, label="going back with nothing up asks first")
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.
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(sb).to_be_visible() # and we're still in Memories
assert len(asked) == 1, f"retracting the list should have asked nothing: {asked}"
print(" PASS: back closes completion")
Retracting the list must not spend the root guard itself, though: a further Back, now with nothing showing, still meets that prompt.
page.go_back() # a further Back: must reach the leave guard
wait_until(page, lambda: len(asked) == 2, label="the guard was still there to meet")
expect(sb).to_be_visible() # cancelled → still in the app
print(" PASS: back after completion still guards exit")
And the other answer to that prompt has to work too, or the guard is a door that never opens: say yes and the app really does let go, back to whatever the tab held before it. That reading comes last of all, because there is nothing on the other side of it.
leaving.append(True) # this time the prompt is answered yes
page.go_back()
wait_until(page, lambda: heading(page).count() == 0,
label="the app let go of the tab",
detail=lambda: f"still showing {page.url}")
print(" PASS: back confirmed leaves")
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.
expect(bar.get_by_text(CABINET_EVENT)).to_be_visible() # whatever slide it rests on, it is that
day = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(bar.get_by_text(day(CABINET_FROM))).to_be_visible() # the span's start, beside the name
expect(bar.get_by_text(day(CABINET_TO))).to_be_visible() # …and its end
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.
day = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", iso)
expect(bar.get_by_text(day(CABINET_FROM))).to_be_visible() # the span's start, beside the name
expect(bar.get_by_text(day(CABINET_TO))).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.
bar.get_by_role("button", name=re.compile(CABINET_EVENT)).click() # follow the occasion
expect(search_box(page)).to_have_value(f"event:{CABINET_EVENT}") # the pill's search, committed
expect(strip).to_have_count(0) # and out of the show with it
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.
In practice that wait is a wait for a navigation, and is spelt as one: a reading taken of the page while it is being replaced is not a reading of anything, and asking for the scale across the reload is how you get told the context has gone.
page.wait_for_url(re.compile(r"[?&]z=")) # left alone, it gives up and reloads itself…
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01,
label="…onto an address the browser has never seen zoomed, so it lands at 1:1")
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.
d.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(d).to_be_hidden() # the lightbox gives way to the show
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == middle["thumbnailCid"],
label="the show opens on the photo you were looking at",
detail=lambda: f"it opened on {strip.evaluate(CENTERED)!r}")
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.
page.go_back() # out of the show…
expect(strip).to_be_hidden()
expect(d.get_by_role("img")).to_have_attribute("src", middle["thumbnailCid"]) # …onto the photo it came from
page.go_back() # out of the lightbox…
expect(d).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.
# 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, shown = named(), docs[photo]
assert said["cid"] == shown["cid"], f"named the wrong doc: {said}"
assert said["webCid"] == shown["webCid"], f"no downscaled address: {said}"
assert said["mimetype"] == shown["mimetype"], f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
[said.get("date"), shown["date"]]), \
f"named a different instant than the doc's {shown['date']}: {said}"
print(" PASS: frame publishes what is showing")
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") == docs[never_downscaled]["cid"],
label="the name follows the slide", detail=lambda: str(named()))
print(" PASS: the name follows the slide")
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") == docs[never_downscaled]["cid"],
label="a touch takes the room back", detail=lambda: str(named()))
print(" PASS: a touch takes the room back")
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. A frame whose names are landing says so.
wait_until(page, lambda: bar.get_by_text("live", exact=True).count() == 1,
label="the bar says the link is live",
detail=lambda: f"the bar is up: {bar.is_visible()}; it reads "
f"{bar.text_content() if bar.count() else '—'!r}")
print(" PASS: the bar says the link is live")
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.
expect(bar.get_by_text("offline", exact=True)).to_be_visible()
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.
Where that ordering is applied is not something this note measures. A wall of four reads the same whether the filter went in before the sample or after it, and a set of fixtures large enough to tell the two apart would have to be most of an archive — so what is pinned below is the narrowing itself, and where the photos went.
A run judged down to nothing is the plainest way to see that narrowing: the wall you were working is empty, and every photo that left it is waiting under the chip you sent it to.
expect(tiles(page)).to_have_count(0) # the todo chip has nothing left
chip(page, "all").click()
expect(tiles(page)).to_have_count(RUN_N) # they are all still there
chip(page, "done").click()
expect(tiles(page)).to_have_count(RUN_N) # under the verdict you gave them
expect(grid(page).get_by_text("done")).to_have_count(RUN_N) # and their badges say 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.
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
expect(tiles(page)).to_have_count(RUN_N) # and the wall it was showing
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.
select_all(page).click()
expect(checks(page)).to_have_count(n) # 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.
t.nth(0).click() # anchor on the first tile
t.nth(n - 1).click(modifiers=["Shift"]) # extend the selection to the last
expect(checks(page)).to_have_count(n) # the ones between are 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.
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(n - 1).click() # a plain tap now extends the run
expect(checks(page)).to_have_count(n)
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).
press_and_hold(page, t.nth(0)); page.mouse.up() # held past the threshold, then let go
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(n - 1).click() # a plain tap completes the range
expect(checks(page)).to_have_count(n)
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.
t = tiles(page)
t.nth(0).click() # pick the first (the 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 two and you are holding two in a row — far 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.
t = tiles(page)
t.nth(0).click() # pick the first (the toolbar appears)
expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
t.nth(1).click(modifiers=["Shift"]) # a shift-click ropes in the run
expect(checks(page)).to_have_count(2)
toolbar(page).get_by_role("button", name="clear", exact=True).click()
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.
press_and_hold(page, t.nth(0)) # held past the threshold → armed at the anchor
expect(checks(page)).to_have_count(1) # just the anchor so far
drag_onto(page, t.nth(n - 1))
expect(checks(page)).to_have_count(n) # 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.
press_and_hold(page, t.nth(0)) # armed at the anchor again
drag_onto(page, t.nth(n - 1))
expect(checks(page)).to_have_count(n) # grew to the whole run…
drag_onto(page, t.nth(n - 2))
expect(checks(page)).to_have_count(n - 1) # …then coming 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.
a = t.nth(0).bounding_box(); end = t.nth(1).bounding_box() # its neighbour, wherever the columns fall
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(LONG_PRESS_MS) # hold past the long-press → arms range
for f in (0.25, 0.5, 0.75, 1.0): # drag off the anchor onto the next tile
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(2) # the run followed the finger, not one lonely anchor
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.
m = len(DRAG_DOCS)
last = t.nth(m - 1).bounding_box()
xl = last["x"] + last["width"] / 2 # the last tile's column; its row is below the fold
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(LONG_PRESS_MS) # 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": DRAG_VIEWPORT["height"] - 10}]})
wait_until(page, lambda: checks(page).count() == m, # the wall scrolls the rest under the finger
label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{m} selected")
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(m)
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.
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(n)
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.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")
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. Which is the point of it: on a card-dump the difference between the two is ticking forty tiles and not.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
t.nth(0).click() # just one, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click() # widen the scope
tb.get_by_role("button", name="done").click()
expect(grid(page).get_by_text("done")).to_have_count(n) # all of them, off one tick
print(" PASS: batch all-matching state")
With the switch off, a state reaches the ticked photos and stops there — the ones you left alone keep the verdict they had, which is the whole reason for ticking rather than widening.
t.nth(0).click(); t.nth(1).click() # two of the three
tb.get_by_role("button", name="next", exact=True).click()
expect(grid(page).get_by_text("next")).to_have_count(2) # the ticked ones move…
expect(grid(page).get_by_text("done")).to_have_count(1) # …and the third keeps what it had
print(" PASS: batch set 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. Seeing
it takes a wall two people share — one photo each, one word between them — and the filter
narrowed to his.
OWN_LABEL = "zzownb"
OWN_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzownb-{who}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzownb-{who}-t",
"labels": OWN_LABEL, "state": "todo", "owner": who}
for i, who in enumerate(["konubinix", "aylapomme"])]
OWN_COUNT = ("query($o:[OwnerType!],$s:[State!]){ photovideosCount(search:\"" + OWN_LABEL + "\","
" since:\"2007-01-01\", until:\"2035-01-01\", owners:$o, states:$s) }")
def own_count(owner, state):
"""Ask the archive itself, since hers is the one photo the wall is not showing."""
return gql(OWN_COUNT, {"o": [owner], "s": [state]})["data"]["photovideosCount"]
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="done").click()
wait_until(page, lambda: own_count("konubinix", "done") == 1,
label="his photo took the verdict")
assert own_count("aylapomme", "todo") == 1, "the widening reached past the owner filter"
print(" PASS: bulk all-matching respects owner")
The same wide scope adds a label across the whole filter, not only a state.
t.nth(0).click()
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill("zzbulkall")
tb.get_by_role("button", name="add label").click()
search_for(page, "zzbulkall") # all of them carry it now
expect(tiles(page)).to_have_count(n)
expect(grid(page).get_by_text("zzbulkall")).to_have_count(n) # and the wall reads it back
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.
COMMA = "zzleft, zzright" # one ;-token, with a comma inside it
search_for(page, FIXTURE_LABEL)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(COMMA)
tb.get_by_role("button", name="add label").click() # the ticked path ;-joins it on
expect(checks(page)).to_have_count(0) # applied, so the box has been emptied
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(n) # it went on as one word
select_all(page).click()
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill(COMMA)
tb.get_by_role("button", name="remove label").click() # the wide path, through the archive
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(0) # the whole word came off, not its halves
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){
return editing(async tick => {
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 }); tick(); }
}
clearSel();
await refetch();
});
}
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.
A caller may narrow the filter it goes out with — the move pins it to one owner — never widen
it: what the person typed is the outer bound of anything the bar does.
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK_ARG = { SetState: ['State', 'toState'], SetDate: ['Datetime', 'toWhen'],
AddLabel: ['String', 'label'], RemoveLabel: ['String', 'label'] };
const BULK = k => { const [ty, arg] = BULK_ARG[k];
return `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${ty}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${arg}:$v}){ result } }`; };
async function applyBulk(kind, v, over){
return editing(async tick => {
await gql(BULK(kind), { ...filterVars(), ...over, v }, PV_CTX);
tick(); clearSel(); await refetch();
});
}
Every edit on the bar picks its path by the all matching flag: add-label, remove-label,
set-state and the date stamp each route to the bulk function when the whole filter is the
target, else to patchSelected over the ticked set. So does moving onto an occasion, which
goes out as a dated bulk edit pinned to the occasion’s owner. 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. The box has to have something to find, so the run lends
it a word of its own.
COMBO_WORDS = ["zzcombolabel"]
box = tb.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzcombo")
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()}>🕓</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}>→ 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">↓</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.
select_all(page).click()
box.click(); box.press_sequentially(RANDO, delay=20)
tb.get_by_role("option").first.click() # the hike itself: the nearer of the two
move_btn.click()
expect(checks(page)).to_have_count(0) # applied → the selection clears
# they wear the occasion's start day now — the wall re-anchors, and a tile's alt is its day
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-01")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers stayed where it was saved
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.
box.fill(""); box.press_sequentially(RANDO, delay=20)
opts = tb.get_by_role("option")
expect(opts).to_have_count(2) # the hike and its namesake both answer to it
expect(opts.first).not_to_contain_text(RANDO_FAR) # the nearer of the two leads
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, a search reads each photo’s own owner’s. Applying it touches only a doc whose owner holds the picked occasion, which is why the count landing on the occasion’s day above is three and not four: the fourth was hers, and the move declined it.
The rule survives the widening, but by a different road — the ticked path declines doc by doc, the wide one pins the filter to the owner before it goes out — so it is worth watching once more from the other side: one tick, the switch on, and every photo of his the search holds moves, and still none of hers.
t.nth(0).click() # one of the three that are mine
tb.get_by_role("button", name=re.compile("all .* matching")).click()
box.click(); box.press_sequentially(PISCINE, delay=20)
tb.get_by_role("option").first.click()
move_btn.click()
expect(checks(page)).to_have_count(0)
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-20")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers, still where it was
print(" PASS: move is owner-scoped when widened")
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.
box.fill(""); box.press_sequentially("zzChez", delay=20)
expect(tb.get_by_role("option", name=re.compile(MINE))).to_be_visible() # mine — offered
expect(tb.get_by_role("option", name=re.compile(AYLAS))).to_have_count(0) # hers — never shown
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 — so the wall re-anchors and the moved photos
slide into the band. It takes the all matching scope like every other edit on the bar, and
an occasion belongs to one person, so widening it means narrowing to that person: the wide
move restamps the match for that owner alone. That is a tightening and never a reach — the
occasion could only have been offered because somebody on the wall owns it — so the widened
move stays inside what the person is looking at, as everything on this bar must.
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
if(allMatching()) await applyBulk('SetDate', ev.starttime, { owners: [ev.owner] });
else 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.
box.fill(""); box.press_sequentially(RANDO, delay=20)
expect(move_btn).to_be_disabled() # typed text is not a pick
tb.get_by_role("option").first.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
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.
box.fill(""); box.press_sequentially(RANDO, delay=20)
opt = tb.get_by_role("option").first
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 shared highlight landed on it
box.press("Enter") # Enter picks the highlighted one → arms
expect(move_btn).to_be_enabled()
expect(box).to_have_value(RANDO) # the pick filled the box
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.
t.nth(0).click() # drop one tile → the selection changes
expect(box).to_have_value("") # the armed pick is dropped, the box cleared
expect(move_btn).to_be_disabled()
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 it need not fall anywhere near the stretch the wall is showing, since
the whole trouble is that the photos landed nowhere near it. Both are why the box reads the
calendar itself rather than the wall’s window of events: an occasion with no photo, a year past
the window, is still there to pick. (The search box’s event: completion, which offers only
occasions that already hold a photo, would hide exactly this one.)
box.fill(""); box.press_sequentially(VIDE, delay=20)
expect(tb.get_by_role("option", name=re.compile(VIDE))).to_be_visible()
print(" PASS: move offers a 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;
const when = new Date(v).toISOString();
if(allMatching()) await applyBulk('SetDate', when);
else await patchSelected(() => ({ date: when }));
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()}>🕓</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}>→ 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. Photos
scattered across a year, some of them ticked and stamped to one midday in another (noon, so no
timezone drags the instant onto an adjacent day), leave the year they were spread over and turn
up together under the stamped day; the ones left alone keep the day they had.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n) # three days months apart
t.nth(0).click(); t.nth(1).click() # two of them
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2018-06-15T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the selection cleared, so the bar goes
search_for(page, FIXTURE_LABEL + "; date:2018-06-15") # the ticked ones land together…
expect(tiles(page)).to_have_count(2)
search_for(page, FIXTURE_LABEL + "; date:2020") # …and the third keeps the day it had
expect(tiles(page)).to_have_count(1)
print(" PASS: batch date stamps the selection")
And with the scope widened the same picker reaches the whole match, through the archive’s own restamp rather than a write per cid — which is the point, since the run it is for is the card whose camera clock was wrong, and that card is a filter and not a list anybody would tick.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
t.nth(0).click() # one tick, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2016-03-04T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the scope let go, so the bar goes
search_for(page, FIXTURE_LABEL + "; date:2016-03-04") # every one of them, off one tick
expect(tiles(page)).to_have_count(n)
print(" PASS: batch date all-matching")
The picker mirrors the bar’s other controls; the popover reuses the upward-opening shape the
label and move completions use, and the → 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")
expect(dialog(page)).to_have_count(0)
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).
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)
d = dialog(page)
expect(d).to_be_visible()
lb_hues = pill_hues(d)
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.
Pressing a state button for real starts a write, and the order below is not free because of
what that write does when it lands. lbPatch sets the open doc again from a
requestAnimationFrame after its refetch, with nothing asking whether the photo is still
open — so a photo shut while its verdict is in flight comes back on its own a second later.
That is a defect, and it is not fixed here; until it is, the state button is pressed first
and its verdict waited for, so that whatever closes the photo next cannot fall inside the
write.
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="done", exact=True)) # a state button
assert held != rest, f"the state button gives no press feedback: rest={rest} held={held}"
expect(grid(page).get_by_text("done")).to_have_count(2) # …and the press was a press: the verdict landed
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}"
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
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 — a folder’s Shift-selection, built
without the mouse. Whatever was selected before the Shift-run is kept underneath.
page.keyboard.press(" ") # one picked already, before any run
expect(checks(page)).to_have_count(1)
page.keyboard.press("ArrowRight") # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(1) # and selects nothing of its own
page.keyboard.press("Shift+ArrowRight") # reach on…
page.keyboard.press("Shift+ArrowRight") # …and on again
expect(checks(page)).to_have_count(4) # the run from the anchor, over the one kept
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(3)
page.keyboard.press("Shift+ArrowLeft") # back onto the anchor → the run is just it
expect(checks(page)).to_have_count(2) # and the one kept underneath is still there
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"); page.keyboard.press("ArrowDown") # two rows down
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(2 * cols))
page.keyboard.press("Escape")
page.keyboard.press("ArrowUp") # back up by a row
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 steps a row, both ways")
All of which needs one piece of state the wall did not have: an anchor, dropped wherever
a plain move lands and left there while a Shift-run reaches away from it. The run is not
accumulated as it goes — it is recomputed from the anchor on every step, over a snapshot of
what was picked before the run began. That is what lets a reversal shrink it instead of
stranding a tail, and what keeps the earlier selection underneath.
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"] + 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.
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' : '';
});
});
Clearing the bar cuts both ways: 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")
An open doc takes the keys off the wall entirely, arrows and all, because the same arrows mean something there. What must not happen is both at once: the wall quietly gathering a run behind a photo you are only looking at.
open_doc(page, 0)
page.keyboard.press("Shift+ArrowRight") # a wall gesture, aimed at the open doc
page.keyboard.press("Escape")
expect(checks(page)).to_have_count(0) # nothing was gathered behind it
print(" PASS: an open doc owns the keys")
And when the whole wall is wanted rather than a run of it, Ctrl+A (⌘A on a Mac) takes
it in one — the keyboard twin of the select-all toggle, so a bulk edit needs no reaching
either.
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")
All of which holds only while the wall is what the keys are for. The same hand types into
the search box, and there an arrow belongs to the text — a cursor creeping along the wall
behind a half-typed query would move what you are about to act on without your seeing it.
So every one of them goes quiet the moment a field takes the keys — the arrows, and
Ctrl+A with them, which in a text box means the text.
on = lambda: grid(page).locator("[data-cursor='1'] img").get_attribute("src")
was = on()
search_box(page).click() # the box takes the keys
page.keyboard.press("ArrowRight")
page.keyboard.press("ArrowDown")
assert on() == was, f"the wall cursor moved while the search box had focus: {was} → {on()}"
print(" PASS: grid cursor stands aside for a field")
All of it hangs on a listener at the window rather than on any tile, since the wall has no focus of its own to hang it from — which is also why standing aside has to be done by hand, surface by surface, rather than falling out of where the focus is. The frame is the third of them, owning the keys the same way for as long as it is up.
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.
tiles(page).nth(0).click() # pick the first — the click plants the cursor there
page.keyboard.press("Shift+ArrowRight") # extend the run to the next; 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(KEY_N) # 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 the second → opens it
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
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.
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
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.
Nothing in this note can pin that one on its own. The cursor and the wall’s scroll are set from a single reading of what you are looking at, and that reading names the two surfaces in one expression, so every break that takes the cursor off a slide takes the wall off it in the same stroke — and the wall is read first. What this block adds is the second surface for a mechanism proved on the first; when it goes red it will be in company, and which of them is the story is what it is here to say.
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
print(" PASS: frame nav follows cursor")
Whether a tile is somewhere you could see it is read off its own box against the window. Both blocks below ask it of the tile the cursor came to rest on; the session that runs them asks it the other way round first, of the same tile, to be sure it was out of sight before the trip that should have brought it back.
IN_VIEW = "el => { const r = el.getBoundingClientRect(); return r.top < window.innerHeight && r.bottom > 0; }"
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.
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled the cursor's tile into view")
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.
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled to the slide we stopped on")
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.
lb_box = d.get_by_placeholder("add a label…")
expect(lb_box).not_to_be_focused()
page.keyboard.press("l")
expect(lb_box).to_be_focused()
expect(lb_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.
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).not_to_be_focused()
page.keyboard.press("l")
expect(tb_box).to_be_focused()
expect(tb_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 — with the word applied last of all, wherever it was
applied, which is why the session reaches this having already put a different one on
from the wall.
lb_box.fill(SECOND_WORD); lb_box.press("Enter") # applied → the newest word remembered
expect(d.get_by_role("button", name=SECOND_WORD, exact=True)).to_be_visible()
lb_box.blur() # leave the field, so '.' is a shortcut again
page.keyboard.press(".")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value(SECOND_WORD) # the newest, not the one before it
print(" PASS: label repeat fills lightbox box")
And on the wall, with a selection up, . refills the toolbar’s box the same way.
tb_box.fill(FIRST_WORD); tb_box.press("Enter") # applied to the one picked → remembered, selection clears
tiles(page).nth(1).click() # pick another, so the bar is back
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).to_have_value("") # the bar lets the word go once the write lands
tb_box.blur() # leave the field, so '.' is a shortcut again
page.keyboard.press(".")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value(FIRST_WORD)
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.
A real install cannot be driven from a test, so what is read is the scaffolding an install
needs: the manifest is linked and declares fullscreen, and the service worker reaches
ready.
href = page.locator("link[rel='manifest']").get_attribute("href")
assert href, "no manifest linked"
man = page.evaluate("h => fetch(h).then(r => r.json())", href) # the one the page declares
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.
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.
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
- a duplicate-photo batch resolver with hyperapp
- frise chrono
- pwa to create printable thumbnails
- testing render and sync stacks for local-first PWAs
- The browser suite
- the data layer behind my photo apps
- trigger list pwa