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
- 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.
@testcase
def test_sharing_a_few_photos(page):
"""Someone asks for an afternoon's photos: search the tag, pick, and take each
at the size that suits."""
docs = send_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page)
search_for(page, SEND_LABEL)
expect(tiles(page)).to_have_count(len(docs))
photo, never_downscaled, video = 0, 1, 2 # shown oldest first
tiles(page).nth(photo).click() # the one worth sending
expect(checks(page)).to_have_count(1)
tb, got = toolbar(page), {}
for res in ["web", "orig"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
got[res] = (di.value.url, di.value.suggested_filename)
assert docs[photo]["webCid"] in got["web"][0], f"web → webCid: {got}"
assert docs[photo]["cid"] in got["orig"][0], f"orig → the doc's own cid: {got}"
print(" PASS: download selection")
names = {res: name for res, (_, name) in got.items()} # the two the photo just yielded
assert len({*names.values()}) == len(names), f"same name → they collide in the folder: {names}"
for res in names:
assert res in names[res], f"rendition missing from the name: {names}"
print(" PASS: download names by resolution")
tiles(page).nth(never_downscaled).click()
expect(checks(page)).to_have_count(2)
saved = [] # armed here: only this click's files count
page.on("download", lambda d: saved.append(d.url))
toolbar(page).get_by_role("button", name="web", exact=True).click()
wait_until(page, lambda: len(saved) >= 1, label="the doc that has a web copy saves it")
page.wait_for_timeout(500) # room for a second, unwanted save to land
assert len(saved) == 1, f"expected the one web copy, got {len(saved)}: {saved}"
assert docs[photo]["webCid"] in saved[0], f"the wrong rendition came down: {saved[0]}"
print(" PASS: download skips a missing rendition")
tiles(page).nth(photo).click()
tiles(page).nth(never_downscaled).click()
tiles(page).nth(video).click()
expect(checks(page)).to_have_count(1)
tb, ext = toolbar(page), {}
for res in ["orig", "web"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
ext[res] = di.value.suggested_filename.rsplit(".", 1)[-1]
assert ext["orig"] == "mov", f"original keeps its real type: {ext}"
assert ext["web"] == "mp4", f"a video's web copy is mp4: {ext}"
print(" PASS: download extensions by rendition")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
unserve_bytes(*SEND_BYTES)
The cabinet plays on its own
A tablet sits on the cabinet showing the archive, and the point of it is that nobody tends it. You set a search going and walk off; it plays through by itself. Somebody passing touches the glass, gets the controls, reads what per is looking at, and leaves — and the controls tidy themselves away. Relaunch it and it is still showing, on the slide it stopped on. Only when you deliberately leave the show does it stay left.
Its tempo and the patience of its control bar are the two knobs the show runs on — a minute a slide and twenty seconds of bar by default, both far too slow to sit and watch, so the cabinet here is wound to a second or two. The third number is the suite’s own: how long to leave a just-exited show alone before believing it really isn’t coming back.
CABINET_MS, CABINET_UI_IDLE_MS = 1200, 2000
CABINET_QS = f"?ms={CABINET_MS}&uiidle={CABINET_UI_IDLE_MS}"
FRAME_REENTRY_GRACE_MS = 300
The three fixtures give it a short loop to play, and the show opens on the wall they make. 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}]
@testcase
def test_the_cabinet_plays_on_its_own(page):
"""A tablet left on the cabinet: it advances by itself, yields its controls to a
passer-by, and comes back where it was after a relaunch."""
make_fixtures()
seed_events(CABINET_EVENTS)
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()
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")
finally:
drop_events(CABINET_EVENTS)
Somebody stops to look
The show is playing to an empty room and you walk up to it. From here on it is yours: you want to go back a couple, hold on one, run forward through a stretch you don’t care about. Sometimes that happens at a desk with arrow keys under your hands, and sometimes at the cabinet where the only instrument is a finger — the same show either way, so both have to drive it. The finger is the harder half, because one instrument has to say four things:
- a tap near an edge — step the show
- a tap in the middle — show me the controls
- two fingers at once — let me look closer
- one finger travelling — run me forward a stretch
Nothing distinguishes them but where they land, how many there are, and whether they move; read one as another and the show lurches when you meant to pause it.
Then you have seen what you came for and give the show back — the way out has to leave the app standing, not navigate off it — and once it is running unattended again it behaves as it did before you arrived: it yields the moment it is touched, and picks up again once you have gone.
So it wants a long strip — twelve years, one photo each, enough that a hard fling has somewhere to travel. A strip that loops carries a copy of its last photo before the first and a copy of its first after the last, so stepping off either end has somewhere to land; that padding is why the opening photo sits one along rather than at the very start.
It also wants two tempos. For the deliberate half, one wound so far down that nothing moves unless you move it; for the closing beat, one brisk enough to watch, with a short patience so its resumption can be seen rather than waited out. Two further spans are the suite’s own, both of them room for something to happen: one for a step already under way to finish before a reading is taken, and one in which a gesture that should have moved the show would have shown it.
SWIPE_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzswipe-{i}", "date": f"20{10+i:02d}-01-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzswipe-t-{i}",
"labels": "zzswipe", "state": "todo"} for i in range(12)]
STILL_QS = "?ms=999999" # a tempo no test will ever outwait
LIVE_MS, LIVE_RESUME_MS = 400, 2500
LIVE_QS = f"?ms={LIVE_MS}&idleresume={LIVE_RESUME_MS}"
LIVE_SETTLE_MS = 300
FRAME_STEP_GRACE_MS = 300
@testcase
def test_somebody_stops_to_look(page):
"""Taking the show over by hand — arrows and taps step it, two fingers don't, a
fling coasts under control — then giving it back and letting it run on."""
for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, STILL_QS)
search_for(page, "zzswipe")
expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > 0, label="the strip settles")
first = strip.evaluate(ON_SLIDE)
box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
page.keyboard.press("ArrowLeft") # off the front → the last doc
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
label="a step back off the first lands on the last",
detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
page.keyboard.press("ArrowRight") # off the end → the first
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame wraps both ways")
page.mouse.click(box["x"] + box["width"] / 2, midY) # centre third → the bar
expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
print(" PASS: frame centre tap reveals the bar")
page.keyboard.press("ArrowRight") # focus is on the body, not a control
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.keyboard.press("ArrowLeft") # back where we started
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame arrow steps off control")
page.mouse.click(box["x"] + box["width"] * 0.92, midY) # right third → forward
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.mouse.click(box["x"] + box["width"] * 0.08, midY) # left third → back
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame tap zones step the show")
x = box["x"] + box["width"] * 0.92 # the right third — a lone tap here would step forward
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": x - 20, "y": midY}, {"x": x + 20, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
page.wait_for_timeout(FRAME_STEP_GRACE_MS) # long enough for a step to show
assert strip.evaluate(ON_SLIDE) == first, "a two-finger gesture must not step the show"
print(" PASS: frame two-finger gesture does not step")
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")
# …and set it going again, unattended, at a tempo worth watching
open_app(page, LIVE_QS)
expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
box = strip.bounding_box()
opened = strip.evaluate(ON_SLIDE) # wherever the show resumed
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened, # …and it is advancing from there
label="the show is running before we touch it")
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) # a centre tap: real interaction, no nav
page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS) # let any in-flight step settle
held = strip.evaluate(ON_SLIDE)
page.wait_for_timeout(LIVE_RESUME_MS // 2) # several tempo ticks, still inside the resume span
assert strip.evaluate(ON_SLIDE) == held, f"interaction must stop the show; it drifted {held}→{strip.evaluate(ON_SLIDE)}"
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held) # quiet long enough → resumes on its own
print(" PASS: frame pauses on interaction")
finally:
for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]})
Fixing a date that came out wrong
A camera with a flat battery stamps everything with the day it was switched on, and a scan carries the day it was scanned. So a run of photos lands in the archive under a date that is plainly not theirs.
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.
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
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"},
]
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)]
def pills_on(page, day):
"""The occasions written across the tile of the photo taken on that day."""
return page.locator(f'.tile:has(img[alt="{day}"]) .ev-pill')
def back_on_the_wall(page):
expect(page.locator(".lb")).to_have_count(0)
@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."""
docs = fix_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: (grid(page).locator(f'.tile:has(img[alt="{day}"]) .ev-pill')
.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: grid(page).locator(f'.tile:has(img[alt="{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 = grid(page).locator(f'.tile:has(img[alt="{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
grid(page).locator(".tile:not(:has(.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
back_on_the_wall(page)
expect(tiles(page)).to_have_count(len(FIX_DOCS))
expect(grid(page).locator(f'.tile:has(img[alt="{STRAY_DAY}"])')).to_have_count(0) # gone from that day
expect(pills_on(page, f"{FIX_YEAR}-06-06")).to_have_text([RANDO, SOMMET]) # and back inside both
print(" PASS: lightbox edit date re-orders")
grid(page).locator(f'.tile:has(img[alt="{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 = f'.tile:has(img[alt="{FIX_YEAR}-08-01"]):has(.ev-pill:text-is("{MINE}"))'
grid(page).locator(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")
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.
@testcase
def test_putting_words_on_a_photo(page):
"""The labelling run: several words at once, the keyboard for add and remove, the
last word reused on the next photo, and all of it written down."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
expect(d).to_be_visible()
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20) # one existing + two new, typed
box.press("Enter")
expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1) # not duplicated
print(" PASS: lightbox adds several labels")
box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible() # added
box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0) # removed
print(" PASS: lightbox enter adds, shift-enter removes")
box.fill("zzreuse"); box.press("Enter")
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # applied here
d.get_by_role("button", name="next photo").click()
reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
expect(reuse).to_be_visible() # offered on the next
reuse.click()
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # reused, no retype
print(" PASS: lightbox reuse last label")
box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
d.get_by_role("button", name="close").click()
search_for(page, "lbadded") # the word put on: the photo answers
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # the word taken off: it no longer does
expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
print(" PASS: lightbox edits labels")
Saying no, quickly
Most of a burst is not worth keeping. Eleven near-identical shots of the same moment, and one of them is the one — so sweeping a stack down to the keepers means saying no far more often than yes, and the no has to be the cheapest gesture in the app.
It is also the only destructive one, which pulls the other way: cheap enough to repeat forty times, dear enough that it cannot happen by accident. So it asks the first time, and there is a way to tell it you know what you are doing — and, because the same keyboard is being used to type labels a second earlier, it has to know the difference between condemning a photo and rubbing out a letter.
A run like this is read with nothing filtered out. Working the todo pile instead, each
rejection would drop the photo off the wall as you condemned it and the run would shrink
under you — which is its own session, and a different feeling. Here you are going
along a row looking at everything, so a photo you have just condemned stays where it is
and the next one is still next. None of them starts out condemned — whatever else they
are — so a photo reading condemned afterwards can only have been marked by the key just
pressed.
@testcase
def test_saying_no_quickly(page):
"""Running down a stack marking rejects: it asks the first time, takes Shift for the
rest, and stays out of the way while you are typing."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
del_btn = d.get_by_role("button", name="delete", exact=True)
expect(del_btn).to_have_attribute("aria-pressed", "false")
asked = []
page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
page.keyboard.press("Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true") # moved to delete
assert asked, "Delete should have asked to confirm"
print(" PASS: lightbox delete confirms")
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false") # a fresh doc, not yet condemned
asked.clear()
page.keyboard.press("Shift+Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true")
assert not asked, "Shift+Delete should not ask to confirm"
print(" PASS: lightbox shift-delete skips confirm")
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false")
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzoops", delay=10)
box.press("ArrowLeft"); box.press("Delete") # rubbing out the last letter
expect(box).to_have_value("zzoop")
expect(del_btn).to_have_attribute("aria-pressed", "false") # the photo is untouched
print(" PASS: lightbox delete in a field is text editing")
Judging a run down to nothing
This is the session the whole triage view exists for. A filter holds everything still waiting on you, you open the first of them, and from then on you should not have to steer: judge, and the next one is in front of you; judge again, and so on until there is nothing left and the app gets out of your way. If you have to reach for the wall between each one, the run is not a run — it is a hundred separate little chores.
What makes it awkward to build is that judging a photo removes it from the filter you are reading. The list shifts under the open doc at the very moment you need it to say where to go next, and the obvious implementations all land you on the one you just finished with, or on the one before it that you dealt with a minute ago.
So the run needs four todos rather than the usual three. The wrap has to be judged from a ring of at least three — in a ring of two, the one after and the one before are the same photo, and a step forward cannot be told from a step back — and one is already gone by then, so four is the smallest run that can show the difference at every stage.
@testcase
def test_judging_a_run_down_to_nothing(page):
"""Working a filter to the end: each judgement hands you the next photo, the last
comes round to the first, and emptying the run closes it."""
make_fixtures()
fourth = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-3", "date": "2020-04-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-3", "labels": FIXTURE_LABEL, "state": "todo"}
gql(DELETE, {"cid": fourth["cid"]}); gql(CREATE, {"p": fourth})
for f in FIXTURES[1:]:
gql(UPDATE, {"cid": f["cid"], "patch": {"state": "todo"}}) # four todos, Jan..Apr
try:
open_app(page)
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(4) # default filter is todo
expect(thumb_imgs(page).nth(3)).to_have_attribute("src", re.compile(r"/ipfs/")) # loaded, not still blank
behind, ahead = (thumb_imgs(page).nth(i).get_attribute("src") for i in (0, 2))
open_doc(page, 1) # the second of four
d = dialog(page)
img = d.get_by_role("img")
d.get_by_role("button", name="done", exact=True).click() # leaves the todo filter
expect(d).to_be_visible() # still open…
expect(img).to_have_attribute("src", ahead) # …on the doc that took its place
assert img.get_attribute("src") != behind, "the modal stepped backwards"
print(" PASS: judging one lands on the next, not the previous")
onto_last = thumb_imgs(page).nth(2).get_attribute("src") # the last of the three left
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", onto_last) # settled on it before judging it
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_visible()
expect(img).to_have_attribute("src", behind) # round to the first, not back to the end
print(" PASS: judging the last comes round to the first")
d.get_by_role("button", name="done", exact=True).click()
expect(img).to_have_attribute("src", ahead) # round again, to the one still standing…
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_hidden() # …and with that one gone, nothing to show
expect(tiles(page)).to_have_count(0)
print(" PASS: the last one judged closes the run")
finally:
gql(DELETE, {"cid": fourth["cid"]})
Doing one thing to forty photos
Some of triage is per-photo and some of it plainly is not. A whole afternoon wants the same word on it; a whole card-dump wants the same verdict. Doing that one at a time is the difference between a job you finish and a job you abandon, so the wall lets you take a run in hand and act on all of it at once.
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. Then the same box that labels one photo labels the lot — by button or by keyboard, the two never drifting apart — and takes words off again as readily as it puts them on, because a batch applied to the wrong run is the other thing that happens at this speed.
@testcase
def test_doing_one_thing_to_forty_photos(page):
"""Taking a run in hand: set the wall to a size you can work at, pick the run up without
the wall moving, then put a word on all of it and take it off again, by button and by
key."""
page.set_viewport_size({"width": 1600, "height": 800}) # wide enough that every step re-columns
open_fixtures(page)
t = tiles(page)
w0 = t.nth(0).bounding_box()["width"]
page.get_by_role("button", name="bigger thumbnails").click()
page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w0 + 8)
w1 = t.nth(0).bounding_box()["width"]
open_app(page)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
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")
select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="add label").click()
expect(checks(page)).to_have_count(0) # selection clears once applied
search_for(page, "addedbybatch") # the fresh word now finds them all
expect(tiles(page)).to_have_count(n)
print(" PASS: batch add label")
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)
print(" PASS: batch remove label")
search_for(page, FIXTURE_LABEL) # back to the run itself
expect(tiles(page)).to_have_count(n)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Enter") # + via RET
expect(checks(page)).to_have_count(0) # applied → selection clears
search_for(page, "zzbatchret")
expect(tiles(page)).to_have_count(n) # every selected photo got it
print(" PASS: batch enter adds")
select_all(page).click()
expect(checks(page)).to_have_count(n) # selection settled
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Shift+Enter") # - via S-RET
expect(tiles(page)).to_have_count(0) # the docs no longer carry the label → gone from the wall
print(" PASS: batch shift-enter removes")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ") # as a picked suggestion leaves it
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0) # the trailing sep didn't defeat the match
print(" PASS: batch remove label trailing separator")
Hunting for a photo you half-remember
You know the photo exists and you know almost nothing that would find it. It was summer, 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.
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) — June across three years,
# a March, one that is not mine, and one that is not a photo at all
(HUNT_YEAR - 1, "06-15", "konubinix", "zzsummer", "image/jpeg"),
(HUNT_YEAR, "06-15", "konubinix", "zzholiday", "image/jpeg"),
(HUNT_YEAR + 1, "06-10", "konubinix", "zzsummer", "image/jpeg"),
(HUNT_YEAR, "03-20", "konubinix", "zzsnow", "image/jpeg"),
(HUNT_YEAR, "06-20", "aylapomme", "zzsummer", "image/jpeg"),
(HUNT_YEAR, "06-25", "konubinix", "zzholiday", "video/mp4"),
]
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}
for i, (y, md, owner, word, kind) 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"},
]
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: a word, an occasion, whose it was, what kind
of thing, what month, what year, what day — each guess cutting the wall down, and one
taken back giving its share straight back."""
docs = hunt_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
seed_events(HUNT_EVENTS)
try:
open_app(page); chip(page, "all").click()
narrow(page)
expect(tiles(page)).to_have_count(len(docs)) # nothing guessed yet
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")
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.
def type_into(page, fragment):
"""Start the term over and type it in, keystroke by keystroke as a hand would."""
sb = search_box(page)
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."""
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
sel = page.get_by_role("option", selected=True)
assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
"ArrowUp from none should select the last suggestion"
print(" PASS: suggestion up enters from none")
guess(page, "zzbal") # a fragment of one word we know
word = options(page).first.inner_text().strip()
options(page).first.click()
expect(sb).to_have_value(word + "; ") # the picked label, then a ';' for the next
print(" PASS: label completion")
page.keyboard.type("zzalo", delay=20) # straight on, into whatever holds the keys
expect(options(page).filter(has_text="zzalois").first).to_be_visible() # must reappear
print(" PASS: completion reopens after pick")
guess(page, "zzup")
offered = [t.strip() for t in options(page).all_inner_texts()]
assert len(offered) == 2, f"this needs two to choose between, got {offered}"
sb.press("ArrowDown"); sb.press("ArrowDown") # past the first, onto the second
sb.press("Enter") # apply the highlighted one
expect(sb).to_have_value(offered[1] + "; ") # the second — taking the first is the easy bug
print(" PASS: search suggestion keyboard")
guess(page, "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")
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}"
@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, and turn back to the box."""
docs = key_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 420, "height": 600}) # narrow and short: it overflows
open_app(page)
search_for(page, "zzkey")
expect(tiles(page)).to_have_count(KEY_N)
page.keyboard.press("ArrowRight") # the first arrow focuses the first tile
page.keyboard.press("ArrowRight") # → the second
page.keyboard.press("Enter") # Enter opens the focused doc
d = dialog(page)
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
print(" PASS: grid cursor opens focused doc")
expect(checks(page)).to_have_count(0) # nothing picked by walking about
page.keyboard.press(" ") # Space picks the focused one
expect(checks(page)).to_have_count(1)
page.keyboard.press(" ") # Space again unpicks it
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor space toggles selection")
tiles(page).nth(5).click() # reach for the mouse for one tile
page.keyboard.press("ArrowRight") # the arrows carry on from there
page.keyboard.press("Enter") # open the now-focused tile
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(6))
page.keyboard.press("Escape")
toolbar(page).get_by_role("button", name="clear").click() # drop what the click picked up
expect(checks(page)).to_have_count(0)
print(" PASS: cursor starts at last clicked tile")
page.keyboard.press(" ") # 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")
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")
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.
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, both ways: a match too big to show, which the wall says it has
sliced and how; 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")
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)}"
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(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) # the new arrival, asked for directly
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # …and again among the run it landed in
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, 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() # back to the wall
search_for(page, "zznwimg") # an image with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zznw-img-t") # its poster, stretched
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
Getting on screen
Three things stand between a tap on the icon and the wall: the app has to boot, handle being turned away when it isn’t authorized, and cope when the data won’t load.
It boots
First, prove the no-build Solid stack loads at all: the import map resolves Solid
from esm.sh and the html template renders its titled shell — the first thing on screen,
and the signal the tests wait on to know the app has booted. Solid’s web and html
submodules must share one core instance (?external=solid-js in the map dedupes them,
else reactivity is dead).
@testcase
def test_boots_into_titled_shell(page):
"""The Solid app loads from the import map and renders its title."""
open_app(page)
expect(heading(page)).to_have_text("Memories")
print(" PASS: boots into titled shell")
import { render } from 'solid-js/web';
import html from 'solid-js/html';
import { createSignal, createResource, createMemo, createEffect, on, For, Index, Show, onMount, onCleanup } from 'solid-js';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { client, getAuthNeeded, subscribeAuth, queryFresh } from '../shared/gql.js';
The data layer is shared: memories’ reactivity is Solid’s createResource, but its
transport is the shared urql client — the same one the frise uses — so there is one
client and one auth gate across the apps. The triage wall reads through the shared
queryFresh, which posts straight to the server, bypassing the client’s document cache
and its in-flight dedup. That last part is what earns its keep: at the end of every edit
memories refetch-es the wall, and a re-read fired the instant a mutation commits, going
through the client, can be handed a query that went out just before the edit — a removed
tag still lingering. Straight to the server, that can’t happen; the wall always shows the
true current result, and on the LAN the round-trip is cheap. (createResource isn’t wired to
the cache’s reactivity the way the frise’s hooks are, which is why memories re-reads the wall
itself.) Label completion still reads network-only through the client — a vocab word
added moments ago has to appear at once — so it carries the same in-flight-dedup risk the wall
sheds; it hasn’t yet been worth a second queryFresh caller. A Solid signal mirrors the
shared gate’s store into the banner.
const IPFS = ''; // https://ipfs.konubinix.eu/p/... is on the same origin as the app
const [authNeeded, setAuthNeeded] = createSignal(getAuthNeeded());
subscribeAuth(setAuthNeeded);
const PV_CTX = { additionalTypenames: ['Photovideo'] };
async function gql(query, variables, ctx){
const r = await (/^\s*mutation\b/.test(query)
? client.mutation(query, variables, ctx)
: client.query(query, variables, ctx ?? { requestPolicy: 'network-only' })).toPromise();
if(r.error) throw new Error(r.error.message);
return r.data;
}
render(App, document.getElementById('app'));
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
What it boots into is the whole screen, because it asks for the whole screen: the app sets its
viewport to cover the display rather than stop where the phone’s status bar and navigation
bar begin, which is what lets a photo fill the glass. Those bars are still painted over the
page, though, and they still take the touches that land on them. So the screen the app is
handed is really three bands, and only the middle one is its own to put a control in.
So env(safe-area-inset-*), the phone’s own report of what the two strips are taking, is what
every surface at an edge stands back by — starting with the body.
:root{ --bg:#1b1d2e; --fg:#e8e8f0; }
body{ background:var(--bg); color:var(--fg); font-family:system-ui,sans-serif; margin:0;
padding: calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right))
calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left)); }
h1{ font-size:18px; margin:0 0 12px; }
When the device isn’t authorized
The test forces the failure deterministically: it routes /graphql to a 401
(overriding the fixture forward, since a page route wins over the context one)
and asserts an alert that mentions authorization.
@testcase
def test_shows_auth_required_when_unauthorized(page):
"""A 401 from /graphql surfaces a clear 'authorization required' banner."""
page.route("**/graphql", lambda route: route.fulfill(
status=401, content_type="text/plain", body="Unauthorized"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("authoriz", re.I))
print(" PASS: shows auth required when unauthorized")
The gql seam (It boots) already flips authNeeded on a 401; the banner is
just a Show on it, dropped in at the top of the App so it’s the first thing
seen. It does not try to authenticate — the app can’t mint a grant — it
only tells the user a fresh access link is needed on this device.
<${Show} when=${() => authNeeded()}>
<div class="authwall" role="alert">
<strong>Authorization required.</strong>
This device can't read your photos yet — open a fresh access link on it.
</div>
<//>
.authwall{ background:#f9a826; color:#1b1d2e; padding:10px 14px; border-radius:8px;
margin:0 0 12px; line-height:1.45; }
.authwall strong{ display:block; }
When the load fails
A 401 is the courteous failure — the gate knows the device only needs a fresh link, and says so. Every other failure hands back the wall empty for a reason the user cannot see: the server down, a 500 on a bad query, a 404 where the route should answer. An empty wall reads as “nothing matches here” — a lie when the truth is that the read never landed.
So a load that fails says so, in the same top-of-app banner the authorization notice uses.
page.route("**/graphql", lambda route: route.fulfill(
status=500, content_type="text/plain", body="Server Error"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("load|reach|server", re.I))
And the wall holds back its own “nothing here” line, which would only compound the lie.
expect(page.get_by_text("No photos.", exact=True)).to_have_count(0)
The banner rides on the wall resource’s own error, and shows only when the failure isn’t the authorization one the gate already owns.
<${Show} when=${() => photos.error && !authNeeded()}>
<div class="loadwall" role="alert">
<strong>Couldn't load your photos.</strong>
The server returned an error — try again in a moment.
</div>
<//>
.loadwall{ background:#e5484d; color:#fff; padding:10px 14px; border-radius:8px;
margin:0 0 12px; line-height:1.45; }
.loadwall strong{ display:block; }
In practice, the wall reads its docs straight from the resource, and that read re-throws the load error — with no error boundary above it the throw would freeze the render before the banner paints. So the wall skips its own read once the load has errored: the resource’s error is left to the banner alone, and the empty-state and the tiles fall to nothing.
The wall
The wall is the home surface — a grid of every doc the query matches, drawn only as far as the eye reaches, each tile carrying what the archive knows about it.
A grid of thumbnails
The point of the app: a wall of thumbnails from the archive. It fills from the search term — memories draws its own even ~2000 spread over the frise’s shared filter (see Drawing our own spread) — one lazily-loaded tile per photo, a placeholder while the fetch is in flight and a word when nothing matches.
So, on the whole archive — the all chip, since the default todo view may be empty
once everything’s triaged — the wall fills with thumbnails.
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
// 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){
setMutating(m => m + 1);
try {
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const patch = patchFor(byCid.get(cid));
if(patch) await gql(UPDATE_PHOTO, { cid, patch });
}
clearSel();
await refetch();
} finally { setMutating(m => m - 1); }
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
async function applyBulk(kind, v){
setMutating(m => m + 1);
try { await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); clearSel(); await refetch(); }
finally { setMutating(m => m - 1); }
}
const addLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
setLastLabel(words[words.length - 1]);
if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
else await patchSelected(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
setLabelText('');
};
const removeLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
: patchSelected(() => ({ state: st }));
const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const p = byCid.get(cid); if(!p) continue;
const path = res === 'web' ? p.webCid : p.cid;
if(!path) continue;
const isVid = (p.mimetype || '').startsWith('video');
const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
const raw = p.filename || p.cid.split('/').pop() || 'download';
const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
const a = document.createElement('a');
a.href = IPFS + path;
a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
document.body.appendChild(a); a.click(); a.remove();
}
}
const [moveText, setMoveText] = createSignal('');
const [moveFocus, setMoveFocus] = createSignal(false);
const [moveTarget, setMoveTarget] = createSignal(null); // the armed occasion, or null → nothing to apply
const [moveEvents] = createResource(moveFocus,
f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
const eventDist = (e, mean) => { if(mean == null) return 0;
const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
return mean < s ? s - mean : mean > en ? mean - en : 0; };
const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
const moveCandidates = () => { const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
return (moveEvents() || []).filter(e => owners.has(e.owner))
.filter(e => !q || (e.summary || '').toLowerCase().includes(q))
.sort((a, b) => eventDist(a, mean) - eventDist(b, mean) || new Date(a.starttime) - new Date(b.starttime))
.slice(0, 8); };
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
setMoveText(''); setMoveTarget(null); };
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
await patchSelected(() => ({ date: new Date(v).toISOString() }));
setDatingSel(false); };
const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
async function applyLabels(labels){
const cid = opened().cid;
setOpened({ ...opened(), labels });
await gql(UPDATE_PHOTO, { cid, patch: { labels } });
await refetch();
}
const lbAdd = input => {
const words = splitWords(input); if(!words.length) return;
setLastLabel(words[words.length - 1]);
const cur = labelsOf(opened());
for(const w of words) if(!cur.includes(w)) cur.push(w);
setLbText('');
return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
async function lbPatch(patch){
const list = items(), cur = opened(); if(!cur) return;
const i = list.findIndex(p => p.cid === cur.cid);
const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
const stay = l2.find(p => p.cid === cur.cid);
setOpened(stay || (nextCid && l2.find(p => p.cid === nextCid)) || l2[0]);
});
}
const lbSetState = st => lbPatch({ state: st });
const step = delta => {
const list = items(); if(!list.length || !opened()) return;
const i = list.findIndex(p => p.cid === opened().cid);
setOpened(list[((i < 0 ? 0 : i) + delta + list.length) % list.length]); setLbText('');
};
let lbVideo = null;
const seekOrStep = dir => {
const v = lbVideo;
if(v && isVideo(opened()) && !v.paused &&
(dir > 0 ? v.currentTime < v.duration - 0.25 : v.currentTime > 0.25))
v.currentTime = Math.max(0, Math.min(v.duration, v.currentTime + dir * 5));
else step(dir);
};
let wheelAt = 0;
const onWheel = e => {
if(!e.shiftKey) return;
const d = e.deltaY || e.deltaX;
const now = performance.now();
if(Math.abs(d) < 1 || now - wheelAt < 200) return;
wheelAt = now; step(d > 0 ? 1 : -1);
};
onMount(() => {
const onKey = e => {
if(!opened()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') dismissPhoto();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); seekOrStep(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); seekOrStep(-1); }
else if(!editing && e.key === 'Delete'){ e.preventDefault(); lbDelete(e.shiftKey); }
else if(!editing && e.key === 'Enter'){ e.preventDefault(); toggle(opened().cid); }
else if(!editing && e.key === ' ' && isVideo(opened()) && lbVideo){
e.preventDefault(); const v = lbVideo;
if(v.currentTime >= v.duration - 0.25){ v.currentTime = 0; v.play(); }
else if(v.paused) v.play(); else v.pause();
}
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
const [frame, setFrame] = createSignal(false);
const [playing, setPlaying] = createSignal(true);
const [frameUI, setFrameUI] = createSignal(false); // controls revealed on tap
const [frameLabel, setFrameLabel] = createSignal('');
const [frameLabelFocus, setFrameLabelFocus] = createSignal(false);
const [frameCenterCid, setFrameCenterCid] = createSignal(null); // the settled slide
const frameDoc = () => items().find(p => p.cid === frameCenterCid()); // the centred doc
const [frameEditingDate, setFrameEditingDate] = createSignal(false);
const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);
const [frameDir, setFrameDir] = createSignal(1); // last travel direction (+1 forward)
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
const FRAME_IDLE_MS = Number(new URLSearchParams(location.search).get('idleresume')) || 60000;
const [pokes, setPokes] = createSignal(0);
const nudge = () => setPokes(n => n + 1);
const [interacting, setInteracting] = createSignal(false);
const [zoomed, setZoomed] = createSignal(false);
const frameSlides = () => { const o = items();
return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
let stripEl, wakeLock = null;
const slideW = () => stripEl && stripEl.children.length ? stripEl.scrollWidth / stripEl.children.length : (stripEl?.clientWidth || 1);
const slideAt = () => Math.round((stripEl?.scrollLeft || 0) / slideW()); // nearest slide index
const slideLeft = i => { const k = stripEl && stripEl.children[i]; return k ? k.offsetLeft : i * slideW(); };
const frameGo = delta => { if(!stripEl) return;
stripEl.scrollTo({ left: slideLeft(slideAt() + delta), behavior: 'smooth' }); };
const FRAME_CID_KEY = 'memories.frame.cid';
let snapT;
const onFrameScroll = () => {
if(stripEl){ const i = slideAt(), c = frameCenterIdx();
if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
clearTimeout(snapT); snapT = setTimeout(() => {
if(!stripEl) return;
if(zoomed()) return;
const n = items().length;
let i = slideAt();
if(i <= 0){ stripEl.scrollLeft = slideLeft(n); i = n; } // leading clone(last) → real last
else if(i >= n + 1){ stripEl.scrollLeft = slideLeft(1); i = 1; } // trailing clone(first) → real first
const target = slideLeft(i);
if(Math.abs(stripEl.scrollLeft - target) > 1) stripEl.scrollTo({ left: target, behavior: 'smooth' });
const doc = items()[i - 1];
setFrameCenterIdx(i);
if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
}, 150); };
const FRAME_ON_KEY = 'memories.frame.on';
async function enterFrame(){
if(!items().length) return;
setFrame(true); setPlaying(true); setFrameUI(false);
localStorage.setItem(FRAME_ON_KEY, '1');
history.pushState({ frame: true }, '');
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
}
function closeFrame(){
setFrame(false);
localStorage.setItem(FRAME_ON_KEY, '0');
try { wakeLock?.release(); } catch(e) {} wakeLock = null;
}
const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();
const frameIndex = () => Math.max(0, Math.min(items().length - 1, slideAt() - 1));
async function frameEdit(patchFor){
const list = items(); if(!list.length || !stripEl) return;
const i = frameIndex(), cur = list[i], prevCid = i > 0 ? list[i - 1].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch: patchFor(cur) });
setFrameLabel('');
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length) { exitFrame(); return; }
const stay = l2.findIndex(p => p.cid === cur.cid);
let t = stay >= 0 ? stay : (prevCid ? l2.findIndex(p => p.cid === prevCid) : 0);
stripEl.scrollLeft = slideLeft(Math.max(0, t) + 1);
});
}
const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
return frameEdit(() => ({ state: st })); };
const frameAddWord = input => { const words = splitWords(input); if(!words.length) return;
return frameEdit(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; }); };
const frameAddLabel = () => frameAddWord(frameLabel());
const frameDropLabel = () => { const words = splitWords(frameLabel()); if(!words.length) return;
return frameEdit(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') })); };
let frameCancelDate = false;
const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false; setFrameEditingDate(false);
if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };
const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1';
let autoEntered = false;
createEffect(() => {
if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
});
createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
const saved = localStorage.getItem(FRAME_CID_KEY);
const r = saved ? items().findIndex(p => p.cid === saved) : -1;
stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1); // +1 for the leading clone
setFrameCenterIdx(r >= 0 ? r + 1 : 1); // seed the centre before any scroll
setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
}); });
createEffect(on([items, frame], () => {
if(!frame() || !stripEl) return;
const io = new IntersectionObserver(
es => es.forEach(e => { if(e.intersectionRatio < 0.5) e.target.pause(); }),
{ root: stripEl, threshold: 0.5 });
requestAnimationFrame(() => stripEl.querySelectorAll('video').forEach(v => io.observe(v)));
onCleanup(() => io.disconnect());
}));
createEffect(() => { if(!frame()) return; const vv = window.visualViewport; if(!vv) return;
const read = () => setZoomed(vv.scale > 1);
read(); vv.addEventListener('resize', read); vv.addEventListener('scroll', read);
onCleanup(() => { vv.removeEventListener('resize', read); vv.removeEventListener('scroll', read); }); });
createEffect(() => {
if(!frame() || !playing() || zoomed() || interacting()) return;
const id = setInterval(() => {
if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
frameGo(1);
}, intervalMs());
onCleanup(() => clearInterval(id));
});
createEffect(() => { if(!pokes()) return;
setInteracting(true);
const id = setTimeout(() => setInteracting(false), FRAME_IDLE_MS); onCleanup(() => clearTimeout(id)); });
createEffect(() => { if(!frame() || !stripEl) return;
stripEl.addEventListener('pointerdown', nudge);
onCleanup(() => stripEl.removeEventListener('pointerdown', nudge)); });
onMount(() => {
const onKey = e => {
if(!frame()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') exitFrame();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); frameGo(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); frameGo(-1); }
};
const onVis = async () => {
if(frame() && document.visibilityState === 'visible' && !wakeLock)
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
};
const onPop = () => {
if(frame()) closeFrame();
const st = history.state || {};
if(opened() && !st.lb) closePhoto();
if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }
};
window.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPop);
document.addEventListener('visibilitychange', onVis);
onCleanup(() => { window.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPop);
document.removeEventListener('visibilitychange', onVis); });
});
const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
createEffect(() => { if(!frame() || !zoomed()) return; pokes(); // any touch re-arms the countdown
const id = setTimeout(() => { const u = new URL(location.href);
u.searchParams.set('z', String(Date.now())); // an address the browser hasn't seen zoomed → it lands at 1:1
location.href = u.href; }, ZOOM_IDLE_MS);
onCleanup(() => clearTimeout(id)); });
const WEB_BEHIND = 1, WEB_AHEAD = 3, KEEP_BEHIND = 6, KEEP_AHEAD = 14;
const inReach = (k, behind, ahead) => { const t = (k - frameCenterIdx()) * frameDir(); // signed steps, in the way you're heading
return t >= -behind && t <= ahead; };
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD) // base layer:
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK; // thumbnail kept in the window, blank past it
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid) // overlay:
? IPFS + p.webCid : ''; // full-res leads the way you're going
const FrameSlide = (slide, k) => {
const thumbSrc = createMemo(() => thumbBand(slide(), k)); // base layer's source
const webSrc = createMemo(() => webBand(slide(), k)); // overlay's source ('' when far from centre)
const [thumbOn, setThumbOn] = createSignal(false); // the base thumbnail has painted
const [webOn, setWebOn] = createSignal(false); // the full-res overlay has painted
createEffect(() => { thumbSrc(); setThumbOn(false); }); // each layer re-arms its mark on its own source change
createEffect(() => { webSrc(); setWebOn(false); });
return html`
<div class="slide" role="listitem">
<${Show} when=${() => hasMedia(slide())}
fallback=${html`<div class="slide-media noimg">
<span class="ph">${() => isVideo(slide()) ? '🎬' : '🖼'}</span></div>`}>
<${Show} when=${() => isVideo(slide())}
fallback=${html`<div class="slide-pic">
<${Show} when=${() => !thumbOn()}>
<span class="ph load-ph" aria-label="loading">🖼</span><//>
<img class="slide-media" loading="lazy"
src=${thumbSrc} onLoad=${() => setThumbOn(true)} />
<${Show} when=${() => webSrc()}>
<img class="slide-media web" classList=${() => ({ shown: webOn() })}
loading="lazy" src=${webSrc} onLoad=${() => setWebOn(true)} />
<${Show} when=${() => thumbOn() && !webOn()}>
<span class="upgrading" aria-label="fetching full resolution"></span><//>
<//></div>`}>
<video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
<//>
<//>
</div>`;
};
const onFrameTap = e => {
const w = window.innerWidth || 1;
if(e.clientX < w / 3) frameGo(-1);
else if(e.clientX > w * 2 / 3) frameGo(1);
else setFrameUI(v => !v);
};
const TAP_SLOP = 10;
let tapFrom = null; // where a lone finger went down, while it could still be a tap
const tapPtrs = new Set();
const onTapDown = e => { tapPtrs.add(e.pointerId);
tapFrom = tapPtrs.size === 1 ? { x: e.clientX, y: e.clientY } : null; };
const onTapMove = e => { if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null; };
const onTapUp = e => { tapPtrs.delete(e.pointerId);
if(e.type === 'pointerup' && tapFrom) onFrameTap(e);
if(!tapPtrs.size) tapFrom = null; };
createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
const on = (t, h) => el.addEventListener(t, h), off = (t, h) => el.removeEventListener(t, h);
on('pointerdown', onTapDown); on('pointermove', onTapMove); on('pointerup', onTapUp); on('pointercancel', onTapUp);
onCleanup(() => { off('pointerdown', onTapDown); off('pointermove', onTapMove);
off('pointerup', onTapUp); off('pointercancel', onTapUp); }); });
const FRAME_UI_IDLE_MS = Number(new URLSearchParams(location.search).get('uiidle')) || 20000;
createEffect(() => { if(!frameUI()) return; pokes();
const id = setTimeout(() => setFrameUI(false), FRAME_UI_IDLE_MS); onCleanup(() => clearTimeout(id)); });
const frameParam = new URLSearchParams(location.search);
const SYNC_URL = frameParam.get('yws') || location.origin.replace(/^http/, 'ws') + '/ywebsocket';
const SYNC_ROOM = frameParam.get('room') || 'memories-nowshowing';
const [roomLink, setRoomLink] = createSignal('idle');
let showingRoom = null;
const nowShowing = () => {
if(!showingRoom){
const shared = new Y.Doc();
const provider = new WebsocketProvider(SYNC_URL, SYNC_ROOM, shared);
setRoomLink('connecting');
provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
showingRoom = shared.getMap('showing');
}
return showingRoom;
};
createEffect(() => {
if(!frame()) return;
pokes();
const d = frameDoc(); if(!d) return;
nowShowing().set('doc', { cid: d.cid, webCid: d.webCid, mimetype: d.mimetype, date: d.date });
});
const frameFromHere = () => { const cur = opened(); if(!cur) return;
localStorage.setItem(FRAME_CID_KEY, cur.cid); // the slide the frame will open on
closePhoto(); // hide the modal but leave its history entry, so Back returns to it
enterFrame(); };
const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };
const toLocalInput = iso => { if(!iso) return '';
const d = new Date(iso), p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
let cancelEdit = false;
const commitDate = v => { const skip = cancelEdit; cancelEdit = false; setEditingDate(false);
if(!skip && v) lbPatch({ date: new Date(v).toISOString() }); };
onMount(() => {
const onKey = e => {
if(e.key !== 'd' || !opened()) return; // only while a doc is open
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
e.preventDefault(); setEditingDate(true);
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
const EVENTS_FOR_DOC = `query($d:Datetime!,$o:OwnerType!){ eventsAt(d:$d, o:$o){ nodes{ summary starttime endtime } } }`;
const fetchDocEvents = async o => (await gql(EVENTS_FOR_DOC, { d: o.date, o: o.owner }))?.eventsAt?.nodes ?? [];
const dayBased = (s, en) => s.getUTCHours() === 0 && s.getUTCMinutes() === 0 && s.getUTCSeconds() === 0
&& en.getUTCHours() === 23 && en.getUTCMinutes() === 59 && en.getUTCSeconds() === 59;
const hhmm = t => t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const eventWhen = e => {
const s = new Date(e.starttime), en = new Date(e.endtime);
const sd = s.toLocaleDateString('fr-FR'), ed = en.toLocaleDateString('fr-FR');
if (dayBased(s, en)) return sd === ed ? sd : `${sd} – ${ed}`;
return sd === ed ? `${sd} ${hhmm(s)} – ${hhmm(en)}` : `${sd} ${hhmm(s)} – ${ed} ${hhmm(en)}`;
};
// run the occasion's own event: search — the pill's jump into it
const searchEvent = summary => { setSearch('event:' + summary); commit(); };
const [lbEvents] = createResource(
() => { const o = opened(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
const [frameEvents] = createResource(
() => { const o = frameDoc(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
const CURSOR_KEY = 'memories.cursor';
const [cursor, setCursor] = createSignal(localStorage.getItem(CURSOR_KEY));
createEffect(() => { const c = cursor(); c ? localStorage.setItem(CURSOR_KEY, c) : localStorage.removeItem(CURSOR_KEY); });
let gridEl, rangeBase = new Set(), extending = false;
const gridCols = () => gridEl ? getComputedStyle(gridEl).gridTemplateColumns.split(' ').length : 1;
const moveCursor = (delta, extend) => {
const list = items(); if(!list.length) return;
const at = list.findIndex(p => p.cid === cursor());
const ni = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + delta));
setCursor(list[ni].cid);
if(extend && anchor() !== null){ extendRun(list[ni].cid); } // grow-or-shrink the run to the cursor
else { extending = false; setAnchor(list[ni].cid); } // a plain move re-anchors and ends the run
requestAnimationFrame(scrollCursorIntoView);
};
const scrollCursorIntoView = () => {
const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
const r = tile.getBoundingClientRect();
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
if(r.top < 0) scrollBy(0, r.top); // head above the fold → pull it down
else if(r.bottom > floor) scrollBy(0, r.bottom - floor); // foot under the bar → push it up
};
createEffect(() => {
const shown = selCount() > 0; // tracked synchronously; the toolbar mounts on this turn
requestAnimationFrame(() => {
const bar = shown && document.querySelector('.toolbar');
document.body.style.paddingBottom = bar ? bar.getBoundingClientRect().height + 'px' : '';
});
});
onMount(() => {
const onKey = e => {
if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
if(e.key === 'ArrowRight') step(1);
else if(e.key === 'ArrowLeft') step(-1);
else if(e.key === 'ArrowDown') step(gridCols());
else if(e.key === 'ArrowUp') step(-gridCols());
else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
else if(e.key === 'Enter' && cursor()){ e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){ e.preventDefault(); setSelected(new Set(shownCids())); }
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
history.scrollRestoration = 'manual'; // Back must not undo the follow (see prose)
createEffect(() => { const c = opened()?.cid || (frame() ? frameCenterCid() : null); if(!c) return;
setCursor(c);
gridEl?.children[items().findIndex(p => p.cid === c)]?.scrollIntoView({ block: 'nearest' }); });
const [marquee, setMarquee] = createSignal(null); // {x0,y0,x1,y1} in client coords, or null
let marqueeFrom = null;
const marqueeRect = m => ({ l: Math.min(m.x0, m.x1), r: Math.max(m.x0, m.x1),
t: Math.min(m.y0, m.y1), b: Math.max(m.y0, m.y1) });
const marqueeSelect = () => {
const m = marquee(); if(!m) return;
const r = marqueeRect(m), list = items(), kids = gridEl.children, next = new Set(selected());
for(let i = 0; i < kids.length && i < list.length; i++){
const b = kids[i].getBoundingClientRect();
if(b.left < r.r && b.right > r.l && b.top < r.b && b.bottom > r.t) next.add(list[i].cid);
}
setAllMatching(false); setSelected(next);
};
const onGridDown = e => {
if(e.pointerType === 'touch' || e.button !== 0 || e.target !== gridEl || !canRange()) return;
marqueeFrom = { x: e.clientX, y: e.clientY };
setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
gridEl.setPointerCapture?.(e.pointerId);
};
const onGridMove = e => {
if(!marqueeFrom) return;
setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
marqueeSelect();
};
const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };
onMount(() => {
const onKey = e => {
if((e.key !== 'l' && e.key !== '.') || frame()) return; // the frame has its own bar
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
const box = opened() ? document.querySelector('.lb .batch-label')
: selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
if(!box) return;
e.preventDefault();
if(e.key === '.' && lastLabel()) // '.' re-drops the label applied last
(opened() ? setLbText : setLabelText)(lastLabel());
box.focus();
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
onMount(() => {
history.pushState({ app: true }, ''); // the root entry the back button stops on
const onExit = () => {
const st = history.state || {};
if(!frame() && !opened() && !st.lb && !st.app){ // popped below the root with nothing open
if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; } // bail out of a frozen read; stay
if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; } // retract the list; keep the root beneath
if(confirm('Leave Memories?')) history.back(); // really leave
else history.pushState({ app: true }, ''); // stay — restore the root
}
};
window.addEventListener('popstate', onExit);
onCleanup(() => window.removeEventListener('popstate', onExit));
});
return html`
<${Show} when=${() => authNeeded()}>
<div class="authwall" role="alert">
<strong>Authorization required.</strong>
This device can't read your photos yet — open a fresh access link on it.
</div>
<//>
<${Show} when=${() => photos.error && !authNeeded()}>
<div class="loadwall" role="alert">
<strong>Couldn't load your photos.</strong>
The server returned an error — try again in a moment.
</div>
<//>
<h1>Memories</h1>
<div class="complete">
<input class=${() => dirty() ? 'search dirty' : 'search'} type="search" role="combobox" aria-label="search labels"
disabled=${() => photos.loading}
aria-description=${() => dirty() ? 'search edited — not yet applied; press Enter or the search button' : undefined}
aria-expanded=${() => searchFocus() && !opened() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
placeholder="labels; since:2010; until:2015; type:video; sort:random"
value=${() => search()}
onInput=${e => { setSearch(e.target.value); setCaret(e.target.selectionStart);
setSearchFocus(true); }}
onKeyUp=${e => setCaret(e.target.selectionStart)}
onClick=${e => setCaret(e.target.selectionStart)}
onFocus=${() => setSearchFocus(true)}
onKeyDown=${e => sugNav(e, w => { if(w) pickSearch(w); else commit(); })}
onBlur=${() => setSearchFocus(false)} />
<button class="search-run" aria-label="run search" disabled=${() => photos.loading}
onMouseDown=${e => e.preventDefault()} onClick=${commit}>🔍</button>
<${Show} when=${() => searchFocus() && !opened()}>
<${Suggest} text=${search} caret=${caret} dsl=${true}
active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading} onPick=${pickSearch} />
<//>
</div>
<div class="chips" role="group" aria-label="filter by state">
${['all', ...STATES].map(st => html`
<button class="chip" data-st=${st} disabled=${() => photos.loading}
aria-pressed=${() => stateFilter() === st ? 'true' : 'false'}
onClick=${() => setStateFilter(st)}>${st}</button>`)}
<button class="chip selall" aria-pressed=${() => allSelected() ? 'true' : 'false'}
onClick=${toggleAll}>${() => allSelected() ? 'clear' : 'select all'}</button>
<button class="chip frame-start" onClick=${enterFrame}>▶ frame</button>
</div>
<${Show} when=${() => selCount() > 0}>
<div class="toolbar" role="toolbar" aria-label="selection actions">
<!-- scope: what the actions apply to -->
<span class="count">${() => allMatching() ? `all ${total()} matching` : `${selCount()} selected`}</span>
<button class="allmatch" aria-pressed=${() => allMatching() ? 'true' : 'false'}
onClick=${() => setAllMatching(m => !m)}>all ${() => total()} matching</button>
<button class="range" aria-pressed=${() => rangeMode() ? 'true' : 'false'}
disabled=${() => !canRange()}
title=${() => canRange() ? undefined : 'a spread is not a run — range select is off here'}
onClick=${() => setRangeMode(m => !m)}>↔ range</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- label edit -->
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="label for the selection"
aria-expanded=${() => labelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
onFocus=${() => setLabelFocus(true)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
onBlur=${() => setLabelFocus(false)} />
<${Show} when=${() => labelFocus()}>
<${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setLabelText(replaceSeg(labelText(), w) + '; ')} />
<//>
</div>
<button aria-label="add label" onClick=${addLabel}>+ label</button>
<button aria-label="remove label" onClick=${removeLabel}>- label</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- move the selection onto an occasion -->
<div class="complete">
<input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
value=${() => moveText()}
onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
onFocus=${() => setMoveFocus(true)}
onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- stamp the selection with one instant -->
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</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>
<//>
<//>
<div class="grid" role="list" aria-label="photos" aria-busy=${() => photos.loading || mutating() ? 'true' : 'false'}
style=${() => '--tile:' + thumbSize() + 'px'}
ref=${el => { gridEl = el; watchCell(el); }} onWheel=${onGridWheel}
onPointerDown=${onGridDown} onPointerMove=${onGridMove}
onPointerUp=${onGridUp} onPointerCancel=${onGridUp}>
<${For} each=${() => items()}>${photo => {
const [near, setNear] = createSignal(false);
const [thumbOn, setThumbOn] = createSignal(false);
const thumbSettled = () => setThumbOn(true); // painted, or failed and never coming
createEffect(() => { near(); setThumbOn(false); }); // re-shown → its source is refetched → waiting again
createEffect(() => { // one of the thumbnails the wall waits for…
if(!(near() && photo.thumbnailCid && !thumbOn())) return;
setThumbsInFlight(n => n + 1);
onCleanup(() => setThumbsInFlight(n => n - 1)); // …until it settles or leaves
});
const [sharp, setSharp] = createSignal(false);
createEffect(() => { if(!near()) setSharp(false); // gone → both layers go
else if(thumbOn() && refining()) setSharp(true); });
const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
const [webOn, setWebOn] = createSignal(false);
createEffect(() => { webSrc(); setWebOn(false); }); // a new source → fade the overlay in again
return html`
<div class="tile" role="listitem" data-selected=${() => isSel(photo.cid) ? '1' : '0'}
data-cursor=${() => cursor() === photo.cid ? '1' : '0'}
onPointerDown=${e => onTileDown(e, photo)} onPointerMove=${onTileMove}
onPointerUp=${onTileUp} onPointerCancel=${onTileUp}
onClick=${e => onTilePress(e, photo.cid)}
onDblClick=${() => openPhoto(photo)}>
<${Show} when=${() => photo.thumbnailCid}
fallback=${html`<div class="thumb noimg" title=${photo.filename || photo.date.slice(0, 10)}>
<span class="ph">${isVideo(photo) ? '🎬' : '🖼'}</span></div>`}>
<img class="thumb" draggable="false" alt=${photo.date.slice(0, 10)} title=${photo.date.slice(0, 10)}
ref=${el => watchVisible(el, setNear)}
src=${() => near() ? IPFS + photo.thumbnailCid : BLANK}
onLoad=${thumbSettled} onError=${thumbSettled} />
<${Show} when=${webSrc}>
<img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
src=${webSrc} onLoad=${() => setWebOn(true)} />
<//>
<//>
<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
<span class="play" title="video">▶</span>
<//>
<${Show} when=${() => isSel(photo.cid)}><span class="check">✓</span><//>
<span class="badge">${photo.state || ''}</span>
<div class="foot">
<${Show} when=${() => !!photo.labels}>
<span class="labels" title=${photo.labels}>${photo.labels}</span>
<//>
<${Show} when=${() => docEvents(photo).length}>
<div class="events">
<${For} each=${() => docEvents(photo)}>${e => html`
<span class="ev-pill" style=${() => 'background:' + eventColour().get(eventKey(e))}>${() => e.summary}</span>`}<//>
</div>
<//>
</div>
</div>`;
}}
<//>
</div>
<${Show} when=${() => marquee()}>
<div class="marquee" style=${() => { const r = marqueeRect(marquee());
return `left:${r.l}px;top:${r.t}px;width:${r.r - r.l}px;height:${r.b - r.t}px`; }}></div>
<//>
<${Show} when=${() => opened()}>
<div class="lb" onClick=${e => {
if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]')) dismissPhoto(); }}>
<div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
<button class="lb-close" aria-label="close" onClick=${dismissPhoto}>✕</button>
<button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid) ? '✓ selected' : 'select'}</button>
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
<button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}>‹</button>
<button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}>›</button>
<${Show} when=${() => hasMedia(opened())}
fallback=${html`<div class="lb-media noimg">
<span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
<span class="mt">no preview · ${() => opened()?.filename || opened()?.mimetype || ''}</span></div>`}>
<${Show} when=${() => isVideo(opened())}
fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
<video class="lb-media" controls autoplay ref=${el => lbVideo = el} src=${() => IPFS + opened()?.webCid}></video>
<//>
<//>
<div class="lb-meta">
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
onBlur=${e => commitDate(e.target.value)} />
<//>
<a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
aria-label="original" title="original — full resolution, new tab">⤢</a>
</div>
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
<div class="lb-states">
<${For} each=${() => STATES}>${st => html`
<button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
onClick=${() => lbSetState(st)}>${st}</button>`}
<//>
</div>
<div class="lb-labels">
<${For} each=${() => labelsOf(opened())}>${w => html`
<span class="lb-chip"><button class="lb-chip-word"
onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
<//>
<${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
<button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
<//>
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
<${Show} when=${() => lbFocus()}>
<${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
onLoading=${setSugLoading} onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
<//>
</div>
</div>
</div>
</div>
<//>
<${Show} when=${() => frame()}>
<div class="frame">
<div class="strip" role="list" aria-label="slideshow"
ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
<${Index} each=${() => frameSlides()}>${(slide, k) => FrameSlide(slide, k)}<//>
</div>
<${Show} when=${() => frameUI()}>
<div class="frame-bar" role="toolbar" aria-label="frame actions" onPointerDown=${nudge}>
<button aria-label=${() => playing() ? 'pause' : 'play'}
onClick=${() => setPlaying(p => !p)}>${() => playing() ? '⏸' : '▶'}</button>
<label>every <input class="ivl" type="number" min="2" aria-label="seconds per photo"
value=${() => Math.round(intervalMs() / 1000)}
onChange=${e => setIntervalMs(Math.max(2, +e.target.value) * 1000)} />s</label>
<${Show} when=${() => frameEditingDate()}
fallback=${html`<button class="frame-date" aria-label="edit date"
onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
<input class="frame-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
onBlur=${e => frameCommitDate(e.target.value)} />
<//>
<span class="room-link" data-state=${roomLink}>${roomLink}</span>
<${For} each=${() => frameEvents() || []}>${e => html`
<button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
${STATES.map(st => html`
<button class="st" data-st=${st} onClick=${() => frameSetState(st)}>${st}</button>`)}
<div class="complete">
<input class="frame-label" role="combobox" placeholder="add a label…" aria-label="add a label in the frame"
aria-expanded=${() => frameLabelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
onFocus=${() => setFrameLabelFocus(true)}
onBlur=${() => setFrameLabelFocus(false)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
<${Show} when=${() => frameLabelFocus()}>
<${Suggest} text=${frameLabel} present=${() => labelsOf(frameDoc())}
active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
<//>
</div>
<button aria-label="exit frame" onClick=${exitFrame}>✕ exit</button>
</div>
<//>
</div>
<//>
`;
}
.search{ width:100%; box-sizing:border-box; margin-bottom:12px; padding:8px 40px 8px 12px; font-size:15px;
background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:6px; }
/* edited-but-not-run: an amber tint says the wall is stale (paired with aria-description for
non-colour a11y — see the commit-search prose) */
.search.dirty{ border-color:#d9a441; background:#2b2718; }
/* the commit button sits at the input's right edge (the completion popover opens below both) */
.search-run{ position:absolute; top:5px; right:6px; width:30px; height:30px; border:0; border-radius:6px;
background:#33395a; color:#dfe6ff; font-size:14px; cursor:pointer; }
.search-run:disabled{ opacity:.5; cursor:default; }
/* frozen while a read is in flight — dimmed so it reads as locked, not broken (see prose) */
.search:disabled, .chip:disabled{ opacity:.5; cursor:default; }
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(var(--tile, 96px), 1fr)); gap:4px; }
/* dim + pill while the wall is settling (photos.loading || mutating()) */
.grid[aria-busy="true"]{ opacity:.55; transition:opacity .12s; }
.wall-busy{ position:fixed; top:12px; left:50%; transform:translateX(-50%); z-index:30;
background:#33395a; color:#dfe6ff; font-size:13px; padding:5px 12px; border-radius:999px;
box-shadow:0 4px 14px #0006; pointer-events:none; }
/* while searching, the pill is a cancel button — re-enable pointer events, reset button chrome */
.busy-cancel{ pointer-events:auto; cursor:pointer; border:0; font:inherit; }
.sizer{ display:flex; gap:6px; justify-content:flex-end; margin:0 0 6px; }
.sizer button{ width:28px; height:28px; padding:0; font-size:16px; line-height:1; cursor:pointer;
border:1px solid #3a3f5a; border-radius:6px; background:#262a40; color:var(--fg); }
.sizer button:hover{ background:#33395a; }
.tile{ position:relative; aspect-ratio:1; cursor:pointer; border-radius:4px; overflow:hidden;
user-select:none; touch-action:manipulation; -webkit-touch-callout:none; }
/* manipulation: kill the double-tap zoom + tap delay (a double-tap opens the doc); no
callout: a long-press starts a selection, so don't let the OS claim it for save-image */
.thumb{ width:100%; height:100%; object-fit:contain; background:#262a40; display:block; }
.thumb.noimg{ display:flex; align-items:center; justify-content:center; }
.thumb.noimg .ph{ font-size:28px; opacity:.55; }
.tile[data-selected='1']{ outline:3px solid #6cf; outline-offset:-3px; }
.check{ position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%;
background:#6cf; color:#08111e; font-size:13px; line-height:20px; text-align:center; font-weight:700; }
.badge{ position:absolute; top:3px; right:3px; font-size:10px; padding:1px 5px; border-radius:8px;
background:#000a; color:#cdd; text-transform:uppercase; letter-spacing:.04em; }
/* the caption strip pinned along the tile's foot */
.tile .foot{ position:absolute; left:0; right:0; bottom:0; }
.labels{ display:block; padding:6px 5px 3px; font-size:10px;
line-height:1.2; color:#eef; background:linear-gradient(transparent, #000d);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
.empty{ color:#8a8ea5; }
.countline{ margin:0 0 10px; font-size:12px; color:#c8b27a; }
/* the rubber-band box — fixed to the viewport (its coords are client-space), inert to the pointer */
.marquee{ position:fixed; z-index:50; border:1px solid #6cf; background:rgba(108,204,255,.18); pointer-events:none; }
Load only the thumbnails on screen
A wall of hundreds — soon thousands — of thumbnails can’t hold a decoded image per
tile: the browser runs out of memory long before the page does. loading“lazy”=
defers the first fetch but, once an image has loaded, never lets it go — scroll a
10K-doc wall top to bottom and every image stays resident. So the tile takes charge
of its own image: a single shared IntersectionObserver flips a per-tile near
signal, and the <img> carries its real /ipfs/ source only while near the
viewport, falling back to a 1×1 blank when it leaves. Loading and unloading — the
decoded image is released the moment the tile scrolls away. This is the groundwork for
raising the sampling cap toward the whole archive.
Dropping an image only to want it again would be a poor bargain if it were paid twice on
the wire. It isn’t: an /ipfs/ path names its own content and can never come to stand
for anything else, and the gateway says so, serving every rendition public, max-age=29030400, immutable. Scrolling back is a cache hit. The memory goes; the bytes
never do.
So on a wall that overflows its fold, a tile waiting far below carries no image at all. Bring it into view and it takes one; leave again and it gives it back. 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.
@testcase
def test_wall_reports_busy(page):
"""The wall marks the grid aria-busy while a query is in flight, false once settled."""
open_app(page)
g = grid(page)
expect(g).to_have_attribute("aria-busy", "false") # settled at rest
# hold the wall query so a fresh fetch stays in flight (the page.route trick the
# completion in-flight test uses) — the busy state is then deterministically observable.
page.route("**/graphql", lambda route:
route.fallback() if "photovideosSample" not in (route.request.post_data or "") else None)
search_box(page).fill("zzbusyprobe")
page.get_by_role("button", name="run search").click() # commit → a wall fetch that never resolves
expect(g).to_have_attribute("aria-busy", "true") # in flight → busy
print(" PASS: wall reports busy")
The busy state also covers an edit’s writes: a mutating counter wraps each batch edit from
its first write to the finally after it, so aria-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.
@testcase
def test_wall_busy_during_edit(page):
"""aria-busy spans an edit's write phase too — held mid-write, before any refetch, the
grid already reads busy, so a screen reader hears the region working through the edit."""
open_fixtures(page)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzeditbusy")
# hold the write in flight so the write phase (before any refetch) is observable
page.route("**/graphql", lambda route:
route.fallback() if "updatePhotovideo" not in (route.request.post_data or "") else None)
box.press("Enter") # fires the (held) writes
expect(grid(page)).to_have_attribute("aria-busy", "true") # busy during the WRITE phase
print(" PASS: wall busy during edit")
aria-busy speaks to a screen reader; a sighted user needs to see it too. So while the wall is
settling it dims and shows a small cue: updating… while an edit writes, and searching «…»,
naming the very query in flight, while a read is out — so the wait reads as working on a known
thing, not as broken or already done.
@testcase
def test_wall_shows_updating_feedback(page):
"""While a batch edit is in flight, the wall shows a visible 'updating…' cue."""
open_fixtures(page)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzfeedback")
page.route("**/graphql", lambda route:
route.fallback() if "updatePhotovideo" not in (route.request.post_data or "") else None)
box.press("Enter") # edit held → the wall is settling
expect(page.get_by_text("updating…")).to_be_visible()
print(" PASS: wall shows updating feedback")
Naming the query only helps if there is one to name — so the wall does not chase the keyboard.
Typing updates the box and its completions, but the wall re-reads only when you commit: press
Enter (which applies a highlighted completion if one is selected, else runs the search) or tap
the run-search button beside the box. The box is a query language — event:, since:,
type:, bare labels — so composing a whole query and then running it beats firing a read at
every keystroke, which would search half-typed tokens. A state chip re-reads at once on the
last committed query — a chip is a deliberate filter, not typing.
Because the box can now hold a query the wall hasn’t run, it says so: while the typed text
differs from the committed one, the box is dirty — tinted amber to read as stale, not yet
applied. Colour alone would be invisible to a screen reader, so the same state also sets an
aria-description on the box (the run button keeps its plain name, since a control’s label
shouldn’t churn); committing, or backing out via Esc/Back, clears both at once.
A committed query runs one-at-a-time and stays legible: a running query drives the wall and
advances to the newest committed query only when the wall is idle, so exactly one read is in
flight at a time and it is never swapped out from under itself — which is what lets the
searching «…» cue name a single, stable query.
And while that read is out the query controls are locked: the search box, the run button, and
the state chips disabled (dimmed — read as held, not broken), so what is on screen cannot drift
from what is being fetched. It frees the instant the read lands. (A slow read is thus felt as a
locked box — the honest cost of a query that should be quick, and the reason a slow event: read
is worth making fast rather than papering over.)
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 arrival is met the way any arrival is, by asking after it — first on its own, and then as part of the run it fell into, which is where the counting matters.
for d in nomedia_docs(): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
search_for(page, NOMEDIA_LABEL) # the new arrival, asked for directly
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # …and again among the run it landed in
expect(tiles(page)).to_have_count(len(FIXTURES) + 1) # the no-thumb one shows too
expect(thumb_imgs(page)).to_have_count(len(FIXTURES)) # but it isn't an image
expect(grid(page).get_by_text("🖼")).to_have_count(1) # it wears the icon, so it reads as a placeholder
expect(page.get_by_text(re.compile(r"showing a spread"))).to_have_count(0) # and no notice
Opening it, when there’s no web_cid either, shows a “no preview” placeholder instead of a
broken image.
search_for(page, NOMEDIA_LABEL) # a label only this doc carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
expect(d.get_by_text("no preview")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # nothing broken to show
expect(d.locator("video")).to_have_count(0)
Which placeholder you get turns on what the doc is. An image missing its web copy still has its thumbnail, and a stretched thumbnail is the same picture, only softer — it answers what was asked. So the lightbox enlarges it rather than refusing the doc.
d.get_by_role("button", name="close").click() # back to the wall
search_for(page, "zznwimg") # an image with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zznw-img-t") # its poster, stretched
A video in the same state is refused: the lightbox opens a clip in order to play it, and a poster cannot be played — offering the still would put a photograph where a film was asked for.
d.get_by_role("button", name="close").click()
search_for(page, "zznwvid") # a clip with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_text("no preview · video/mp4")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # and the poster is not offered in its place
def nomedia_docs():
"""Three rows that arrived ahead of their pictures: one with no rendition at all,
and a clip and a photo that have their poster but not their full copy."""
return [NOTHUMB_FIXTURE,
{"cid": "https://ipfs.konubinix.eu/p/zznw-vid", "date": "2020-04-18T12:00:00Z", "mimetype": "video/mp4",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zznw-vid-t", "labels": "zznwvid", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zznw-img", "date": "2020-04-19T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zznw-img-t", "labels": "zznwimg", "state": "todo"}]
for d in nomedia_docs(): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
search_for(page, NOMEDIA_LABEL) # the new arrival, asked for directly
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # …and again among the run it landed in
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, 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() # back to the wall
search_for(page, "zznwimg") # an image with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zznw-img-t") # its poster, stretched
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")
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}"
def pick_docs():
"""Eight photos, one a day through a January — enough for two ends and a gap between."""
return [{"cid": f"https://ipfs.konubinix.eu/p/zzpick-{i}", "date": f"2020-01-0{i + 1}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzpick-t-{i}",
"labels": "zzpick", "state": "todo"} for i in range(8)]
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)}"
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")
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: (grid(page).locator(f'.tile:has(img[alt="{day}"]) .ev-pill')
.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: grid(page).locator(f'.tile:has(img[alt="{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 = grid(page).locator(f'.tile:has(img[alt="{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.
The test searches a fixture set holding one video among the stills, and checks exactly one tile — the video’s — carries the badge.
@testcase
def test_video_tiles_are_marked(page):
"""A video tile wears a play badge; a photo tile doesn't — so the two are
distinguishable on the wall."""
make_fixtures() # 3 stills
gql(CREATE, {"p": VIDEO_FIXTURE}) # + one video, all carry FIXTURE_LABEL
try:
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
expect(grid(page).get_by_title("video")).to_have_count(1) # only the video tile is badged
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: video tiles are marked")
The badge is a play triangle laid over the poster — shown for a video that carries a thumbnail. It is pure decoration, so it lets pointer events fall through to the tile, leaving the tap, the double-click, and the long-press untouched.
<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
<span class="play" title="video">▶</span>
<//>
A small translucent disc, centred, with the triangle nudged right so it reads as centred to the eye.
.tile .play{ position:absolute; top:50%; left:50%; transform:translate(-50%, -50%);
width:34px; height:34px; border-radius:50%; background:#000a; color:#fff;
display:flex; align-items:center; justify-content:center;
font-size:15px; padding-left:3px; pointer-events:none; }
Searching and filtering
You narrow the wall to what you want: a free-text label search, a small query language, and the calendar as a filter.
Search by label
Typing a label narrows the wall to matching photos — the search term is a Solid
signal, and createResource re-fetches whenever it changes (no manual wiring, the
resource tracks the signal). The shared filter behind photovideosSample does the FTS.
The test types a label that exists and checks the wall shrinks to a non-empty set.
@testcase
def test_search_narrows(page):
"""Typing a label narrows the grid to matching photos."""
make_fixtures() # 3 fixtures, all carrying FIXTURE_LABEL
narrow = {"cid": "https://ipfs.konubinix.eu/p/zznarrow", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zznarrow-t", "labels": FIXTURE_LABEL + "; zznarrowonly", "state": "todo"}
gql(DELETE, {"cid": narrow["cid"]}); gql(CREATE, {"p": narrow}) # one also wears a rarer label
try:
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL) # the broad label: every fixture + the narrow doc
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
before = tiles(page).count()
search_for(page, "zznarrowonly") # the rarer label narrows to just its one doc
wait_until(page, lambda: 0 < tiles(page).count() < before)
expect(tiles(page)).to_have_count(1)
finally:
gql(DELETE, {"cid": narrow["cid"]})
print(" PASS: search narrows")
@testcase
def test_search_persists(page):
"""The search box is saved locally, surviving a reload (and the frame's reboot)."""
open_app(page)
search_box(page).fill("type:image; since:2010")
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(search_box(page)).to_have_value("type:image; since:2010")
print(" PASS: search persists")
Completing labels
Free-text labels rot into near-duplicates (cosmo, cosmos, cosmo =) unless you can see what already exists while typing. The vocabulary and the =labelCompletions function
this reads aren’t ours — they live in the frise’s labels schema (the label_vocab
table and the label_completions SQL function); this app only consumes them, through the shared LABEL_COMPLETIONS query the frise asks too.
One trap, worth carrying when chasing why a freshly-saved label fails to suggest:
photovideo is an inheritance parent, so the rows — and the trigger that keeps
label_vocab in step on every edit — live on its photo and video children. A
trigger or row check against photovideo itself comes back empty and misleads; look at
the children, where the frise’s labels schema wires the trigger up (and
rebuilds the vocabulary) — see its apply-vocab-child-triggers block.
A small Suggest dropdown reads it as you type and lets you pick an existing word — the
same component under the search box and every add-label box, so they stay consistent.
It completes the fragment under the cursor, not the whole field: the search box splits on
whitespace (its query is space-separated terms), while an add-label box completes the
segment after the last ; (its content is a ;-separated list), so balade;aure
offers aurelie and a pick swaps just that segment.
Suggest is the shared completion dropdown: mounted under any box that wants it, it
offers words for the live text and hands the picked one back to whoever mounted it. (Under
the hood the pick cancels the press that carried it, so pointing at the list never moves
the focus off the box at all; the panel then closes because the segment it was completing
is finished, not because anything was blurred.) A box that edits a known doc
can also hand it the labels already on that doc, which it drops from the offers — so
completion never proposes a label the doc already wears. Below two characters it returns
nothing — completion on one letter is just noise.
Each box reports its current list to the App and reads the highlight index back — only
one box is focused at a time, so a single shared highlight (sugNav) suffices. In the
lightbox and the frame this is also why ←=/=→ step the wall only when no field has
focus — inside the label box the arrows belong to the text.
Type a fragment of a known label, pick the first suggestion, and the box holds exactly
that word — followed by a ;, so the next term can start without reaching for it.
guess(page, "zzbal") # a fragment of one word we know
word = options(page).first.inner_text().strip()
options(page).first.click()
expect(sb).to_have_value(word + "; ") # the picked label, then a ';' for the next
print(" PASS: label completion")
Completion drives the DSL’s own tokens, not just labels. A suggestion that completes a
token — a label, a full date, a type:=/=owner: value, an event — appends a ;, so the
next term starts without your typing the separator, in the search box and the add-label
boxes alike. A suggestion that still has somewhere to go keeps the box on that token
instead, no ;: a bare key (since:, event:) about to take a value, or a partial date
the picker will drill from year to month to day. Which it is isn’t wired token by token —
the box asks the suggester whether the picked word still has a longer completion to
offer. So owner offers the key, then its values, and owner:konubinix — a closed-vocab
value with nothing longer — is done, and a ; opens the next term.
guess(page, "owner") # 2+ chars → the key is offered
expect(options(page).filter(has_text="owner:").first).to_be_visible()
sb.press_sequentially(":k", delay=20) # owner:k → its values
opt = options(page).filter(has_text="konubinix").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value("owner:konubinix; ") # a closed-vocab value is done — a ';' opens the next token
print(" PASS: owner token completes")
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.
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.
guess(page, "zzup") # two words, so there is a bottom to enter at
items = [t.strip() for t in options(page).all_inner_texts()]
assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
sb.press("ArrowUp") # from nothing selected — must enter at the bottom
sel = page.get_by_role("option", selected=True)
assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
"ArrowUp from none should select the last suggestion"
print(" PASS: suggestion up enters from none")
When the list runs longer than the dropdown can show at once, the moving highlight scrolls
it into view, so ↑=/=↓ never land on a row hidden below the fold.
@testcase
def test_completion_keeps_highlight_in_view(page):
"""A list taller than the popover scrolls as ↓ walks it, so the highlight stays visible."""
open_app(page)
box = search_box(page)
box.click() # empty box → all the DSL keys, taller than the popover
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
assert listbox.evaluate("el => el.scrollHeight > el.clientHeight + 4"), \
"setup: the menu must overflow the popover for scrolling to matter"
opts = page.get_by_role("option")
for _ in range(opts.count()): box.press("ArrowDown") # walk down to the last key
active = page.get_by_role("option", selected=True)
expect(active).to_have_count(1)
assert active.evaluate("""el => { const b = el.getBoundingClientRect(),
l = el.closest('[role=listbox]').getBoundingClientRect();
return b.top >= l.top - 1 && b.bottom <= l.bottom + 1; }"""), \
"the highlighted option is not within the visible completion area"
print(" PASS: completion keeps highlight in view")
Applying a suggestion leaves the box on a fresh, empty segment, so the list momentarily blanks. The next keystroke has to bring it back — otherwise completing one term would silently kill completion for the rest, and you would have to click out and in. What makes that work is that picking never costs the box its focus to begin with: a press on the list is stopped from taking it, so the caret stays where you were typing and the next character simply arrives.
In practice that is why what follows types at the page rather than at the box: aimed at the box, a keystroke would focus it on the way in, which is the very click-out-and-in this says you will not need. Typed at the page, the keys go wherever focus actually is, and if the pick had taken it they go nowhere.
page.keyboard.type("zzalo", delay=20) # straight on, into whatever holds the keys
expect(options(page).filter(has_text="zzalois").first).to_be_visible() # must reappear
print(" PASS: completion reopens after pick")
The match reaches inside a word, not only its start: a mid-word smo surfaces cosmo.
guess(page, "osmo") # sits inside zzcosmo, nowhere near its start
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
print(" PASS: completion matches inside label")
And it folds case and accent while offering the label exactly as written — a bare zzelod
surfaces Zzélodie, its capital and accent kept. Kept on the way in, too: what the box
takes is the word as the archive holds it, not the plain thing you typed to find it.
guess(page, "zzelod") # no capital, no accent
texts = [t.strip() for t in options(page).all_inner_texts()]
assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
options(page).filter(has_text="Zzélodie").first.click()
expect(sb).to_have_value("Zzélodie; ")
print(" PASS: completion preserves case and accent")
The vocabulary sits behind a GraphQL round-trip, and on a cold box that takes a noticeable
beat — long enough that an empty dropdown reads as broken rather than thinking. So the
popover opens while the query is still in flight, not only once it has results, and
renders a pulsing completing… row; the words replace it the moment they arrive, and on a
refine the previous segment’s list stays underneath so the panel never blanks while it
catches up.
@testcase
def test_completion_shows_in_flight_hint(page):
"""A completion query in flight shows a 'completing…' hint, so a slow round-trip never
reads as a broken box. We hold the query open so the in-flight state is observable."""
open_app(page)
# let every /graphql through except the label-completion query, which we leave unanswered
# so the resource stays loading rather than flashing in a sub-second.
page.route("**/graphql", lambda route:
route.fallback() if "labelCompletions" not in (route.request.post_data or "") else None)
search_box(page).click()
search_box(page).press_sequentially("au", delay=20) # 2+ chars → a held label-completion query
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: completion shows in-flight hint")
The popover has one collision to win. On a short screen, selecting a doc raises the fixed bottom toolbar, and the search box’s list opens downward far enough to reach it. Where the two overlap, the suggestion you can see must be the one you touch — so the completion draws above the bar, never behind it.
@testcase
def test_search_completion_sits_above_selection_toolbar(page):
"""On a short screen, selecting a doc raises the fixed bottom toolbar. The top search box's
completion opens downward and can reach the bar — where they overlap the completion must be
what the user touches, drawn above the bar, not hidden behind it."""
VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
words = [f"zzov{c}" for c in "abcdefghijkl"] # 12 words → a list tall enough to reach the bar
for w in words: gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
try:
page.set_viewport_size({"width": 360, "height": 300}) # short: the list overlaps the bottom bar
open_fixtures(page)
tiles(page).nth(0).click() # select one → the bottom toolbar appears
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20) # replace search → its completion
expect(options(page).first).to_be_visible() # completion open
expect(toolbar(page)).to_be_visible() # selection kept → bottom bar still up
tb = toolbar(page).bounding_box()
sug = page.get_by_role("listbox", name="suggestions").bounding_box()
lo, hi = max(tb["y"], sug["y"]), min(tb["y"] + tb["height"], sug["y"] + sug["height"])
assert hi > lo, f"setup: toolbar {tb} and completion {sug} don't overlap — nothing to test"
x, y = tb["x"] + tb["width"] / 2, (lo + hi) / 2
# what the user actually touches at the overlap must be the completion, not the bar behind it
hit = page.evaluate(
"([x,y]) => { const el = document.elementFromPoint(x,y);"
" return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }",
[x, y])
assert hit["list"] and not hit["bar"], f"completion is behind the toolbar at the overlap: {hit}"
print(" PASS: search completion sits above the selection toolbar")
finally:
for w in words: gql(VOCAB_DEL, {"w": w})
The search box’s completion wears a few read-only faces — none commits a query — so they
share one boot: type, read the popover, and hand back an empty box for the next. The year:
token completes like the others — typing it offers the key, then its years.
# the search box offers the year: token, then its year values, like month:/day: do
box = search_box(page)
box.click(); box.press_sequentially("year", delay=20) # 2+ chars → the key is offered
expect(options(page).filter(has_text="year:").first).to_be_visible()
box.press_sequentially(":202", delay=20) # year:202 → its year values
expect(options(page).filter(has_text=re.compile(r"year:202\d")).first).to_be_visible()
print(" PASS: year token completes")
box.fill(""); box.blur() # hand back an empty box
expect(page.get_by_role("listbox", name="suggestions")).to_have_count(0) # popover gone
The query language is only useful if you can find it. So the empty search box, the moment it takes focus, drops down the whole token menu — every prefix at once — turning the box from something you must already know into something you can discover.
# focusing the empty box offers the whole token menu (so the language is discoverable)
search_box(page).click() # empty box, just focused — no typing
expect(options(page)).to_have_count(14) # the whole menu, at once
got = sorted(t.strip() for t in options(page).all_inner_texts())
assert got == sorted(["since:", "until:", "type:", "sort:", "owner:", "month:", "day:",
"year:", "date:", "event:", "first:", "last:", "sample:",
"onthisday"]), got # every prefix present (order-free)
print(" PASS: empty box suggests all prefixes")
search_box(page).blur() # hand back closed
expect(page.get_by_role("listbox", name="suggestions")).to_have_count(0) # popover gone
The box and its list form a combobox: the input carries role=combobox and an
aria-expanded that reads true whenever the completion popover is up — while it loads (the
completing… flash) and while it lists results. The popover shows in both states, so an
aria-expanded that tracked only the results would announce collapsed over a visible
loading popover; keyed on either, it is the one place — for a screen reader and anything else
— that says whether the popover is up. Closing it is a state flip, not a timed fade: blur the
box and the list is gone at once, nothing to wait out.
# the box reports its popover via aria-expanded; closing is a state flip, no timing
sb = search_box(page)
sb.click(); sb.press_sequentially("cos")
expect(sb).to_have_attribute("aria-expanded", "true") # open popover → state true
sb.blur()
expect(sb).to_have_attribute("aria-expanded", "false") # closed → state false (what search_for waits on)
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover must be gone the instant the state flips"
print(" PASS: search completion state, no timing")
sb.fill(""); sb.blur(); expect(sb).to_have_attribute("aria-expanded", "false") # hand back an empty box
@testcase
def test_search_expanded_while_loading(page):
"""The search box reads aria-expanded=true while its completion list is loading."""
open_app(page); hold_completions(page)
expanded_while_loading(page, search_box(page))
print(" PASS: search expanded while loading")
@testcase
def test_lightbox_label_expanded_while_loading(page):
"""The lightbox add-label box reads aria-expanded=true while its list is loading."""
open_fixtures(page); open_doc(page); hold_completions(page)
expanded_while_loading(page, dialog(page).get_by_placeholder("add a label…"))
print(" PASS: lightbox label expanded while loading")
@testcase
def test_frame_label_expanded_while_loading(page):
"""The frame add-label box reads aria-expanded=true while its list is loading."""
open_fixtures(page)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
page.get_by_role("list", name="slideshow").click() # a tap reveals the frame bar
hold_completions(page)
expanded_while_loading(page, page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…"))
print(" PASS: frame label expanded while loading")
@testcase
def test_batch_label_expanded_while_loading(page):
"""The selection's add-label box reads aria-expanded=true while its list is loading."""
open_fixtures(page); select_all(page).click(); hold_completions(page)
expanded_while_loading(page, toolbar(page).get_by_placeholder("add a label…"))
print(" PASS: batch label expanded while loading")
Completion draws on two capped vocabularies, each its own query: the labels already in use
(labelCompletions), and the calendar’s event summaries (eventCompletions), scoped to a date
window.
const fetchCompletions = async word => {
if((word || '').length < 2) return [];
const d = await gql(LABEL_COMPLETIONS, { prefix: word, first: 8 });
return d?.labelCompletions?.nodes ?? [];
};
const EVENT_COMPLETIONS = `query($prefix:String!,$since:Datetime,$until:Datetime,$first:Int){
eventCompletions(prefix:$prefix, since:$since, until:$until, first:$first){ nodes } }`;
const fetchEventCompletions = async (prefix, since, until) => {
const d = await gql(EVENT_COMPLETIONS, { prefix: prefix || '', since, until, first: 8 });
return d?.eventCompletions?.nodes ?? [];
};
What the current word wants depends on where the caret sits: in the search box’s DSL an empty box
offers every key, an event: token the calendar’s summaries, a since:=/date fragment the date picker, a bare key its own name. A bare word completes a label — and, in the search box, also any event whose name it matches, offered as a ready =event: token, so the same letters reach both a
photo’s label and the occasion it was shot during. The label boxes stay labels-only: a doc takes a
label, not an event.
async function suggestFor(text, caret, dsl, present){
if(dsl && !(text || '').trim()) return DSL_KEYS;
const word = segAt(text, caret);
const ev = dsl && /^event:(.*)$/i.exec(word);
if(ev){ const q = parseQuery(text);
return (await fetchEventCompletions(ev[1], q.since, q.until)).map(s => 'event:' + s); }
if(dsl){ const d = dslSuggestions(word); if(d.length) return d; }
if(word.includes(':')) return [];
const has = (present || []).map(s => s.toLowerCase());
const drop = ws => has.length ? ws.filter(w => !has.includes(w.toLowerCase())) : ws;
if(!dsl) return drop(await fetchCompletions(word));
const q = parseQuery(text);
const [labels, events] = await Promise.all([
fetchCompletions(word),
word.length >= 2 ? fetchEventCompletions(word, q.since, q.until) : Promise.resolve([]),
]);
return [...drop(labels).slice(0, 5), ...events.map(s => 'event:' + s).slice(0, 3)];
}
Suggest is that shared popover made concrete: it recomputes its offers whenever the
text or caret moves, the caret defaulting to end-of-text so a box with no cursor of its own still
completes its last segment.
function Suggest(props){
const caret = () => props.caret == null ? (props.text || '').length : props.caret;
const [items] = createResource(() => [props.text, caret(), props.present],
([t, c, p]) => suggestFor(t, c, props.dsl, p));
createEffect(() => props.onItems?.(items() || []));
createEffect(() => props.onLoading?.(items.loading));
const active = () => props.active == null ? -1 : props.active; // a 0-arg accessor prop arrives unwrapped
let listEl;
createEffect(() => { const i = active();
if(i >= 0 && listEl) listEl.querySelectorAll('[role=option]')[i]?.scrollIntoView({ block: 'nearest' }); });
return html`
<${Show} when=${() => items.loading || (items() || []).length > 0}>
<ul class="suggest" role="listbox" aria-label="suggestions" ref=${el => listEl = el} aria-busy=${() => items.loading ? 'true' : 'false'}>
<${Show} when=${() => items.loading}>
<li class="sug-loading" aria-disabled="true">completing…</li>
<//>
<${For} each=${() => items() || []}>${(w, i) => html`
<li class=${() => 'sug' + (i() === active() ? ' active' : '')} role="option"
aria-selected=${() => i() === active() ? 'true' : 'false'}
onMouseDown=${e => { e.preventDefault(); props.onPick(w); }}>${w}</li>`}
<//>
</ul>
<//>`;
}
The screen stacks in named layers: the content wall at the base, the fixed selection toolbar
above it (z-index:20), then the transient overlays — a completion popover (25), the busy
updating… pill (30), the drag marquee (50), the lightbox (100), the frame (200). The
popover sits just above the toolbar because the two are the only pair that share the screen at
once: on a short screen the search box’s list opens downward far enough to reach the bottom bar,
and a suggestion you can see but not touch is worse than none. It stays below busy/marquee/
lightbox/frame, which never coexist with an open search completion.
.complete{ position:relative; }
/* completion-popover layer — see the stacking order in prose */
.complete .suggest{ z-index:25; }
/* a long list still scrolls, but with a thin themed bar — the chunky default one clashes
with the dark panel (scrollbar-color for Firefox/modern Chromium, ::-webkit for the rest). */
.suggest{ position:absolute; z-index:10; left:0; right:0; top:100%; margin:2px 0 0; padding:4px;
list-style:none; background:#1b1d2e; border:1px solid #3a3f5a; border-radius:6px;
max-height:240px; overflow:auto; box-shadow:0 6px 20px #0008;
scrollbar-width:thin; scrollbar-color:#4a4f6e transparent; }
.suggest::-webkit-scrollbar{ width:8px }
.suggest::-webkit-scrollbar-thumb{ background:#4a4f6e; border-radius:8px }
.suggest::-webkit-scrollbar-track{ background:transparent }
.sug{ padding:9px 10px; border-radius:4px; cursor:pointer; font-size:14px; }
.sug:hover, .sug.active{ background:#33395a; }
.sug-loading{ padding:9px 10px; font-size:14px; color:#8a8ea5; cursor:default;
animation:sug-pulse 1s ease-in-out infinite; }
@keyframes sug-pulse{ 0%,100%{ opacity:.45 } 50%{ opacity:.9 } }
A query language in the search box
The search box wears the shared query language — bare labels beside
since:=/=until:=/=type:=/=sort:=/=owner:=/=onthisday tokens, ;-separated so a label
keeps its spaces (parc des oiseaux, famille sam). (State stays on the chips for now —
folding it in would mean either retiring the chips or syncing them, a separate decision.)
What’s memories’ own is how the box carries it: completion is segment-aware, working on
the segment the cursor sits in — offering the language’s tokens when you start one and
vocabulary labels otherwise — and a picked label gets a trailing ; so the next one
starts without your typing the separator.
The free text — every segment that isn’t a typed token — is a boolean label search,
handed to Postgres websearch_to_tsquery untouched, 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.
@testcase
def test_today_photos_shown_by_default(page):
"""The default window runs to the end of today, so a photo shot today is on the wall."""
from datetime import date
doc = {"cid": "https://ipfs.konubinix.eu/p/zztoday", "date": date.today().isoformat() + "T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zztoday-t",
"labels": "zztodayphoto", "state": "todo"}
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page) # default view, default window (no until: token)
search_for(page, "zztodayphoto")
expect(tiles(page)).to_have_count(1) # today's photo is in range
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: today photos shown by default")
A bound can also drop its year and name just the season you mean: until:july is the July
just gone, until:07-14 the last 14th of July, until:july-14 the same in words. The year
it takes is whichever one that date last came round in, so the token keeps meaning last
without ever being retyped. Here two docs sit either side of one 14th of July, and each
spelling of it separates them the same way — the same way exactly, since both land on the
identical instant.
@testcase
def test_bare_month_and_day_bounds(page):
"""until: takes a month, or a month and a day, with no year — the most recent one."""
from datetime import date
# last July's 14th, whichever year that was: this year if it has been, else the year before
y = date.today().year - (0 if (date.today().month, date.today().day) >= (7, 14) else 1)
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbare-before", "date": f"{y}-07-10T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-before-t", "labels": "zzbare", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzbare-after", "date": f"{y}-07-20T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-after-t", "labels": "zzbare", "state": "todo"}]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page)
for spelling in ("07-14", "july-14"):
search_for(page, "zzbare")
expect(tiles(page)).to_have_count(2) # both, unbounded
search_for(page, f"zzbare; until:{spelling}")
expect(tiles(page)).to_have_count(1) # only the 10th survives the bound
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbare-before-t")
search_for(page, "zzbare; since:july") # the whole of that July, both back
expect(tiles(page)).to_have_count(2)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: bare month and day bounds")
onthisday is the anniversary view: today’s month-day ±1 (or onthisday:N) across
all years — what the frame shows to surface “this day in years past”. It’s a server
predicate (anniv_dist, year ignored, wrapping at the year boundary), so it samples and
bulk-edits like any other filter, and composes with since:=/=until: to bound the years.
@testcase
def test_onthisday(page):
"""onthisday matches today's month-day ±N across every year."""
from datetime import date, timedelta
t = date.today()
anchored = lambda days, yr: (t + timedelta(days=days)).replace(year=yr).isoformat() + "T12:00:00Z"
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzanniv-{i}", "date": anchored(off, yr), "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzanniv-t-{i}", "labels": "zzanniv", "state": "todo"}
for i, (off, yr) in enumerate([(0, 2010), (1, 2015), (5, 2018)])] # ±0, ±1, ±5 days
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page) # default state todo; all three are todo
search_for(page, "zzanniv; onthisday")
expect(tiles(page)).to_have_count(2) # ±0 and ±1 only
search_for(page, "zzanniv; onthisday:5")
expect(tiles(page)).to_have_count(3) # ±5 now included
search_box(page).fill("ontH") # and it completes
expect(options(page)).to_have_text(["onthisday"])
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: onthisday")
@testcase
def test_sort_random(page):
"""sort:random reorders the wall by myrandom (and completes); date is the default."""
open_fixtures(page) # date order → thumb-0 first
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
search_box(page).fill(FIXTURE_LABEL + "; sort:random") # myrandom asc → thumb-1 first
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
search_box(page).fill("sort:") # and it completes
expect(options(page)).to_have_text(["sort:date", "sort:random"])
print(" PASS: sort random")
The lightbox
One doc, full size — where you look closely and edit what the archive holds about it: its labels, its state, its date.
Opening a doc — the lightbox
The wall is for scanning; sometimes you need one doc up close — to actually watch a video, or to read and fix its labels.
Open one doc and the modal shows its full web_cid media (a <video controls> when the
mimetype is video, else the image), its date, and its labels as removable chips; the ✕
or Escape closes it again.
@testcase
def test_lightbox_opens_and_closes(page):
"""Double-clicking a tile opens a modal with the media and labels; close dismisses it."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
expect(d).to_be_visible()
expect(d.get_by_role("img")).to_have_count(1) # the image is shown
expect(d.locator(".lb-date")).to_have_text(page.evaluate(f"() => new Date('{FIXTURES[0]['date']}').toLocaleString('fr-FR')")) # its date
expect(d.get_by_text(FIXTURE_LABEL)).to_be_visible() # its labels are shown
d.get_by_role("button", name="close").click()
expect(d).to_be_hidden()
print(" PASS: lightbox opens and closes")
A click that lands on nothing that acts — the backdrop, the image, the empty margins — also closes it; only a control swallows the click and keeps it open.
@testcase
def test_lightbox_click_outside_content_closes(page):
"""A click on nothing that acts — the image — closes the modal; a control click keeps it."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
expect(d).to_be_visible()
sel = d.get_by_role("button", name=re.compile("select"))
sel.click() # a control acts → the modal stays
expect(sel).to_have_text(re.compile("selected")) # the click reached the button, didn't leak to close
expect(d).to_be_visible()
d.get_by_role("img").click() # the image doesn't act → the modal closes
expect(d).to_be_hidden()
print(" PASS: lightbox click outside content closes")
The open gesture itself is a double-click: a single tap stays the triage select and a long-press arms a range, so opening one doc full-size has its own gesture and the three don’t collide.
@testcase
def test_double_click_opens_lightbox(page):
"""A double-click on a tile is the open gesture — the modal comes up on that doc."""
open_fixtures(page)
tiles(page).nth(0).dblclick()
d = dialog(page)
expect(d).to_be_visible()
expect(d.get_by_role("img")).to_have_count(1)
print(" PASS: double-click opens lightbox")
That long-press is also what a touchscreen reads as a request for its own save image
callout. But the app has its own use for every press — this one starts a selection — so on
a tile it swallows the native context menu.
@testcase
def test_tile_context_menu_suppressed(page):
"""A long-press starts a selection — an owned gesture — so the app swallows the native context menu on a tile."""
open_fixtures(page)
fired = tiles(page).nth(0).evaluate(
"el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
assert fired is False, "a tile press should preventDefault on contextmenu"
print(" PASS: tile context menu suppressed")
It swallows that menu across all its own surfaces, not just tiles — the open lightbox’s media too.
@testcase
def test_context_menu_suppressed_app_wide(page):
"""The native context menu is swallowed across the app's own surfaces, not just tiles — here the lightbox media."""
open_fixtures(page)
open_doc(page)
fired = dialog(page).get_by_role("img").evaluate(
"el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
assert fired is False, "the lightbox media should suppress the native context menu"
print(" PASS: context menu suppressed app-wide")
It keeps the native menu only in the text fields, where paste-and-select still earns its place.
@testcase
def test_context_menu_allowed_in_text_field(page):
"""A text field keeps its native menu — paste and select still help there."""
open_app(page)
fired = search_box(page).evaluate(
"el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
assert fired is True, "the search box should keep its native context menu"
print(" PASS: context menu allowed in text field")
The lightbox has a few read-only looks — none of them touches a doc — so they share one
boot: each opens a doc, checks its one thing, and hands the wall back closed for the next.
First, the device back button leaves the lightbox: opening a doc pushes a history entry, so
Back pops it — landing back on the grid — while Esc and ✕ unwind that same entry, so
explicit-close and Back stay balanced (just as they do for the frame).
# Back steps out of the lightbox to the grid, like ✕ does
open_doc(page, 0)
expect(dialog(page)).to_be_visible()
page.go_back()
expect(dialog(page)).to_be_hidden()
expect(grid(page)).to_be_visible()
print(" PASS: back button closes lightbox")
The modal media is the downscaled web_cid, so the lightbox also offers the original
file — the doc’s cid is itself the original’s /ipfs/ address — opened in a new tab for a
full-resolution look or a download. The metadata rows should leave the media every pixel
they can, so the offer rides on the date row as a small ⤢ glyph: an icon that reads as
“open full” carries it without spending a line or a word. An icon has no text to name it,
so the link labels itself original for a screen reader, and its hover title spells out
the full-resolution, new-tab behaviour.
# the lightbox links to the original doc at its /ipfs/ cid (full-res, new tab)
open_doc(page)
link = dialog(page).get_by_role("link", name="original")
expect(link).to_be_visible()
expect(link).to_have_attribute("href", FIXTURES[0]["cid"]) # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
page.keyboard.press("Escape") # hand the wall back for the next view
expect(dialog(page)).to_be_hidden()
print(" PASS: lightbox links to original")
Up close means big: the doc is what you came to see, so the dialog takes the whole band the
system leaves it and the media — photo or video alike, they share the same .lb-media box —
gets every pixel the metadata rows leave, scaled up as well as down. A cap-only
sizing (max-width=/=max-height) would shrink an oversized media but leave the
downscaled web_cid floating small in the overlay — on a tablet, most of the screen
wasted. But it is shown whole, never cropped: object-fit:contain scales the doc to
the largest size that fits the box, so a portrait photo on a landscape screen (or the
reverse) keeps every edge — the bars on the spare axis are the price of seeing all of it,
and cropping away the very thing you opened the doc to look at is the worse trade.
open_doc(page)
img = dialog(page).get_by_role("img")
box = img.bounding_box()
assert box["width"] >= VIEWPORT["width"] * 0.9, f"media width {box['width']} < 90% of viewport"
assert box["height"] >= VIEWPORT["height"] * 0.65, f"media height {box['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", "media crops instead of fitting whole"
page.keyboard.press("Escape") # hand the wall back (last view, kept uniform)
expect(dialog(page)).to_be_hidden()
print(" PASS: lightbox media fills screen")
A doc’s labels show as chips, and a chip is also a shortcut: click one and the lightbox closes, the wall re-filtered to that label.
@testcase
def test_lightbox_chip_filters(page):
"""Clicking a label chip in the lightbox sets the search to that label."""
make_fixtures()
chip_doc = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-chip", "date": "2020-05-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-chip-t",
"labels": "zzchiponly; " + FIXTURE_LABEL, "state": "todo"}
gql(CREATE, {"p": chip_doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzchiponly") # narrow to just the chip doc
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
d.get_by_role("button", name=FIXTURE_LABEL, exact=True).click() # click its other chip
expect(d).to_be_hidden() # the lightbox closes
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # the filter switched to that label
finally:
gql(DELETE, {"cid": chip_doc["cid"]})
print(" PASS: lightbox chip filters")
The box takes several labels at once, ;-separated, and skips any the doc already
carries.
box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20) # one existing + two new, typed
box.press("Enter")
expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1) # not duplicated
print(" PASS: lightbox adds several labels")
Completion targets the segment after the last ;, so a pick fills that one and keeps the
earlier labels already typed.
@testcase
def test_lightbox_completes_last_segment(page):
"""Completion targets the segment after the last ';' (and a pick keeps the earlier ones)."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zztest;cos", delay=20) # typed, as a user would
opt = options(page).filter(has_text="cosmo").first # the live segment 'cos' completes to a vocab word
expect(opt).to_be_visible()
opt.click()
expect(box).to_have_value("zztest;cosmo; ") # earlier segment kept, completed, auto-';
box.press("Enter") # commit the built list
expect(d.get_by_role("button", name="zztest", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="cosmo", exact=True)).to_be_visible()
print(" PASS: lightbox completes last segment")
It’s the same keyboard path as the search box: ↓ then Enter completes the segment
(with the auto-;), and a plain Enter commits the built list.
@testcase
def test_lightbox_label_keyboard(page):
"""In the lightbox add-label box, ↓+Enter completes (with auto-';'), then Enter commits."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos", delay=20)
expect(options(page).first).to_be_visible()
word = options(page).first.inner_text().strip()
box.press("ArrowDown"); box.press("Enter") # apply the highlight → fills "word; "
expect(box).to_have_value(word + "; ")
box.press("Enter") # nothing highlighted now → commit
expect(d.get_by_role("button", name=word, exact=True)).to_be_visible() # chip added
print(" PASS: lightbox label keyboard")
With nothing highlighted, Enter adds the typed labels and Shift+Enter removes them —
the keyboard twins of add and drop.
box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible() # added
box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0) # removed
print(" PASS: lightbox enter adds, shift-enter removes")
This box — and the frame’s add-label box — is a combobox on the same terms as the
search box: an aria-expanded that tracks its popover, and a close that is a state flip,
not a timed guess.
@testcase
def test_lightbox_label_combobox_state(page):
"""The lightbox add-label box is a combobox too: aria-expanded tracks its list, no-timing close."""
open_fixtures(page)
open_doc(page)
box = dialog(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos")
expect(box).to_have_attribute("aria-expanded", "true")
box.blur()
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: lightbox label combobox state")
And while the box has focus, ←=/=→ edit the text rather than stepping to another doc —
inside the field the arrows belong to the text, as the shared highlight set out.
@testcase
def test_label_edit_keeps_arrows_in_text(page):
"""While the add-label box is focused, ← / → edit the text — they don't step to another doc."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
img = d.get_by_role("img")
src0 = img.get_attribute("src")
box = d.get_by_placeholder("add a label…")
box.click(); box.fill("abc")
box.press("ArrowRight") # would step to the next doc if unguarded
page.wait_for_timeout(200)
expect(img).to_have_attribute("src", src0) # same doc — the arrow stayed in the field
print(" PASS: label edit keeps arrows in text")
Completion also won’t waste a row on a label the doc already wears: it drops the open doc’s current labels from its offers, so you only ever see words you could actually add.
@testcase
def test_lightbox_completion_skips_present(page):
"""The lightbox's completion never re-offers a label the open doc already has."""
make_fixtures()
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpresent", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzpresent-t", "labels": "cosmo; zzpresent", "state": "todo"}
gql(CREATE, {"p": doc})
try:
open_app(page)
search_for(page, "zzpresent") # narrow to just this doc
expect(tiles(page)).to_have_count(1)
open_doc(page)
box = dialog(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("balade", delay=20) # a label the doc lacks…
expect(options(page).filter(has_text=re.compile(r"^balade$")).first).to_be_visible() # …is offered
box.fill(""); box.press_sequentially("cosmo", delay=20) # one it already has…
expect(options(page).filter(has_text=re.compile(r"^cosmo$"))).to_have_count(0) # …is not
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: lightbox completion skips present")
One thing about that box is peculiar to the lightbox: it sits at the very foot of the modal, and nothing in the lightbox scrolls. Whatever ends up below that foot is not awkward but gone — a doc you opened in order to label it is a doc you cannot label. Which is why the modal ends where the navigation bar’s strip begins; and why the completion list opens upward, above the input, rather than dropping off the bottom of a short screen with the rows you want out of reach and the wall behind catching the scroll you would try to chase them with.
@testcase
def test_lightbox_completion_opens_upward(page):
"""On a short screen the lightbox label completion opens above the input, on screen — reachable."""
open_fixtures(page)
open_doc(page)
page.set_viewport_size({"width": 420, "height": 470}) # short: a downward list would fall off the foot
box = dialog(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos", delay=20) # a 2+-char prefix with vocabulary
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
lb = listbox.bounding_box(); ib = box.bounding_box(); vh = page.viewport_size["height"]
assert lb["y"] + lb["height"] <= ib["y"] + 1, f"the completion must open above the input: list {lb} input {ib}"
assert lb["y"] >= 0 and lb["y"] + lb["height"] <= vh + 1, f"the completion must sit on screen: {lb} vh={vh}"
print(" PASS: lightbox completion opens upward")
And the video branch: a video-mimetype doc opens as a <video> pointed at its
web_cid. (A throwaway video fixture — a fake cid — proves the element and source are
right without needing real playback.)
@testcase
def test_lightbox_video(page):
"""A video doc opens as a <video> sourced from its web_cid."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE})
try:
open_app(page)
chip(page, "all").click()
search_for(page, VIDEO_LABEL) # a label only the video carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
video = dialog(page).locator("video") # no ARIA role exists for <video>
expect(video).to_be_visible()
assert VIDEO_FIXTURE["webCid"] in (video.get_attribute("src") or ""), "wrong video src"
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: lightbox video")
Browsing without closing. Once a doc is open you move through the wall in place — the
‹ / › buttons or the keyboard arrows — stepping to the previous/next doc in the current
(filtered, dated) order, wrapping at the ends. Nothing here reads a drag across the media:
the media is the one place you want the browser’s gestures intact, which the next section
comes to.
@testcase
def test_lightbox_prev_next(page):
"""The arrows and the nav buttons step through the wall in the lightbox."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
open_doc(page)
img = dialog(page).get_by_role("img")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.press("ArrowRight")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.keyboard.press("ArrowLeft")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
dialog(page).get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: lightbox prev/next")
The lightbox also shows whether the current doc is selected and lets you toggle it, so you can build a selection while reviewing one by one; the change shows on the wall behind.
@testcase
def test_lightbox_select(page):
"""The lightbox shows selection state and toggles it; the wall reflects it."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
sel = d.get_by_role("button", name=re.compile("select", re.I))
expect(sel).to_have_attribute("aria-pressed", "false")
sel.click()
expect(sel).to_have_attribute("aria-pressed", "true") # now selected
d.get_by_role("button", name="close").click()
expect(checks(page)).to_have_count(1) # the wall shows it selected
print(" PASS: lightbox select")
A Shift+wheel over the photo steps prev/next as well — plain scroll is left for the panel, so the two don’t fight.
@testcase
def test_lightbox_shift_scroll_nav(page):
"""Shift+wheel over the photo steps prev/next (plain scroll is left for the panel)."""
open_fixtures(page)
open_doc(page)
img = dialog(page).get_by_role("img")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = img.bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.keyboard.down("Shift")
page.mouse.wheel(0, 240) # shift-scroll down → next
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.wait_for_timeout(250) # clear the one-step cooldown
page.mouse.wheel(0, -240) # shift-scroll up → prev
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.up("Shift")
print(" PASS: lightbox shift-scroll nav")
Browsing to the next doc is where last-label reuse pays off: the label you just applied is offered there for a single tap, no retype.
box.fill("zzreuse"); box.press("Enter")
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # applied here
d.get_by_role("button", name="next photo").click()
reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
expect(reuse).to_be_visible() # offered on the next
reuse.click()
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # reused, no retype
print(" PASS: lightbox reuse last label")
A chip’s × is the other half of the box: the way to take a word off a photo without
typing it again. And whatever the chips show, the words have to have actually gone to the
archive — so the proof is not a chip on screen but a fresh search: the photo answering to
the word just put on it, and no longer answering to the word just taken off. A removal that
never landed looks, on screen, exactly like one that did.
box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
d.get_by_role("button", name="close").click()
search_for(page, "lbadded") # the word put on: the photo answers
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # the word taken off: it no longer does
expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
print(" PASS: lightbox edits labels")
Selection has a keyboard twin too: with no field focused, Enter toggles the open doc’s
selection — the lightbox echo of a click on the wall — so you can build a batch while
reviewing one by one, hands on the keys.
@testcase
def test_lightbox_enter_toggles_select(page):
"""In the lightbox, Enter toggles the open doc's selection (when no field is focused)."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
sel = d.get_by_role("button", name=re.compile("select", re.I))
expect(sel).to_have_attribute("aria-pressed", "false")
page.keyboard.press("Enter")
expect(sel).to_have_attribute("aria-pressed", "true") # selected
page.keyboard.press("Enter")
expect(sel).to_have_attribute("aria-pressed", "false") # toggled back off
print(" PASS: lightbox enter toggles select")
This is what earns the lightbox its name. The whole point of opening a doc full-size is to look closer, and closer than the screen means two fingers — to read a sign in the background, or to be sure whose face that is. Then, magnified, you have to move about: a zoom you cannot travel shows you the middle of the photo and nothing else. The browser does both of those perfectly on its own, so the app’s whole job on the media is to claim no gesture there and let them through.
@testcase
def test_lightbox_pinch_zooms_and_pans(page):
"""Two fingers magnify the open photo, and a finger then travels across it."""
open_fixtures(page)
open_doc(page)
box = dialog(page).get_by_role("img").bounding_box()
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
seen = lambda: page.evaluate("() => [visualViewport.scale, visualViewport.pageLeft]")
assert seen()[0] <= 1.01, "started already magnified"
pinch_in(page, cx, cy)
wait_until(page, lambda: seen()[0] > 1.5, label="the pinch reached the browser's zoom",
detail=lambda: f"scale is {seen()[0]}")
at = seen()[1]
drag_touch(page, cx + 150, cy, cx - 150, cy) # one finger, straight across the photo
wait_until(page, lambda: abs(seen()[1] - at) > 5, label="the magnified photo panned sideways",
detail=lambda: f"pageLeft went {at} → {seen()[1]}")
print(" PASS: lightbox pinch zooms and pans")
A long video shouldn’t make you scrub with the tiny native bar from across the room. So
while a video is playing, the arrows seek it — → jumps 5s on, ← 5s back — instead of
leaving the doc; only at the very end (or start) does an arrow give up and step to the next
(or previous) doc, so the wall is still one key away. A paused video, or a photo, steps as
before.
@testcase
def test_lightbox_video_arrows(page):
"""While a video plays, → seeks +5s; from the end, → rolls to the next doc."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzvarrow", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzvarrow-t", "webCid": "https://ipfs.konubinix.eu/p/zzvarrow-web",
"labels": "zzvarrow", "state": "todo"}
nxt = {"cid": "https://ipfs.konubinix.eu/p/zzvarrow-next", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzvarrow-next-t", "labels": "zzvarrow", "state": "todo"}
for d in (vid, nxt): gql(CREATE, {"p": d})
page.route("**/ipfs/zzvarrow-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page); chip(page, "all").click(); search_for(page, "zzvarrow")
expect(tiles(page)).to_have_count(2)
open_doc(page, 0) # date order → the video first
v = dialog(page).locator("video")
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }") # headless blocks unmuted autoplay
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
v.evaluate("el => el.currentTime = 0")
page.keyboard.press("ArrowRight") # → seeks +5s
wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
v.evaluate("el => el.currentTime = el.duration - 0.05") # park at the end
page.keyboard.press("ArrowRight") # → now rolls to the next doc
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzvarrow-next-t")
print(" PASS: lightbox video arrows")
finally:
page.unroute("**/ipfs/zzvarrow-web")
for d in (vid, nxt):
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
SPC is the video’s play control in the lightbox: it toggles play/pause, and once the clip
has run to its end SPC replays it from the start rather than sitting on a frozen last
frame. Driving it from the keyboard means it works whether or not the native control bar
has focus.
@testcase
def test_lightbox_video_spc(page):
"""SPC toggles a lightbox video's play/pause, and replays it from the end."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzvspc", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzvspc-t", "webCid": "https://ipfs.konubinix.eu/p/zzvspc-web",
"labels": "zzvspc", "state": "todo"}
gql(CREATE, {"p": vid})
page.route("**/ipfs/zzvspc-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page); chip(page, "all").click(); search_for(page, "zzvspc")
expect(tiles(page)).to_have_count(1) # wait for the settled result — not a stale tile mid-read
open_doc(page, 0)
v = dialog(page).locator("video")
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press(" ") # SPC pauses
wait_until(page, lambda: v.evaluate("el => el.paused"))
page.keyboard.press(" ") # SPC plays again
wait_until(page, lambda: v.evaluate("el => !el.paused"))
v.evaluate("el => el.currentTime = el.duration") # run it to the end
page.keyboard.press(" ") # SPC replays from the start
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 1
and not v.evaluate("el => el.paused"))
print(" PASS: lightbox video SPC")
finally:
page.unroute("**/ipfs/zzvspc-web")
try: gql(DELETE, {"cid": vid["cid"]})
except Exception: pass
The lightbox turns on one opened signal — the doc in view, or null — with the label-box text and
its focus beside it. A photo can fall back to its thumbnail when its web copy is missing, but a
video has nothing to show without one. Opening pushes a history entry, so the Back button leaves
the modal the way ✕ does.
const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
Adding a label goes through the optimistic applyLabels the intro described, and remembers the
last word applied so labelling the next doc is one tap away; its Shift-Enter twin removes instead
of adds.
async function applyLabels(labels){
const cid = opened().cid;
setOpened({ ...opened(), labels });
await gql(UPDATE_PHOTO, { cid, patch: { labels } });
await refetch();
}
const lbAdd = input => {
const words = splitWords(input); if(!words.length) return;
setLastLabel(words[words.length - 1]);
const cur = labelsOf(opened());
for(const w of words) if(!cur.includes(w)) cur.push(w);
setLbText('');
return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
The ‹ / › buttons, the arrows and a Shift+wheel are three ways of asking for the same
thing, so they meet in one step. A playing video takes the arrows first, though — seeking
within the clip before it steps off it. The wheel reads whichever axis Shift maps it to, and
is held to one step per burst so a fast fling doesn’t overshoot — plain scroll left free for
the panel.
const step = delta => {
const list = items(); if(!list.length || !opened()) return;
const i = list.findIndex(p => p.cid === opened().cid);
setOpened(list[((i < 0 ? 0 : i) + delta + list.length) % list.length]); setLbText('');
};
let lbVideo = null;
const seekOrStep = dir => {
const v = lbVideo;
if(v && isVideo(opened()) && !v.paused &&
(dir > 0 ? v.currentTime < v.duration - 0.25 : v.currentTime > 0.25))
v.currentTime = Math.max(0, Math.min(v.duration, v.currentTime + dir * 5));
else step(dir);
};
let wheelAt = 0;
const onWheel = e => {
if(!e.shiftKey) return;
const d = e.deltaY || e.deltaX;
const now = performance.now();
if(Math.abs(d) < 1 || now - wheelAt < 200) return;
wheelAt = now; step(d > 0 ? 1 : -1);
};
One window listener routes the keyboard while a doc is open, gathering the browsing and triage keys already met into one place. Its own rule is the guard: the whole set stands aside whenever a text field has focus, where those keys belong to the text and the label box’s suggestions.
onMount(() => {
const onKey = e => {
if(!opened()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') dismissPhoto();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); seekOrStep(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); seekOrStep(-1); }
else if(!editing && e.key === 'Delete'){ e.preventDefault(); lbDelete(e.shiftKey); }
else if(!editing && e.key === 'Enter'){ e.preventDefault(); toggle(opened().cid); }
else if(!editing && e.key === ' ' && isVideo(opened()) && lbVideo){
e.preventDefault(); const v = lbVideo;
if(v.currentTime >= v.duration - 0.25){ v.currentTime = 0; v.play(); }
else if(v.paused) v.play(); else v.pause();
}
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
<${Show} when=${() => opened()}>
<div class="lb" onClick=${e => {
if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]')) dismissPhoto(); }}>
<div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
<button class="lb-close" aria-label="close" onClick=${dismissPhoto}>✕</button>
<button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid) ? '✓ selected' : 'select'}</button>
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
<button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}>‹</button>
<button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}>›</button>
<${Show} when=${() => hasMedia(opened())}
fallback=${html`<div class="lb-media noimg">
<span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
<span class="mt">no preview · ${() => opened()?.filename || opened()?.mimetype || ''}</span></div>`}>
<${Show} when=${() => isVideo(opened())}
fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
<video class="lb-media" controls autoplay ref=${el => lbVideo = el} src=${() => IPFS + opened()?.webCid}></video>
<//>
<//>
<div class="lb-meta">
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
onBlur=${e => commitDate(e.target.value)} />
<//>
<a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
aria-label="original" title="original — full resolution, new tab">⤢</a>
</div>
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
<div class="lb-states">
<${For} each=${() => STATES}>${st => html`
<button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
onClick=${() => lbSetState(st)}>${st}</button>`}
<//>
</div>
<div class="lb-labels">
<${For} each=${() => labelsOf(opened())}>${w => html`
<span class="lb-chip"><button class="lb-chip-word"
onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
<//>
<${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
<button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
<//>
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
<${Show} when=${() => lbFocus()}>
<${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
onLoading=${setSugLoading} onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
<//>
</div>
</div>
</div>
</div>
<//>
.lb{ position:fixed; inset:0; z-index:100; background:#000d; display:flex;
padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right))
calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); }
.lb-inner{ position:relative; flex:1; display:flex; flex-direction:column; gap:10px; }
.lb-media{ flex:1; min-height:0; width:100%; object-fit:contain; border-radius:6px; background:#000; }
.lb-media.noimg{ display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:10px; color:#9aa; }
.lb-media.noimg .ph{ font-size:64px; opacity:.55; }
.lb-media.noimg .mt{ font-size:13px; }
.lb-close{ position:absolute; top:-6px; right:-6px; width:32px; height:32px; border-radius:50%;
border:none; background:#262a40; color:var(--fg); font-size:16px; cursor:pointer; z-index:2; }
.lb-nav{ position:absolute; top:50%; transform:translateY(-50%); z-index:2; width:40px; height:64px;
border:none; border-radius:8px; background:#262a40cc; color:var(--fg); font-size:28px; cursor:pointer; }
.lb-nav:hover{ background:#33395a; }
.lb-nav:active{ transform:translateY(-50%) scale(0.88); background:#3d4468; }
.lb-prev{ left:-6px; } .lb-next{ right:-6px; }
.lb-select{ position:absolute; top:-6px; left:-6px; z-index:2; padding:6px 10px; border:none;
border-radius:8px; background:#262a40; color:var(--fg); font-size:12px; cursor:pointer; }
.lb-select[aria-pressed='true']{ background:#6cf; color:#08111e; font-weight:700; }
.lb-meta{ display:flex; align-items:center; gap:8px; }
.lb-date{ font-size:13px; color:#9aa; }
.lb-orig{ color:#6cf; text-decoration:none; font-size:17px; line-height:1; }
.lb-orig:hover{ color:#9df; }
.lb-states{ display:flex; gap:6px; flex-wrap:wrap; }
.lb-st{ padding:4px 10px; border:1px solid #3a3f5a; border-radius:999px; background:#262a40;
color:var(--fg); font-size:12px; cursor:pointer; }
.lb-st[data-st]{ border-color:var(--st); color:var(--st); }
.lb-st[aria-pressed='true']{ background:var(--st,#6cf); color:#08111e; font-weight:700; }
.lb-labels{ display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.lb-labels .suggest{ top:auto; bottom:100%; margin:0 0 4px; } /* open upward from the foot */
.lb-chip{ display:inline-flex; align-items:center; gap:4px; padding:4px 6px 4px 10px; font-size:13px;
background:#262a40; border:1px solid #3a3f5a; border-radius:999px; }
.lb-chip-word{ border:none; background:none; color:inherit; font:inherit; cursor:pointer; padding:0; }
.lb-chip-word:hover{ text-decoration:underline; }
.lb-chip .x{ border:none; background:none; color:#9aa; font-size:16px; line-height:1; cursor:pointer; padding:0 2px; }
.lb-chip .x:hover{ color:#f88; }
/* one-tap reuse of the last-applied label — dashed accent so it reads as an offer, not a set tag */
.lb-reuse{ border:1px dashed #6cf; background:#16243b; color:#6cf; padding:4px 10px;
border-radius:999px; font-size:13px; cursor:pointer; }
.lb-reuse:hover{ background:#1d2f4d; }
Seeing a doc’s events
A photo sits in time, and the calendar knows what was happening then — so the lightbox
names it. The events the photo falls within — from the calendar’s eventsAt, keyed on the
doc’s date and owner — ride under the date as small chips. The calendar is Google’s,
managed elsewhere, so memories never edits the events themselves — it only surfaces the
overlap they name.
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.
grid(page).locator(f'.tile:has(img[alt="{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.
mine = f'.tile:has(img[alt="{FIX_YEAR}-08-01"]):has(.ev-pill:text-is("{MINE}"))'
grid(page).locator(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 — it slides to its rightful place on the wall and the lightbox keeps
its footing.
The edit is a small state machine, worth seeing whole before the pieces.
d.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = d.get_by_label("date", exact=True)
box.fill(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
back_on_the_wall(page)
expect(tiles(page)).to_have_count(len(FIX_DOCS))
expect(grid(page).locator(f'.tile:has(img[alt="{STRAY_DAY}"])')).to_have_count(0) # gone from that day
expect(pills_on(page, f"{FIX_YEAR}-06-06")).to_have_text([RANDO, SOMMET]) # and back inside both
print(" PASS: lightbox edit date re-orders")
The date on the meta row is a button; opening the picker seeds it with what the date held.
want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
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
grid(page).locator(".tile:not(:has(.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.
w1 = t.nth(0).bounding_box()["width"]
open_app(page)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
A hand already on the wall shouldn’t have to travel to the controls, so Ctrl+scroll over the tiles does the same: scroll up to grow them, down to shrink.
w2 = t.nth(0).bounding_box()["width"]
box = grid(page).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + 10)
page.keyboard.down("Control")
page.mouse.wheel(0, -240) # ctrl-scroll up → bigger
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w2 + 8)
That gesture is not ours to borrow quietly — Ctrl+wheel is the browser’s own page zoom, and a handler that merely listened would leave the whole app scaling around the wall instead of the tiles in it. So the wall claims the event outright before it resizes anything. 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);
};
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)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
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")
A tile at the top of that range shows a 256px thumbnail at 320 — a stretch of a quarter on
a desktop, and several-fold on a phone, whose device pixels outnumber its CSS ones by two
or three to one. On a dense wall that softness is worth what it saves, because sharpness here
is priced per tile. Sampled across the archive — 126 photos from twelve evenly spaced points
through all seventy-five thousand — a thumbnail runs 16–64 kB where the
web_cid beside it runs 102–474 kB: the same photo, five to twelve times heavier in the
second. At the default size a fold
holds some seventy tiles, and the next search draws a fresh wall of them, so buying the heavier
rendition for a contact sheet would run into hundreds of megabytes spent sharpening pictures
nobody is looking at yet.
What changes as the tiles grow is not that price but the number of tiles paying it. Three
columns of 330px fill the same fold with fewer than ten tiles where the default fits seventy,
and that drop is a factor of twelve against the five to twelve the rendition costs: a grown fold
comes in at or below what a dense one already spent. Nor is that a bargain of one screen — a
fold holds area over cell squared tiles, so the area falls out, and a phone strikes the same
trade as a desktop. What the same bytes buy, though, is incomparably
better, because at that width the upscale has stopped being a softness you can overlook: the
tile has become a picture you are looking at rather than a stamp you are picking out of a
sheet. So past that width the wall
refines: each tile on screen trades up to its web_cid, the same rendition the
lightbox and the frame show — a wall grown that wide has more in common with those
surfaces than with a contact sheet. A video’s web_cid is a film rather than a bigger
poster, so a video tile keeps the poster it has.
Where the two curves cross is a judgement call, fixed at a drawn cell of 300px — to move
by eye if the wall comes to feel either soft or heavy. Drawn, not chosen: the wall’s columns
are auto-fill tracks running to 1fr, so they stretch into whatever width is left over,
and a phone at the top of the slider draws a single column across its whole screen. The
threshold has to read the pixels the picture is actually painted at, so it is taken from the
resolved column and not from the setting.
At the default size, then, the wall buys thumbnails and nothing else — scroll it as far as you like.
page.set_viewport_size({"width": 1000, "height": 420}) # short, so the dense wall really does overflow
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
open_app(page)
search_for(page, "zzref")
expect(tiles(page)).to_have_count(80)
assert cell_px(page) <= 300, f"the default wall should be a contact sheet, drawn at {cell_px(page)}px"
page.mouse.move(500, 210)
for _ in range(10): # all the way down the wall
page.mouse.wheel(0, 400)
page.wait_for_timeout(100)
assert tiles(page).last.bounding_box()["y"] < 420, "the wall never overflowed — nothing was scrolled into reach"
assert not web(), f"the dense wall fetched {len(web())} web renditions"
Grow the tiles past the threshold and that same wall sharpens, the thumbnail staying underneath so that nothing blinks out while the fuller picture is on its way.
tiles(page).first.scroll_into_view_if_needed()
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click() # to the 320 ceiling
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
wait_until(page, lambda: any(u.endswith("zzref-web-1") for u in web()),
label="the tile on screen trades up to its web rendition",
detail=lambda: f"srcs={tile_srcs(page, 1)} web={web()}")
assert any("zzref-t-1" in (s or "") for s in tile_srcs(page, 1)), "the thumbnail left from under it"
Only what you are looking at is bought, and only if it is a photo: the tiles far below the fold keep their thumbnails, and the video keeps its poster instead of pulling a film onto the wall.
assert not [u for u in web() if u.endswith("web-79")], f"a tile far below the fold refined: {web()}"
assert not [u for u in web() if u.endswith("web-vid")], f"the video tile fetched its film: {web()}"
Refinement must never cost the wall its legibility. The thumbnail pass is what turns a screenful of blanks into pictures you can triage, and that moment is what the wall is for; the sharpening after it is a luxury. A web rendition is ten thumbnails on the wire, and issued alongside them it competes for the same bandwidth and pushes that moment back. So the two passes are ordered: nothing refines until every thumbnail the wall is waiting for has settled. Settled, not painted — an address the gateway cannot serve must not hold the whole wall soft for good.
page.route("**/ipfs/zzwait-t-0", lambda route: None) # never answered: this one is still on its way
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
page.set_viewport_size({"width": 1000, "height": 700})
open_app(page)
search_for(page, "zzwait")
expect(tiles(page)).to_have_count(12)
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwait-t-0") # asked for…
assert not [u for u in fetched() if u.endswith("zzwait-t-0")], "…and still unanswered"
wait_until(page, lambda: len([u for u in fetched() if "zzwait-t-" in u]) >= 2,
label="its neighbours' thumbnails have settled",
detail=lambda: f"settled={[u for u in fetched() if 'zzwait-t-' in u]}")
page.wait_for_timeout(600) # ample room for a refinement to fire
assert not web(), f"the wall refined with a thumbnail still in flight: {web()}"
Scrolling reopens the question: fresh tiles bring fresh thumbnails to wait for, and the wall is waiting again. A tile that has already traded up keeps what it has — handing a picture back because a neighbour arrived late would be a flicker bought with nothing — so the sharpening only ever moves forward, a screenful at a time.
page.route("**/ipfs/zzfwd-t-4", lambda route: None) # doc 4's thumbnail never arrives
sharp = lambda i: any(f"zzfwd-web-{i}" in (s or "") for s in tile_srcs(page, i))
inreach = lambda i: any(f"zzfwd-t-{i}" in (s or "") for s in tile_srcs(page, i))
page.set_viewport_size({"width": 480, "height": 700}) # one tall column at the ceiling
open_app(page)
search_for(page, "zzfwd")
expect(tiles(page)).to_have_count(8)
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
wait_until(page, lambda: sharp(0), label="the tile on screen sharpens",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
tiles(page).nth(2).scroll_into_view_if_needed()
wait_until(page, lambda: sharp(3), label="the tiles scrolled into reach sharpen too",
detail=lambda: f"srcs={tile_srcs(page, 3)}")
tiles(page).nth(4).scroll_into_view_if_needed() # doc 4 comes into reach, and never lands
wait_until(page, lambda: inreach(5), label="doc 5 has come into reach behind it",
detail=lambda: f"srcs={tile_srcs(page, 5)}")
page.wait_for_timeout(600) # ample room for doc 5 to settle and refine
assert inreach(4), f"doc 4 must be in reach to hold anything back: {tile_srcs(page, 4)}"
assert sharp(3), f"a tile handed its picture back: {tile_srcs(page, 3)}"
assert not sharp(5), f"a tile refined behind a thumbnail in flight: {tile_srcs(page, 5)}"
What a tile does give back is what it gave back before. Scrolled out of reach, it drops the fuller picture along with its thumbnail, so a wall you have left behind holds no more decoded pixels for having been sharpened.
wait_until(page, lambda: not inreach(0), label="doc 0 has been left behind",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
assert not sharp(0), f"the fuller picture outlived the thumbnail: {tile_srcs(page, 0)}"
A tile can also leave the wall altogether, when a search swaps the whole of it while a thumbnail is still on its way. The tally has to let go of whatever left with it: a wall still counting thumbnails that are no longer on it would never refine again.
search_for(page, "zzfwd2") # swapped out from under a thumbnail in flight
expect(tiles(page)).to_have_count(3)
wait_until(page, lambda: any("zzfwd2-web-0" in (s or "") for s in tile_srcs(page, 0)),
label="the wall that replaced it sharpens too",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
In code the threshold is read off the grid itself, and it takes two triggers to keep that
reading true: a ResizeObserver re-measures the resolved column whenever the wall’s own box
changes, and an effect re-measures when the slider moves the columns inside a box that has
not changed.
Under the hood, getComputedStyle is what makes the second reading current: it flushes the
pending layout, so the effect reads the columns the new setting produces rather than the ones
the last frame drew.
const REFINE_CELL = 300; // drawn px
const [cellPx, setCellPx] = createSignal(0);
const measureCell = () => gridEl && setCellPx(parseFloat(getComputedStyle(gridEl).gridTemplateColumns) || 0);
const watchCell = el => { const ro = new ResizeObserver(measureCell);
ro.observe(el); onCleanup(() => ro.disconnect()); };
createEffect(() => { thumbSize(); measureCell(); }); // the slider moves the columns, not the grid's box
const [thumbsInFlight, setThumbsInFlight] = createSignal(0);
const refining = () => cellPx() > REFINE_CELL && thumbsInFlight() === 0;
A tile’s part is to declare itself: it counts itself among what the wall is waiting for from the moment it comes into reach until its thumbnail settles, and once it has traded up it holds that claim until it leaves — whether it leaves the viewport or the wall.
const [thumbOn, setThumbOn] = createSignal(false);
const thumbSettled = () => setThumbOn(true); // painted, or failed and never coming
createEffect(() => { near(); setThumbOn(false); }); // re-shown → its source is refetched → waiting again
createEffect(() => { // one of the thumbnails the wall waits for…
if(!(near() && photo.thumbnailCid && !thumbOn())) return;
setThumbsInFlight(n => n + 1);
onCleanup(() => setThumbsInFlight(n => n - 1)); // …until it settles or leaves
});
const [sharp, setSharp] = createSignal(false);
createEffect(() => { if(!near()) setSharp(false); // gone → both layers go
else if(thumbOn() && refining()) setSharp(true); });
const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
const [webOn, setWebOn] = createSignal(false);
createEffect(() => { webSrc(); setWebOn(false); }); // a new source → fade the overlay in again
The picture is then two stacked images, as in the frame: the thumbnail carries the tile, and
the fuller rendition fades in over it once it has painted. Both are the same photo, so only
the layer beneath is named — the overlay’s alt is empty, which keeps it out of the
accessibility tree.
<${Show} when=${webSrc}>
<img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
src=${webSrc} onLoad=${() => setWebOn(true)} />
<//>
.tile .thumb.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; }
.tile .thumb.web.shown{ opacity:1; }
Two layers is also two decoded images, and decoded pixels — not bytes on the wire — are what the reach window exists to bound. Whichever way a browser unpacks a rendition, that comes to at most about twice the decoded image a dense wall already held; the annex works it out.
Frame mode
Left to itself the app becomes a photo frame: a fullscreen slideshow over whatever the wall is showing.
The frame — a fullscreen slideshow
This folds the photo-frame in: no separate app. ▶ frame turns the current wall —
whatever the query language has narrowed to — into a frame show. It is not the
lightbox: it’s a full-screen horizontal filmstrip, each doc a viewport-wide slide laid
side by side in a native scroll container, which gives it the feel of a hand-built slider:
a fluid lateral swipe whose momentum coasts across several docs, easing onto whichever one
it comes to rest nearest. It plays the wall in the order shown — chronological by
default, or the myrandom draw under sort:random (ordering is an app-wide concern, not
a frame one). The media fills the screen, videos don’t auto-play, and there’s no chrome —
a small bar (pause, interval, exit) is a tap away.
Which slide it has come to rest on is a question the strip cannot answer directly: a filmstrip has no notion of a current item, only a scroll offset. In practice the answer is arithmetic — divide the offset by a slide’s width and round to the nearest. The width has to be the one the show itself places slides by, the strip’s own rather than the viewport’s, or the reading drifts from the thing it is measuring; the measure the show places by says why the two differ. The whole chapter asks the question in three shapes: the bare position along the strip, the picture sitting there, and which of the fixtures that picture belongs to.
SLIDE_W = "el => (el.scrollWidth / (el.children.length || 1)) || 1"
ON_SLIDE = f"el => Math.round(el.scrollLeft / ({SLIDE_W})(el))"
CENTERED = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" const im = el.children[i] && el.children[i].querySelector('img');"
" return im && im.getAttribute('src'); }")
def centred_slide(strip):
src = strip.evaluate(CENTERED) or ""
return next((k for k, f in enumerate(FIXTURES) if f["thumbnailCid"] == src), None)
Left alone, the show auto-advances: a smooth scroll to the next slide every interval
(default 60s, ?ms= overrides), reading the current scroll position each tick. It runs in
date order, so the fixtures play thumb-0, thumb-1, thumb-2.
for src in ["thumb-0", "thumb-1", "thumb-2"]: # date order
wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
print(" PASS: frame auto-advances")
Touch it and the show yields: any tap or swipe stops the auto-advance, which picks back up
only after a span of quiet (?idleresume overrides) — so a slide you’ve stopped on to
look at is never pulled out from under you.
opened = strip.evaluate(ON_SLIDE) # wherever the show resumed
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened, # …and it is advancing from there
label="the show is running before we touch it")
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) # a centre tap: real interaction, no nav
page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS) # let any in-flight step settle
held = strip.evaluate(ON_SLIDE)
page.wait_for_timeout(LIVE_RESUME_MS // 2) # several tempo ticks, still inside the resume span
assert strip.evaluate(ON_SLIDE) == held, f"interaction must stop the show; it drifted {held}→{strip.evaluate(ON_SLIDE)}"
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held) # quiet long enough → resumes on its own
print(" PASS: frame pauses on interaction")
The strip loops both ways: an arrow or swipe before the first slide lands on the last, and
past the last on the first, so the show never dead-ends. A native scroller cannot wrap, so
the strip is laid out with a copy of the last doc before the first and a copy of the first
after the last: stepping off either end lands on a copy, and the settle silently jumps the
scroll to the real doc it duplicates. That padding is why the first doc sits at offset one
rather than zero, and why the last of N sits at N.
page.keyboard.press("ArrowLeft") # off the front → the last doc
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
label="a step back off the first lands on the last",
detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
page.keyboard.press("ArrowRight") # off the end → the first
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame wraps both ways")
The strip is a keyboard-focusable scroller, so an unguarded arrow would just scroll it natively. The frame claims the arrows itself, so they step the show even once focus has dropped to the page — after a centre tap, which only reveals the bar.
page.keyboard.press("ArrowRight") # focus is on the body, not a control
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.keyboard.press("ArrowLeft") # back where we started
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame arrow steps off control")
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")
A video the viewer started is paused once it scrolls out of view — an
IntersectionObserver over the strip stops any slide video that drops below half-visible,
so sound doesn’t keep playing from a slide you’ve left.
@testcase
def test_frame_pauses_offscreen_video(page):
"""A video scrolled out of view in the frame is paused."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE}) # dated earliest → the first slide
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("el => el.scrollLeft === el.clientWidth")) # settled on slide 1 (the video)
vid = strip.locator("video").first # the centered (first) slide
vid.evaluate("v => { v.dataset.paused = '0';"
" const o = v.pause.bind(v); v.pause = () => { v.dataset.paused = '1'; return o(); }; }")
page.keyboard.press("ArrowRight") # scroll the video off-screen
wait_until(page, lambda: vid.get_attribute("data-paused") == "1")
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: frame pauses offscreen video")
The swipe is the frame’s main gesture, and across the room it has to feel like the
slider it grew out of: you fling the strip and it coasts a few docs on its own momentum.
The browser’s own scroll-snap reaches for that feel but overshoots — a quick flick is
flung clear across the wall, ten docs gone in one careless swipe, which is exactly what
makes the cabinet frame unusable from the couch.
Both checks below ride one gesture. A mouse drag carries no momentum, so the behaviour only shows under a real touch fling, driven (as for the lightbox) through Chromium’s touch pipeline: the finger flies left across the glass and lets go.
Knowing when the fling is over is the awkward part, because the strip goes still twice. Momentum runs out first, then the settle waits out its own beat of quiet before easing onto the nearest doc — so a reading taken during that lull would measure a resting place the show is about to leave. The way past it is to insist on a stillness longer than the beat: only the rest that follows the ease can be that quiet, and any motion starts the count again. The ceiling on waiting has to clear the worst case — the fling, the beat, and the ease together — and is generous, since it costs nothing when the strip settles early.
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.
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 glass is measurable — the frame is watched from across the room, so a
web_cid smaller than the display must be grown to it, photo and video alike — but the
doc is shown whole: object-fit:contain scales it to the largest size that fits without
cropping, so a differently-shaped doc keeps all of itself, the black glass framing its
spare axis:
@testcase
def test_frame_media_fills_screen(page):
"""A slide's media — video or photo — spans the whole viewport, on black glass."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE}) # earliest → the first slide
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
for media in [strip.locator("video").first, strip.locator("img").first]:
box = media.bounding_box()
assert box["width"] >= VIEWPORT["width"] * 0.95, f"media width {box['width']} < 95% of viewport"
assert box["height"] >= VIEWPORT["height"] * 0.95, f"media height {box['height']} < 95% of viewport"
assert media.evaluate("el => getComputedStyle(el).objectFit") == "contain", "slide media crops instead of fitting whole"
assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", "the frame's glass must be black"
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: frame media fills screen on black")
Showing the doc whole is no good if it shows up late: web_cid is heavy, so a slide is built
in two layers. The thumbnail is the base — light, and already cached from the wall you came in
through — so every slide within range paints something at once instead of sitting black. Over
it, the full web_cid leads the way you’re heading: it is carried one slide behind the centre
and three ahead, so each next step lands on a doc already whole; if it isn’t in hand yet, the
thumbnail stands in (marked, below) until it arrives. The kept-thumbnail window leans the same
way — six slides behind, fourteen ahead — and past it a doc is dropped to a blank, so a long wall
never keeps thousands of decoded images alive at once. Turn around and the lean turns with you,
so the loading always anticipates where you’re going next.
To read the bands the test jumps the strip to a chosen slide, locating it by arithmetic on
scrollWidth — total width over slide count. Two things must hold for that jump to land true.
First, the strip must be laid out: the slides reach their 100vw width a beat after they
render, and the arithmetic divides by a scrollWidth that is only right once they have — so the
test waits for scrollWidth to come within one slide of full width (a slide of slack, so a
sub-pixel settle doesn’t hang the wait), not merely for the nodes to appear. Second, entering the
frame starts its own auto-centre — an animation frame that scrolls to the opening slide; were the
test to jump before that frame fires, the auto-centre would land afterward and undo the jump — so
the test also waits until the strip has settled on that first slide. Only then does it scroll.
@testcase
def test_frame_preloads_web_window(page):
"""The load window leans toward travel: full-res reaches further ahead than behind, the
kept-thumbnail band likewise, and the lean flips when you reverse direction."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzwin-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzwin-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzwin-web-{i}", "labels": "zzwin", "state": "todo"} for i in range(40)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzwin")
expect(tiles(page)).to_have_count(40)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
# laid out at full width, not merely populated (see the note just above)
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(docs) + 2),
label="strip laid out at full width (all slides + 2 clones)",
detail=lambda: f"children={strip.evaluate('el => el.children.length')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}"
f" clientWidth={strip.evaluate('el => el.clientWidth')}")
# …and for the frame's own initial auto-centre to have landed (see the note above)
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1,
label="frame centred on its first slide before we jump",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')} scrollWidth={strip.evaluate('el => el.scrollWidth')}")
# slot k holds doc k-1; read each slot's loaded image sources (thumbnail base + web overlay)
srcs_at = "(el, k) => { const s = el.children[k]; return s ? Array.from(s.querySelectorAll('img')).map(im => im.getAttribute('src')) : []; }"
srcs = lambda k: strip.evaluate(srcs_at, k)
web = lambda k: any("zzwin-web-" in x for x in srcs(k))
thumb = lambda k: any("zzwin-t-" in x for x in srcs(k))
blank = lambda k: any(x and x.startswith("data:image/gif") for x in srcs(k))
goto = lambda n: strip.evaluate("(el, n) => el.scrollLeft = n * (el.scrollWidth / el.children.length)", n)
c = 20; goto(c) # head FORWARD to a mid slide → direction is +1
wait_until(page, lambda: web(c), # the centre arrives, full-res on it
label=f"web overlay reaches the centre (slot {c})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{c}]={srcs(c)}")
# full-res leads the way you're going: it reaches 3 ahead but only 1 behind
assert web(c + 3) and not web(c + 4), f"web should reach 3 ahead, got {srcs(c + 3)} / {srcs(c + 4)}"
assert web(c - 1) and not web(c - 2), f"web should reach only 1 behind, got {srcs(c - 1)} / {srcs(c - 2)}"
# the kept-thumbnail band leans the same way: 14 ahead, 6 behind
assert thumb(c + 14) and blank(c + 15), f"thumbnail kept 14 ahead, got {srcs(c + 14)} / {srcs(c + 15)}"
assert thumb(c - 6) and blank(c - 7), f"thumbnail kept 6 behind, got {srcs(c - 6)} / {srcs(c - 7)}"
c2 = 15; goto(c2) # reverse → head BACK → direction flips
wait_until(page, lambda: web(c2 - 3), # full-res now leads the OTHER way
label=f"web overlay leads the reversed way (slot {c2 - 3})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')} srcs[{c2 - 3}]={srcs(c2 - 3)} srcs[{c2}]={srcs(c2)}")
assert web(c2 - 3) and not web(c2 + 3), f"after reversing, web should reach 3 the new way, got {srcs(c2 - 3)} / {srcs(c2 + 3)}"
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame preloads web window")
Those bands are only as honest as the centre they measure from, and a hard fling tests that centre. Were it moved only when the strip comes to rest, a fast swipe would land deep in the blank band — a black slide, held the sixth of a second until the settle caught up. So the centre is followed live, on every scroll and not just at rest; the slide you fling onto is already in band, and requested, by the time you reach it.
This test lands its fling by the same scrollWidth arithmetic as the window above, so it too
waits for the strip to be laid out and centred before it scrolls.
@testcase
def test_frame_fling_loads_into_view(page):
"""A fast fling re-centres the bands live, so the slide you land on is requested — not the
blank gif the pre-fling centre would leave it."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzfling-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfling-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzfling-web-{i}", "labels": "zzfling", "state": "todo"} for i in range(30)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzfling")
expect(tiles(page)).to_have_count(30)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(docs) + 2),
label="strip laid out at full width",
detail=lambda: f"scrollWidth={strip.evaluate('el => el.scrollWidth')} clientWidth={strip.evaluate('el => el.clientWidth')}")
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
# fling deep into the strip, then read the landing slot two frames later — far under the
# 0.15s settle, so we observe the bands MID-fling, before any settle re-centres them
src = page.evaluate("""async (k) => {
const el = document.querySelector('.strip');
el.scrollLeft = k * (el.scrollWidth / el.children.length);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const im = el.children[k] && el.children[k].querySelector('img');
return im && im.getAttribute('src');
}""", 20)
assert src and not src.startswith("data:image/gif"), f"slide flung-to must be loaded, not blank: {src}"
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame fling loads into view")
In code each slide measures its signed distance from the centre in the travel direction — from
the strip index frameCenterIdx and the last direction frameDir — and picks each layer’s
source: the thumbnail base anywhere in the leaning window, the web_cid overlay only at its
leading edge.
const WEB_BEHIND = 1, WEB_AHEAD = 3, KEEP_BEHIND = 6, KEEP_AHEAD = 14;
const inReach = (k, behind, ahead) => { const t = (k - frameCenterIdx()) * frameDir(); // signed steps, in the way you're heading
return t >= -behind && t <= ahead; };
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD) // base layer:
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK; // thumbnail kept in the window, blank past it
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid) // overlay:
? IPFS + p.webCid : ''; // full-res leads the way you're going
A photo with nothing yet to show should say so rather than sit black — you need to read it as
loading, not broken. So until the thumbnail base paints, the slide carries a 🖼 mark — the
wall’s missing-thumbnail glyph — labelled loading so a screen reader announces it too; the
instant the thumbnail’s load fires, the mark clears and the picture stands.
@testcase
def test_frame_shows_placeholder_while_loading(page):
"""A slide whose image hasn't painted shows a 'loading' mark; it clears once the image loads."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzph-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzph-t-{i}",
"labels": "zzph", "state": "todo"} for i in range(2)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
# doc 0's thumbnail actually loads (a real 1×1 PNG); doc 1's never does
png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
page.route("**/ipfs/zzph-t-0", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzph")
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.clientWidth||1))") == 1)
slides = strip.get_by_role("listitem") # [clone, doc0(centre), doc1(neighbour), clone]
# the neighbour's thumbnail never loads, so its loading mark stays up
expect(slides.nth(2).get_by_label("loading")).to_be_visible()
# the centred doc's thumbnail loads, so its mark clears (wait for the load event)
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0)
finally:
page.unroute("**/ipfs/zzph-t-0")
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame shows placeholder while loading")
Once the thumbnail stands, a near doc’s full web_cid may still be on its way. The big icon has
no place there — the picture is already up, only sharper detail is pending — so a quieter mark
takes its place: a small pulse tucked in the corner, labelled fetching full resolution, that
reads as “this is the thumbnail; the full image is coming.” When the web_cid paints over the
base, it clears.
@testcase
def test_frame_thumbnail_shows_upgrade_mark(page):
"""Once the thumbnail paints, the big mark gives way to a subtle 'fetching full resolution
mark until the web_cid lands; when it lands, that mark clears too."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzup-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzup-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzup-web-{i}", "labels": "zzup", "state": "todo"} for i in range(2)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
for c in ("zzup-t-0", "zzup-t-1", "zzup-web-1"): # both thumbnails load; only doc1's web does
page.route(f"**/ipfs/{c}", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzup")
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
slides = strip.get_by_role("listitem") # [clone, doc0(centre), doc1, clone]
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0) # doc0's thumbnail painted → big mark gone
expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible() # doc0's web 404s → subtle mark stays
wait_until(page, lambda: slides.nth(2).get_by_label("fetching full resolution").count() == 0) # doc1's web painted → subtle gone
finally:
for c in ("zzup-t-0", "zzup-t-1", "zzup-web-1"): page.unroute(f"**/ipfs/{c}")
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame thumbnail shows upgrade mark")
A slide is a reused box: after an edit re-anchors the show (a doc leaves the filter and the strip closes the gap), a box that held one doc comes to hold another. The mark has to follow the new doc, not linger from the old — a freshly-shown doc whose image is still arriving must wear the mark even though the box it landed in had finished loading something else.
@testcase
def test_frame_placeholder_after_edit_remaps_slot(page):
"""When an edit re-uses a slide box for a different doc, the loading mark follows the new doc."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzbstale-{i}", "date": f"2020-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbstale-t-{i}",
"labels": "zzbstale", "state": "todo"} for i in range(2)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
page.route("**/ipfs/zzbstale-t-0", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
try:
open_app(page, "?ms=999999")
search_for(page, "zzbstale") # default chip = todo
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
slides = strip.get_by_role("listitem")
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbstale-t-0")
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0) # doc0's image loaded → mark cleared
strip.click() # reveal the bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="done", exact=True).click() # doc0 leaves todo → slot 1 reused for doc1
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbstale-t-1")
# doc1's image never loads, so its mark must be up — not inherited "loaded" from doc0
expect(slides.nth(1).get_by_label("loading")).to_be_visible()
finally:
page.unroute("**/ipfs/zzbstale-t-0")
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame placeholder after edit remaps slot")
A slot swap is not the only way a slide’s base image changes under it. As the centre moves, a slide keeps its doc but its base layer is re-picked — a blank far out, the thumbnail in range. A slide flung in from the blank band carries an already-painted blank, so on its own it would read as loaded and sit black while the thumbnail arrives. So the big mark keys on the base layer’s current source: it returns whenever that source changes — a new doc dropped in the box, or the thumbnail re-picked for the doc already there — and clears once the thumbnail paints.
@testcase
def test_frame_placeholder_returns_on_band_flip(page):
"""When the band flips a far slide from the blank gif to a real image, the loading mark
returns until it paints — the gif's finished-loading state must not leave you on black."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzbflip-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbflip-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzbflip-web-{i}", "labels": "zzbflip", "state": "todo"} for i in range(30)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzbflip")
expect(tiles(page)).to_have_count(30)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
far = strip.get_by_role("listitem").nth(20).get_by_label("loading")
expect(far).to_have_count(0) # slot 20 starts in the blank band — its gif painted, no mark
strip.evaluate("el => el.scrollLeft = 20 * (el.scrollWidth / el.children.length)") # fling onto it
expect(far).to_have_count(1) # its source flips to a real image → the mark returns until it paints
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame placeholder returns on band flip")
Assembling the slide, then: two stacked images — the thumbnail base and, when close enough, the
web_cid overlay above it — with the marks between them. Each layer re-arms its own mark when its
source changes (a new doc, a band that swaps blank↔thumbnail, or one that adds or drops the
overlay), so neither mark lingers from a source no longer shown:
const FrameSlide = (slide, k) => {
const thumbSrc = createMemo(() => thumbBand(slide(), k)); // base layer's source
const webSrc = createMemo(() => webBand(slide(), k)); // overlay's source ('' when far from centre)
const [thumbOn, setThumbOn] = createSignal(false); // the base thumbnail has painted
const [webOn, setWebOn] = createSignal(false); // the full-res overlay has painted
createEffect(() => { thumbSrc(); setThumbOn(false); }); // each layer re-arms its mark on its own source change
createEffect(() => { webSrc(); setWebOn(false); });
return html`
<div class="slide" role="listitem">
<${Show} when=${() => hasMedia(slide())}
fallback=${html`<div class="slide-media noimg">
<span class="ph">${() => isVideo(slide()) ? '🎬' : '🖼'}</span></div>`}>
<${Show} when=${() => isVideo(slide())}
fallback=${html`<div class="slide-pic">
<${Show} when=${() => !thumbOn()}>
<span class="ph load-ph" aria-label="loading">🖼</span><//>
<img class="slide-media" loading="lazy"
src=${thumbSrc} onLoad=${() => setThumbOn(true)} />
<${Show} when=${() => webSrc()}>
<img class="slide-media web" classList=${() => ({ shown: webOn() })}
loading="lazy" src=${webSrc} onLoad=${() => setWebOn(true)} />
<${Show} when=${() => thumbOn() && !webOn()}>
<span class="upgrading" aria-label="fetching full resolution"></span><//>
<//></div>`}>
<video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
<//>
<//>
</div>`;
};
Triaging from the frame. A tap reveals the control bar, and it turns the slideshow into a review station: everything on it acts on the centred doc, so you can triage without leaving the show.
An edit often pushes that doc out of the current filter — mark a todo done while
viewing todos. frameEdit handles it: if the doc leaves the set it re-anchors on the
previous doc, so the next advance lands on whatever filled the gap instead of skipping
it; if it stays, it keeps it centred.
@testcase
def test_frame_edit_reanchors(page):
"""Editing a frame doc out of the filter re-centers on the previous doc."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzedit-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzedit-t-{i}",
"labels": "zzeditframe", "state": "todo"} for i in range(3)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
search_for(page, "zzeditframe") # default state chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-0")
page.keyboard.press("ArrowRight") # centre the middle doc
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-1")
strip.click() # reveal the frame bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="done", exact=True).click() # → leaves the todo filter
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-0") # back to previous
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame edit re-anchors")
The bar’s state buttons are each tinted their own hue — todo cyan, next amber, done green, delete red — so the one you want is easy to pick across the room.
strip.click() # a tap reveals the frame bar
expect(bar).to_be_visible()
colours = [bar.get_by_role("button", name=st, exact=True).evaluate("el => getComputedStyle(el).color")
for st in ["todo", "next", "done", "delete"]]
assert len(set(colours)) == 4, f"each state pill should have its own colour, got {colours}"
print(" PASS: frame state pills are colour coded")
It also names the centred slide’s date, and the date follows the slide as the show advances.
def shown(k): # the date the bar should be reading, as the browser renders it
return page.evaluate("d => new Date(d).toLocaleString('fr-FR')", FIXTURES[k]["date"])
i = centred_slide(strip) # whichever slide the show stopped on
expect(bar.locator(".frame-date")).to_have_text(shown(i))
page.keyboard.press("ArrowRight") # step to the next slide
j = (i + 1) % len(FIXTURES)
wait_until(page, lambda: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{j}")
expect(bar.locator(".frame-date")).to_have_text(shown(j)) # follows the slide
print(" PASS: frame shows date")
Having said its piece the bar should step back out of the way. So after a span of quiet it hides itself and the frame is clean glass again — twenty seconds by default, long enough to read the controls and act, short enough that a bar tapped up by accident doesn’t sit over the show. Under the hood the wait needs no clock of its own: the assertion polls, so the window simply elapses inside it.
expect(bar).to_be_visible() # still up from the tap that raised it
expect(bar).to_be_hidden() # and gone once the window has run out
print(" PASS: frame bar auto-hides when idle")
And “quiet” means quiet: any interaction while the bar is up — a tap, a swipe, a press on its own controls — restarts the count, so it never vanishes mid-use and leaves only once you have truly stopped. Pressing it repeatedly at less than a window’s interval therefore keeps it alive past the moment a bar that ignored the presses would already have gone; stop pressing, and it goes.
strip.click() # bring the bar back up
expect(bar).to_be_visible()
pause = bar.get_by_role("button", name=re.compile("pause|play"))
for _ in range(3):
pause.click(); page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)
page.wait_for_timeout(CABINET_UI_IDLE_MS // 3) # now well past a window since the first press
expect(bar).to_be_visible() # still up: each press restarted the count
expect(bar).to_be_hidden() # now left alone → it finally hides
print(" PASS: frame bar idle resets on use")
The centred slide’s date is editable in place — a click opens the picker, Enter or a
click away saves it through frameEdit, the same path the lightbox uses.
@testcase
def test_frame_edits_date(page):
"""The frame's date is editable in place: set a new date and it saves through frameEdit."""
strip = enter_frame(page, 999999)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
strip.click() # reveal the bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = bar.get_by_label("date", exact=True)
box.fill("2020-12-15T12:00") # move the centred doc to December
box.press("Enter") # save
want = page.evaluate("() => new Date('2020-12-15T12:00').toLocaleString('fr-FR')")
expect(bar.get_by_role("button", name="edit date")).to_have_text(want) # saved, and shown
print(" PASS: frame edits date")
The picker opens seeded with the slide’s current date, time and all, so a small correction isn’t a re-entry of the whole stamp.
@testcase
def test_frame_date_seeds(page):
"""The frame's date picker opens seeded with the centred slide's date — time and all."""
strip = enter_frame(page, 999999)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
strip.click()
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="edit date").click()
want = page.evaluate("() => { const t = new Date('2020-01-15T12:00:00Z'), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }")
expect(bar.get_by_label("date", exact=True)).to_have_value(want)
print(" PASS: frame date seeds")
Escape backs out of the picker without saving, and leaves you in the frame rather than
exiting the show.
@testcase
def test_frame_date_escape_cancels(page):
"""Escape backs out of the frame's date picker without saving, and stays in the frame."""
strip = enter_frame(page, 999999)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
strip.click()
bar = page.get_by_role("toolbar", name="frame actions")
before = bar.get_by_role("button", name="edit date").text_content()
bar.get_by_role("button", name="edit date").click()
box = bar.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(strip).to_be_visible() # Escape didn't exit the frame
expect(bar.get_by_role("button", name="edit date")).to_have_text(before) # date unchanged
print(" PASS: frame date escape cancels")
The red button asks before it acts: a delete is a soft, undoable mark, but across the room a stray tap shouldn’t bin a photo, so it waits for a yes — dismiss keeps the doc, accept marks it (and, leaving the filter, re-anchors like any other edit).
@testcase
def test_frame_delete_confirms(page):
"""Marking a doc for deletion in the frame asks first: dismiss keeps it, accept removes it."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdelcfm-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdelcfm-t-{i}",
"labels": "zzdelcfm", "state": "todo"} for i in range(3)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
search_for(page, "zzdelcfm") # default chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-0")
page.keyboard.press("ArrowRight") # centre the middle doc
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-1")
strip.click() # reveal the frame bar
delete_btn = page.get_by_role("toolbar", name="frame actions").get_by_role("button", name="delete", exact=True)
seen = []
dismiss = lambda d: (seen.append(d.message), d.dismiss())
page.on("dialog", dismiss)
delete_btn.click() # ask, then DISMISS
wait_until(page, lambda: bool(seen), label="delete asks for confirmation")
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-1", "dismiss must keep the doc"
page.remove_listener("dialog", dismiss)
page.on("dialog", lambda d: d.accept())
delete_btn.click() # ask, then ACCEPT
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-0") # left the filter, re-anchored
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame delete confirms")
The show is meant to run unattended, so it remembers where it was: on a reboot it resumes the slide it was last centred on, not the first. Two things make that hard to catch in the act. Coming back on the first slide is what a show with no memory does, so if it went down on the opening slide a resume and a cold start are the same picture. And a show that is playing will wander onto the right slide by itself within a few ticks, so an answer waited for is no answer at all — the slide it comes back on has to be read the moment it lands, before the first tick moves it.
In practice both ends of the reboot need pinning down. Going down, the slide is written a beat after the show settles on it, so waiting for merely something to be written would accept the slide before last. Coming back, the show is placed from what was written only once the wall’s docs are in hand, in an animation frame of its own; before they arrive it has nothing to go on and sits on the opening slide. So the reading waits for the write to name the slide we are on, and afterwards for the docs and that frame — never for the position to become the expected one, which is the very thing in question.
if centred_slide(strip) == 0: # park it away from the cold-start slide
page.keyboard.press("ArrowRight")
wait_until(page, lambda: centred_slide(strip) != 0,
label="the show steps off the slide a cold start would pick")
i = centred_slide(strip)
was = strip.evaluate(CENTERED) # the slide it is showing when it goes down
remembered = lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')")
wait_until(page, lambda: remembered() == FIXTURES[i]["cid"],
label="the slide it is on is the one written down",
detail=lambda: f"written down: {remembered()}")
open_app(page, CABINET_QS) # reboot: a plain relaunch
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs it places from are in
page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
came_back_on = strip.evaluate(CENTERED) # read at once, not waited for
assert came_back_on == was, f"resumed on {came_back_on}, not the {was} it went down on"
print(" PASS: frame resumes position")
Leaving, on the other hand, is meant to stick: exit the show deliberately and the next launch leaves you on the wall. 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.
@testcase
def test_frame_label_completion(page):
"""The frame's add-label box completes on the existing vocabulary."""
strip = enter_frame(page, 999999)
strip.click() # reveal the frame bar
box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos", delay=20)
expect(options(page).first).to_be_visible() # vocabulary suggestions
assert "cos" in options(page).first.inner_text().strip().lower()
print(" PASS: frame label completion")
It’s a combobox on the same terms as the others — aria-expanded tracks its list, and it
closes on a state flip, not a timer.
@testcase
def test_frame_label_combobox_state(page):
"""The frame add-label box is a combobox too: aria-expanded tracks its list, no-timing close."""
strip = enter_frame(page, 999999)
strip.click() # reveal the frame bar
box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos")
expect(box).to_have_attribute("aria-expanded", "true")
box.blur()
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: frame label combobox state")
And like the lightbox, its completion drops the centred doc’s own labels, so it never offers a word the doc already wears.
@testcase
def test_frame_completion_skips_present(page):
"""Like the lightbox, the frame's completion drops the centred doc's own labels."""
make_fixtures()
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpresentframe", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzpresentframe-t", "labels": "cosmo; zzpresentframe", "state": "todo"}
gql(CREATE, {"p": doc})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzpresentframe") # narrow to just this doc
expect(tiles(page)).to_have_count(1)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
strip.click() # reveal the frame bar
box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
box.click(); box.press_sequentially("balade", delay=20) # a label the doc lacks…
expect(options(page).filter(has_text=re.compile(r"^balade$")).first).to_be_visible() # …is offered
box.fill(""); box.press_sequentially("cosmo", delay=20) # one the centred doc has…
expect(options(page).filter(has_text=re.compile(r"^cosmo$"))).to_have_count(0) # …is not
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: frame completion skips present")
The frame state and clock. The interval timer smooth-scrolls one slide on from wherever the strip currently sits, so manual swipes are respected and it wraps. A playing video holds it too: each tick checks the slides first and skips the advance while one is still rolling, so a clip you started isn’t scrolled off before it ends.
A pinch holds it hardest of all. The pinch itself is the browser’s own — spread two fingers
and it magnifies the slide, no code of ours in the loop — but the frame has to notice,
because a slide that auto-advances or snap-realigns out from under a magnified look is
useless. The browser reports the pinch through visualViewport: its scale sits at 1
unzoomed and climbs past it the moment a pinch takes hold, so scale > 1 is our reading
that one is on, watched off the viewport’s own resize and scroll.
One measure underlies all the positioning: a slide is located by its own geometry — each
slide’s offsetLeft, and the true per-slide width (the strip’s scrollWidth over every
slide) — never the viewport’s rounded clientWidth. A slide is a full viewport (100vw)
wide, a length that can sit a fraction off the integer clientWidth; across a wall of
thousands of slides that fraction compounds into whole slides adrift, so only the slides'
real geometry keeps the show landing dead-centre.
@testcase
def test_frame_video_holds_the_show(page):
"""While a slide's video is playing, the show doesn't auto-advance; when it ends, it resumes."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzfvid", "date": "2019-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvid-web",
"labels": "zzfvid", "state": "todo"}
# three stills after the video, so an un-held show marches visibly past it instead of
# wrapping the short clone-cycle back onto the video within the test window
stills = [{"cid": f"https://ipfs.konubinix.eu/p/zzfvid-{i}", "date": f"20{19 + i}-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfvid-{i}-t", "labels": "zzfvid", "state": "todo"} for i in range(1, 4)]
for d in (vid, *stills): gql(CREATE, {"p": d})
page.route("**/ipfs/zzfvid-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm", headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, "?ms=1000")
chip(page, "all").click(); search_for(page, "zzfvid")
expect(tiles(page)).to_have_count(4)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
on_slide = "el => Math.round(el.scrollLeft / (el.clientWidth || 1))"
wait_until(page, lambda: strip.evaluate(on_slide) == 1) # settled on the video (first real slide)
v = strip.locator("video").first
v.evaluate("el => { el.muted = true; el.currentTime = 0; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.wait_for_timeout(2500) # several 1s ticks pass…
assert strip.evaluate(on_slide) == 1, "a playing video must hold the show"
v.evaluate("el => { el.currentTime = el.duration; }") # let it play out
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzfvid-1-t") # show resumes onto the first still
finally:
page.unroute("**/ipfs/zzfvid-web")
for d in (vid, *stills):
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
print(" PASS: frame video holds the show")
So the auto-advance stands still while a pinch is on — lean in on a face and the show won’t step away from it, even once the after-touch idle span that also pauses it has lapsed.
@testcase
def test_frame_pinch_pauses_autoadvance(page):
"""A magnified slide (native pinch) holds the show even after the touch-idle span lapses."""
make_fixtures()
open_app(page, "?ms=500&idleresume=600") # brisk tempo, short touch-idle so only the zoom can hold
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5) # the browser magnified
page.wait_for_timeout(1600) # past the 600ms touch-idle AND several 500ms ticks
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0", "a magnified slide must not auto-advance"
print(" PASS: frame pinch pauses auto-advance")
And the settle-snap stands off while a pinch is on, so a scroll nudged mid-slide isn’t yanked back to a slide edge and out from under the magnified view.
@testcase
def test_frame_pinch_freezes_snap(page):
"""While magnified, the settle-snap stands off — a mid-slide scroll fires no realign."""
make_fixtures()
open_app(page, "?ms=999999") # no auto-advance to muddy the scroll
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # let the app read the now-live scale
# count the settle-snap's realign action; a mid-slide scroll would trigger it when not frozen
strip.evaluate("el => { window.__snaps = 0; const o = el.scrollTo.bind(el); el.scrollTo = (...a) => { window.__snaps++; return o(...a); }; }")
strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4); // 40% into slide 1: well off any boundary
el.dispatchEvent(new Event('scroll')); }""")
page.wait_for_timeout(400) # well past the ~150ms settle-snap
assert page.evaluate("() => window.__snaps") == 0, "a magnified slide's settle-snap must not realign"
print(" PASS: frame pinch freezes snap")
Everything the frame does is a reaction to a small cluster of state: whether it is open and playing, which slide has settled — and at what strip index, so the load bands know where they are — and whether a recent touch or a live pinch should hold the auto-advance. We keep the signals together so every effect below reads one live picture.
const [frame, setFrame] = createSignal(false);
const [playing, setPlaying] = createSignal(true);
const [frameUI, setFrameUI] = createSignal(false); // controls revealed on tap
const [frameLabel, setFrameLabel] = createSignal('');
const [frameLabelFocus, setFrameLabelFocus] = createSignal(false);
const [frameCenterCid, setFrameCenterCid] = createSignal(null); // the settled slide
const frameDoc = () => items().find(p => p.cid === frameCenterCid()); // the centred doc
const [frameEditingDate, setFrameEditingDate] = createSignal(false);
const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);
const [frameDir, setFrameDir] = createSignal(1); // last travel direction (+1 forward)
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
const FRAME_IDLE_MS = Number(new URLSearchParams(location.search).get('idleresume')) || 60000;
const [pokes, setPokes] = createSignal(0);
const nudge = () => setPokes(n => n + 1);
const [interacting, setInteracting] = createSignal(false);
const [zoomed, setZoomed] = createSignal(false);
The frame plays the wall in the order shown — chronological, or the sort:random draw. To loop
seamlessly it builds an infinite carousel: a clone of the last slide sits before the first and a
clone of the first after the last, so real slide 0 lands at strip index 1. Positions and indices
read the slides’ own geometry rather than a rounded clientWidth (which the settle would then
have to correct), and a step is just a smooth scroll to the neighbour.
const frameSlides = () => { const o = items();
return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
let stripEl, wakeLock = null;
const slideW = () => stripEl && stripEl.children.length ? stripEl.scrollWidth / stripEl.children.length : (stripEl?.clientWidth || 1);
const slideAt = () => Math.round((stripEl?.scrollLeft || 0) / slideW()); // nearest slide index
const slideLeft = i => { const k = stripEl && stripEl.children[i]; return k ? k.offsetLeft : i * slideW(); };
const frameGo = delta => { if(!stripEl) return;
stripEl.scrollTo({ left: slideLeft(slideAt() + delta), behavior: 'smooth' }); };
A native scroll flows freely; the frame lets it, then tidies up once it stops. While the strip moves, the load bands ride the live position and lean the way it is going. About 150ms after the last scroll, the settle re-centres on the nearest real slide — swapping a clone for its twin at the edges — and remembers the doc it came to rest on, so a reboot resumes there. A pinch that has taken hold is left alone: a realign queued before it must stand off rather than yank the magnified view back to a slide edge.
const FRAME_CID_KEY = 'memories.frame.cid';
let snapT;
const onFrameScroll = () => {
if(stripEl){ const i = slideAt(), c = frameCenterIdx();
if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
clearTimeout(snapT); snapT = setTimeout(() => {
if(!stripEl) return;
if(zoomed()) return;
const n = items().length;
let i = slideAt();
if(i <= 0){ stripEl.scrollLeft = slideLeft(n); i = n; } // leading clone(last) → real last
else if(i >= n + 1){ stripEl.scrollLeft = slideLeft(1); i = 1; } // trailing clone(first) → real first
const target = slideLeft(i);
if(Math.abs(stripEl.scrollLeft - target) > 1) stripEl.scrollTo({ left: target, behavior: 'smooth' });
const doc = items()[i - 1];
setFrameCenterIdx(i);
if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
}, 150); };
Entering the frame is a small ceremony: mark it open and playing, remember it in localStorage
so a PWA relaunch comes straight back, push a history entry so the back button leaves, and take a
wake lock so the cabinet screen stays lit. Closing is the teardown — turn the auto-enter off and
release the lock. Exit unwinds through the history entry when there is one, so the back button
and an explicit exit both leave history balanced.
const FRAME_ON_KEY = 'memories.frame.on';
async function enterFrame(){
if(!items().length) return;
setFrame(true); setPlaying(true); setFrameUI(false);
localStorage.setItem(FRAME_ON_KEY, '1');
history.pushState({ frame: true }, '');
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
}
function closeFrame(){
setFrame(false);
localStorage.setItem(FRAME_ON_KEY, '0');
try { wakeLock?.release(); } catch(e) {} wakeLock = null;
}
const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();
You can edit the centred doc without leaving the show — set its state, add or drop a label, fix
its date. An edit can push the doc out of the current filter; when it does we re-anchor on the
previous doc, so the next advance shows whatever filled the gap rather than skipping it, and
when it stays we keep it centred. Each editor below is a thin wrapper over that one frameEdit.
const frameIndex = () => Math.max(0, Math.min(items().length - 1, slideAt() - 1));
async function frameEdit(patchFor){
const list = items(); if(!list.length || !stripEl) return;
const i = frameIndex(), cur = list[i], prevCid = i > 0 ? list[i - 1].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch: patchFor(cur) });
setFrameLabel('');
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length) { exitFrame(); return; }
const stay = l2.findIndex(p => p.cid === cur.cid);
let t = stay >= 0 ? stay : (prevCid ? l2.findIndex(p => p.cid === prevCid) : 0);
stripEl.scrollLeft = slideLeft(Math.max(0, t) + 1);
});
}
const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
return frameEdit(() => ({ state: st })); };
const frameAddWord = input => { const words = splitWords(input); if(!words.length) return;
return frameEdit(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; }); };
const frameAddLabel = () => frameAddWord(frameLabel());
const frameDropLabel = () => { const words = splitWords(frameLabel()); if(!words.length) return;
return frameEdit(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') })); };
let frameCancelDate = false;
const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false; setFrameEditingDate(false);
if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };
The cabinet tablet has no address bar: the frame launches from a PWA home-screen shortcut, which opens with no query string, so an auto-start can’t ride a URL param. Being in the frame is remembered instead — when the persisted wall loads and we were last in the frame, the show auto-starts, once only, so exiting doesn’t immediately re-enter. It opens on the slide we left off, resuming across a reboot, and falls back to the first real slide.
const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1';
let autoEntered = false;
createEffect(() => {
if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
});
createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
const saved = localStorage.getItem(FRAME_CID_KEY);
const r = saved ? items().findIndex(p => p.cid === saved) : -1;
stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1); // +1 for the leading clone
setFrameCenterIdx(r >= 0 ? r + 1 : 1); // seed the centre before any scroll
setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
}); });
A video the viewer started should not keep playing once it has scrolled away — a soundtrack from a slide nobody can see, over a photo that has nothing to do with it.
@testcase
def test_frame_video_pauses_when_it_leaves(page):
"""A video playing in the frame stops once the show has moved off its slide."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzfvid", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvid-web",
"labels": "zzfvid", "state": "todo"}
nxt = {"cid": "https://ipfs.konubinix.eu/p/zzfvid-next", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-next-t", "labels": "zzfvid", "state": "todo"}
for d in (vid, nxt): gql(CREATE, {"p": d})
page.route("**/ipfs/zzfvid-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, "?ms=999999") # the show holds still; the test moves it
search_for(page, "zzfvid") # default chip = todo
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
v = strip.locator("video").first # slide 1 — slide 0 is the wrap-around clone of the last
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }") # headless blocks unmuted autoplay
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
strip.evaluate("el => el.scrollLeft = el.children[2].offsetLeft") # onto the next slide
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the video paused once its slide left the screen")
print(" PASS: frame video pauses when it leaves")
finally:
page.unroute("**/ipfs/zzfvid-web")
for d in (vid, nxt):
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
And it has to keep doing so after the strip has re-flowed under it. Triage from the frame removes docs from the filter, so the boxes shuffle: a box that held a photo comes to hold the video that used to sit after it. That video is a different element from the one the show opened with, and it must be watched just the same.
@testcase
def test_frame_video_pauses_after_reflow(page):
"""A video that only lands in its slide box after an edit re-flows the strip still pauses."""
drop_fixtures()
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzfvr-a", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-a-t", "labels": "zzfvr", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzfvr-v", "date": "2020-02-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-v-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvr-web",
"labels": "zzfvr", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzfvr-c", "date": "2020-03-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-c-t", "labels": "zzfvr", "state": "todo"}]
for d in docs: gql(CREATE, {"p": d})
page.route("**/ipfs/zzfvr-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, "?ms=999999")
search_for(page, "zzfvr")
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
strip.click() # reveal the bar, on the first photo
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → the strip closes the gap
expect(tiles(page)).to_have_count(2)
v = strip.locator("video").first # the video now sits in the box the photo had
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
strip.evaluate("el => el.scrollLeft = el.children[2].offsetLeft")
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the re-flowed video paused once its slide left the screen")
print(" PASS: frame video pauses after reflow")
finally:
page.unroute("**/ipfs/zzfvr-web")
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
An IntersectionObserver over the strip pauses any slide video that drops below half-visible.
It can only watch the videos it was handed, though, and it is handed them by one sweep of the
strip — so the sweep has to happen again every time the boxes are re-dealt, not just when the
show opens. Hence the observer is thrown away and rebuilt on each of those two events, which
is what on([items, frame]) says: rebuild when the docs change, and when the show opens.
createEffect(on([items, frame], () => {
if(!frame() || !stripEl) return;
const io = new IntersectionObserver(
es => es.forEach(e => { if(e.intersectionRatio < 0.5) e.target.pause(); }),
{ root: stripEl, threshold: 0.5 });
requestAnimationFrame(() => stripEl.querySelectorAll('video').forEach(v => io.observe(v)));
onCleanup(() => io.disconnect());
}));
The auto-advance is a smooth step to the next slide every interval, but it must yield. The
browser’s own pinch-zoom is watched off visualViewport, so a magnified slide holds the show; a
playing video holds it too; and a recent touch holds it — each interaction marks the show busy,
then quiet again after the idle span, and every tap or swipe on the strip bumps that timer. When
nothing holds it, the show steps and wraps at the end.
createEffect(() => { if(!frame()) return; const vv = window.visualViewport; if(!vv) return;
const read = () => setZoomed(vv.scale > 1);
read(); vv.addEventListener('resize', read); vv.addEventListener('scroll', read);
onCleanup(() => { vv.removeEventListener('resize', read); vv.removeEventListener('scroll', read); }); });
createEffect(() => {
if(!frame() || !playing() || zoomed() || interacting()) return;
const id = setInterval(() => {
if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
frameGo(1);
}, intervalMs());
onCleanup(() => clearInterval(id));
});
createEffect(() => { if(!pokes()) return;
setInteracting(true);
const id = setTimeout(() => setInteracting(false), FRAME_IDLE_MS); onCleanup(() => clearTimeout(id)); });
createEffect(() => { if(!frame() || !stripEl) return;
stripEl.addEventListener('pointerdown', nudge);
onCleanup(() => stripEl.removeEventListener('pointerdown', nudge)); });
A few listeners ride on the window for the frame’s whole life. Esc and the arrow keys work the
show from a desktop keyboard — except while the label box holds focus, where the arrows must edit
the text rather than step the slide. The back button unwinds the stack in order — out of the
frame, then out of the lightbox it may have launched from, then to the wall. And when the tab
returns to the foreground the wake lock, dropped while hidden, is taken again so the screen stays
lit.
onMount(() => {
const onKey = e => {
if(!frame()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') exitFrame();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); frameGo(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); frameGo(-1); }
};
const onVis = async () => {
if(frame() && document.visibilityState === 'visible' && !wakeLock)
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
};
const onPop = () => {
if(frame()) closeFrame();
const st = history.state || {};
if(opened() && !st.lb) closePhoto();
if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }
};
window.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPop);
document.addEventListener('visibilitychange', onVis);
onCleanup(() => { window.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPop);
document.removeEventListener('visibilitychange', onVis); });
});
On a desktop keyboard, ← and → step the show — the same one-slide move the side-taps
make. The frame claims that keypress wholly, so frameGo alone drives the step: a
modern browser hands keyboard focus to a scrollable region, and left to its own devices it
would answer the arrow by scrolling the strip a notch itself — jerking the doc sideways
and fighting the step the settle then has to undo. Taking the key keeps the arrow a clean
step, the same animation as a side-tap.
Those arrows want a keyboard the cabinet tablet doesn’t have, and one-handed from across the room a dependable swipe wants two hands. So the screen itself carries the same step, and where the tap lands is the whole of it.
Two of the three zones go to travel, because stepping is what you do most and it has to be reachable without looking: the outer thirds move the show, left back and right forward.
page.mouse.click(box["x"] + box["width"] * 0.92, midY) # right third → forward
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.mouse.click(box["x"] + box["width"] * 0.08, midY) # left third → back
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame tap zones step the show")
The centre third is what is left, and it has to be the bar’s: put the controls behind an edge and every reach for them would step the show first.
page.mouse.click(box["x"] + box["width"] / 2, midY) # centre third → the bar
expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
print(" PASS: frame centre tap reveals the bar")
In code the zones are the whole handler: an x against two thirds of the width, and nothing else to decide.
const onFrameTap = e => {
const w = window.innerWidth || 1;
if(e.clientX < w / 3) frameGo(-1);
else if(e.clientX > w * 2 / 3) frameGo(1);
else setFrameUI(v => !v);
};
A plain click can’t carry that step: a <video controls> in the slide swallows the tap
before any click bubbles up. So we read the strip’s raw pointers instead, watching for a
lone finger that presses and lifts in place — travelling no more than about ten pixels, past
which it is a swipe rather than a tap. A second finger is not a tap at all: it is a pinch,
and pinching now belongs to the browser’s own zoom, so the watch lets that gesture go and
never steps the show.
const TAP_SLOP = 10;
let tapFrom = null; // where a lone finger went down, while it could still be a tap
const tapPtrs = new Set();
const onTapDown = e => { tapPtrs.add(e.pointerId);
tapFrom = tapPtrs.size === 1 ? { x: e.clientX, y: e.clientY } : null; };
const onTapMove = e => { if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null; };
const onTapUp = e => { tapPtrs.delete(e.pointerId);
if(e.type === 'pointerup' && tapFrom) onFrameTap(e);
if(!tapPtrs.size) tapFrom = null; };
createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
const on = (t, h) => el.addEventListener(t, h), off = (t, h) => el.removeEventListener(t, h);
on('pointerdown', onTapDown); on('pointermove', onTapMove); on('pointerup', onTapUp); on('pointercancel', onTapUp);
onCleanup(() => { off('pointerdown', onTapDown); off('pointermove', onTapMove);
off('pointerup', onTapUp); off('pointercancel', onTapUp); }); });
The second finger is the watch’s other half: two fingers planted in an outer third and lifted must leave the show exactly where it stood — only a lone finger carries the side-step.
x = box["x"] + box["width"] * 0.92 # the right third — a lone tap here would step forward
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": x - 20, "y": midY}, {"x": x + 20, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
page.wait_for_timeout(FRAME_STEP_GRACE_MS) # long enough for a step to show
assert strip.evaluate(ON_SLIDE) == first, "a two-finger gesture must not step the show"
print(" PASS: frame two-finger gesture does not step")
The 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.
@testcase
def test_back_at_grid_asks_before_exit(page):
"""At the grid, with nothing open, the back button asks before leaving the app."""
open_fixtures(page)
asked = []
page.on("dialog", lambda d: (asked.append(d.message), d.dismiss())) # cancel → stay
page.go_back()
wait_until(page, lambda: bool(asked)) # the dialog is delivered async
expect(grid(page)).to_be_visible() # cancelling kept us in the app
print(" PASS: back at grid asks before exit")
The completion list under the search box is the one thing that can be showing without being a stacked overlay — yet the same reflex applies: with it up, Back should retract it, not ask whether to leave. Retracting the last thing that appeared is what the button is for, so a Back that finds the list open spends itself closing it, and only a Back with nothing showing reaches the leave prompt. And retracting the list must not spend the root guard itself: a further Back, now with nothing showing, still meets that prompt.
@testcase
def test_back_closes_completion(page):
"""With the completion list up, the back button retracts it and stays in the app."""
open_app(page)
search_box(page).click() # focus the empty box → the token menu drops down
expect(options(page).first).to_be_visible()
page.go_back() # Back retracts the list — it must not leave the app
expect(options(page)).to_have_count(0) # the list is gone
expect(search_box(page)).to_be_visible() # and we're still in Memories
print(" PASS: back closes completion")
@testcase
def test_back_after_completion_still_guards_exit(page):
"""After Back retracts the completion list, a further Back still asks before leaving."""
open_app(page)
search_box(page).click() # focus → the list drops down
expect(options(page).first).to_be_visible()
page.go_back() # first Back: retract the list
expect(options(page)).to_have_count(0)
asked = []
page.on("dialog", lambda d: (asked.append(d.message), d.dismiss()))
page.go_back() # second Back: must reach the leave guard
wait_until(page, lambda: bool(asked))
expect(search_box(page)).to_be_visible() # cancelled → still in the app
print(" PASS: back after completion still guards exit")
In practice the guard reads whether the list is up straight from the DOM — the presence
of the rendered .suggest node — rather than from a state flag: a flag holding the
last-offered items would keep its value after the box blurs and the list unmounts, and so
would claim a list that is already gone.
onMount(() => {
history.pushState({ app: true }, ''); // the root entry the back button stops on
const onExit = () => {
const st = history.state || {};
if(!frame() && !opened() && !st.lb && !st.app){ // popped below the root with nothing open
if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; } // bail out of a frozen read; stay
if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; } // retract the list; keep the root beneath
if(confirm('Leave Memories?')) history.back(); // really leave
else history.pushState({ app: true }, ''); // stay — restore the root
}
};
window.addEventListener('popstate', onExit);
onCleanup(() => window.removeEventListener('popstate', onExit));
});
The filmstrip: a natively-scrolled row of viewport-wide slides (incl. the two clones). The control bar lays over it as a sibling — outside the strip that reads taps — so a press on the bar’s own buttons is never taken for a tap on the show.
<${Show} when=${() => frame()}>
<div class="frame">
<div class="strip" role="list" aria-label="slideshow"
ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
<${Index} each=${() => frameSlides()}>${(slide, k) => FrameSlide(slide, k)}<//>
</div>
<${Show} when=${() => frameUI()}>
<div class="frame-bar" role="toolbar" aria-label="frame actions" onPointerDown=${nudge}>
<button aria-label=${() => playing() ? 'pause' : 'play'}
onClick=${() => setPlaying(p => !p)}>${() => playing() ? '⏸' : '▶'}</button>
<label>every <input class="ivl" type="number" min="2" aria-label="seconds per photo"
value=${() => Math.round(intervalMs() / 1000)}
onChange=${e => setIntervalMs(Math.max(2, +e.target.value) * 1000)} />s</label>
<${Show} when=${() => frameEditingDate()}
fallback=${html`<button class="frame-date" aria-label="edit date"
onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
<input class="frame-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
onBlur=${e => frameCommitDate(e.target.value)} />
<//>
<span class="room-link" data-state=${roomLink}>${roomLink}</span>
<${For} each=${() => frameEvents() || []}>${e => html`
<button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
${STATES.map(st => html`
<button class="st" data-st=${st} onClick=${() => frameSetState(st)}>${st}</button>`)}
<div class="complete">
<input class="frame-label" role="combobox" placeholder="add a label…" aria-label="add a label in the frame"
aria-expanded=${() => frameLabelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
onFocus=${() => setFrameLabelFocus(true)}
onBlur=${() => setFrameLabelFocus(false)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
<${Show} when=${() => frameLabelFocus()}>
<${Suggest} text=${frameLabel} present=${() => labelsOf(frameDoc())}
active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
<//>
</div>
<button aria-label="exit frame" onClick=${exitFrame}>✕ exit</button>
</div>
<//>
</div>
<//>
The date on the bar is the lightbox’s date editor moved here: a button showing the
centred slide’s date that, pressed, becomes a datetime-local seeded with what it held.
Saving routes through frameEdit, so fixing a wrong timestamp mid-show re-orders the strip
and keeps the doc under you, exactly as a state change does.
<${Show} when=${() => frameEditingDate()}
fallback=${html`<button class="frame-date" aria-label="edit date"
onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
<input class="frame-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
onBlur=${e => frameCommitDate(e.target.value)} />
<//>
Backing out must not leave the show: Escape otherwise exits the frame, so the picker swallows
it at the input and marks the edit cancelled before it lets go.
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); }
The frame carries the same overlap the lightbox does: beside the date, the centred slide’s events ride on the bar, so a slideshow tells you not just when a photo was taken but what was happening then — and stepping to the next slide brings its own.
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.
@testcase
def test_frame_pinch_resets_after_idle(page):
"""Left magnified and untouched, the frame reloads itself back to 1:1 after the idle span."""
make_fixtures()
open_app(page, "?ms=999999&zoomidle=700") # a short idle window for the test
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # register the zoom → arm the idle timer
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01, timeout=8000) # reloaded → 1:1
assert "z=" in page.url, "the reset lands on a fresh throwaway url"
print(" PASS: frame pinch resets after idle")
The countdown rides the same pokes() interaction signal the rest of the frame uses, so
every touch restarts it; only a stretch of pure quiet while zoomed reaches the navigation.
const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
createEffect(() => { if(!frame() || !zoomed()) return; pokes(); // any touch re-arms the countdown
const id = setTimeout(() => { const u = new URL(location.href);
u.searchParams.set('z', String(Date.now())); // an address the browser hasn't seen zoomed → it lands at 1:1
location.href = u.href; }, ZOOM_IDLE_MS);
onCleanup(() => clearTimeout(id)); });
Into the frame, from the lightbox
The frame launches from the wall, over whatever the query narrowed to. But the moment you most want the big show is usually when you’re already lingering on one photo in the lightbox — so the lightbox carries a ▶ frame button too, and it opens the show on the doc you’re looking at rather than back at the first slide.
Landing on a chosen slide is something the frame already knows how to do: it opens on whichever slide matches the remembered cid, the way it resumes after a reboot. So launching from the lightbox is just seeding that memory with the open doc, dropping the modal, and entering — the strip settles on that very slide.
@testcase
def test_frame_from_lightbox(page):
"""▶ frame in the lightbox enters the slideshow centered on the open doc."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
open_doc(page, 1) # open the middle doc, not the first
d = dialog(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
d.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(d).to_be_hidden() # the lightbox gives way to the frame
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: frame from lightbox")
The lightbox’s history entry stays underneath the frame’s, so the back button unwinds the whole way down: leaving the frame reopens the doc it was launched from, and leaving that returns to the wall — frame → lightbox → grid.
@testcase
def test_back_from_frame_returns_to_lightbox(page):
"""A frame launched from a doc steps back to that doc's lightbox, then to the grid."""
open_fixtures(page)
open_doc(page, 1) # lightbox on the middle doc
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
dialog(page).get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
page.go_back() # out of the frame …
expect(strip).to_be_hidden()
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") # … back to its lightbox
page.go_back() # out of the lightbox …
expect(dialog(page)).to_be_hidden()
expect(grid(page)).to_be_visible() # … back to the wall
print(" PASS: back from frame returns to lightbox")
The handler does exactly that: it writes the open doc’s cid where the frame looks for the slide to resume on, closes the modal, and enters.
const frameFromHere = () => { const cur = opened(); if(!cur) return;
localStorage.setItem(FRAME_CID_KEY, cur.cid); // the slide the frame will open on
closePhoto(); // hide the modal but leave its history entry, so Back returns to it
enterFrame(); };
The button sits at the top of the modal, between select and close.
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
.lb-frame{ position:absolute; top:-6px; left:50%; transform:translateX(-50%); z-index:2;
padding:6px 12px; border:none; border-radius:8px; background:#262a40;
color:var(--fg); font-size:12px; cursor:pointer; }
.lb-frame:hover{ background:#33395a; }
Handing the slide to a phone
The photo on the cabinet is often one you want to send to someone right then — and the conversation you would send it in is on your phone. The tablet has no way to hand a file to an app on another device, so the phone has to fetch it, and a small companion app is what does that: it holds whatever the frame is showing and passes it to the phone’s own share sheet. Everything memories owes it is a name for the photo.
That name lives in a shared document — a room on the house’s sync server, holding one entry that every frame writes and the companion reads. It is a heavier instrument than one value needs, and it is chosen for what surrounds the value rather than the value itself: the companion is a page with no server of its own, so nothing can hand it an update unless the update arrives over a connection it already holds.
Yjs holds such a document, and y-websocket carries it. Yjs has Solid’s one-instance
requirement and meets it the same way — the socket library comes in marked ?external=yjs so
that it and the map’s own yjs are the same copy. Two copies fail quietly in the worst way:
the room connects, reports itself live, and never syncs anything.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
Which room, and which server, a frame can work out for itself: the sync server answers on the same host the app was loaded from, so swapping the scheme is the whole of it. Both can be named at launch as well — every app here that joins a room takes its address that way, since the address is configuration and not a fact about where the page came from.
const frameParam = new URLSearchParams(location.search);
const SYNC_URL = frameParam.get('yws') || location.origin.replace(/^http/, 'ws') + '/ywebsocket';
const SYNC_ROOM = frameParam.get('room') || 'memories-nowshowing';
const [roomLink, setRoomLink] = createSignal('idle');
A frame names every slide it comes to rest on, unasked. Waiting for a gesture would defeat the point: you look up from the cabinet, take out your phone, and the photo has to be there already — a phone that needs you to go back and prod the tablet first is a phone you would not bother with.
Memories runs on the phones as much as on the cabinet, so several frames can be playing at once, all writing that same one entry, and the last to settle wins. There is no arbitration and no need for one: in practice the frame that keeps settling is the one that is on, which is the cabinet. Where two really are going at once, a touch breaks the tie.
A name is both of the doc’s addresses — the downscaled copy and the original, so the companion can offer you the choice the lightbox already draws — what kind of file it is, which the companion needs to build a file the share sheet will accept, and when the photo was taken, which is what the companion calls the file it hands over. Only the archive knows that date, and a frame that kept it to itself would leave the companion handing over a file it cannot name.
Under the hood the archive gives an instant back in its own offset rather than the Z it was
handed, so the two read differently as text while naming the same moment; what is checked is
therefore the instant, not its spelling.
named = watch_room(page.context, sync_url, room) # the entry, watched as the phone will
open_app(page, f"?ms=999999&uiidle=1500&yws={sync_url}&room={room}")
search_for(page, "zzshare") # default state chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
# nothing is touched here: entering the show is the whole of it
wait_until(page, lambda: bool(named()), label="the frame names its slide unasked")
said = named()
assert said["cid"] == "https://ipfs.konubinix.eu/p/zzshare-0", f"named the wrong doc: {said}"
assert said["webCid"] == "https://ipfs.konubinix.eu/p/zzshare-web-0", f"no downscaled address: {said}"
assert said["mimetype"] == "image/jpeg", f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
[said.get("date"), SHARE_DOCS[0]["date"]]), \
f"named a different instant than the doc's {SHARE_DOCS[0]['date']}: {said}"
A name rides the settle, not the scroll — the centred slide reads true long before the debounce that ends a step — so a step only counts as over once the show has come to rest, and that is when the next name goes out. Keep browsing and the name keeps up with you.
page.keyboard.press("ArrowRight") # a step, with nothing touched
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzshare-1",
label="the name follows the slide", detail=lambda: str(named()))
Taking the room back is the one thing a touch is for. If another frame has spoken over you, touching yours puts your slide back without your having to move it.
speak_over(page.context, "https://ipfs.konubinix.eu/p/zzelsewhere") # another frame claims the room
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzelsewhere")
strip.click() # a touch, and the slide is not moved
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzshare-1",
label="a touch takes the room back", detail=lambda: str(named()))
bar = page.get_by_role("toolbar", name="frame actions")
expect(bar.get_by_text("live", exact=True)).to_be_visible() # the bar owns up to the link
The addresses go out exactly as the doc carries them, unresolved: the companion is served from elsewhere and reaches the archive by its own route, so it is the one that can turn an address into something it can fetch.
The socket opens on the first slide a frame names and lives as long as the page does — on a cabinet tablet, for days. Dropping it between shows would buy a little idle quiet and cost the next name a dial-up first, which is the wrong way round: the name is wanted the instant somebody looks up.
Naming a slide is a thing you cannot see working. The frame writes, says nothing, and looks exactly the same whether the name arrived or the socket is down — and if it is down, somebody on a sofa is looking at a phone that still shows the last photo the room heard about. The person standing at the frame is the only one who can notice, so the bar carries the link’s state in one word beside the date: idle until a slide has been named, connecting while the socket is being opened, then live or offline for as long as it holds or does not.
The word that has to be right is offline, because it is the only one that contradicts what the screen otherwise implies. A frame pointed somewhere nothing answers looks exactly like a frame whose every slide is landing. Pointing one nowhere takes a little care: the browser vetoes the low port numbers on its own, before any connection is attempted, so a frame aimed at one of those never learns anything about its link. A high port nobody is listening on is refused outright, which is the answer wanted.
@testcase
def test_the_frame_owns_up_to_a_dead_link(page):
"""A frame that cannot reach the room says so, instead of looking like it published."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdead-{i}", "date": f"2021-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdead-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzdead-web-{i}", "labels": "zzdead", "state": "todo"}
for i in range(3)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999&uiidle=999999&yws=ws://127.0.0.1:45999&room=nowhere")
search_for(page, "zzdead")
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
strip.click() # a touch, so the bar is up
bar = page.get_by_role("toolbar", name="frame actions")
expect(bar.get_by_text("offline", exact=True)).to_be_visible(timeout=20000)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: the frame owns up to a dead link")
Whoever reads that word is standing back from a screen on a cabinet, and is not looking for it anyway — they are looking at the photo. So it has to catch the eye when it is bad news and otherwise stay out of the way. Only one of the four states is bad news: only offline takes a colour that interrupts, the two that are on their way somewhere share a muted one, and live stays quiet, being the answer you already assumed.
<span class="room-link" data-state=${roomLink}>${roomLink}</span>
.frame-bar .room-link{ font:10px/1.6 monospace; text-transform:uppercase; letter-spacing:.08em;
color:#6a7; }
.frame-bar .room-link[data-state="offline"]{ color:#d55; }
.frame-bar .room-link[data-state="connecting"],
.frame-bar .room-link[data-state="idle"]{ color:#aa6; }
let showingRoom = null;
const nowShowing = () => {
if(!showingRoom){
const shared = new Y.Doc();
const provider = new WebsocketProvider(SYNC_URL, SYNC_ROOM, shared);
setRoomLink('connecting');
provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
showingRoom = shared.getMap('showing');
}
return showingRoom;
};
createEffect(() => {
if(!frame()) return;
pokes();
const d = frameDoc(); if(!d) return;
nowShowing().set('doc', { cid: d.cid, webCid: d.webCid, mimetype: d.mimetype, date: d.date });
});
Under the hood, reading pokes() there is what makes a touch republish. An effect re-runs when
anything it read has changed, so naming that counter alongside the doc buys the tie-breaker for
one line: the settle path comes from the doc, and the claim path from the counter the show
already bumps on every touch. Nothing has to notice which of the two happened.
Filtering by state
Triage needs a way to ask “show me only what’s still todo” — and since triage is the
whole point of the app, that’s where a first-ever visit opens. Thereafter it opens on
whatever you last chose, remembered like the search box (so the frame comes back to the
view you were working). Each photo carries a workflow state (todo / next / done / delete); a row of chips under the search box switches an all / todo / next / done
filter. The filter is a Solid signal — seeded from localStorage, defaulting to todo —
folded into the resource key alongside search, so flipping a chip re-fetches and saves.
Crucially the filter is a server argument (states), applied inside
photovideos_search before the ~2000 sample — an archive that’s almost entirely
done would otherwise leave a “todo” view nearly empty (the sample is mostly done,
so client-side filtering finds nothing). With the server filter, todo surfaces ~2000
todos spread across the whole span.
The test searches the fixtures (which span todo/next/done), confirms all three show
under all, clicks the todo chip, and asserts only the todo tile remains.
@testcase
def test_state_filter(page):
"""The state chips narrow the wall to one workflow state, server-side."""
open_fixtures(page) # starts on 'all' → all three states
chip(page, "todo").click()
expect(tiles(page)).to_have_count(1) # only the todo fixture remains
expect(grid(page).get_by_text("todo")).to_have_count(1) # and its badge says so
print(" PASS: state filter")
And the chosen chip is remembered: like the search box, the filter persists to
localStorage, so a reload — or the frame’s reboot — comes back to the state you were
last triaging, not always todo.
@testcase
def test_state_filter_persists(page):
"""The chosen state chip is saved locally, surviving a reload (and the frame's reboot)."""
open_app(page)
chip(page, "done").click() # pick a non-default state
expect(chip(page, "done")).to_have_attribute("aria-pressed", "true")
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(chip(page, "done")).to_have_attribute("aria-pressed", "true") # remembered, not back to todo
print(" PASS: state filter persists")
The chips: all plus one per state, in a labelled group so the filter buttons are
unambiguous from the like-named batch buttons. The active one carries aria-pressed,
which both styles it and names the active chip for a screen reader.
<div class="chips" role="group" aria-label="filter by state">
${['all', ...STATES].map(st => html`
<button class="chip" data-st=${st} disabled=${() => photos.loading}
aria-pressed=${() => stateFilter() === st ? 'true' : 'false'}
onClick=${() => setStateFilter(st)}>${st}</button>`)}
<button class="chip selall" aria-pressed=${() => allSelected() ? 'true' : 'false'}
onClick=${toggleAll}>${() => allSelected() ? 'clear' : 'select all'}</button>
<button class="chip frame-start" onClick=${enterFrame}>▶ frame</button>
</div>
.chips{ display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
.chip{ padding:4px 12px; font-size:12px; text-transform:uppercase; letter-spacing:.04em;
cursor:pointer; border-radius:999px; border:1px solid #3a3f5a;
background:#262a40; color:#9aa; }
.chip[data-st]{ border-color:var(--st,#3a3f5a); color:var(--st,#9aa); }
.chip[aria-pressed='true']{ background:var(--st,#6cf); color:#08111e; border-color:var(--st,#6cf); font-weight:700; }
.selall{ margin-left:auto; }
Selecting and editing in bulk
Triage goes faster in bulk — pick a run of docs and edit them together.
Selecting tiles and batch-editing
Triage goes far faster in bulk: click tiles to build a selection, then apply one change
to all of them at once —
add a label (free-text, merged into each photo’s labels) or set a state
(todo / next / done / delete, the photovideo workflow enum). Both ride the
auto-generated updatePhotovideo(input:{cid,patch}) mutation, one call per selected
cid; the batch runs under the mutating flag, so when it settles the wall re-reads the
current query and reflects the change.
But the wall is only a ~2000 sample — to retag the thousands a filter may match, the
toolbar’s all N matching toggle switches the same buttons to the server-side bulk
functions (photovideosSetState / AddLabel / RemoveLabel), which update every row the
current query matches in one statement. Touching the selection or the filter cancels the
toggle, so you can’t bulk-edit a stale scope by accident.
Selection is a Solid Set signal — toggling replaces the set so fine-grained
reactivity re-renders only the touched tiles’ outline/check. A sticky toolbar appears
only while something is selected. A plain click toggles one tile and remembers it as
the anchor, and a contiguous run from that anchor to a target tile (in the wall’s date
order) can be selected several ways — all routed through the same extendTo, so they
can’t drift apart.
A select all toggle sits at the end of the chips row (it has to live outside the
selection toolbar, which is hidden when nothing is selected): one tap selects the whole
shown wall, another clears it. Combined with the state filter it’s the fast path — e.g.
filter next, select all, batch done.
@testcase
def test_select_all(page):
"""The select-all toggle selects the whole shown wall, then clears it."""
open_fixtures(page)
select_all(page).click()
expect(checks(page)).to_have_count(len(FIXTURES)) # every tile shows its ✓
select_all(page).click()
expect(checks(page)).to_have_count(0) # second tap clears
print(" PASS: select all")
On desktop, shift-click the target tile and the whole run from the anchor to it is selected in one go.
@testcase
def test_range_select(page):
"""Shift-click selects the contiguous range between anchor and target."""
open_fixtures(page)
t = tiles(page)
t.nth(0).click() # anchor on the first tile
t.nth(2).click(modifiers=["Shift"]) # extend the selection to the third
expect(checks(page)).to_have_count(3) # the middle tile is roped in too
print(" PASS: range select")
Touch has no modifier key, so the toolbar’s ↔ range toggle stands in: arm it, tap the target, and the same run is roped in — the toggle disarming once it has.
@testcase
def test_range_select_touch(page):
"""The range toggle extends with plain taps — no shift key, for touch."""
open_fixtures(page)
t = tiles(page)
t.nth(0).click() # anchor + toolbar appears
rng = toolbar(page).get_by_role("button", name="range")
rng.click() # arm range mode (the touch route)
t.nth(2).click() # a plain tap now extends the run
expect(checks(page)).to_have_count(3)
expect(rng).to_have_attribute("aria-pressed", "false") # disarmed after extending
print(" PASS: range select (touch)")
Or long-press a tile to arm range mode anchored there — the touch gesture for multi-selection, no trip to the toolbar; the next tap completes the run (a double-click stays the open gesture, so the two don’t collide).
@testcase
def test_long_press_arms_range(page):
"""A long-press on a tile arms range mode (it becomes the anchor); the next tap
selects the run to it — the touch gesture for multi-selection, no trip to the toolbar."""
open_fixtures(page)
t = tiles(page)
box = t.nth(0).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.mouse.down(); page.wait_for_timeout(600); page.mouse.up() # hold past the long-press threshold
expect(dialog(page)).to_have_count(0) # no lightbox — the long-press starts a selection
expect(checks(page)).to_have_count(1) # the pressed tile, now the anchor
t.nth(2).click() # a plain tap completes the range
expect(checks(page)).to_have_count(len(FIXTURES))
print(" PASS: long-press arms range")
When the window is too dense the wall shows only a spread of the matches (the “showing a
spread” notice). That breaks the premise of all this: its tiles are an arbitrary handful, not
a run — and a contiguous-batch selection over them can’t mean what it looks like it means. So
the range gestures stand down: a shift-click ropes in only the tiles you touch, and the
↔ range toggle is disabled and says why. Single taps still pick tiles one by one.
@testcase
def test_spread_disables_multiselect(page):
"""On a spread the tiles are an arbitrary handful, not a run, so range selection
stands down: a shift-click ropes in only the two tiles touched, and the ↔ range toggle
is disabled and says why."""
open_app(page); chip(page, "all").click() # the whole archive → a genuine spread
expect(page.get_by_text(re.compile(r"showing a spread of \d+ from \d+"))).to_be_visible()
t = tiles(page)
t.nth(0).click() # select the first (toolbar appears)
t.nth(2).click(modifiers=["Shift"]) # shift-click a later tile
expect(checks(page)).to_have_count(2) # only the two touched — no run roped in
rng = toolbar(page).get_by_role("button", name="range")
expect(rng).to_be_disabled() # the range affordance stands down
title = rng.get_attribute("title")
assert title and ("spread" in title.lower() or "run" in title.lower()), \
f"the disabled range toggle should say why: {title!r}"
print(" PASS: spread disables multiselect")
Not every short wall is a spread, though. Ask for the earliest twenty and you are holding twenty in a row — fewer than match, but a run all the same, and a range across them means exactly what it looks like. What stands the gesture down is the spread, not the shortfall.
@testcase
def test_first_last_keep_range_select(page):
"""A first:/last: wall is short but contiguous, so range selection stays available."""
open_app(page); chip(page, "all").click()
search_for(page, "first:4") # the archive's earliest four
expect(tiles(page)).to_have_count(4)
expect(page.get_by_text(re.compile(r"showing the first 4 of \d+"))).to_be_visible()
t = tiles(page)
t.nth(0).click() # select the first (toolbar appears)
expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
t.nth(2).click(modifiers=["Shift"]) # a shift-click ropes in the run
expect(checks(page)).to_have_count(3)
print(" PASS: first/last keep range select")
Lifting the finger to hunt for the far tile is a wasted gesture, though — the finger is already on the wall. So the long-press need not end in a second tap: keep it down and slide, and the run follows it live, the tile under the finger becoming the run’s far end. It grows as the finger travels away from the anchor.
@testcase
def test_long_press_drag_extends_live(page):
"""Holding after the long-press and dragging the finger extends the run live to the
tile under it — the range grows mid-drag, before the finger ever lifts."""
open_fixtures(page)
t = tiles(page)
a = t.nth(0).bounding_box(); far = t.nth(2).bounding_box()
page.mouse.move(a["x"] + a["width"] / 2, a["y"] + a["height"] / 2)
page.mouse.down(); page.wait_for_timeout(600) # hold past the threshold → arms range at the anchor
expect(checks(page)).to_have_count(1) # just the anchor so far
page.mouse.move(far["x"] + far["width"] / 2, far["y"] + far["height"] / 2, steps=5)
expect(checks(page)).to_have_count(len(FIXTURES)) # the whole run roped in mid-drag…
expect(dialog(page)).to_have_count(0) # …and no lightbox — this is a selection gesture
page.mouse.up()
print(" PASS: long-press drag extends live")
And it shrinks as the finger comes back toward the anchor, never stranding a tail. A drag
can retreat as well as advance, where a tap only ever adds once — so it runs on the same
grow-or-shrink engine as the keyboard’s Shift-arrow run (extendRun, which recomputes the run
from the fixed anchor over the selection snapshotted when the drag began), not the one-shot
extendTo the tap routes share.
@testcase
def test_long_press_drag_shrinks_on_return(page):
"""Dragging back toward the anchor shrinks the run — like the keyboard's Shift-arrow,
it follows the finger both ways and never strands a tail."""
open_fixtures(page)
t = tiles(page)
a = t.nth(0).bounding_box(); mid = t.nth(1).bounding_box(); far = t.nth(2).bounding_box()
page.mouse.move(a["x"] + a["width"] / 2, a["y"] + a["height"] / 2)
page.mouse.down(); page.wait_for_timeout(600) # arm range at the anchor
page.mouse.move(far["x"] + far["width"] / 2, far["y"] + far["height"] / 2, steps=5)
expect(checks(page)).to_have_count(3) # grew to the whole run…
page.mouse.move(mid["x"] + mid["width"] / 2, mid["y"] + mid["height"] / 2, steps=5)
expect(checks(page)).to_have_count(2) # …then dragging back drops the far tile
page.mouse.up()
print(" PASS: long-press drag shrinks on return")
Under the hood, the run’s far end can’t be read from the move event’s target: once the
press fires it captures the pointer, so every later move reports the pressed tile, not the
one under the finger. So the far tile is hit-tested instead — document.elementFromPoint at
the finger, mapped to its doc through the grid’s tile order.
A pointer drag over the wall competes with two things the browser would sooner do with it.
On a desktop an <img> is draggable by default, so the browser tries to peel the thumbnail
off as a drag-and-drop ghost and the drag never reaches the run; the tiles’ images are
marked draggable“false”= to refuse it.
On a touch screen the wall is a scroll surface, so the browser reads a moving finger as a
pan and cancels the pointer out from under the run; while a press is armed the drag vetoes
that scroll and keeps the gesture. Those two defences answer to different inputs — a mouse
drag is no touch gesture at all, so it never triggers the touch-scroll the veto guards
against and slips past untouched — so proving the touch side takes a test on Chromium’s real
touch pipeline.
In practice the scroll veto is a touchmove listener that calls preventDefault while the
press is live; it has to be a non-passive listener, because a passive one — the default
the framework would attach — is forbidden from cancelling the scroll at all.
@testcase
def test_long_press_drag_touch(page):
"""A real finger (CDP touch, not a mouse) long-presses then drags, and the run
follows — the gesture stays the app's instead of scrolling the wall."""
n = 18
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdragt-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdragt-t-{i}",
"labels": "zzdragt", "state": "todo"} for i in range(n)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 700}) # narrow so the wall overflows (a drag can pan), tall so row 0 clears the edge-scroll margin
open_app(page); chip(page, "all").click(); search_for(page, "zzdragt")
expect(tiles(page)).to_have_count(n)
t = tiles(page)
a = t.nth(0).bounding_box(); end = t.nth(2).bounding_box() # first row, on screen, clear of both the toolbar and the edge margin
x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
x1, y1 = end["x"] + end["width"] / 2, end["y"] + end["height"] / 2
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(600) # hold past the long-press → arms range
for f in (0.25, 0.5, 0.75, 1.0): # drag across the row to tile 2
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
"touchPoints": [{"x": x0 + (x1 - x0) * f, "y": y0 + (y1 - y0) * f}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(3) # the contiguous run 0..2, not one lonely anchor
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: long-press drag (touch)")
A run shouldn’t halt at the last visible tile when the wall runs on below the fold. So a finger that comes within a ~48px margin of the bottom edge drags the wall up under itself — a steady ~14px a frame — the run growing onto each row that rises into view, down to the last tile; the top margin pulls the other way. That margin is read against the same toolbar-lifted floor the keyboard reveal measures, so the finger lands on a tile and not on the bar pinned across the foot.
@testcase
def test_long_press_drag_autoscrolls(page):
"""Holding the drag against the bottom edge scrolls the wall, so the run keeps growing
onto tiles that started below the fold — all the way to the last one."""
n = 30
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdrags-{i}", "date": f"2021-03-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdrags-t-{i}",
"labels": "zzdrags", "state": "todo"} for i in range(n)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 480}) # the wall overflows well past the fold
open_app(page); chip(page, "all").click(); search_for(page, "zzdrags")
expect(tiles(page)).to_have_count(n)
t = tiles(page)
a = t.nth(0).bounding_box(); last = t.nth(n - 1).bounding_box()
x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
xl = last["x"] + last["width"] / 2 # the last tile's column (its row is below the fold)
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(600) # arm range at the anchor
for f in (0.5, 1.0): # drag to the bottom edge, in the last column, and hold
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x0 + (xl - x0) * f, "y": 470}]})
wait_until(page, lambda: checks(page).count() == n, timeout=6000, # the wall scrolls the rest under the finger
label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{n} selected")
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(n)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: long-press drag auto-scrolls")
On a desktop the fastest way to grab a block is to sweep a rectangle around it. A mouse drag that starts on empty grid space — never on a tile, so the tap/double-click/ long-press gestures stay untouched — rubber-bands a box, and every tile it covers joins the selection. We drag from the empty cells past the last fixture back across the row and expect all of them checked.
@testcase
def test_marquee_selects(page):
"""A rubber-band drag from empty grid space selects the tiles it covers."""
open_fixtures(page)
g = grid(page).bounding_box()
y = g["y"] + 30
page.mouse.move(g["x"] + g["width"] - 12, y) # empty cells right of the row
page.mouse.down()
page.mouse.move(g["x"] + 12, y + 25, steps=10) # sweep left across every tile
page.mouse.up()
expect(checks(page)).to_have_count(len(FIXTURES))
print(" PASS: marquee selects")
Testing against prod, safely. These tests mutate rows, so they don’t touch real
photos: make_fixtures inserts a few clearly-marked rows (sentinel label zzbatchfix,
fake cids) before each batch test, and drop_fixtures deletes exactly those at the end
of the run. Searching the sentinel yields only the fixtures, so assertions are exact.
Taking the first one is where the toolbar arrives, and it must not cost you your place: if the grid slid down to make room for it, the tile under your finger would move away as you reached for the next one. So the measurement has to be made across that first pick, while the toolbar is still absent — once it is up, its arrival can no longer disturb anything.
t = tiles(page)
expect(toolbar(page)).to_be_hidden() # nothing picked yet, so the bar is not there
before = t.first.bounding_box()
t.nth(0).click() # the first pick — the bar arrives on this one
expect(toolbar(page)).to_be_visible()
after = t.first.bounding_box()
assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
print(" PASS: select doesn't shift grid")
From there the wall is a set you build up and pare back: each tile toggles, and the toolbar keeps the count so you know what you are about to act on.
t.nth(1).click() # a second one joins it
expect(checks(page)).to_have_count(2)
expect(toolbar(page).get_by_text("2 selected")).to_be_visible()
t.nth(0).click() # toggle one back off
expect(checks(page)).to_have_count(1)
print(" PASS: select toggles tiles")
One box serves both + label and - label, so the two have to agree about what they read out of it. Adding merges the typed words into every selected doc.
select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="add label").click()
expect(checks(page)).to_have_count(0) # selection clears once applied
search_for(page, "addedbybatch") # the fresh word now finds them all
expect(tiles(page)).to_have_count(n)
print(" PASS: batch add label")
- label strips the typed words from every selected doc instead. The wall is standing on the word being stripped, so when it is gone from all of them there is nothing left to show — which is the whole of the proof, and no chip has to be believed for it.
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)
print(" PASS: batch remove label")
Hands stay on the keys for a run this size, so Enter stands in for +. In practice the box
is a controlled input the toolbar tears down when the selection clears and rebuilds on the
next select-all, so the box reached for after a selection settles is a new one.
search_for(page, FIXTURE_LABEL) # back to the run itself
expect(tiles(page)).to_have_count(n)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Enter") # + via RET
expect(checks(page)).to_have_count(0) # applied → selection clears
search_for(page, "zzbatchret")
expect(tiles(page)).to_have_count(n) # every selected photo got it
print(" PASS: batch enter adds")
And Shift+Enter for −, so a word put on the wrong run comes off it without the hands
moving either.
select_all(page).click()
expect(checks(page)).to_have_count(n) # selection settled
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Shift+Enter") # - via S-RET
expect(tiles(page)).to_have_count(0) # the docs no longer carry the label → gone from the wall
print(" PASS: batch shift-enter removes")
Picking a word from the completion list rather than typing it out leaves a ; behind it,
ready for the next one — so that trailing separator is the state the box is normally in,
not an edge case. Removing has to read the box the way adding does and strip the word
anyway.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ") # as a picked suggestion leaves it
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0) # the trailing sep didn't defeat the match
print(" PASS: batch remove label trailing separator")
Setting a state applies it to every selected photo at once; once the edit settles and the wall re-reads, each tile’s badge shows the new value.
@testcase
def test_batch_set_state(page):
"""A state clicked on the selection is written to every selected photo."""
open_fixtures(page)
select_all(page).click()
toolbar(page).get_by_role("button", name="done").click()
# once the edit settles and the wall re-reads, every shown tile's badge reads done
expect(grid(page).get_by_text("done")).to_have_count(len(FIXTURES))
print(" PASS: batch set state")
Sometimes the whole filter is the target, not just the tiles you’ve picked — an all N matching button widens the scope, applying the state to every doc the search matches, selected or not.
@testcase
def test_batch_all_matching_state(page):
"""'all N matching' applies a state to the whole filter, not just the selection."""
open_fixtures(page)
tiles(page).nth(0).click() # select just one, to raise the toolbar
tb = toolbar(page)
tb.get_by_role("button", name=re.compile("all .* matching")).click() # widen the scope
tb.get_by_role("button", name="done").click()
# all three matching docs become done, though only one was selected
expect(grid(page).get_by_text("done")).to_have_count(len(FIXTURES))
print(" PASS: batch all-matching state")
That widening stays inside the active filter, though: an owner: scope spares every other
owner’s photos, so “all matching” never reaches past what you’re actually looking at.
@testcase
def test_bulk_all_matching_respects_owner(page):
"""'all N matching' stays within the owner filter, sparing other owners' photos."""
drop_fixtures()
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzownb-k", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzownb-k-t", "labels": "zzownb", "state": "todo", "owner": "konubinix"},
{"cid": "https://ipfs.konubinix.eu/p/zzownb-a", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzownb-a-t", "labels": "zzownb", "state": "todo", "owner": "aylapomme"}]
for d in docs: gql(CREATE, {"p": d})
cnt = lambda owner, state: gql(
"query($o:[OwnerType!],$s:[State!]){ photovideosCount(search:\"zzownb\","
" since:\"2007-01-01\", until:\"2035-01-01\", owners:$o, states:$s) }",
{"o": [owner], "s": [state]})["data"]["photovideosCount"]
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzownb; owner:konubinix")
expect(tiles(page)).to_have_count(1)
tiles(page).nth(0).click() # raise the toolbar
tb = toolbar(page)
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="done").click()
wait_until(page, lambda: cnt("konubinix", "done") == 1) # the filtered owner flips
assert cnt("aylapomme", "todo") == 1, "bulk leaked across owners"
print(" PASS: bulk all-matching respects owner")
finally:
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
The same wide scope adds a label across the whole filter, not only a state.
@testcase
def test_batch_all_matching_label(page):
"""'all N matching' adds a label to the whole filter, not just the selection."""
open_fixtures(page)
tiles(page).nth(0).click()
tb = toolbar(page)
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill("bulkall")
tb.get_by_role("button", name="add label").click()
search_for(page, "bulkall") # all three carry it now
expect(tiles(page)).to_have_count(len(FIXTURES))
# the fresh read shows the committed label captioned on every matching tile
expect(grid(page).get_by_text("bulkall")).to_have_count(len(FIXTURES))
print(" PASS: batch all-matching label")
Labels split on ;, not on commas, so a label with a comma inside it is a single token —
removing it across the filter must drop the whole token, not the fragments around the
comma.
@testcase
def test_all_matching_removes_comma_label(page):
"""'all N matching' strips a label that itself contains a comma. Labels split on
';', so the comma is part of one token — the server must drop the whole label,
not the comma-fragments around it."""
comma_label = "zzleft, zzright" # one ;-token, with a comma inside it
open_fixtures(page) # 3 fixtures carry FIXTURE_LABEL
select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill(comma_label)
tb.get_by_role("button", name="add label").click() # selection path ;-joins it on
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(len(FIXTURES)) # sanity: the comma-label took
select_all(page).click() # raise the toolbar again
# the add clears the box only after its awaited mutation resolves — a late clear that, if
# it lands after the refill below, would blank it and make the remove a no-op. Waiting for
# the empty box here proves that clear has already fired, so the refill sticks.
box = tb.get_by_placeholder("add a label…")
wait_until(page, lambda: box.input_value() == "",
label="batch label box clears after the selection add",
detail=lambda: f"box={box.input_value()!r} alert={page.get_by_role('alert').count()} tiles={tiles(page).count()}")
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill(comma_label)
tb.get_by_role("button", name="remove label").click() # bulk path → server remove_label
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(0) # the whole comma-label is gone
print(" PASS: all-matching removes comma label")
Past triage you often just want the files, and there are two reasons to want them: a
web copy (the downscaled web_cid) to hand to someone now, and the orig (the doc’s
own /ipfs/ cid) to rework. The toolbar’s ↓ group saves the explicit selection — one
file per doc — at whichever of the two you ask for, and hands over no other rendition.
tiles(page).nth(photo).click() # the one worth sending
expect(checks(page)).to_have_count(1)
tb, got = toolbar(page), {}
for res in ["web", "orig"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
got[res] = (di.value.url, di.value.suggested_filename)
assert docs[photo]["webCid"] in got["web"][0], f"web → webCid: {got}"
assert docs[photo]["cid"] in got["orig"][0], f"orig → the doc's own cid: {got}"
print(" PASS: download selection")
A doc missing that rendition is passed over rather than served its original in the
rendition’s place: a file named …-web.jpg holding several megabytes of full-resolution
original is worse than no file, because nothing about it admits what it is. So the photo
that was never downscaled joins the first in the selection, and asking the pair for web
has to bring down one file, not two.
tiles(page).nth(never_downscaled).click()
expect(checks(page)).to_have_count(2)
saved = [] # armed here: only this click's files count
page.on("download", lambda d: saved.append(d.url))
toolbar(page).get_by_role("button", name="web", exact=True).click()
wait_until(page, lambda: len(saved) >= 1, label="the doc that has a web copy saves it")
page.wait_for_timeout(500) # room for a second, unwanted save to land
assert len(saved) == 1, f"expected the one web copy, got {len(saved)}: {saved}"
assert docs[photo]["webCid"] in saved[0], f"the wrong rendition came down: {saved[0]}"
print(" PASS: download skips a missing rendition")
Taking both forms of one photo — the copy to send and the original to rework — would put
two files of the same name in the download folder, and the second would silently replace
the first. Each saved file therefore carries its rendition in the name,
holiday-orig.jpg beside holiday-web.jpg. In practice nothing needs downloading again
to see this: both forms of the first photo are already in hand, and it is their names
that are read.
names = {res: name for res, (_, name) in got.items()} # the two the photo just yielded
assert len({*names.values()}) == len(names), f"same name → they collide in the folder: {names}"
for res in names:
assert res in names[res], f"rendition missing from the name: {names}"
print(" PASS: download names by resolution")
A cid carries no extension and a missing object answers text/plain, so a name from
either is unopenable. The right extension depends on the rendition: a video’s web
copy is an MP4 (an image’s stays a JPEG), while the original keeps its true type from
the DB mimetype. So a .mov original saves …-orig.mov and its web copy …-web.mp4.
The photos are put back down and the clip taken up on its own, since it is the one doc
whose two forms disagree about their type.
tiles(page).nth(photo).click()
tiles(page).nth(never_downscaled).click()
tiles(page).nth(video).click()
expect(checks(page)).to_have_count(1)
tb, ext = toolbar(page), {}
for res in ["orig", "web"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
ext[res] = di.value.suggested_filename.rsplit(".", 1)[-1]
assert ext["orig"] == "mov", f"original keeps its real type: {ext}"
assert ext["web"] == "mp4", f"a video's web copy is mp4: {ext}"
print(" PASS: download extensions by rendition")
Selecting tiles and editing them in bulk is a cluster of small machines: the picked set and the gestures that grow it, the two ways an edit reaches the docs, and the download. We lay them out one at a time; the tangle reassembles them into the component.
At the heart is the picked set — a set of cids that persists, so an accidental refresh mid-triage doesn’t drop the run — beside the flags the gestures read: whether a range is armed for the next tap, and whether an action should reach the whole matching filter rather than only the shown sample. A range only makes sense over a contiguous run, so it stands down whenever the wall is a spread.
const STATES = ['todo', 'next', 'done', 'delete'];
const SEL_KEY = 'memories.selected';
const [selected, setSelected] = createSignal(new Set(JSON.parse(localStorage.getItem(SEL_KEY) || '[]')));
createEffect(() => localStorage.setItem(SEL_KEY, JSON.stringify([...selected()])));
const [labelText, setLabelText] = createSignal('');
const [labelFocus, setLabelFocus] = createSignal(false);
const [anchor, setAnchor] = createSignal(null);
const [rangeMode, setRangeMode] = createSignal(false);
const [allMatching, setAllMatching] = createSignal(false);
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };
Select-all lives outside the toolbar — which only appears once something is picked — so it toggles the whole shown wall on or off straight from the empty state.
const shownCids = () => items().map(p => p.cid);
const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));
A range can grow two ways. extendTo takes the whole contiguous run from the anchor to a tile
in one shot — a shift-click, or an armed tap. extendRun is the continuous version the keyboard
Shift-arrow and the held-finger drag share: because it can grow or shrink as the far end moves,
it snapshots the selection once at the run’s start and recomputes from the fixed anchor each step.
const extendTo = cid => {
const list = items().map(p => p.cid);
const a = list.indexOf(anchor()), b = list.indexOf(cid);
if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
const [lo, hi] = a < b ? [a, b] : [b, a];
setSelected(s => { const n = new Set(s);
for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
};
const extendRun = cid => {
if(!canRange()) return;
const list = items();
const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
if(a < 0 || ni < 0) return;
if(!extending){ extending = true; rangeBase = new Set(selected()); }
const [lo, hi] = a < ni ? [a, ni] : [ni, a];
setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
};
A plain click toggles one tile, re-anchors there, and plants the keyboard cursor so the arrows carry on from where you clicked. It becomes a range extension only when a modifier is present — shift-click on the desktop, or the toolbar’s armed range-mode then a tap on touch — and either way the tap clears range mode. Arming a range from a tile makes it the anchor and readies a fresh run for the first drag-move to snapshot.
const onTileClick = (e, cid) => {
setCursor(cid);
if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
extendTo(cid); setRangeMode(false); return;
}
toggle(cid); setAnchor(cid);
};
const armRange = cid => {
if(!canRange()) return;
setSelected(s => { const n = new Set(s); n.add(cid); return n; });
setAnchor(cid); setRangeMode(true); extending = false;
};
The held-finger drag has to know which tile the finger is over. tileCidAt hit-tests a screen
point down to a tile and maps it through the grid’s own child order to a cid.
const tileCidAt = (x, y) => {
const tile = document.elementFromPoint(x, y)?.closest('.tile');
if(!tile || !gridEl) return null;
const i = [...gridEl.children].indexOf(tile);
return i < 0 ? null : items()[i]?.cid;
};
A long-press arms a range and then drives it live while the finger stays down. Its state is a bundle: the press timer, whether it has fired, the press origin, the captured pointer, whether the drag has moved, the last finger position, and the edge-scroll frame. Each move re-extends the run to the tile under the finger. And when the finger nears a top or bottom margin, an animation loop scrolls the wall a step (~14px) per frame and keeps extending, so a run reaches past the fold — its floor lifted clear of the pinned toolbar.
let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false, lpPos = null, lpRaf = 0;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
const EDGE = 48, SCROLL_STEP = 14;
const dragTick = () => {
lpRaf = 0;
if(!lpFired || !lpPos) return;
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
if(!dy) return; // finger left the margin → stop the loop
scrollBy(0, dy);
dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1))); // hit-test clear of the bar
lpRaf = requestAnimationFrame(dragTick);
};
The press starts a 500ms timer; survive it without moving and it fires — capturing the pointer so the held drag’s moves route here, and arming the range. A move before it fires (past ~10px) abandons it; a move after drives the drag, entering the edge-scroll when the finger sits in a margin. Releasing a moved drag lands the run and disarms range mode, while a press that never moved leaves the range armed for a following tap. The press swallows its own trailing click, and while a drag is armed the wall’s scroll is blocked so the gesture and the scroll don’t fight.
const onTileDown = (e, photo) => {
lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
lpTimer = setTimeout(() => { lpFired = true; lpCancel();
try { lpEl.setPointerCapture(lpId); } catch(_){}
armRange(photo.cid); }, 500);
};
const onTileMove = e => {
if(lpFired){
lpPos = { x: e.clientX, y: e.clientY };
dragExtendAt(e.clientX, e.clientY);
if(!lpRaf) dragTick();
return;
}
if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
};
const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; } dragStop(); lpCancel(); };
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
onMount(() => gridEl?.addEventListener('touchmove',
e => { if(lpFired) e.preventDefault(); }, { passive: false }));
Now the two ways an edit reaches the docs. patchSelected is the per-doc path: it maps the shown
photos by cid (so a label-merge can read each row’s existing labels), fires one updatePhotovideo
per selected cid — skipping any the patch-maker declines, so a caller can leave some docs
untouched (as moving onto an occasion leaves other owners’) — then clears the selection and
re-reads the wall. Labels are a ;-delimited list, so one split parses both a doc’s stored labels
and what’s typed into a box, and a; b adds two in one go.
async function patchSelected(patchFor){
setMutating(m => m + 1);
try {
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const patch = patchFor(byCid.get(cid));
if(patch) await gql(UPDATE_PHOTO, { cid, patch });
}
clearSel();
await refetch();
} finally { setMutating(m => m - 1); }
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);
applyBulk is the all-matching counterpart: it edits every doc the filter matches
server-side — beyond the ~2000-row sample — through the bulk functions, over the same query the
wall is showing. Those return only a count of the rows touched, so where the per-cid path hands
back each changed row for the cache to act on, the bulk path tags the Photovideo type instead.
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
async function applyBulk(kind, v){
setMutating(m => m + 1);
try { await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); clearSel(); await refetch(); }
finally { setMutating(m => m - 1); }
}
The three edits pick their path by the all matching flag: add-label, remove-label and set-state
each route to the bulk function when the whole filter is the target, else to patchSelected over
the ticked set. Add-label also remembers the last word applied, for one-tap reuse on the next doc.
const addLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
setLastLabel(words[words.length - 1]);
if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
else await patchSelected(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
setLabelText('');
};
const removeLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
: patchSelected(() => ({ state: st }));
Finally the download: save the picked docs as files, at the resolution asked for. Like the move edit it acts on the explicit ticked selection (there is no client-side list of the whole filter to fetch), and the browser asks once before saving several.
const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const p = byCid.get(cid); if(!p) continue;
const path = res === 'web' ? p.webCid : p.cid;
if(!path) continue;
const isVid = (p.mimetype || '').startsWith('video');
const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
const raw = p.filename || p.cid.split('/').pop() || 'download';
const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
const a = document.createElement('a');
a.href = IPFS + path;
a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
document.body.appendChild(a); a.click(); a.remove();
}
}
The rubber-band feeds that same selection set. A drag counts as a sweep only when its
pointerdown lands on the grid container itself — an empty cell — so a press that begins
on a tile still belongs to that tile’s tap, double-click or long-press; touch is left out
entirely (it has the long-press range gesture already). While the button is held, each
move grows the box and re-selects every tile it intersects, tested in client coordinates
against each tile’s rectangle. The sweep is additive — it extends the current selection
rather than replacing it, matching how clicks accrue.
const [marquee, setMarquee] = createSignal(null); // {x0,y0,x1,y1} in client coords, or null
let marqueeFrom = null;
const marqueeRect = m => ({ l: Math.min(m.x0, m.x1), r: Math.max(m.x0, m.x1),
t: Math.min(m.y0, m.y1), b: Math.max(m.y0, m.y1) });
const marqueeSelect = () => {
const m = marquee(); if(!m) return;
const r = marqueeRect(m), list = items(), kids = gridEl.children, next = new Set(selected());
for(let i = 0; i < kids.length && i < list.length; i++){
const b = kids[i].getBoundingClientRect();
if(b.left < r.r && b.right > r.l && b.top < r.b && b.bottom > r.t) next.add(list[i].cid);
}
setAllMatching(false); setSelected(next);
};
const onGridDown = e => {
if(e.pointerType === 'touch' || e.button !== 0 || e.target !== gridEl || !canRange()) return;
marqueeFrom = { x: e.clientX, y: e.clientY };
setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
gridEl.setPointerCapture?.(e.pointerId);
};
const onGridMove = e => {
if(!marqueeFrom) return;
setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
marqueeSelect();
};
const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };
The toolbar: only mounted while a selection exists, and clustered by purpose so the row
of controls reads as groups rather than a wall of buttons — scope (the count, the all
matching toggle, the ↔ range toggle), label (the input with +/- — Enter in the
input is +, Shift+Enter is -), move (an event box with a → event button, for
re-anchoring a stray onto its occasion), state (one button per state),
download (↓ then orig=/=web), then clear — with thin dividers between the
groups.
The label input is a combobox on the same terms as the search box: an aria-expanded that
tracks its popover, and a close that is a state flip rather than a timed fade — so a caller
waits on the state, never a clock.
@testcase
def test_batch_label_combobox_state(page):
"""The selection's add-label box is a combobox too: aria-expanded tracks its list, and
it closes on a state flip, no timing."""
open_fixtures(page)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos")
expect(box).to_have_attribute("aria-expanded", "true") # listing completions → true
box.blur()
expect(box).to_have_attribute("aria-expanded", "false") # closed → false
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: batch label combobox state")
<${Show} when=${() => selCount() > 0}>
<div class="toolbar" role="toolbar" aria-label="selection actions">
<!-- scope: what the actions apply to -->
<span class="count">${() => allMatching() ? `all ${total()} matching` : `${selCount()} selected`}</span>
<button class="allmatch" aria-pressed=${() => allMatching() ? 'true' : 'false'}
onClick=${() => setAllMatching(m => !m)}>all ${() => total()} matching</button>
<button class="range" aria-pressed=${() => rangeMode() ? 'true' : 'false'}
disabled=${() => !canRange()}
title=${() => canRange() ? undefined : 'a spread is not a run — range select is off here'}
onClick=${() => setRangeMode(m => !m)}>↔ range</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- label edit -->
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="label for the selection"
aria-expanded=${() => labelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
onFocus=${() => setLabelFocus(true)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
onBlur=${() => setLabelFocus(false)} />
<${Show} when=${() => labelFocus()}>
<${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setLabelText(replaceSeg(labelText(), w) + '; ')} />
<//>
</div>
<button aria-label="add label" onClick=${addLabel}>+ label</button>
<button aria-label="remove label" onClick=${removeLabel}>- label</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- move the selection onto an occasion -->
<div class="complete">
<input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
value=${() => moveText()}
onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
onFocus=${() => setMoveFocus(true)}
onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- stamp the selection with one instant -->
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</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.
@testcase
def test_move_selection_to_event(page):
"""A stray-dated selection, moved onto an occasion, takes that event's start date."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-move", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzMoveKarate", "owner": "konubinix", "status": "confirmed"}
# two WhatsApp copies stamped with their save-date (2024), years off their real occasion
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzmv-1", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmv-1-t"},
{"cid": "https://ipfs.konubinix.eu/p/zzmv-2", "date": "2024-11-02T13:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmv-2-t"}]
for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzmove", "owner": "konubinix", "state": "todo"})
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzmove")
expect(tiles(page)).to_have_count(2)
select_all(page).click()
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzMoveK", delay=20)
tb.get_by_role("option", name=re.compile("zzMoveKarate")).click() # pick the occasion explicitly
tb.get_by_role("button", name="move to event").click()
expect(checks(page)).to_have_count(0) # applied → the selection clears
# both now wear the event's start day — the wall re-anchors, and the tile's alt is that day
expect(grid(page).get_by_role("img", name="2020-03-07")).to_have_count(2)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move selection to event")
The move box lives in the selection toolbar, beside the label box. It holds three things: the text you type, whether it has focus, and — once you pick — the occasion you armed.
const [moveText, setMoveText] = createSignal('');
const [moveFocus, setMoveFocus] = createSignal(false);
const [moveTarget, setMoveTarget] = createSignal(null); // the armed occasion, or null → nothing to apply
The occasions to pick from are the whole calendar’s, not the wall’s window. A stray’s date sits nowhere near the event it belongs to, so a list bounded by what the wall happens to show could never reach it. We read them the moment the box opens, over an all-time span — capped like the wall’s own event read so the connection never truncates before we use it.
const [moveEvents] = createResource(moveFocus,
f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
When the name fits several occasions — a « Karaté » that recurs every week — the list should lead with the one nearest the strays. So we rank each occasion by the mean of the selection’s dates: how far that mean sits from the occasion’s span — zero when it falls inside, otherwise the gap to the nearer bound, ties going to the earlier occasion. The order only suggests — you still pick — so a misleading mean costs a glance, never a silent mis-move.
@testcase
def test_move_ranks_by_mean_closeness(page):
"""When several occasions match the name, the one nearest the selection's mean date leads."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
evs = [{"rowId": "zzev-rfar", "starttime": "2010-01-01T00:00:00Z", "endtime": "2010-01-31T23:59:59Z",
"summary": "zzRankFar", "owner": "konubinix", "status": "confirmed"},
{"rowId": "zzev-rnear", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
"summary": "zzRankNear", "owner": "konubinix", "status": "confirmed"}]
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzrk-1", "date": "2020-06-10T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzrk-1-t"},
{"cid": "https://ipfs.konubinix.eu/p/zzrk-2", "date": "2020-06-20T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzrk-2-t"}] # mean ~2020-06-15
for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzrankmv", "owner": "konubinix", "state": "todo"})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzrankmv")
expect(tiles(page)).to_have_count(2)
select_all(page).click()
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzRank", delay=20)
opts = tb.get_by_role("option")
expect(opts).to_have_count(2) # both occasions match "zzRank"
expect(opts.first).to_contain_text("zzRankNear") # the one nearest the mean leads
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: move ranks by mean closeness")
That closeness rests on two readings of the selection: its mean date, and — for each occasion — the distance from that mean to the occasion’s span, folded to the single number the sort orders on.
const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
const eventDist = (e, mean) => { if(mean == null) return 0;
const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
return mean < s ? s - mean : mean > en ? mean - en : 0; };
The move is owner-scoped, on both sides — a calendar belongs to one person, a search reads each photo’s own owner’s. Applying it touches only a doc whose owner holds the picked occasion, leaving anyone else’s where they are.
@testcase
def test_move_is_owner_scoped(page):
"""Moving onto an occasion touches only docs whose owner holds it; another owner's is left."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-ownmove", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzOwnMove", "owner": "konubinix", "status": "confirmed"} # konubinix's occasion
K = {"cid": "https://ipfs.konubinix.eu/p/zzomv-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzomv-k-t", "owner": "konubinix"}
A = {"cid": "https://ipfs.konubinix.eu/p/zzomv-a", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzomv-a-t", "owner": "aylapomme"}
for d in (K, A): d.update({"mimetype": "image/jpeg", "labels": "zzownmove", "state": "todo"})
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in (K, A): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzownmove")
expect(tiles(page)).to_have_count(2)
select_all(page).click()
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzOwnMove", delay=20)
tb.get_by_role("option", name=re.compile("zzOwnMove")).click()
tb.get_by_role("button", name="move to event").click()
expect(checks(page)).to_have_count(0)
# konubinix's photo took the occasion's day; aylapomme's kept its stray date
expect(grid(page).get_by_role("img", name="2020-03-07")).to_have_count(1)
expect(grid(page).get_by_role("img", name="2024-11-02")).to_have_count(1)
finally:
for d in (K, A): gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move is owner-scoped")
And the list offers only the selection’s own owners’ occasions, so a namesake in someone else’s calendar — which could never apply — is never even shown, and a pick can’t land on a silent no-op.
@testcase
def test_move_offers_only_selection_owner_events(page):
"""The move box lists only occasions of the selection's own owners — a namesake owner's is not."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
evs = [{"rowId": "zzev-scmk", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzScopeKonu", "owner": "konubinix", "status": "confirmed"},
{"rowId": "zzev-scma", "starttime": "2020-03-08T09:00:00Z", "endtime": "2020-03-08T18:00:00Z",
"summary": "zzScopeAyla", "owner": "aylapomme", "status": "confirmed"}]
doc = {"cid": "https://ipfs.konubinix.eu/p/zzscm-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscm-k-t",
"mimetype": "image/jpeg", "labels": "zzscopemove", "owner": "konubinix", "state": "todo"}
for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzscopemove")
expect(tiles(page)).to_have_count(1)
select_all(page).click() # the one konubinix doc
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzScope", delay=20)
expect(tb.get_by_role("option", name=re.compile("zzScopeKonu"))).to_be_visible() # own owner's — offered
expect(tb.get_by_role("option", name=re.compile("zzScopeAyla"))).to_have_count(0) # another owner's — not
finally:
gql(DELETE, {"cid": doc["cid"]})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: move offers only selection-owner events")
const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
Now the list itself. As you type, the box narrows the calendar to the matching occasions: those whose summary contains the text, kept to the selection’s own owners, and ordered by the closeness above — the eight nearest shown, so a long calendar never floods the box.
const moveCandidates = () => { const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
return (moveEvents() || []).filter(e => owners.has(e.owner))
.filter(e => !q || (e.summary || '').toLowerCase().includes(q))
.sort((a, b) => eventDist(a, mean) - eventDist(b, mean) || new Date(a.starttime) - new Date(b.starttime))
.slice(0, 8); };
Picking one arms that exact occasion and closes the list; applying then gives every selected doc the occasion’s own start — its first day — through the same per-doc patch a batch label uses, so the wall re-anchors and the moved photos slide into the band.
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
setMoveText(''); setMoveTarget(null); };
In the toolbar the box is a combobox like the others, and its keyboard is theirs too: the one
shared completion handler arrows through the rows and Enter picks the highlighted occasion,
exactly as in the search and label boxes; with nothing highlighted Enter applies the armed
pick instead, the same as the → event button beside it. For that shared handler to see them,
the box hands its candidates up to the single keyboard-highlight the whole app keeps.
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
Each row names the occasion and, beside it, when it ran (the same when aside the lightbox
reads off a pill), so a recurring occasion is told apart by its date; the highlighted row is
marked as the shared index moves over it.
<div class="complete">
<input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
value=${() => moveText()}
onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
onFocus=${() => setMoveFocus(true)}
onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
A move fires only from an explicit pick. The → event button stays disabled until a pick
arms it — so an unpicked box, however fully typed, can never fire it — and editing the name
after picking disables it again, waiting for a fresh pick.
@testcase
def test_move_button_armed_only_by_a_pick(page):
"""The → event button is disabled until a pick arms it; typed text never enables it, and editing disables it again."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-arm", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzArmEvent", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzarm-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzarm-{i}-t",
"mimetype": "image/jpeg", "labels": "zzarm", "owner": "konubinix", "state": "todo"} for i in range(3)]
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzarm")
expect(tiles(page)).to_have_count(3)
select_all(page).click()
expect(checks(page)).to_have_count(3)
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
move_btn = tb.get_by_role("button", name="move to event")
expect(move_btn).to_be_disabled() # nothing picked yet
box.click(); box.press_sequentially("zzArm", delay=20)
expect(move_btn).to_be_disabled() # typed text is not a pick
tb.get_by_role("option", name=re.compile("zzArmEvent")).click()
expect(move_btn).to_be_enabled() # a pick arms it
box.press("x") # edit the name → the pick drops
expect(move_btn).to_be_disabled() # disabled again, until a fresh pick
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move button armed only by a pick")
A pick need not be a click. Arrowing down to an occasion and pressing Enter is a pick just the
same — the keyboard route the box shares with every other completion box — so it arms the
→ event button no differently.
@testcase
def test_move_box_keyboard_picks(page):
"""Arrow keys highlight a move-box occasion and Enter picks it, arming the move — no mouse."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-kbd", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzKbdEvent", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzkbd-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzkbd-{i}-t",
"mimetype": "image/jpeg", "labels": "zzkbd", "owner": "konubinix", "state": "todo"} for i in range(3)]
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzkbd")
expect(tiles(page)).to_have_count(3)
select_all(page).click()
expect(checks(page)).to_have_count(3)
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
move_btn = tb.get_by_role("button", name="move to event")
box.click(); box.press_sequentially("zzKbd", delay=20)
opt = tb.get_by_role("option", name=re.compile("zzKbdEvent"))
expect(opt).to_be_visible() # the list is open
expect(move_btn).to_be_disabled() # nothing picked yet
box.press("ArrowDown") # highlight the first candidate
expect(opt).to_have_attribute("aria-selected", "true") # the arrow moved the shared highlight onto it
box.press("Enter") # Enter picks the highlighted one → arms
expect(move_btn).to_be_enabled()
expect(box).to_have_value("zzKbdEvent") # the pick filled the box
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move box keyboard picks")
A pick is also bound to the selection it was made against: touching the selection drops the armed pick — the box clears, ready for a fresh pick — so a stale pick can never move a set you have since changed.
@testcase
def test_move_target_resets_on_selection_change(page):
"""Changing the selection drops an armed pick — the box clears, so a stale pick can't move a new set."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-resetmove", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzResetMove", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzrst-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzrst-{i}-t",
"mimetype": "image/jpeg", "labels": "zzresetmv", "owner": "konubinix", "state": "todo"} for i in range(3)]
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzresetmv")
expect(tiles(page)).to_have_count(3)
select_all(page).click()
expect(checks(page)).to_have_count(3) # all three selected
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzReset", delay=20) # a prefix, so a pick visibly completes it
tb.get_by_role("option", name=re.compile("zzResetMove")).click()
expect(box).to_have_value("zzResetMove") # the pick registered — the occasion is armed
tiles(page).nth(0).click() # drop one tile → the selection changes
expect(box).to_have_value("") # the armed pick is dropped, the box cleared
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move target resets on selection change")
createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
The occasion you move onto usually has no photo of its own yet — assigning photos to it is
the whole point — and a plain label search carries no date window for the wall to hang events
on. Both are why the box reads the calendar itself rather than the wall’s window of events: even
a photoless occasion, on a windowless search, is there to pick. (The search box’s event:
completion, which offers only occasions that already hold a photo, would hide exactly this one.)
@testcase
def test_move_offers_photoless_event(page):
"""The move box offers an occasion with no photos yet, on a windowless search — the point is filling it."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-empty", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzEmptyOcc", "owner": "konubinix", "status": "confirmed"} # no photo falls in its span
doc = {"cid": "https://ipfs.konubinix.eu/p/zzem-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzem-k-t",
"mimetype": "image/jpeg", "labels": "zzemptymove", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzemptymove") # a bare label — no since:/until: window
expect(tiles(page)).to_have_count(1)
select_all(page).click()
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzEmptyOcc", delay=20)
expect(tb.get_by_role("option", name=re.compile("zzEmptyOcc"))).to_be_visible() # offered though photoless
finally:
gql(DELETE, {"cid": doc["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move offers photoless event")
The box mirrors the batch label box’s styling; the row list reuses the shared completion look
(.suggest / .sug) and the pill’s muted when aside, and the → event button dims while
no pick is armed.
.batch-move{ width:100%; box-sizing:border-box; padding:5px 8px; font-size:14px;
background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:5px; }
.movebtn:disabled{ opacity:.45; cursor:default; }
Stamping a selection with one time
Photos that arrive re-shared — a batch off WhatsApp — carry a junk timestamp: the moment they
were forwarded, not the moment they were shot. So a pile of them scatters across the wall, each
landing on a day that never happened. There is no true instant to recover, so the useful move is
to pin the whole pile to one plausible moment — a given day, one hour — and let them sit
together. The lightbox already re-dates one doc; this does the same to every ticked doc at once,
through the same per-doc patchSelected. The picker seeds from the selection’s mean date, so
you begin in the middle of what you’re fixing and nudge to the day you mean.
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
await patchSelected(() => ({ date: new Date(v).toISOString() }));
setDatingSel(false); };
The control cannot simply sit in the toolbar. The bar overlays the wall’s foot, and a
double-tap opens a doc — its first tap selects, which is what mounts the bar. A permanently-shown
datetime-local is wide enough to wrap the bar onto a second row on a phone, and that taller
overlay swallows the double-tap’s second tap before it reaches the tile. So the picker hides
behind one compact button and opens in a popover above the bar — out of the bar’s own flow —
only when asked; closed, the bar keeps the single height the double-tap depends on.
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</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. Two shots
dated years apart, ticked together and stamped to one midday (noon, so no timezone drags the
instant onto an adjacent day), then drop out of a search for their old year and turn up under the
stamped day.
@testcase
def test_batch_date_stamps_selection(page):
"""One chosen instant lands on every ticked doc — for a pile of junk-timestamped shots."""
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbd-a", "date": "2019-03-01T08:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbd-a-t"},
{"cid": "https://ipfs.konubinix.eu/p/zzbd-b", "date": "2019-11-22T19:30:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbd-b-t"}] # two junk dates, far apart
for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzbatchdate", "owner": "konubinix", "state": "todo"})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzbatchdate")
expect(tiles(page)).to_have_count(2)
select_all(page).click()
tb = toolbar(page)
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2020-06-15T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # selection cleared → the bar dismisses itself
search_for(page, "zzbatchdate; date:2019") # dropped out of the old year: 2 → 0
expect(tiles(page)).to_have_count(0)
search_for(page, "zzbatchdate; date:2020-06-15") # and turns up under the stamped day: 0 → 2
expect(tiles(page)).to_have_count(2)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: batch date stamps the selection")
The picker mirrors the bar’s other controls; the popover reuses the upward-opening shape the
label and move completions use, and the → date button dims until a moment is set.
.batch-date{ position:relative; }
.date-pop{ position:absolute; bottom:100%; left:0; margin:0 0 4px; z-index:26;
display:flex; gap:6px; padding:6px; background:#11131f;
border:1px solid #3a3f5a; border-radius:6px; }
.date-pop input{ padding:5px 8px; font-size:14px; background:#262a40; color:var(--fg);
border:1px solid #3a3f5a; border-radius:5px; }
.datestamp:disabled{ opacity:.45; cursor:default; }
Across the app
A few concerns run through every surface at once: the room the phone’s own bars leave, colour, the keyboard, where the view comes to rest, and quick reach for the label box.
Room left by the phone’s bars
Standing back from the strips is not a courtesy one surface pays: a control drawn under a bar
is a control no thumb reaches, whichever surface put it there. So take a phone showing both of
them and walk the app. A desktop Chromium reports no insets at all, so the phone has to be
asked for: Emulation.setSafeAreaInsetsOverride is what makes env() answer with a status bar
and a navigation bar, and the band between them is what everything below has to stay inside.
page.set_viewport_size({"width": 360, "height": 640})
TOP, BOT = 28, 48 # a status bar, and a three-button navigation bar
page.context.new_cdp_session(page).send(
"Emulation.setSafeAreaInsetsOverride",
{"insets": {"top": TOP, "left": 0, "right": 0, "bottom": BOT}})
floor = page.viewport_size["height"] - BOT
The wall comes first, and its title is what sits nearest the top edge — what it clears, the status bar gave back.
title = heading(page).bounding_box()
assert title["y"] >= TOP, f"the title sits under the status bar: {title}"
At the other edge the selection toolbar is pinned to the very foot, and it is a wide, wrapping
bar — so it is not enough for the one control you thought of to clear the strip; the download
buttons and the clear at its far end have to as well, and they are the ones that sit lowest.
tiles(page).nth(0).click() # one tap selects → the wall's toolbar
all_clear("the selection toolbar", toolbar(page))
tiles(page).nth(0).click() # drop the selection again
Then the open doc — where the stakes are highest, since nothing there scrolls past a bar.
open_doc(page)
all_clear("the open doc", dialog(page))
page.keyboard.press("Escape")
And the frame, whose bar floats just above that same foot.
page.get_by_role("button", name=re.compile("frame", re.I)).click()
page.get_by_role("list", name="slideshow").click() # a tap brings the bar up
all_clear("the frame bar", page.get_by_role("toolbar", name="frame actions"))
print(" PASS: controls clear the system bars")
A colour per state, everywhere it shows
A doc’s state earns its colour wherever it appears, not only in the frame: one source defines the four state hues and every surface reads it, so the eye learns the code once — on the wall’s chips, the batch toolbar, and the open doc alike.
On the wall, the filter chips wear those same hues, so the state you can switch to reads at a
glance (the neutral all chip keeps its plain look).
make_fixtures()
open_app(page)
expect(tiles(page).first).to_be_visible()
hue = lambda loc: loc.evaluate("el => getComputedStyle(el).color")
pill_hues = lambda scope: [hue(scope.get_by_role("button", name=st, exact=True))
for st in ("todo", "next", "done", "delete")]
chip_hues = pill_hues(filters(page))
assert len(set(chip_hues)) == 4, f"chip state pills want distinct colours, got {chip_hues}"
The batch toolbar’s state buttons carry the same hues, so setting a selection’s state speaks the same colour vocabulary.
select_all(page).click() # the selection toolbar appears
expect(toolbar(page)).to_be_visible()
tb_hues = pill_hues(toolbar(page))
assert len(set(tb_hues)) == 4, f"toolbar state pills want distinct colours, got {tb_hues}"
select_all(page).click() # clear, so the next leg opens a doc cleanly
And the open doc’s own state buttons, in the lightbox.
open_doc(page, 0)
expect(dialog(page)).to_be_visible()
lb_hues = pill_hues(dialog(page))
assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"
@testcase
def test_state_pills_colour_coded(page):
make_fixtures()
open_app(page)
expect(tiles(page).first).to_be_visible()
hue = lambda loc: loc.evaluate("el => getComputedStyle(el).color")
pill_hues = lambda scope: [hue(scope.get_by_role("button", name=st, exact=True))
for st in ("todo", "next", "done", "delete")]
chip_hues = pill_hues(filters(page))
assert len(set(chip_hues)) == 4, f"chip state pills want distinct colours, got {chip_hues}"
select_all(page).click() # the selection toolbar appears
expect(toolbar(page)).to_be_visible()
tb_hues = pill_hues(toolbar(page))
assert len(set(tb_hues)) == 4, f"toolbar state pills want distinct colours, got {tb_hues}"
select_all(page).click() # clear, so the next leg opens a doc cleanly
open_doc(page, 0)
expect(dialog(page)).to_be_visible()
lb_hues = pill_hues(dialog(page))
assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"
print(" PASS: state pills colour coded")
Colour tells you what a button is; a press tells you it took. On a touchscreen a tap that
changes state runs a round-trip, and with nothing to show for the tap the finger tends to go again
— so every state button, and the lightbox’s ‹ / ›, depresses the instant it is pressed, a
quick scale felt before the result lands. It rides on :active, so no button is wired for it by
hand.
@testcase
def test_buttons_acknowledge_a_press(page):
"""Press-and-hold a nav button and a state button; each control's transform changes under :active."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
def held_vs_rest(btn):
bb = btn.bounding_box()
page.mouse.move(bb["x"] + bb["width"] / 2, bb["y"] + bb["height"] / 2)
rest = btn.evaluate("el => getComputedStyle(el).transform")
page.mouse.down()
held = btn.evaluate("el => getComputedStyle(el).transform") # :active while the pointer is held
page.mouse.up()
return rest, held
rest, held = held_vs_rest(d.get_by_role("button", name="next photo")) # the › nav
assert held != rest, f"the nav gives no press feedback: rest={rest} held={held}"
rest, held = held_vs_rest(d.get_by_role("button", name="done", exact=True)) # a state button
assert held != rest, f"the state button gives no press feedback: rest={rest} held={held}"
print(" PASS: buttons acknowledge a press")
Driving the wall from the keyboard
Triage is a two-handed rhythm — glance, judge, move on — and reaching for the mouse between every photo breaks it. The wall already answers a click and a hold, but a reviewer running down a folder of photos wants what every file manager gives: a focused tile the eye can follow, moved with the arrow keys. So the grid grows a cursor.
The arrows walk it across the wall — left and right by one tile, down and up by a whole
row — and Enter opens the focused doc in the lightbox (Escape leaves it, as it
already does).
page.keyboard.press("ArrowRight") # the first arrow focuses the first tile
page.keyboard.press("ArrowRight") # → the second
page.keyboard.press("Enter") # Enter opens the focused doc
d = dialog(page)
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
print(" PASS: grid cursor opens focused doc")
And Space toggles the focused tile’s selection — the keyboard twin of a click.
expect(checks(page)).to_have_count(0) # nothing picked by walking about
page.keyboard.press(" ") # Space picks the focused one
expect(checks(page)).to_have_count(1)
page.keyboard.press(" ") # Space again unpicks it
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor space toggles selection")
A click plants the cursor on the tile it touched, so the arrows carry on from there rather than snapping back to the wall’s first tile.
tiles(page).nth(5).click() # reach for the mouse for one tile
page.keyboard.press("ArrowRight") # the arrows carry on from there
page.keyboard.press("Enter") # open the now-focused tile
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(6))
page.keyboard.press("Escape")
toolbar(page).get_by_role("button", name="clear").click() # drop what the click picked up
expect(checks(page)).to_have_count(0)
print(" PASS: cursor starts at last clicked tile")
Holding Shift while arrowing selects: the selection becomes the contiguous run from the
anchor to the cursor, so arrowing away grows it — 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.
@testcase
def test_cursor_and_selection_survive_reload(page):
"""The picked run and the keyboard cursor outlast a reload, like the search does."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
tiles(page).nth(0).click() # pick tile 0 — the click plants the cursor there
page.keyboard.press("Shift+ArrowRight") # extend the run to tile 1; the cursor lands on it
expect(checks(page)).to_have_count(2)
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(tiles(page)).to_have_count(3) # the saved search brings the same wall back
expect(checks(page)).to_have_count(2) # the picked run survived
page.keyboard.press("Enter") # the cursor survived on tile 1 → opens it
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: cursor and selection survive reload")
The cursor follows what you’re viewing
The wall’s keyboard cursor marks where you are. But dive into a doc in the lightbox — or step into the frame — and move through the wall in place, and the cursor is left behind on the tile you started from: come back and the arrows resume there, not on the doc you actually stopped on. So the cursor rides along, tracking whatever doc you are looking at, and leaving either surface lands you back on it.
@testcase
def test_lightbox_nav_follows_cursor(page):
"""Stepping through the lightbox carries the wall cursor along: close it and the
cursor rests on the doc you stopped on, not the one you opened."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
open_doc(page, 0) # double-click opens the first
d = dialog(page)
d.get_by_role("button", name="next photo").click() # step to the second doc
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.keyboard.press("Escape")
expect(d).to_be_hidden()
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: lightbox nav follows cursor")
The frame is the same story: run the slideshow on a bit, leave it, and the cursor is on the slide you stopped on — ready for the arrows to carry on.
@testcase
def test_frame_nav_follows_cursor(page):
"""Moving through the frame carries the wall cursor: exit and the cursor rests on
the slide you stopped on."""
strip = enter_frame(page, 999999) # 3 fixtures, frame opens on thumb-0 (date order)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.press("ArrowRight") # advance to the second slide
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
wait_until(page, lambda: page.evaluate( # the slide has settled (its cid committed) — as when you pause on it
"() => localStorage.getItem('memories.frame.cid')") == "https://ipfs.konubinix.eu/p/zzbatchfix-1")
page.keyboard.press("Escape") # leave the frame → back to the wall
expect(strip).to_be_hidden()
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: frame nav follows cursor")
A cursor riding off-screen would be no help — you’d leave the surface and land on a wall scrolled to where you started, hunting for the tile you actually stopped on. So the wall scrolls to keep that cursor tile in view: navigate deep into a spread from the lightbox, and leaving it lands the wall on your tile, not at the top.
IN_VIEW = "el => { const r = el.getBoundingClientRect(); return r.top < window.innerHeight && r.bottom > 0; }"
@testcase
def test_wall_scrolls_to_lightbox_cursor(page):
"""Stepping the lightbox scrolls the wall to the cursor; leaving lands on that tile, in view."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}", "date": f"2020-02-{i + 1:02d}T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}-t", "webCid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}-web",
"labels": "zzlbscroll", "state": "todo"} for i in range(18)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 480}) # narrow + short: the 18-tile wall overflows
open_app(page); chip(page, "all").click()
search_for(page, "zzlbscroll")
expect(tiles(page)).to_have_count(18)
assert not tiles(page).nth(17).evaluate(IN_VIEW), "setup: the last tile must start below the fold"
open_doc(page, 0) # open the first (wall at top)
expect(dialog(page)).to_be_visible() # wait for the lightbox before driving it
page.keyboard.press("ArrowLeft") # wrap to the last doc, far down the wall
page.keyboard.press("Escape")
expect(dialog(page)).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(17).evaluate(IN_VIEW),
label="the wall scrolled the cursor tile into view")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: wall scrolls to lightbox cursor")
The frame scrolls the wall the same way: step the show far along, leave it, and the wall lands on the slide you stopped on — in view.
@testcase
def test_wall_scrolls_to_frame_cursor(page):
"""The frame does the same, through the same follow: stepping the show scrolls the wall to the slide."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzfrscroll-{i:02d}", "date": f"2020-03-{i + 1:02d}T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfrscroll-{i:02d}-t", "labels": "zzfrscroll", "state": "todo"} for i in range(18)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 480})
open_app(page); chip(page, "all").click()
search_for(page, "zzfrscroll")
expect(tiles(page)).to_have_count(18)
assert not tiles(page).nth(17).evaluate(IN_VIEW), "setup: the last tile must start below the fold"
page.get_by_role("button", name=re.compile("frame", re.I)).click() # into the slideshow
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: bool(page.evaluate("() => localStorage.getItem('memories.frame.cid')")),
label="the show settled on its opening slide")
init = page.evaluate("() => localStorage.getItem('memories.frame.cid')")
page.keyboard.press("ArrowLeft") # wrap to the last slide
wait_until(page, lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')") != init,
label="the show settled on the far slide")
page.keyboard.press("Escape") # leave the show
expect(strip).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(17).evaluate(IN_VIEW),
label="the wall scrolled to the slide we stopped on")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: wall scrolls to frame cursor")
The doc you are looking at is opened() while the lightbox is up, and otherwise the
frame’s centred slide. Every move on either surface updates one of those — the ‹/›
buttons, the arrows, a swipe, a slideshow step — so a single effect mirroring whichever
is live onto the cursor — and scrolling that cursor’s tile into view on the wall behind —
covers them all. With neither surface open it has nothing to mirror and leaves the cursor
untouched, right where you last left it. One thing would silently undo that scroll: leaving
either surface rides on history.back(), and the browser’s own scroll restoration then
snaps the window back to where it stood when the surface opened. So the app turns
restoration off and owns the wall’s scroll itself — the follow is the authority on where
the wall stands.
history.scrollRestoration = 'manual'; // Back must not undo the follow (see prose)
createEffect(() => { const c = opened()?.cid || (frame() ? frameCenterCid() : null); if(!c) return;
setCursor(c);
gridEl?.children[items().findIndex(p => p.cid === c)]?.scrollIntoView({ block: 'nearest' }); });
Jumping to the label box
Selecting a run of photos and then dragging the mouse all the way to the label field
breaks the keyboard flow the cursor just built. So l — for label — jumps straight to
the add-label box: the lightbox’s when a doc is open, or the selection toolbar’s when a
batch is waiting on the wall. Hands stay on the keys — select with Shift+→, press l,
type the word; or press . to drop in the label you used last and just hit Enter.
With a doc open, l focuses its add-label box.
@testcase
def test_label_shortcut_focuses_lightbox_box(page):
"""In the lightbox, pressing l jumps focus to the add-label box, ready to type."""
open_fixtures(page)
open_doc(page, 0)
box = dialog(page).get_by_placeholder("add a label…")
expect(box).not_to_be_focused()
page.keyboard.press("l")
expect(box).to_be_focused()
expect(box).to_have_value("") # l opened the box; it didn't type into it
print(" PASS: label shortcut focuses lightbox box")
On the wall, with a selection up, l focuses the toolbar’s label box instead.
@testcase
def test_label_shortcut_focuses_batch_box(page):
"""On the wall, with a selection up, l jumps focus to the toolbar's label box."""
open_fixtures(page)
tiles(page).nth(0).click() # select one → the toolbar appears
box = toolbar(page).get_by_placeholder("add a label…")
page.keyboard.press("l")
expect(box).to_be_focused()
expect(box).to_have_value("")
print(" PASS: label shortcut focuses batch box")
. — repeat — goes one further: it fills that same box with the label applied last and
focuses it, so a run of photos can take the same tag without retyping. With a doc open,
. refills the lightbox’s box.
@testcase
def test_label_repeat_fills_lightbox_box(page):
"""In the lightbox, '.' refills the add-label box with the label applied last."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
box = d.get_by_placeholder("add a label…")
box.click(); box.fill("zzrep"); box.press("Enter") # apply → remembered as last
expect(d.get_by_role("button", name="zzrep", exact=True)).to_be_visible()
box.blur() # leave the field so '.' is a shortcut
page.keyboard.press(".")
expect(box).to_be_focused()
expect(box).to_have_value("zzrep") # refilled, ready to commit again
print(" PASS: label repeat fills lightbox box")
And on the wall, with a selection up, . refills the toolbar’s box the same way.
@testcase
def test_label_repeat_fills_batch_box(page):
"""On the wall, '.' refills the toolbar's label box with the label applied last."""
open_fixtures(page)
tiles(page).nth(0).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.fill("zzrep"); box.press("Enter") # apply to one → remembered, selection clears
tiles(page).nth(1).click() # select another → toolbar back
page.keyboard.press(".")
box2 = toolbar(page).get_by_placeholder("add a label…")
expect(box2).to_be_focused()
expect(box2).to_have_value("zzrep")
print(" PASS: label repeat fills batch box")
A small window listener routes l and . to whichever box is live — the open lightbox’s,
else the selection toolbar’s — and stands aside while a field already has focus or the
frame is up. l just focuses; . first drops in lastLabel, the word any earlier
apply (here or in a batch) left behind. The preventDefault keeps that keystroke from
landing in the box it just opened.
onMount(() => {
const onKey = e => {
if((e.key !== 'l' && e.key !== '.') || frame()) return; // the frame has its own bar
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
const box = opened() ? document.querySelector('.lb .batch-label')
: selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
if(!box) return;
e.preventDefault();
if(e.key === '.' && lastLabel()) // '.' re-drops the label applied last
(opened() ? setLbText : setLabelText)(lastLabel());
box.focus();
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
Installable, fullscreen (PWA)
On a phone the wall wants the whole screen. A web-app manifest (display:fullscreen,
an SVG icon, the app’s dark theme_color) plus a service worker make it installable to
the home screen and launchable chrome-free. The worker earns its keep beyond that: it makes
launches instant and keeps the app current after a deploy — without the hard-reload a phone
makes painful. It caches only its own shell; the live data (/graphql) and the archive’s
media (/ipfs/) stay on the network.
The test can’t drive a real install, so it asserts the observable scaffolding: the
manifest is linked and declares fullscreen, and the service worker reaches ready.
@testcase
def test_pwa_installable(page):
"""The app ships a fullscreen manifest and a registered service worker."""
open_app(page)
assert page.locator("link[rel='manifest']").get_attribute("href"), "no manifest linked"
man = page.evaluate("() => fetch('manifest.json').then(r => r.json())")
assert man["display"] == "fullscreen", f"display is {man.get('display')!r}"
assert man["icons"], "manifest has no icons"
ready = page.evaluate("""() => Promise.race([
navigator.serviceWorker.ready.then(r => !!r.active),
new Promise(res => setTimeout(() => res(false), 6000))])""")
assert ready, "service worker did not become ready"
print(" PASS: pwa installable")
Its cache is keyed to the build, and on activate it drops caches left by any other build —
so after a deploy the next open finds the stale cache gone and re-fetches the fresh files. That
re-fetch reaches past the browser’s own HTTP cache (the very thing a hard-reload exists to
bypass), so the new build lands with an ordinary open — no hard-reload.
@testcase
def test_sw_drops_stale_version_cache(page):
"""On activate the worker deletes caches from other builds, so a new build isn't served stale."""
open_app(page)
page.evaluate("() => navigator.serviceWorker.ready")
# seed a cache from a different build, then unregister so the next load re-installs fresh
page.evaluate("""async () => {
await (await caches.open('memories-stale')).put('/x', new Response('x'));
for (const r of await navigator.serviceWorker.getRegistrations()) await r.unregister();
}""")
open_app(page) # re-register → fresh install/activate purges other builds
page.evaluate("() => navigator.serviceWorker.ready")
wait_until(page, lambda: "memories-stale" not in page.evaluate("() => caches.keys()"),
label="stale-version cache purged on activate")
print(" PASS: sw drops stale version cache")
Within a build it serves the shell cache-first — an instant launch that survives a flaky link.
@testcase
def test_sw_serves_from_cache(page):
"""Within a build the worker serves the shell from its cache, not the network."""
open_app(page)
page.evaluate("() => navigator.serviceWorker.ready")
ver = "memories-" + (page.locator(".build-tag").text_content() or "").strip() # the SW's cache name
page.evaluate(f"""() => caches.open({ver!r}).then(c => c.put('manifest.json',
new Response('{{\\"display\\":\\"CACHED\\"}}', {{headers:{{'content-type':'application/json'}}}})))""")
display = page.evaluate("() => fetch('manifest.json').then(r => r.json()).then(m => m.display)")
assert display == "CACHED", f"served network instead of cache: {display!r}"
print(" PASS: sw serves from cache")
{
"name": "Memories",
"short_name": "Memories",
"start_url": ".",
"scope": ".",
"display": "fullscreen",
"background_color": "#1b1d2e",
"theme_color": "#1b1d2e",
"icons": [
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#1b1d2e"/>
<g fill="#6cf">
<rect x="120" y="120" width="120" height="120" rx="14"/>
<rect x="272" y="120" width="120" height="120" rx="14"/>
<rect x="120" y="272" width="120" height="120" rx="14"/>
<rect x="272" y="272" width="120" height="120" rx="14"/>
</g>
</svg>
const CACHE = 'memories-nil';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil((async () => {
for(const k of await caches.keys()) if(k !== CACHE) await caches.delete(k);
await self.clients.claim();
})()));
self.addEventListener('fetch', e => {
const req = e.request, url = new URL(req.url);
if(req.method !== 'GET' || url.origin !== location.origin
|| url.pathname.includes('/graphql') || url.pathname.includes('/ipfs/')) return;
e.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(req);
if(hit) return hit;
const res = await fetch(req.url, { cache: 'reload' });
if(res.ok) cache.put(req, res.clone());
return res;
})());
});
Annexe
What a sharpened wall holds
The refined wall draws two pictures per tile, and a picture unpacked for drawing costs four bytes a pixel however little it weighed compressed. Measured on the same sample as the byte figures, a thumbnail is 0.045 Mpx — 256px on its long side — and a rendition 0.79 Mpx, cut to fit 1024px: some eighteen times the pixels, where it is only five to twelve times the bytes.
How many of those pixels a browser actually keeps is not settled here. It may unpack a rendition at the size the file was written, or at the size the tile draws it, and the two lead to opposite readings of what happens as the cells grow. Both, though, land on the same ceiling.
Unpacking at the file’s size, a fold of cells c wide holds area-over-c-squared tiles of 0.18 + 3.15 MB against the dense wall’s tiles of 0.18 MB at 96px, so the ratio is (96/c)² × 18.5 — 1.9 at the 300px threshold, level by 400px, and falling after that. The threshold is the worst of it, and the wall gets lighter the further past it you go.
Unpacking at the drawn size instead, the arithmetic collapses: tiles times cell area is just the fold’s area, so a fold holds its own area in pixels once per layer, whatever the cells measure. Two layers is then twice one, at every size, and growing the tiles changes nothing.
Neither reading is a bound on the other — at cells past 256px the thumbnail layer is upscaled, so drawn-size unpacking holds more for it than its file would suggest, while for the dense wall it holds less. What survives both is a ceiling: about twice what the dense wall already held. The reach window was drawn around a single fold of thumbnails, so this is that bound doubled — bounded rather than free, and what a device actually holds at that ceiling is a reading still to be taken.
Notes linking here
- 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