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
- Typing a word you only half-know
- Working the wall without the mouse
- 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.
@testcase
def test_the_cabinet_plays_on_its_own(page):
"""A tablet left on the cabinet: it advances by itself, yields its controls to a
passer-by, and comes back where it was after a relaunch."""
make_fixtures()
open_app(page, CABINET_QS)
search_for(page, FIXTURE_LABEL)
chip(page, "all").click()
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
bar = page.get_by_role("toolbar", name="frame actions")
for src in ["thumb-0", "thumb-1", "thumb-2"]: # date order
wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
print(" PASS: frame auto-advances")
strip.click() # a tap reveals the frame bar
expect(bar).to_be_visible()
colours = [bar.get_by_role("button", name=st, exact=True).evaluate("el => getComputedStyle(el).color")
for st in ["todo", "next", "done", "delete"]]
assert len(set(colours)) == 4, f"each state pill should have its own colour, got {colours}"
print(" PASS: frame state pills are colour coded")
def shown(k): # the date the bar should be reading, as the browser renders it
return page.evaluate("d => new Date(d).toLocaleString('fr-FR')", FIXTURES[k]["date"])
i = centred_slide(strip) # whichever slide the show stopped on
expect(bar.locator(".frame-date")).to_have_text(shown(i))
page.keyboard.press("ArrowRight") # step to the next slide
j = (i + 1) % len(FIXTURES)
wait_until(page, lambda: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{j}")
expect(bar.locator(".frame-date")).to_have_text(shown(j)) # follows the slide
print(" PASS: frame shows date")
expect(bar).to_be_visible() # still up from the tap that raised it
expect(bar).to_be_hidden() # and gone once the window has run out
print(" PASS: frame bar auto-hides when idle")
strip.click() # bring the bar back up
expect(bar).to_be_visible()
pause = bar.get_by_role("button", name=re.compile("pause|play"))
for _ in range(3):
pause.click(); page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)
page.wait_for_timeout(CABINET_UI_IDLE_MS // 3) # now well past a window since the first press
expect(bar).to_be_visible() # still up: each press restarted the count
expect(bar).to_be_hidden() # now left alone → it finally hides
print(" PASS: frame bar idle resets on use")
if centred_slide(strip) == 0: # park it away from the cold-start slide
page.keyboard.press("ArrowRight")
wait_until(page, lambda: centred_slide(strip) != 0,
label="the show steps off the slide a cold start would pick")
i = centred_slide(strip)
was = strip.evaluate(CENTERED) # the slide it is showing when it goes down
remembered = lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')")
wait_until(page, lambda: remembered() == FIXTURES[i]["cid"],
label="the slide it is on is the one written down",
detail=lambda: f"written down: {remembered()}")
open_app(page, CABINET_QS) # reboot: a plain relaunch
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs it places from are in
page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
came_back_on = strip.evaluate(CENTERED) # read at once, not waited for
assert came_back_on == was, f"resumed on {came_back_on}, not the {was} it went down on"
print(" PASS: frame resumes position")
page.keyboard.press("Escape") # leaving turns the memory off
expect(strip).to_be_hidden()
open_app(page, CABINET_QS)
expect(page.get_by_role("list", name="slideshow")).to_be_hidden() # stays on the wall
print(" PASS: frame mode persists")
expect(tiles(page)).to_have_count(len(FIXTURES)) # the persisted query brought the wall back
page.get_by_role("button", name=re.compile("frame", re.I)).click() # set it going again → remembered
expect(page.get_by_role("list", name="slideshow")).to_be_visible()
open_app(page, CABINET_QS) # relaunch: remembered → auto-enters, no click
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
page.keyboard.press("Escape") # exit within this launch
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for a re-entry to fire
expect(strip).to_be_hidden() # once per launch — it doesn't
print(" PASS: frame autostart")
Somebody stops to look
The show is playing to an empty room and you walk up to it. From here on it is yours: you want to go back a couple, hold on one, run forward through a stretch you don’t care about. Sometimes that happens at a desk with arrow keys under your hands, and sometimes at the cabinet where the only instrument is a finger — the same show either way, so both have to drive it. The finger is the harder half, because one instrument has to say four things:
- a tap near an edge — step the show
- a tap in the middle — show me the controls
- two fingers at once — let me look closer
- one finger travelling — run me forward a stretch
Nothing distinguishes them but where they land, how many there are, and whether they move; read one as another and the show lurches when you meant to pause it.
Then you have seen what you came for and give the show back — the way out has to leave the app standing, not navigate off it — and once it is running unattended again it behaves as it did before you arrived: it yields the moment it is touched, and picks up again once you have gone.
So it wants a long strip — twelve years, one photo each, enough that a hard fling has somewhere to travel. A strip that loops carries a copy of its last photo before the first and a copy of its first after the last, so stepping off either end has somewhere to land; that padding is why the opening photo sits one along rather than at the very start.
It also wants two tempos. For the deliberate half, one wound so far down that nothing moves unless you move it; for the closing beat, one brisk enough to watch, with a short patience so its resumption can be seen rather than waited out. Two further spans are the suite’s own, both of them room for something to happen: one for a step already under way to finish before a reading is taken, and one in which a gesture that should have moved the show would have shown it.
SWIPE_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzswipe-{i}", "date": f"20{10+i:02d}-01-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzswipe-t-{i}",
"labels": "zzswipe", "state": "todo"} for i in range(12)]
STILL_QS = "?ms=999999" # a tempo no test will ever outwait
LIVE_MS, LIVE_RESUME_MS = 400, 2500
LIVE_QS = f"?ms={LIVE_MS}&idleresume={LIVE_RESUME_MS}"
LIVE_SETTLE_MS = 300
FRAME_STEP_GRACE_MS = 300
@testcase
def test_somebody_stops_to_look(page):
"""Taking the show over by hand — arrows and taps step it, two fingers don't, a
fling coasts under control — then giving it back and letting it run on."""
for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, STILL_QS)
search_for(page, "zzswipe")
expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > 0, label="the strip settles")
first = strip.evaluate(ON_SLIDE)
box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
page.keyboard.press("ArrowLeft") # off the front → the last doc
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
label="a step back off the first lands on the last",
detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
page.keyboard.press("ArrowRight") # off the end → the first
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame wraps both ways")
page.mouse.click(box["x"] + box["width"] / 2, midY) # centre third → the bar
expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
print(" PASS: frame centre tap reveals the bar")
page.keyboard.press("ArrowRight") # focus is on the body, not a control
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.keyboard.press("ArrowLeft") # back where we started
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame arrow steps off control")
page.mouse.click(box["x"] + box["width"] * 0.92, midY) # right third → forward
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.mouse.click(box["x"] + box["width"] * 0.08, midY) # left third → back
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame tap zones step the show")
x = box["x"] + box["width"] * 0.92 # the right third — a lone tap here would step forward
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": x - 20, "y": midY}, {"x": x + 20, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
page.wait_for_timeout(FRAME_STEP_GRACE_MS) # long enough for a step to show
assert strip.evaluate(ON_SLIDE) == first, "a two-finger gesture must not step the show"
print(" PASS: frame two-finger gesture does not step")
CONTROLLED_SLIDES = 4
before = strip.evaluate(ON_SLIDE)
hard_frame_flick(page, strip)
landed = strip.evaluate(ON_SLIDE)
assert landed - before <= CONTROLLED_SLIDES, \
f"the swipe flung {landed - before} slides on — past the controlled range"
print(" PASS: frame swipe does not overshoot")
CENTRED_PX = 20
w = strip.evaluate(SLIDE_W); rest = strip.evaluate("el => el.scrollLeft")
off = min(rest % w, w - (rest % w)) # distance to the nearest slide boundary
assert off < CENTRED_PX, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
print(" PASS: frame swipe settles centered")
page.go_back()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible() # still on the app, not gone
print(" PASS: frame exits on back")
# …and set it going again, unattended, at a tempo worth watching
open_app(page, LIVE_QS)
expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
box = strip.bounding_box()
opened = strip.evaluate(ON_SLIDE) # wherever the show resumed
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened, # …and it is advancing from there
label="the show is running before we touch it")
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) # a centre tap: real interaction, no nav
page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS) # let any in-flight step settle
held = strip.evaluate(ON_SLIDE)
page.wait_for_timeout(LIVE_RESUME_MS // 2) # several tempo ticks, still inside the resume span
assert strip.evaluate(ON_SLIDE) == held, f"interaction must stop the show; it drifted {held}→{strip.evaluate(ON_SLIDE)}"
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held) # quiet long enough → resumes on its own
print(" PASS: frame pauses on interaction")
finally:
for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]})
Fixing a date that came out wrong
A camera with a flat battery stamps everything with the day it was switched on, and a scan carries the day it was scanned. So a run of photos lands in the archive under a date that is plainly not theirs, and putting it right is triage work you do a photo at a time: open one, see what it says, correct it, and watch it move to where it belongs among the others.
Correcting is the easy half. The hard half is not correcting — reaching for the picker and thinking better of it has to leave the photo exactly as it was, and the key that opens the picker must not go off while you are typing a label or standing on the wall with nothing open at all. A date editor that fires by accident is worse than none, because a wrong date you introduced looks exactly like a wrong date the camera gave you.
The three fixtures are a month apart, so a date moved across them has somewhere visible to land. One span is the suite’s own: a save travels to the archive and back before the wall shows it, so claiming a save did not happen means waiting out the time one would have taken.
SAVE_GRACE_MS = 800
@testcase
def test_fixing_a_date(page):
"""A photo dated wrong: open it, reach for the picker (and back out of it), then
correct the date and watch the wall re-order."""
open_fixtures(page)
page.keyboard.press("d") # no doc open
open_doc(page, 0)
d = dialog(page)
expect(d.get_by_role("button", name="edit date")).to_be_visible() # opens in display mode…
expect(d.get_by_label("date", exact=True)).to_have_count(0) # …not the picker
print(" PASS: date shortcut ignored without a doc")
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_be_focused()
print(" PASS: date shortcut focuses editor")
want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
FIXTURES[0]["date"])
expect(d.get_by_label("date", exact=True)).to_have_value(want) # seeded with the stored instant, in local time
print(" PASS: date editor seeds current value")
unmoved = tile_dates(page) # the wall as it stands
box = d.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(d).to_be_visible() # still open
page.wait_for_timeout(SAVE_GRACE_MS) # room for a save to land
assert tile_dates(page) == unmoved, f"the backed-out date was written anyway: {tile_dates(page)}"
print(" PASS: date edit escape cancels")
d.get_by_placeholder("add a label…").click() # focus the label box
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_have_count(0) # the picker stayed shut
print(" PASS: date shortcut stands aside in a field")
d.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = d.get_by_label("date", exact=True)
box.fill("2020-12-15T12:00") # push it past the other two
box.press("Enter") # save
d.get_by_role("button", name="close").click() # back to the wall
expect(tiles(page)).to_have_count(len(FIXTURES))
# the tile's alt is its day (set straight from the date, no lazy load), so the edited
# doc — now the latest — sits last on the date-sorted wall.
expect(thumb_imgs(page).nth(2)).to_have_attribute("alt", "2020-12-15")
print(" PASS: lightbox edit date re-orders")
Putting words on a photo
Labelling is the whole of triage, really — the state chips say how far along a photo is, but the labels are what make it findable again in ten years. So this is the session that runs most: open a photo, put the words on it, move to the next.
What makes it bearable is that a run of photos usually wants the same words. Typing
vacances 2019 onto forty photos one letter at a time is how a person gives up
halfway, so the box takes several at once, the keyboard does add and remove without
reaching for a chip’s ×, and the word you just used is offered on the next photo for
a single tap. And the archive has to actually keep them: a label that shows as a chip
but was never written is worse than no label, because you will not go back and check.
@testcase
def test_putting_words_on_a_photo(page):
"""The labelling run: several words at once, the keyboard for add and remove, the
last word reused on the next photo, and all of it written down."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
expect(d).to_be_visible()
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20) # one existing + two new, typed
box.press("Enter")
expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1) # not duplicated
print(" PASS: lightbox adds several labels")
box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible() # added
box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0) # removed
print(" PASS: lightbox enter adds, shift-enter removes")
box.fill("zzreuse"); box.press("Enter")
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # applied here
d.get_by_role("button", name="next photo").click()
reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
expect(reuse).to_be_visible() # offered on the next
reuse.click()
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # reused, no retype
print(" PASS: lightbox reuse last label")
box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
d.get_by_role("button", name="close").click()
search_for(page, "lbadded") # the word put on: the photo answers
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # the word taken off: it no longer does
expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
print(" PASS: lightbox edits labels")
Saying no, quickly
Most of a burst is not worth keeping. Eleven near-identical shots of the same moment, and one of them is the one — so sweeping a stack down to the keepers means saying no far more often than yes, and the no has to be the cheapest gesture in the app.
It is also the only destructive one, which pulls the other way: cheap enough to repeat forty times, dear enough that it cannot happen by accident. So it asks the first time, and there is a way to tell it you know what you are doing — and, because the same keyboard is being used to type labels a second earlier, it has to know the difference between condemning a photo and rubbing out a letter.
A run like this is read with nothing filtered out. Working the todo pile instead, each
rejection would drop the photo off the wall as you condemned it and the run would shrink
under you — which is its own session, and a different feeling. Here you are going
along a row looking at everything, so a photo you have just condemned stays where it is
and the next one is still next. None of them starts out condemned — whatever else they
are — so a photo reading condemned afterwards can only have been marked by the key just
pressed.
@testcase
def test_saying_no_quickly(page):
"""Running down a stack marking rejects: it asks the first time, takes Shift for the
rest, and stays out of the way while you are typing."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
del_btn = d.get_by_role("button", name="delete", exact=True)
expect(del_btn).to_have_attribute("aria-pressed", "false")
asked = []
page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
page.keyboard.press("Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true") # moved to delete
assert asked, "Delete should have asked to confirm"
print(" PASS: lightbox delete confirms")
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false") # a fresh doc, not yet condemned
asked.clear()
page.keyboard.press("Shift+Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true")
assert not asked, "Shift+Delete should not ask to confirm"
print(" PASS: lightbox shift-delete skips confirm")
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false")
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzoops", delay=10)
box.press("ArrowLeft"); box.press("Delete") # rubbing out the last letter
expect(box).to_have_value("zzoop")
expect(del_btn).to_have_attribute("aria-pressed", "false") # the photo is untouched
print(" PASS: lightbox delete in a field is text editing")
Judging a run down to nothing
This is the session the whole triage view exists for. A filter holds everything still waiting on you, you open the first of them, and from then on you should not have to steer: judge, and the next one is in front of you; judge again, and so on until there is nothing left and the app gets out of your way. If you have to reach for the wall between each one, the run is not a run — it is a hundred separate little chores.
What makes it awkward to build is that judging a photo removes it from the filter you are reading. The list shifts under the open doc at the very moment you need it to say where to go next, and the obvious implementations all land you on the one you just finished with, or on the one before it that you dealt with a minute ago.
So the run needs four todos rather than the usual three. The wrap has to be judged from a ring of at least three — in a ring of two, the one after and the one before are the same photo, and a step forward cannot be told from a step back — and one is already gone by then, so four is the smallest run that can show the difference at every stage.
@testcase
def test_judging_a_run_down_to_nothing(page):
"""Working a filter to the end: each judgement hands you the next photo, the last
comes round to the first, and emptying the run closes it."""
make_fixtures()
fourth = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-3", "date": "2020-04-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-3", "labels": FIXTURE_LABEL, "state": "todo"}
gql(DELETE, {"cid": fourth["cid"]}); gql(CREATE, {"p": fourth})
for f in FIXTURES[1:]:
gql(UPDATE, {"cid": f["cid"], "patch": {"state": "todo"}}) # four todos, Jan..Apr
try:
open_app(page)
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(4) # default filter is todo
expect(thumb_imgs(page).nth(3)).to_have_attribute("src", re.compile(r"/ipfs/")) # loaded, not still blank
behind, ahead = (thumb_imgs(page).nth(i).get_attribute("src") for i in (0, 2))
open_doc(page, 1) # the second of four
d = dialog(page)
img = d.get_by_role("img")
d.get_by_role("button", name="done", exact=True).click() # leaves the todo filter
expect(d).to_be_visible() # still open…
expect(img).to_have_attribute("src", ahead) # …on the doc that took its place
assert img.get_attribute("src") != behind, "the modal stepped backwards"
print(" PASS: judging one lands on the next, not the previous")
onto_last = thumb_imgs(page).nth(2).get_attribute("src") # the last of the three left
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", onto_last) # settled on it before judging it
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_visible()
expect(img).to_have_attribute("src", behind) # round to the first, not back to the end
print(" PASS: judging the last comes round to the first")
d.get_by_role("button", name="done", exact=True).click()
expect(img).to_have_attribute("src", ahead) # round again, to the one still standing…
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_hidden() # …and with that one gone, nothing to show
expect(tiles(page)).to_have_count(0)
print(" PASS: the last one judged closes the run")
finally:
gql(DELETE, {"cid": fourth["cid"]})
Doing one thing to forty photos
Some of triage is per-photo and some of it plainly is not. A whole afternoon wants the same word on it; a whole card-dump wants the same verdict. Doing that one at a time is the difference between a job you finish and a job you abandon, so the wall lets you take a run in hand and act on all of it at once.
Picking things up has to be quiet: the toolbar appears the moment the first one is taken, and nothing on the wall may move as it does. Then the same box that labels one photo labels the lot — by button or by keyboard, the two never drifting apart — and takes words off again as readily as it puts them on, because a batch applied to the wrong run is the other thing that happens at this speed.
@testcase
def test_doing_one_thing_to_forty_photos(page):
"""Taking a run in hand: pick it up without the wall moving, then put a word on all
of it and take it off again, by button and by key."""
open_fixtures(page)
n = tiles(page).count()
t = tiles(page)
expect(toolbar(page)).to_be_hidden() # nothing picked yet, so the bar is not there
before = t.first.bounding_box()
t.nth(0).click() # the first pick — the bar arrives on this one
expect(toolbar(page)).to_be_visible()
after = t.first.bounding_box()
assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
print(" PASS: select doesn't shift grid")
t.nth(1).click() # a second one joins it
expect(checks(page)).to_have_count(2)
expect(toolbar(page).get_by_text("2 selected")).to_be_visible()
t.nth(0).click() # toggle one back off
expect(checks(page)).to_have_count(1)
print(" PASS: select toggles tiles")
select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="add label").click()
expect(checks(page)).to_have_count(0) # selection clears once applied
search_for(page, "addedbybatch") # the fresh word now finds them all
expect(tiles(page)).to_have_count(n)
print(" PASS: batch add label")
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)
print(" PASS: batch remove label")
search_for(page, FIXTURE_LABEL) # back to the run itself
expect(tiles(page)).to_have_count(n)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Enter") # + via RET
expect(checks(page)).to_have_count(0) # applied → selection clears
search_for(page, "zzbatchret")
expect(tiles(page)).to_have_count(n) # every selected photo got it
print(" PASS: batch enter adds")
select_all(page).click()
expect(checks(page)).to_have_count(n) # selection settled
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Shift+Enter") # - via S-RET
expect(tiles(page)).to_have_count(0) # the docs no longer carry the label → gone from the wall
print(" PASS: batch shift-enter removes")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ") # as a picked suggestion leaves it
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0) # the trailing sep didn't defeat the match
print(" PASS: batch remove label trailing separator")
Hunting for a photo you half-remember
You know the photo exists and you know almost nothing that would find it. It was summer. It was one of mine, not Ayla’s. Five or six years back. That is the whole of what you have, and none of it is a word anybody typed onto the photo at the time.
So the search box has to take guesses rather than facts, and take them a piece at a time: each token you add cuts the wall down, and a guess you withdraw costs you only itself. The pieces are the ones memory actually keeps — whose it was, what month, what year, what day — and they combine, so a hunt is a funnel rather than a single lucky query.
The set below is built so each guess in turn has something to cut away: six photos, five of them in June and spread over three years, one taken in March, and one of the Junes somebody else’s.
HUNT_LABEL = "zzhunt"
HUNT_DOCS = [
# (day, owner) — June across three years, a March, and one that is not mine
("2019-06-15", "konubinix"), ("2020-06-15", "konubinix"), ("2021-06-10", "konubinix"),
("2020-03-20", "konubinix"), ("2020-06-20", "aylapomme"), ("2020-06-25", "konubinix"),
]
def hunt_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}", "date": f"{day}T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}-t", "labels": HUNT_LABEL, "state": "todo",
"owner": owner}
for i, (day, owner) in enumerate(HUNT_DOCS)]
Asking is always asking afresh: the box holds one query and committing it replaces whatever stood there, so a guess is added by re-stating the question with the new piece in it. That is why each step below names every guess made so far.
def narrow(page, *tokens):
search_for(page, "; ".join([HUNT_LABEL, *tokens]))
@testcase
def test_hunting_for_a_photo(page):
"""Finding a photo from what memory kept: whose, what month, what year, what day —
each guess cutting the wall down, and one taken back giving its share straight
back."""
docs = hunt_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
narrow(page)
expect(tiles(page)).to_have_count(len(docs)) # nothing guessed yet
narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5) # everyone else's set aside
print(" PASS: filter by owner")
narrow(page, "owner:konubinix", "month:6")
expect(tiles(page)).to_have_count(4) # every June, whatever the year
narrow(page, "owner:konubinix", "month:march") # named, not numbered
expect(tiles(page)).to_have_count(1)
print(" PASS: filter by month")
narrow(page, "owner:konubinix", "month:6", "year:2020")
expect(tiles(page)).to_have_count(2) # of those Junes, the one year
print(" PASS: filter by year")
narrow(page, "owner:konubinix", "month:6") # the year guess withdrawn
expect(tiles(page)).to_have_count(4) # its Junes returned, and no more
print(" PASS: a withdrawn guess costs only itself")
narrow(page, "owner:konubinix", "date:2020-06") # the month and the year, in one token
expect(tiles(page)).to_have_count(2) # the same two the pair had left
narrow(page, "owner:konubinix", "date:2020-06-15") # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", "2020-06-15") # the one asked for
print(" PASS: filter by date")
finally:
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
Typing a word you only half-know
The archive’s words are your own, and you will not remember them. Was it balade or
rando? Was Élodie spelt with the accent? A search box that demands the exact word
is a box that only finds what you can already recall, which is the small part of the
archive you did not need help with.
So the box offers what it knows as you type, and the offer has to survive the ways a half-memory arrives: the fragment you have may sit in the middle of the word rather than at its start, and it will come without the capital or the accent you cannot be sure of. Whatever you took from the list goes in exactly as it is written down.
Then it gets out of the way. A word accepted ends with the separator for the next one already typed, the list comes back the moment you carry on, and the whole of it works without the hand leaving the keys — because the box is where a query is built up over several guesses, not somewhere you visit once.
Six words, covering four ways this can go wrong. Two of them are the shapes just named, a word with its distinctive part buried in the middle and a word spelt with a capital and an accent. The other four are there for the list itself: a pair, because a list of one has no bottom to reach and no second row to prefer over its first, and two to be taken in turn, so the list can be caught going away and coming back.
VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
HALF_KNOWN = ["zzcosmo", "Zzélodie", "zzupa", "zzupb", "zzbalade", "zzalois"]
def seed_vocab(words):
for w in words: gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
def drop_vocab(words):
for w in words:
try: gql(VOCAB_DEL, {"w": w})
except Exception: pass
A guess that comes to nothing is rubbed out and another tried, which is why nearly every step below starts its term over rather than adding to it. The one that does not is the one asking whether the list survives a word being accepted — and that question only exists if you carry straight on from the word you accepted.
def guess(page, fragment):
sb = search_box(page)
sb.fill(""); sb.click()
sb.press_sequentially(fragment, delay=20)
expect(options(page).first).to_be_visible()
@testcase
def test_typing_a_word_you_half_know(page):
"""Building a query out of half-memories: a fragment from the middle of a word, a
word whose spelling you are unsure of, and the list staying with you throughout."""
seed_vocab(HALF_KNOWN)
try:
open_app(page)
sb = search_box(page)
guess(page, "osmo") # sits inside zzcosmo, nowhere near its start
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
print(" PASS: completion matches inside label")
guess(page, "zzelod") # no capital, no accent
texts = [t.strip() for t in options(page).all_inner_texts()]
assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
options(page).filter(has_text="Zzélodie").first.click()
expect(sb).to_have_value("Zzélodie; ")
print(" PASS: completion preserves case and accent")
guess(page, "zzup") # two words, so there is a bottom to enter at
items = [t.strip() for t in options(page).all_inner_texts()]
assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
sb.press("ArrowUp") # from nothing selected — must enter at the bottom
sel = page.get_by_role("option", selected=True)
assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
"ArrowUp from none should select the last suggestion"
print(" PASS: suggestion up enters from none")
guess(page, "zzbal") # a fragment of one word we know
word = options(page).first.inner_text().strip()
options(page).first.click()
expect(sb).to_have_value(word + "; ") # the picked label, then a ';' for the next
print(" PASS: label completion")
page.keyboard.type("zzalo", delay=20) # straight on, into whatever holds the keys
expect(options(page).filter(has_text="zzalois").first).to_be_visible() # must reappear
print(" PASS: completion reopens after pick")
guess(page, "zzup")
offered = [t.strip() for t in options(page).all_inner_texts()]
assert len(offered) == 2, f"this needs two to choose between, got {offered}"
sb.press("ArrowDown"); sb.press("ArrowDown") # past the first, onto the second
sb.press("Enter") # apply the highlighted one
expect(sb).to_have_value(offered[1] + "; ") # the second — taking the first is the easy bug
print(" PASS: search suggestion keyboard")
guess(page, "owner") # 2+ chars → the key is offered
expect(options(page).filter(has_text="owner:").first).to_be_visible()
sb.press_sequentially(":k", delay=20) # owner:k → its values
opt = options(page).filter(has_text="konubinix").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value("owner:konubinix; ") # a closed-vocab value is done — a ';' opens the next token
print(" PASS: owner token completes")
finally:
drop_vocab(HALF_KNOWN)
Working the wall without the mouse
Triage is a rhythm — glance, judge, move on — and the hand that keeps leaving the keys to fetch the mouse is the hand that breaks it. Somebody working a card-dump down wants what every file manager gives: a tile the eye can follow, walked with the arrows, opened and picked and gathered up without reaching for anything.
That means the keyboard has to carry the whole gesture set the mouse already carries, and agree with it. The two are not alternatives used by different people; they are used by the same person within seconds of each other — a click here, an arrow there — so the cursor a click leaves behind is the cursor the next arrow moves.
It wants a wall too big for the window: enough photos for several rows, on a screen short enough that the last of them sits below the fold and behind the selection bar. A run dragged down to it is where the two hardest promises live — that a row is however many columns are on show, and that the tile you land on is whole and in front of you.
KEY_N = 24
def key_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzkey-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzkey-t-{i}",
"labels": "zzkey", "state": "todo"} for i in range(KEY_N)]
def key_thumb(i):
return f"https://ipfs.konubinix.eu/p/zzkey-t-{i}"
@testcase
def test_working_the_wall_without_the_mouse(page):
"""Running a wall down from the keys: walk it, open one, pick one, drag a run to
the foot of it, and gather the lot."""
docs = key_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 420, "height": 600}) # narrow and short: it overflows
open_app(page)
search_for(page, "zzkey")
expect(tiles(page)).to_have_count(KEY_N)
page.keyboard.press("ArrowRight") # the first arrow focuses the first tile
page.keyboard.press("ArrowRight") # → the second
page.keyboard.press("Enter") # Enter opens the focused doc
d = dialog(page)
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
print(" PASS: grid cursor opens focused doc")
expect(checks(page)).to_have_count(0) # nothing picked by walking about
page.keyboard.press(" ") # Space picks the focused one
expect(checks(page)).to_have_count(1)
page.keyboard.press(" ") # Space again unpicks it
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor space toggles selection")
tiles(page).nth(5).click() # reach for the mouse for one tile
page.keyboard.press("ArrowRight") # the arrows carry on from there
page.keyboard.press("Enter") # open the now-focused tile
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(6))
page.keyboard.press("Escape")
toolbar(page).get_by_role("button", name="clear").click() # drop what the click picked up
expect(checks(page)).to_have_count(0)
print(" PASS: cursor starts at last clicked tile")
page.keyboard.press("ArrowRight") # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(0) # and selects nothing
page.keyboard.press("Shift+ArrowRight") # reach on…
page.keyboard.press("Shift+ArrowRight") # …and on again
expect(checks(page)).to_have_count(3) # the whole run from the anchor
print(" PASS: grid cursor shift extends selection")
page.keyboard.press("Shift+ArrowLeft") # back one → the far end drops off
expect(checks(page)).to_have_count(2)
page.keyboard.press("Shift+ArrowLeft") # back onto the anchor → only it remains
expect(checks(page)).to_have_count(1)
toolbar(page).get_by_role("button", name="clear").click()
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor shift reduces on return")
tiles(page).nth(0).click() # plant the cursor at the top-left
toolbar(page).get_by_role("button", name="clear").click()
cols = grid(page).evaluate("el => getComputedStyle(el).gridTemplateColumns.split(' ').length")
page.keyboard.press("ArrowDown") # → one row down, i.e. index `cols`
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(cols))
page.keyboard.press("Escape")
print(" PASS: grid cursor down steps a row")
tiles(page).nth(0).click() # start the run at the top-left
for _ in range(KEY_N): page.keyboard.press("Shift+ArrowDown") # drag it down past the fold
last = tiles(page).nth(KEY_N - 1).bounding_box()
bar = toolbar(page).bounding_box() # the run raised the toolbar
assert last["y"] >= 0, f"tile head scrolled above the fold: {last}"
assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
toolbar(page).get_by_role("button", name="clear").click()
print(" PASS: grid cursor reveals tile above toolbar")
for _ in range(KEY_N): page.keyboard.press("ArrowUp") # climb back out, plain moves
first = tiles(page).nth(0).bounding_box()
assert first["y"] >= 0, f"the cursor climbed above the fold and stayed there: {first}"
print(" PASS: grid cursor reveals a tile climbed back to")
page.keyboard.press("Control+a")
expect(checks(page)).to_have_count(KEY_N) # the whole wall at once
print(" PASS: ctrl+a selects whole wall")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
Getting on screen
Three things stand between a tap on the icon and the wall: the app has to boot, handle being turned away when it isn’t authorized, and cope when the data won’t load.
It boots
First, prove the no-build Solid stack loads at all: the import map resolves Solid
from esm.sh and the html template renders its titled shell — the first thing on screen,
and the signal the tests wait on to know the app has booted. Solid’s web and html
submodules must share one core instance (?external=solid-js in the map dedupes them,
else reactivity is dead).
@testcase
def test_boots_into_titled_shell(page):
"""The Solid app loads from the import map and renders its title."""
open_app(page)
expect(heading(page)).to_have_text("Memories")
print(" PASS: boots into titled shell")
import { render } from 'solid-js/web';
import html from 'solid-js/html';
import { createSignal, createResource, createMemo, createEffect, on, For, Index, Show, onMount, onCleanup } from 'solid-js';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { client, getAuthNeeded, subscribeAuth, queryFresh } from '../shared/gql.js';
The data layer is shared: memories’ reactivity is Solid’s createResource, but its
transport is the shared urql client — the same one the frise uses — so there is one
client and one auth gate across the apps. The triage wall reads through the shared
queryFresh, which posts straight to the server, bypassing the client’s document cache
and its in-flight dedup. That last part is what earns its keep: at the end of every edit
memories refetch-es the wall, and a re-read fired the instant a mutation commits, going
through the client, can be handed a query that went out just before the edit — a removed
tag still lingering. Straight to the server, that can’t happen; the wall always shows the
true current result, and on the LAN the round-trip is cheap. (createResource isn’t wired to
the cache’s reactivity the way the frise’s hooks are, which is why memories re-reads the wall
itself.) Label completion still reads network-only through the client — a vocab word
added moments ago has to appear at once — so it carries the same in-flight-dedup risk the wall
sheds; it hasn’t yet been worth a second queryFresh caller. A Solid signal mirrors the
shared gate’s store into the banner.
const IPFS = ''; // https://ipfs.konubinix.eu/p/... is on the same origin as the app
const [authNeeded, setAuthNeeded] = createSignal(getAuthNeeded());
subscribeAuth(setAuthNeeded);
const PV_CTX = { additionalTypenames: ['Photovideo'] };
async function gql(query, variables, ctx){
const r = await (/^\s*mutation\b/.test(query)
? client.mutation(query, variables, ctx)
: client.query(query, variables, ctx ?? { requestPolicy: 'network-only' })).toPromise();
if(r.error) throw new Error(r.error.message);
return r.data;
}
render(App, document.getElementById('app'));
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
What it boots into is the whole screen, because it asks for the whole screen: the app sets its
viewport to cover the display rather than stop where the phone’s status bar and navigation
bar begin, which is what lets a photo fill the glass. Those bars are still painted over the
page, though, and they still take the touches that land on them. So the screen the app is
handed is really three bands, and only the middle one is its own to put a control in.
So env(safe-area-inset-*), the phone’s own report of what the two strips are taking, is what
every surface at an edge stands back by — starting with the body.
:root{ --bg:#1b1d2e; --fg:#e8e8f0; }
body{ background:var(--bg); color:var(--fg); font-family:system-ui,sans-serif; margin:0;
padding: calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right))
calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left)); }
h1{ font-size:18px; margin:0 0 12px; }
When the device isn’t authorized
The test forces the failure deterministically: it routes /graphql to a 401
(overriding the fixture forward, since a page route wins over the context one)
and asserts an alert that mentions authorization.
@testcase
def test_shows_auth_required_when_unauthorized(page):
"""A 401 from /graphql surfaces a clear 'authorization required' banner."""
page.route("**/graphql", lambda route: route.fulfill(
status=401, content_type="text/plain", body="Unauthorized"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("authoriz", re.I))
print(" PASS: shows auth required when unauthorized")
The gql seam (It boots) already flips authNeeded on a 401; the banner is
just a Show on it, dropped in at the top of the App so it’s the first thing
seen. It does not try to authenticate — the app can’t mint a grant — it
only tells the user a fresh access link is needed on this device.
<${Show} when=${() => authNeeded()}>
<div class="authwall" role="alert">
<strong>Authorization required.</strong>
This device can't read your photos yet — open a fresh access link on it.
</div>
<//>
.authwall{ background:#f9a826; color:#1b1d2e; padding:10px 14px; border-radius:8px;
margin:0 0 12px; line-height:1.45; }
.authwall strong{ display:block; }
When the load fails
A 401 is the courteous failure — the gate knows the device only needs a fresh link, and says so. Every other failure hands back the wall empty for a reason the user cannot see: the server down, a 500 on a bad query, a 404 where the route should answer. An empty wall reads as “nothing matches here” — a lie when the truth is that the read never landed.
So a load that fails says so, in the same top-of-app banner the authorization notice uses.
page.route("**/graphql", lambda route: route.fulfill(
status=500, content_type="text/plain", body="Server Error"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("load|reach|server", re.I))
And the wall holds back its own “nothing here” line, which would only compound the lie.
expect(page.get_by_text("No photos.", exact=True)).to_have_count(0)
The banner rides on the wall resource’s own error, and shows only when the failure isn’t the authorization one the gate already owns.
<${Show} when=${() => photos.error && !authNeeded()}>
<div class="loadwall" role="alert">
<strong>Couldn't load your photos.</strong>
The server returned an error — try again in a moment.
</div>
<//>
.loadwall{ background:#e5484d; color:#fff; padding:10px 14px; border-radius:8px;
margin:0 0 12px; line-height:1.45; }
.loadwall strong{ display:block; }
In practice, the wall reads its docs straight from the resource, and that read re-throws the load error — with no error boundary above it the throw would freeze the render before the banner paints. So the wall skips its own read once the load has errored: the resource’s error is left to the banner alone, and the empty-state and the tiles fall to nothing.
The wall
The wall is the home surface — a grid of every doc the query matches, drawn only as far as the eye reaches, each tile carrying what the archive knows about it.
A grid of thumbnails
The point of the app: a wall of thumbnails from the archive. It fills from the search term — memories draws its own even ~2000 spread over the frise’s shared filter (see Drawing our own spread) — one lazily-loaded tile per photo, a placeholder while the fetch is in flight and a word when nothing matches.
So, on the whole archive — the all chip, since the default todo view may be empty
once everything’s triaged — the wall fills with thumbnails.
@testcase
def test_shows_thumbnails(page):
"""The grid fills with thumbnail images from the archive."""
open_app(page)
chip(page, "all").click() # the whole archive — not the (possibly empty) todo default
# clicking "all" keeps the previous (todo) tiles until the heavy full-archive fetch
# resolves, so *wait* for the wall to populate rather than reading an instant count —
# which otherwise catches a single leftover todo tile, or the wall mid-load, and flakes.
wait_until(page, lambda: tiles(page).count() > 10, timeout=15000)
wait_until(page, lambda: "/ipfs/" in (thumb_imgs(page).first.get_attribute("src") or ""))
print(" PASS: shows thumbnails")
A tile earns its keep by letting you recognize the doc at a glance, so the one thing the thumbnail must never do is crop away what identifies it — a face at the frame’s edge, a landscape’s horizon. The tile shows the image whole, fitting it entire rather than filling the square with a cropped centre; the tile background shows through on whichever axis the image leaves spare.
@testcase
def test_thumbnail_shows_whole_image(page):
"""A grid thumbnail fits the whole image, never a cropped centre."""
open_fixtures(page)
img = thumb_imgs(page).first
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
"thumbnail crops instead of fitting whole"
print(" PASS: thumbnail shows whole image")
The window spans the whole dated archive (floored at 2007, like the frise); the
server samples it down. Each tile is an img that holds its /ipfs/ thumbnail only
while the thumbnail is worth holding — a wall this long cannot keep them all, and
letting them go is the next chapter. The date rides along as alt=/=title, so a screen
reader and the eye can name each tile the way you read it.
Memories doesn’t spell out the server filter itself — the parse-to-variables mapping and the
GraphQL declarations and arguments that carry it are the same in every app, so they live in
the shared data module beside photoVars and the PhotoCore field set. Memories splices
those in, adding only its own: a states filter, a sampling cap (the shared
SAMPLE_CAP), the pick that says which rows fill it, and the
state=/=myrandom fields. GraphQL’s own page size first is
set well above that cap, so it is the sampler that thins the wall, never the page limit.
const SEARCH = `query(${PHOTO_FILTER_DECL}, $states:[State!], $cap:Int!, $pick:String){
photovideosSample(${PHOTO_FILTER_ARGS}, states:$states, cap:$cap, pick:$pick, first:8000){
nodes{ ...PhotoCore state myrandom owner }
}
photovideosCount(${PHOTO_FILTER_ARGS}, states:$states)
}
${PHOTO_CORE}`;
const fetchPhotos = async (key, signal) => {
const d = await queryFresh(SEARCH, { ...photoVars(key), states: key.state === 'all' ? null : [key.state],
cap: key.cap ?? SAMPLE_CAP, pick: key.pick ?? null }, signal);
const nodes = d?.photovideosSample?.nodes ?? [];
const total = d?.photovideosCount ?? nodes.length;
return { items: nodes, total, sampled: nodes.length < total, pick: key.pick ?? null };
};
The box is a tiny query language — bare words search the labels, while since:=/=until:
bound a date range, type:=/=sort:=/=owner: filter, onthisday opens the anniversary
view, month:=/=day: keep a recurring month or day across every year, year: pins a single
calendar year, and first:=/=last:=/=sample: say how many to bring back. That language
isn’t memories’ own: it’s how every photo app addresses the shared
contract, so it lives once in the data layer’s DSL module and memories imports its
parser together with the completion primitives the box will need.
import { parseQuery, segSpan, segAt, replaceSeg, dslSuggestions, DSL_KEYS } from '../shared/dsl.js';
import { SAMPLE_CAP, photoVars, UPDATE_PHOTO, PHOTO_CORE, PHOTO_FILTER_DECL, PHOTO_FILTER_ARGS, LABEL_COMPLETIONS } from '../shared/data.js';
The archive holds more than one person’s photos, so an owner:NAME token narrows the wall
to a chosen owner (repeatable for several). It threads through to the very photovideos_match
filter the frise owns, so the wall and its count agree on whose photos are in scope.
narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5) # everyone else's set aside
print(" PASS: filter by owner")
A month: token keeps a month across every year, threaded through the same
photovideos_match filter as the rest, so the wall and its count agree on which months
are in scope. Junes from three different years
standing together is what tells it apart from a date range: a range wide enough to reach
the last of them must swallow every March and December on the way, and this one holds
none. The month can be named as readily as numbered, because march is how one says it.
narrow(page, "owner:konubinix", "month:6")
expect(tiles(page)).to_have_count(4) # every June, whatever the year
narrow(page, "owner:konubinix", "month:march") # named, not numbered
expect(tiles(page)).to_have_count(1)
print(" PASS: filter by month")
A year: token pins one calendar year — sugar for a since:=/=until: pair on that year,
threaded through the same filter. Laid over the Junes, it keeps the ones from the year
named and drops the rest.
narrow(page, "owner:konubinix", "month:6", "year:2020")
expect(tiles(page)).to_have_count(2) # of those Junes, the one year
print(" PASS: filter by year")
Each token stands on its own, so a guess can be taken back as easily as it was made: drop the year and the Junes it was hiding come back, with the guesses either side of it — whose, and which month — still holding.
narrow(page, "owner:konubinix", "month:6") # the year guess withdrawn
expect(tiles(page)).to_have_count(4) # its Junes returned, and no more
print(" PASS: a withdrawn guess costs only itself")
year: names a bounded period in a single token, but only ever a whole year; a month or a
day still means spelling the since:2020-06; until:2020-06 pair out twice. date: is that
pair in one token at any granularity — date:2020 a year, date:2020-06 a month,
date:2020-06-15 a day — each snapped to the period’s edges, exactly its since:=/=until:
form. So the year just taken back can come again in a smaller form: one token saying what
the month and the year said together, and then carrying on where the pair of them runs
out, down to the day itself.
narrow(page, "owner:konubinix", "date:2020-06") # the month and the year, in one token
expect(tiles(page)).to_have_count(2) # the same two the pair had left
narrow(page, "owner:konubinix", "date:2020-06-15") # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", "2020-06-15") # the one asked for
print(" PASS: filter by date")
function App(){
// the search box persists locally, so a reload (or the frame's reboot) restores
// the same query — and thus the same frame show.
const SEARCH_KEY = 'memories.search';
const [search, setSearch] = createSignal(localStorage.getItem(SEARCH_KEY) || '');
createEffect(() => localStorage.setItem(SEARCH_KEY, search()));
const [searchFocus, setSearchFocus] = createSignal(false);
const [caret, setCaret] = createSignal(0); // cursor position, so completion follows it
// keyboard-driving the suggestion list (only one box is focused at a time, so a single
// highlight is enough): the focused box's Suggest reports its items here and reads the
// active index back. ↓ enters at the top and ↑ at the bottom — both cycle through the
// -1 "none" slot so either key reaches the list and either escapes it. Enter applies the
// highlighted word (or, with nothing highlighted, runs the box's own commit).
const [sugItems, setSugItems] = createSignal([]);
const [sugActive, setSugActive] = createSignal(-1);
const [sugLoading, setSugLoading] = createSignal(false); // the focused box's list is fetching
const reportSug = list => { setSugItems(list); setSugActive(-1); }; // a new list clears the highlight
const sugNav = (e, commit) => {
const n = sugItems().length;
if(e.key === 'ArrowDown' && n){ e.preventDefault(); setSugActive(i => i >= n - 1 ? -1 : i + 1); }
else if(e.key === 'ArrowUp' && n){ e.preventDefault(); setSugActive(i => i < 0 ? n - 1 : i - 1); }
else if(e.key === 'Enter'){ e.preventDefault();
commit(sugActive() >= 0 ? sugItems()[sugActive()] : null); setSugActive(-1); }
};
const pickSearch = w => { const [s, e] = segSpan(search(), caret());
const lead = (search().slice(s, e).match(/^\s*/) || [''])[0];
// still drillable → hold the box on this token
const drilling = w.endsWith(':') || dslSuggestions(w).some(v => v.length > w.length);
const tail = drilling ? '' : '; ';
const next = search().slice(0, s) + lead + w + tail + search().slice(e);
setSearch(next); setCaret(s + lead.length + w.length + tail.length); setSearchFocus(true); };
// the chip choice persists; the first-ever visit defaults to the triage view (todo)
const STATE_KEY = 'memories.state';
const [stateFilter, setStateFilter] = createSignal(localStorage.getItem(STATE_KEY) || 'todo');
createEffect(() => localStorage.setItem(STATE_KEY, stateFilter()));
// the last label applied (lightbox or batch), offered for one-tap reuse on the next
// doc so labelling a run of photos doesn't mean retyping the same word each time.
const [lastLabel, setLastLabel] = createSignal('');
const SIZE_KEY = 'memories.thumbSize';
const [thumbSize, setThumbSize] = createSignal(+localStorage.getItem(SIZE_KEY) || 96);
createEffect(() => localStorage.setItem(SIZE_KEY, thumbSize()));
const bumpSize = d => setThumbSize(s => Math.max(80, Math.min(320, s + d * 40)));
let zoomAt = 0;
const onGridWheel = e => {
if(!e.ctrlKey) return;
e.preventDefault();
const now = performance.now();
if(now - zoomAt < 120) return;
zoomAt = now; bumpSize(e.deltaY < 0 ? 1 : -1);
};
const REFINE_CELL = 300; // drawn px
const [cellPx, setCellPx] = createSignal(0);
const measureCell = () => gridEl && setCellPx(parseFloat(getComputedStyle(gridEl).gridTemplateColumns) || 0);
const watchCell = el => { const ro = new ResizeObserver(measureCell);
ro.observe(el); onCleanup(() => ro.disconnect()); };
createEffect(() => { thumbSize(); measureCell(); }); // the slider moves the columns, not the grid's box
const [thumbsInFlight, setThumbsInFlight] = createSignal(0);
const refining = () => cellPx() > REFINE_CELL && thumbsInFlight() === 0;
// the fetch key excludes sort (a client-side reorder shouldn't re-query); compared
// as a string so a sort-only change doesn't refetch the same sample.
// the wall reads a COMMITTED query, not the live text — see the commit-search prose. Typing
// updates the box + its completions; the wall re-reads only on commit (Enter / search button)
// or a state chip. Seeded from the persisted search so the wall loads on first paint.
const [committed, setCommitted] = createSignal(search());
const commit = () => setCommitted(search());
const dirty = () => search() !== committed(); // typed but not yet run → the wall is stale (see prose)
const queryStr = createMemo(() => { const p = parseQuery(committed());
return JSON.stringify({ search: p.search, since: p.since, until: p.until,
kinds: p.kinds, owners: p.owners, aday: p.aday, awin: p.awin,
month: p.month, mwin: p.mwin, events: p.events, eventsX: p.eventsX,
cap: p.cap, pick: p.pick, state: stateFilter() }); });
const [mutating, setMutating] = createSignal(0); // in-flight edit count → aria-busy
// FREEZE: advance to the committed query only when idle — see the commit-search prose.
const target = createMemo(() => ({ key: queryStr(), label: committed().trim() }));
const [running, setRunning] = createSignal(target());
let readCtl = null; // the in-flight read's AbortController, for the escape hatch
const [photos, { refetch }] = createResource(() => JSON.parse(running().key),
async (key, info) => { readCtl = new AbortController();
try { return await fetchPhotos(key, readCtl.signal); }
catch(e){ if(e && e.name === 'AbortError') return info.value ?? { items: [], total: 0, sampled: false }; throw e; } });
createEffect(() => { if(!photos.loading && target().key !== running().key) setRunning(target()); });
// ESCAPE HATCH: flush to lastSettled + abort the in-flight read — see the cancel-flow prose.
const [lastSettled, setLastSettled] = createSignal(null);
createEffect(() => { if(!photos.loading && !photos.error) setLastSettled(running()); });
const cancelRead = () => {
if(!photos.loading) return;
const ctl = readCtl, s = lastSettled();
if(s && s.key !== running().key){ setSearch(s.label); setCommitted(s.label); setRunning(s); }
else { setSearch(''); setCommitted(''); setRunning(target()); } // boot / no prior → everything
ctl?.abort();
};
onMount(() => { // Esc bails out of a frozen read (Back bails too, via the exit guard)
const onEsc = e => { if(e.key === 'Escape' && photos.loading && !opened() && !frame()){ e.preventDefault(); cancelRead(); } };
window.addEventListener('keydown', onEsc);
onCleanup(() => window.removeEventListener('keydown', onEsc));
});
onMount(() => { // suppress the OS context menu on our surfaces; text fields keep it (paste/select)
const noMenu = e => { if(!e.target.closest('input, textarea, [contenteditable]')) e.preventDefault(); };
window.addEventListener('contextmenu', noMenu);
onCleanup(() => window.removeEventListener('contextmenu', noMenu));
});
const sortKey = createMemo(() => parseQuery(search()).sort);
const total = () => photos()?.total ?? 0; // how many actually match
const items = createMemo(() => { const a = (photos.error ? undefined : photos())?.items ?? []; // shown docs, in wall order
return sortKey() === 'random' ? [...a].sort((x, y) => (x.myrandom ?? 0) - (y.myrandom ?? 0)) : a; });
const eventWindow = createMemo(() => { const k = JSON.parse(running().key);
return JSON.stringify({ since: k.since, until: k.until }); });
const [wallEvents] = createResource(eventWindow, w => fetchWindowEvents(JSON.parse(w)));
const docEvents = photo => { const evs = wallEvents(); if(!evs || !photo.owner) return [];
const t = new Date(photo.date).getTime();
return evs.filter(e => e.owner === photo.owner
&& new Date(e.starttime).getTime() <= t && t <= new Date(e.endtime).getTime()); };
const EVENT_HUES = ['#8ecae6', '#ffb703', '#90be6d', '#ff8fab', '#bdb2ff', '#ffd6a5', '#a0c4ff', '#caffbf'];
const eventKey = e => e.summary + '|' + e.starttime + '|' + e.owner;
const eventColour = createMemo(() => { const m = new Map(); let i = 0;
for(const p of items()) for(const e of docEvents(p))
if(!m.has(eventKey(e))) m.set(eventKey(e), EVENT_HUES[i++ % EVENT_HUES.length]);
return m; });
const STATES = ['todo', 'next', 'done', 'delete'];
const SEL_KEY = 'memories.selected';
const [selected, setSelected] = createSignal(new Set(JSON.parse(localStorage.getItem(SEL_KEY) || '[]')));
createEffect(() => localStorage.setItem(SEL_KEY, JSON.stringify([...selected()])));
const [labelText, setLabelText] = createSignal('');
const [labelFocus, setLabelFocus] = createSignal(false);
const [anchor, setAnchor] = createSignal(null);
const [rangeMode, setRangeMode] = createSignal(false);
const [allMatching, setAllMatching] = createSignal(false);
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };
const shownCids = () => items().map(p => p.cid);
const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));
const extendTo = cid => {
const list = items().map(p => p.cid);
const a = list.indexOf(anchor()), b = list.indexOf(cid);
if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
const [lo, hi] = a < b ? [a, b] : [b, a];
setSelected(s => { const n = new Set(s);
for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
};
const extendRun = cid => {
if(!canRange()) return;
const list = items();
const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
if(a < 0 || ni < 0) return;
if(!extending){ extending = true; rangeBase = new Set(selected()); }
const [lo, hi] = a < ni ? [a, ni] : [ni, a];
setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
};
const onTileClick = (e, cid) => {
setCursor(cid);
if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
extendTo(cid); setRangeMode(false); return;
}
toggle(cid); setAnchor(cid);
};
const armRange = cid => {
if(!canRange()) return;
setSelected(s => { const n = new Set(s); n.add(cid); return n; });
setAnchor(cid); setRangeMode(true); extending = false;
};
const tileCidAt = (x, y) => {
const tile = document.elementFromPoint(x, y)?.closest('.tile');
if(!tile || !gridEl) return null;
const i = [...gridEl.children].indexOf(tile);
return i < 0 ? null : items()[i]?.cid;
};
let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false, lpPos = null, lpRaf = 0;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
const EDGE = 48, SCROLL_STEP = 14;
const dragTick = () => {
lpRaf = 0;
if(!lpFired || !lpPos) return;
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
if(!dy) return; // finger left the margin → stop the loop
scrollBy(0, dy);
dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1))); // hit-test clear of the bar
lpRaf = requestAnimationFrame(dragTick);
};
const onTileDown = (e, photo) => {
lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
lpTimer = setTimeout(() => { lpFired = true; lpCancel();
try { lpEl.setPointerCapture(lpId); } catch(_){}
armRange(photo.cid); }, 500);
};
const onTileMove = e => {
if(lpFired){
lpPos = { x: e.clientX, y: e.clientY };
dragExtendAt(e.clientX, e.clientY);
if(!lpRaf) dragTick();
return;
}
if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
};
const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; } dragStop(); lpCancel(); };
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
onMount(() => gridEl?.addEventListener('touchmove',
e => { if(lpFired) e.preventDefault(); }, { passive: false }));
async function patchSelected(patchFor){
setMutating(m => m + 1);
try {
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const patch = patchFor(byCid.get(cid));
if(patch) await gql(UPDATE_PHOTO, { cid, patch });
}
clearSel();
await refetch();
} finally { setMutating(m => m - 1); }
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
async function applyBulk(kind, v){
setMutating(m => m + 1);
try { await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); clearSel(); await refetch(); }
finally { setMutating(m => m - 1); }
}
const addLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
setLastLabel(words[words.length - 1]);
if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
else await patchSelected(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
setLabelText('');
};
const removeLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
: patchSelected(() => ({ state: st }));
const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const p = byCid.get(cid); if(!p) continue;
const path = res === 'web' ? p.webCid : p.cid;
if(!path) continue;
const isVid = (p.mimetype || '').startsWith('video');
const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
const raw = p.filename || p.cid.split('/').pop() || 'download';
const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
const a = document.createElement('a');
a.href = IPFS + path;
a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
document.body.appendChild(a); a.click(); a.remove();
}
}
const [moveText, setMoveText] = createSignal('');
const [moveFocus, setMoveFocus] = createSignal(false);
const [moveTarget, setMoveTarget] = createSignal(null); // the armed occasion, or null → nothing to apply
const [moveEvents] = createResource(moveFocus,
f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
const eventDist = (e, mean) => { if(mean == null) return 0;
const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
return mean < s ? s - mean : mean > en ? mean - en : 0; };
const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
const moveCandidates = () => { const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
return (moveEvents() || []).filter(e => owners.has(e.owner))
.filter(e => !q || (e.summary || '').toLowerCase().includes(q))
.sort((a, b) => eventDist(a, mean) - eventDist(b, mean) || new Date(a.starttime) - new Date(b.starttime))
.slice(0, 8); };
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
setMoveText(''); setMoveTarget(null); };
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
await patchSelected(() => ({ date: new Date(v).toISOString() }));
setDatingSel(false); };
const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
async function applyLabels(labels){
const cid = opened().cid;
setOpened({ ...opened(), labels });
await gql(UPDATE_PHOTO, { cid, patch: { labels } });
await refetch();
}
const lbAdd = input => {
const words = splitWords(input); if(!words.length) return;
setLastLabel(words[words.length - 1]);
const cur = labelsOf(opened());
for(const w of words) if(!cur.includes(w)) cur.push(w);
setLbText('');
return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
async function lbPatch(patch){
const list = items(), cur = opened(); if(!cur) return;
const i = list.findIndex(p => p.cid === cur.cid);
const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
const stay = l2.find(p => p.cid === cur.cid);
setOpened(stay || (nextCid && l2.find(p => p.cid === nextCid)) || l2[0]);
});
}
const lbSetState = st => lbPatch({ state: st });
const step = delta => {
const list = items(); if(!list.length || !opened()) return;
const i = list.findIndex(p => p.cid === opened().cid);
setOpened(list[((i < 0 ? 0 : i) + delta + list.length) % list.length]); setLbText('');
};
let lbVideo = null;
const seekOrStep = dir => {
const v = lbVideo;
if(v && isVideo(opened()) && !v.paused &&
(dir > 0 ? v.currentTime < v.duration - 0.25 : v.currentTime > 0.25))
v.currentTime = Math.max(0, Math.min(v.duration, v.currentTime + dir * 5));
else step(dir);
};
let wheelAt = 0;
const onWheel = e => {
if(!e.shiftKey) return;
const d = e.deltaY || e.deltaX;
const now = performance.now();
if(Math.abs(d) < 1 || now - wheelAt < 200) return;
wheelAt = now; step(d > 0 ? 1 : -1);
};
onMount(() => {
const onKey = e => {
if(!opened()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') dismissPhoto();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); seekOrStep(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); seekOrStep(-1); }
else if(!editing && e.key === 'Delete'){ e.preventDefault(); lbDelete(e.shiftKey); }
else if(!editing && e.key === 'Enter'){ e.preventDefault(); toggle(opened().cid); }
else if(!editing && e.key === ' ' && isVideo(opened()) && lbVideo){
e.preventDefault(); const v = lbVideo;
if(v.currentTime >= v.duration - 0.25){ v.currentTime = 0; v.play(); }
else if(v.paused) v.play(); else v.pause();
}
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
const [frame, setFrame] = createSignal(false);
const [playing, setPlaying] = createSignal(true);
const [frameUI, setFrameUI] = createSignal(false); // controls revealed on tap
const [frameLabel, setFrameLabel] = createSignal('');
const [frameLabelFocus, setFrameLabelFocus] = createSignal(false);
const [frameCenterCid, setFrameCenterCid] = createSignal(null); // the settled slide
const frameDoc = () => items().find(p => p.cid === frameCenterCid()); // the centred doc
const [frameEditingDate, setFrameEditingDate] = createSignal(false);
const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);
const [frameDir, setFrameDir] = createSignal(1); // last travel direction (+1 forward)
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
const FRAME_IDLE_MS = Number(new URLSearchParams(location.search).get('idleresume')) || 60000;
const [pokes, setPokes] = createSignal(0);
const nudge = () => setPokes(n => n + 1);
const [interacting, setInteracting] = createSignal(false);
const [zoomed, setZoomed] = createSignal(false);
const frameSlides = () => { const o = items();
return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
let stripEl, wakeLock = null;
const slideW = () => stripEl && stripEl.children.length ? stripEl.scrollWidth / stripEl.children.length : (stripEl?.clientWidth || 1);
const slideAt = () => Math.round((stripEl?.scrollLeft || 0) / slideW()); // nearest slide index
const slideLeft = i => { const k = stripEl && stripEl.children[i]; return k ? k.offsetLeft : i * slideW(); };
const frameGo = delta => { if(!stripEl) return;
stripEl.scrollTo({ left: slideLeft(slideAt() + delta), behavior: 'smooth' }); };
const FRAME_CID_KEY = 'memories.frame.cid';
let snapT;
const onFrameScroll = () => {
if(stripEl){ const i = slideAt(), c = frameCenterIdx();
if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
clearTimeout(snapT); snapT = setTimeout(() => {
if(!stripEl) return;
if(zoomed()) return;
const n = items().length;
let i = slideAt();
if(i <= 0){ stripEl.scrollLeft = slideLeft(n); i = n; } // leading clone(last) → real last
else if(i >= n + 1){ stripEl.scrollLeft = slideLeft(1); i = 1; } // trailing clone(first) → real first
const target = slideLeft(i);
if(Math.abs(stripEl.scrollLeft - target) > 1) stripEl.scrollTo({ left: target, behavior: 'smooth' });
const doc = items()[i - 1];
setFrameCenterIdx(i);
if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
}, 150); };
const FRAME_ON_KEY = 'memories.frame.on';
async function enterFrame(){
if(!items().length) return;
setFrame(true); setPlaying(true); setFrameUI(false);
localStorage.setItem(FRAME_ON_KEY, '1');
history.pushState({ frame: true }, '');
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
}
function closeFrame(){
setFrame(false);
localStorage.setItem(FRAME_ON_KEY, '0');
try { wakeLock?.release(); } catch(e) {} wakeLock = null;
}
const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();
const frameIndex = () => Math.max(0, Math.min(items().length - 1, slideAt() - 1));
async function frameEdit(patchFor){
const list = items(); if(!list.length || !stripEl) return;
const i = frameIndex(), cur = list[i], prevCid = i > 0 ? list[i - 1].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch: patchFor(cur) });
setFrameLabel('');
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length) { exitFrame(); return; }
const stay = l2.findIndex(p => p.cid === cur.cid);
let t = stay >= 0 ? stay : (prevCid ? l2.findIndex(p => p.cid === prevCid) : 0);
stripEl.scrollLeft = slideLeft(Math.max(0, t) + 1);
});
}
const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
return frameEdit(() => ({ state: st })); };
const frameAddWord = input => { const words = splitWords(input); if(!words.length) return;
return frameEdit(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; }); };
const frameAddLabel = () => frameAddWord(frameLabel());
const frameDropLabel = () => { const words = splitWords(frameLabel()); if(!words.length) return;
return frameEdit(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') })); };
let frameCancelDate = false;
const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false; setFrameEditingDate(false);
if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };
const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1';
let autoEntered = false;
createEffect(() => {
if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
});
createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
const saved = localStorage.getItem(FRAME_CID_KEY);
const r = saved ? items().findIndex(p => p.cid === saved) : -1;
stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1); // +1 for the leading clone
setFrameCenterIdx(r >= 0 ? r + 1 : 1); // seed the centre before any scroll
setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
}); });
createEffect(on([items, frame], () => {
if(!frame() || !stripEl) return;
const io = new IntersectionObserver(
es => es.forEach(e => { if(e.intersectionRatio < 0.5) e.target.pause(); }),
{ root: stripEl, threshold: 0.5 });
requestAnimationFrame(() => stripEl.querySelectorAll('video').forEach(v => io.observe(v)));
onCleanup(() => io.disconnect());
}));
createEffect(() => { if(!frame()) return; const vv = window.visualViewport; if(!vv) return;
const read = () => setZoomed(vv.scale > 1);
read(); vv.addEventListener('resize', read); vv.addEventListener('scroll', read);
onCleanup(() => { vv.removeEventListener('resize', read); vv.removeEventListener('scroll', read); }); });
createEffect(() => {
if(!frame() || !playing() || zoomed() || interacting()) return;
const id = setInterval(() => {
if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
frameGo(1);
}, intervalMs());
onCleanup(() => clearInterval(id));
});
createEffect(() => { if(!pokes()) return;
setInteracting(true);
const id = setTimeout(() => setInteracting(false), FRAME_IDLE_MS); onCleanup(() => clearTimeout(id)); });
createEffect(() => { if(!frame() || !stripEl) return;
stripEl.addEventListener('pointerdown', nudge);
onCleanup(() => stripEl.removeEventListener('pointerdown', nudge)); });
onMount(() => {
const onKey = e => {
if(!frame()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') exitFrame();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); frameGo(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); frameGo(-1); }
};
const onVis = async () => {
if(frame() && document.visibilityState === 'visible' && !wakeLock)
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
};
const onPop = () => {
if(frame()) closeFrame();
const st = history.state || {};
if(opened() && !st.lb) closePhoto();
if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }
};
window.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPop);
document.addEventListener('visibilitychange', onVis);
onCleanup(() => { window.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPop);
document.removeEventListener('visibilitychange', onVis); });
});
const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
createEffect(() => { if(!frame() || !zoomed()) return; pokes(); // any touch re-arms the countdown
const id = setTimeout(() => { const u = new URL(location.href);
u.searchParams.set('z', String(Date.now())); // an address the browser hasn't seen zoomed → it lands at 1:1
location.href = u.href; }, ZOOM_IDLE_MS);
onCleanup(() => clearTimeout(id)); });
const WEB_BEHIND = 1, WEB_AHEAD = 3, KEEP_BEHIND = 6, KEEP_AHEAD = 14;
const inReach = (k, behind, ahead) => { const t = (k - frameCenterIdx()) * frameDir(); // signed steps, in the way you're heading
return t >= -behind && t <= ahead; };
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD) // base layer:
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK; // thumbnail kept in the window, blank past it
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid) // overlay:
? IPFS + p.webCid : ''; // full-res leads the way you're going
const FrameSlide = (slide, k) => {
const thumbSrc = createMemo(() => thumbBand(slide(), k)); // base layer's source
const webSrc = createMemo(() => webBand(slide(), k)); // overlay's source ('' when far from centre)
const [thumbOn, setThumbOn] = createSignal(false); // the base thumbnail has painted
const [webOn, setWebOn] = createSignal(false); // the full-res overlay has painted
createEffect(() => { thumbSrc(); setThumbOn(false); }); // each layer re-arms its mark on its own source change
createEffect(() => { webSrc(); setWebOn(false); });
return html`
<div class="slide" role="listitem">
<${Show} when=${() => hasMedia(slide())}
fallback=${html`<div class="slide-media noimg">
<span class="ph">${() => isVideo(slide()) ? '🎬' : '🖼'}</span></div>`}>
<${Show} when=${() => isVideo(slide())}
fallback=${html`<div class="slide-pic">
<${Show} when=${() => !thumbOn()}>
<span class="ph load-ph" aria-label="loading">🖼</span><//>
<img class="slide-media" loading="lazy"
src=${thumbSrc} onLoad=${() => setThumbOn(true)} />
<${Show} when=${() => webSrc()}>
<img class="slide-media web" classList=${() => ({ shown: webOn() })}
loading="lazy" src=${webSrc} onLoad=${() => setWebOn(true)} />
<${Show} when=${() => thumbOn() && !webOn()}>
<span class="upgrading" aria-label="fetching full resolution"></span><//>
<//></div>`}>
<video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
<//>
<//>
</div>`;
};
const onFrameTap = e => {
const w = window.innerWidth || 1;
if(e.clientX < w / 3) frameGo(-1);
else if(e.clientX > w * 2 / 3) frameGo(1);
else setFrameUI(v => !v);
};
const TAP_SLOP = 10;
let tapFrom = null; // where a lone finger went down, while it could still be a tap
const tapPtrs = new Set();
const onTapDown = e => { tapPtrs.add(e.pointerId);
tapFrom = tapPtrs.size === 1 ? { x: e.clientX, y: e.clientY } : null; };
const onTapMove = e => { if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null; };
const onTapUp = e => { tapPtrs.delete(e.pointerId);
if(e.type === 'pointerup' && tapFrom) onFrameTap(e);
if(!tapPtrs.size) tapFrom = null; };
createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
const on = (t, h) => el.addEventListener(t, h), off = (t, h) => el.removeEventListener(t, h);
on('pointerdown', onTapDown); on('pointermove', onTapMove); on('pointerup', onTapUp); on('pointercancel', onTapUp);
onCleanup(() => { off('pointerdown', onTapDown); off('pointermove', onTapMove);
off('pointerup', onTapUp); off('pointercancel', onTapUp); }); });
const FRAME_UI_IDLE_MS = Number(new URLSearchParams(location.search).get('uiidle')) || 20000;
createEffect(() => { if(!frameUI()) return; pokes();
const id = setTimeout(() => setFrameUI(false), FRAME_UI_IDLE_MS); onCleanup(() => clearTimeout(id)); });
const frameParam = new URLSearchParams(location.search);
const SYNC_URL = frameParam.get('yws') || location.origin.replace(/^http/, 'ws') + '/ywebsocket';
const SYNC_ROOM = frameParam.get('room') || 'memories-nowshowing';
const [roomLink, setRoomLink] = createSignal('idle');
let showingRoom = null;
const nowShowing = () => {
if(!showingRoom){
const shared = new Y.Doc();
const provider = new WebsocketProvider(SYNC_URL, SYNC_ROOM, shared);
setRoomLink('connecting');
provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
showingRoom = shared.getMap('showing');
}
return showingRoom;
};
createEffect(() => {
if(!frame()) return;
pokes();
const d = frameDoc(); if(!d) return;
nowShowing().set('doc', { cid: d.cid, webCid: d.webCid, mimetype: d.mimetype, date: d.date });
});
const frameFromHere = () => { const cur = opened(); if(!cur) return;
localStorage.setItem(FRAME_CID_KEY, cur.cid); // the slide the frame will open on
closePhoto(); // hide the modal but leave its history entry, so Back returns to it
enterFrame(); };
const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };
const toLocalInput = iso => { if(!iso) return '';
const d = new Date(iso), p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
let cancelEdit = false;
const commitDate = v => { const skip = cancelEdit; cancelEdit = false; setEditingDate(false);
if(!skip && v) lbPatch({ date: new Date(v).toISOString() }); };
onMount(() => {
const onKey = e => {
if(e.key !== 'd' || !opened()) return; // only while a doc is open
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
e.preventDefault(); setEditingDate(true);
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
const EVENTS_FOR_DOC = `query($d:Datetime!,$o:OwnerType!){ eventsAt(d:$d, o:$o){ nodes{ summary starttime endtime } } }`;
const fetchDocEvents = async o => (await gql(EVENTS_FOR_DOC, { d: o.date, o: o.owner }))?.eventsAt?.nodes ?? [];
const dayBased = (s, en) => s.getUTCHours() === 0 && s.getUTCMinutes() === 0 && s.getUTCSeconds() === 0
&& en.getUTCHours() === 23 && en.getUTCMinutes() === 59 && en.getUTCSeconds() === 59;
const hhmm = t => t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const eventWhen = e => {
const s = new Date(e.starttime), en = new Date(e.endtime);
const sd = s.toLocaleDateString('fr-FR'), ed = en.toLocaleDateString('fr-FR');
if (dayBased(s, en)) return sd === ed ? sd : `${sd} – ${ed}`;
return sd === ed ? `${sd} ${hhmm(s)} – ${hhmm(en)}` : `${sd} ${hhmm(s)} – ${ed} ${hhmm(en)}`;
};
// run the occasion's own event: search — the pill's jump into it
const searchEvent = summary => { setSearch('event:' + summary); commit(); };
const [lbEvents] = createResource(
() => { const o = opened(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
const [frameEvents] = createResource(
() => { const o = frameDoc(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
const CURSOR_KEY = 'memories.cursor';
const [cursor, setCursor] = createSignal(localStorage.getItem(CURSOR_KEY));
createEffect(() => { const c = cursor(); c ? localStorage.setItem(CURSOR_KEY, c) : localStorage.removeItem(CURSOR_KEY); });
let gridEl, rangeBase = new Set(), extending = false;
const gridCols = () => gridEl ? getComputedStyle(gridEl).gridTemplateColumns.split(' ').length : 1;
const moveCursor = (delta, extend) => {
const list = items(); if(!list.length) return;
const at = list.findIndex(p => p.cid === cursor());
const ni = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + delta));
setCursor(list[ni].cid);
if(extend && anchor() !== null){ extendRun(list[ni].cid); } // grow-or-shrink the run to the cursor
else { extending = false; setAnchor(list[ni].cid); } // a plain move re-anchors and ends the run
requestAnimationFrame(scrollCursorIntoView);
};
const scrollCursorIntoView = () => {
const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
const r = tile.getBoundingClientRect();
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
if(r.top < 0) scrollBy(0, r.top); // head above the fold → pull it down
else if(r.bottom > floor) scrollBy(0, r.bottom - floor); // foot under the bar → push it up
};
createEffect(() => {
const shown = selCount() > 0; // tracked synchronously; the toolbar mounts on this turn
requestAnimationFrame(() => {
const bar = shown && document.querySelector('.toolbar');
document.body.style.paddingBottom = bar ? bar.getBoundingClientRect().height + 'px' : '';
});
});
onMount(() => {
const onKey = e => {
if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
if(e.key === 'ArrowRight') step(1);
else if(e.key === 'ArrowLeft') step(-1);
else if(e.key === 'ArrowDown') step(gridCols());
else if(e.key === 'ArrowUp') step(-gridCols());
else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
else if(e.key === 'Enter' && cursor()){ e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){ e.preventDefault(); setSelected(new Set(shownCids())); }
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
history.scrollRestoration = 'manual'; // Back must not undo the follow (see prose)
createEffect(() => { const c = opened()?.cid || (frame() ? frameCenterCid() : null); if(!c) return;
setCursor(c);
gridEl?.children[items().findIndex(p => p.cid === c)]?.scrollIntoView({ block: 'nearest' }); });
const [marquee, setMarquee] = createSignal(null); // {x0,y0,x1,y1} in client coords, or null
let marqueeFrom = null;
const marqueeRect = m => ({ l: Math.min(m.x0, m.x1), r: Math.max(m.x0, m.x1),
t: Math.min(m.y0, m.y1), b: Math.max(m.y0, m.y1) });
const marqueeSelect = () => {
const m = marquee(); if(!m) return;
const r = marqueeRect(m), list = items(), kids = gridEl.children, next = new Set(selected());
for(let i = 0; i < kids.length && i < list.length; i++){
const b = kids[i].getBoundingClientRect();
if(b.left < r.r && b.right > r.l && b.top < r.b && b.bottom > r.t) next.add(list[i].cid);
}
setAllMatching(false); setSelected(next);
};
const onGridDown = e => {
if(e.pointerType === 'touch' || e.button !== 0 || e.target !== gridEl || !canRange()) return;
marqueeFrom = { x: e.clientX, y: e.clientY };
setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
gridEl.setPointerCapture?.(e.pointerId);
};
const onGridMove = e => {
if(!marqueeFrom) return;
setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
marqueeSelect();
};
const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };
onMount(() => {
const onKey = e => {
if((e.key !== 'l' && e.key !== '.') || frame()) return; // the frame has its own bar
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
const box = opened() ? document.querySelector('.lb .batch-label')
: selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
if(!box) return;
e.preventDefault();
if(e.key === '.' && lastLabel()) // '.' re-drops the label applied last
(opened() ? setLbText : setLabelText)(lastLabel());
box.focus();
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
onMount(() => {
history.pushState({ app: true }, ''); // the root entry the back button stops on
const onExit = () => {
const st = history.state || {};
if(!frame() && !opened() && !st.lb && !st.app){ // popped below the root with nothing open
if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; } // bail out of a frozen read; stay
if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; } // retract the list; keep the root beneath
if(confirm('Leave Memories?')) history.back(); // really leave
else history.pushState({ app: true }, ''); // stay — restore the root
}
};
window.addEventListener('popstate', onExit);
onCleanup(() => window.removeEventListener('popstate', onExit));
});
return html`
<${Show} when=${() => authNeeded()}>
<div class="authwall" role="alert">
<strong>Authorization required.</strong>
This device can't read your photos yet — open a fresh access link on it.
</div>
<//>
<${Show} when=${() => photos.error && !authNeeded()}>
<div class="loadwall" role="alert">
<strong>Couldn't load your photos.</strong>
The server returned an error — try again in a moment.
</div>
<//>
<h1>Memories</h1>
<div class="complete">
<input class=${() => dirty() ? 'search dirty' : 'search'} type="search" role="combobox" aria-label="search labels"
disabled=${() => photos.loading}
aria-description=${() => dirty() ? 'search edited — not yet applied; press Enter or the search button' : undefined}
aria-expanded=${() => searchFocus() && !opened() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
placeholder="labels; since:2010; until:2015; type:video; sort:random"
value=${() => search()}
onInput=${e => { setSearch(e.target.value); setCaret(e.target.selectionStart);
setSearchFocus(true); }}
onKeyUp=${e => setCaret(e.target.selectionStart)}
onClick=${e => setCaret(e.target.selectionStart)}
onFocus=${() => setSearchFocus(true)}
onKeyDown=${e => sugNav(e, w => { if(w) pickSearch(w); else commit(); })}
onBlur=${() => setSearchFocus(false)} />
<button class="search-run" aria-label="run search" disabled=${() => photos.loading}
onMouseDown=${e => e.preventDefault()} onClick=${commit}>🔍</button>
<${Show} when=${() => searchFocus() && !opened()}>
<${Suggest} text=${search} caret=${caret} dsl=${true}
active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading} onPick=${pickSearch} />
<//>
</div>
<div class="chips" role="group" aria-label="filter by state">
${['all', ...STATES].map(st => html`
<button class="chip" data-st=${st} disabled=${() => photos.loading}
aria-pressed=${() => stateFilter() === st ? 'true' : 'false'}
onClick=${() => setStateFilter(st)}>${st}</button>`)}
<button class="chip selall" aria-pressed=${() => allSelected() ? 'true' : 'false'}
onClick=${toggleAll}>${() => allSelected() ? 'clear' : 'select all'}</button>
<button class="chip frame-start" onClick=${enterFrame}>▶ frame</button>
</div>
<${Show} when=${() => selCount() > 0}>
<div class="toolbar" role="toolbar" aria-label="selection actions">
<!-- scope: what the actions apply to -->
<span class="count">${() => allMatching() ? `all ${total()} matching` : `${selCount()} selected`}</span>
<button class="allmatch" aria-pressed=${() => allMatching() ? 'true' : 'false'}
onClick=${() => setAllMatching(m => !m)}>all ${() => total()} matching</button>
<button class="range" aria-pressed=${() => rangeMode() ? 'true' : 'false'}
disabled=${() => !canRange()}
title=${() => canRange() ? undefined : 'a spread is not a run — range select is off here'}
onClick=${() => setRangeMode(m => !m)}>↔ range</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- label edit -->
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="label for the selection"
aria-expanded=${() => labelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
onFocus=${() => setLabelFocus(true)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
onBlur=${() => setLabelFocus(false)} />
<${Show} when=${() => labelFocus()}>
<${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setLabelText(replaceSeg(labelText(), w) + '; ')} />
<//>
</div>
<button aria-label="add label" onClick=${addLabel}>+ label</button>
<button aria-label="remove label" onClick=${removeLabel}>- label</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- move the selection onto an occasion -->
<div class="complete">
<input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
value=${() => moveText()}
onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
onFocus=${() => setMoveFocus(true)}
onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- stamp the selection with one instant -->
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</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.
@testcase
def test_thumbs_load_in_view_and_unload(page):
docs =[{"cid": f"https://ipfs.konubinix.eu/p/zzvpl-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzvpl-t-{i}",
"labels": "zzvpl", "state": "todo"} for i in range(60)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 400, "height": 500}) # small, so 60 tiles overflow well past the fold
open_app(page)
search_for(page, "zzvpl") # default chip = todo; all 60 are todo
expect(tiles(page)).to_have_count(60)
last = thumb_imgs(page).last
expect(last).not_to_have_attribute("src", re.compile(r"/ipfs/")) # below the fold → unloaded
last.scroll_into_view_if_needed()
expect(last).to_have_attribute("src", re.compile(r"/ipfs/")) # scrolled in → loaded
tiles(page).first.scroll_into_view_if_needed()
expect(last).not_to_have_attribute("src", re.compile(r"/ipfs/")) # left again → unloaded
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: thumbs load in view and unload when they leave")
One shared observer serves every tile (a WeakMap from element to its setter), with a
300px margin so an image is ready a little before it scrolls in and dropped a little
after it scrolls out. Each image registers on mount and unobserves on cleanup, so a
search that swaps the wall doesn’t leak observations.
// a 1×1 transparent gif — what a tile shows when its image is unloaded (the .thumb
// background fills the square)
const BLANK = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const watchVisible = (() => {
const cbs = new WeakMap();
const io = new IntersectionObserver(
entries => entries.forEach(e => cbs.get(e.target)?.(e.isIntersecting)),
{ rootMargin: '300px' });
return (el, set) => {
if(!el) return;
cbs.set(el, set);
io.observe(el);
onCleanup(() => { io.unobserve(el); cbs.delete(el); });
};
})();
Saying when the wall is sampled
When the range holds more than the ~2000 memories samples to, silently showing a slice
reads as “this is everything”, which is a lie. So the query also asks photovideosCount for
the true total, and a line appears naming the slice against it — showing a spread of N from M — narrow… when the sampler is the one that chose.
The subtle part is when: the notice must fire only on genuine sampling. Every match is
shown (no thumbnail → a placeholder tile), so “shown” equals
what the server returned, and the honest signal is simply whether the server dropped
rows — it returned fewer nodes than match. That’s the sampled flag, and it’s what the
notice watches; a thumbnail-less match never raises it.
@testcase
def test_sampling_notice(page):
"""A genuinely over-cap range (the whole archive) shows the spread notice."""
open_app(page)
chip(page, "all").click() # the whole archive dwarfs the ~2000 sample
expect(page.get_by_text(re.compile(r"showing a spread of \d+ from \d+"))).to_be_visible()
print(" PASS: sampling notice")
@testcase
def test_count_always_shown(page):
"""Even within the cap, the count is shown — 'showing all N'."""
open_fixtures(page) # 3 fixtures, well under the cap
expect(page.get_by_text("showing all " + str(len(FIXTURES)))).to_be_visible()
print(" PASS: count always shown")
A spread is not the only slice the wall can be holding. Ask it for the first or last twenty and twenty is what arrives, but calling those a spread would be the same lie in smaller form — they are one end of the match, not a taste of the whole of it. So the notice names the slice it is holding. It advises only where advice helps: a count you typed is one you can raise yourself. And the distinction reaches past the notice — an end is a run of tiles where a spread is not, and a run is what a range selection needs to mean anything.
| the wall holds | the line reads | it advises | range selection |
|---|---|---|---|
| every match | showing all N | — | yes |
| the first N | showing the first N of M | — | yes |
| the last N | showing the last N of M | — | yes |
| a spread | showing a spread of N from M | narrow it | no |
open_app(page)
search_for(page, "zzpick; first:2")
expect(page.get_by_text(re.compile(r"showing the first 2 of \d+$"))).to_be_visible() # $: nothing follows
search_for(page, "zzpick; last:2")
expect(page.get_by_text(re.compile(r"showing the last 2 of \d+$"))).to_be_visible()
search_for(page, "zzpick; sample:4")
expect(page.get_by_text(re.compile(r"showing a spread of 4 from \d+"))).to_be_visible()
expect(page.get_by_text(re.compile(r"narrow the search or date range"))).to_be_visible()
The wall re-queries on every filter change; while that query is in flight it marks the grid
aria-busy, so a screen reader holds its reading — and anything watching can wait for the
settled result rather than read a wall mid-load.
@testcase
def test_wall_reports_busy(page):
"""The wall marks the grid aria-busy while a query is in flight, false once settled."""
open_app(page)
g = grid(page)
expect(g).to_have_attribute("aria-busy", "false") # settled at rest
# hold the wall query so a fresh fetch stays in flight (the page.route trick the
# completion in-flight test uses) — the busy state is then deterministically observable.
page.route("**/graphql", lambda route:
route.fallback() if "photovideosSample" not in (route.request.post_data or "") else None)
search_box(page).fill("zzbusyprobe")
page.get_by_role("button", name="run search").click() # commit → a wall fetch that never resolves
expect(g).to_have_attribute("aria-busy", "true") # in flight → busy
print(" PASS: wall reports busy")
The busy state also covers an edit’s writes: a mutating counter wraps each batch edit from
its first write to the finally after it, so aria-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.)
The network is held in the test, so “typing does not read, committing does” is checked as a fact on the wire — no clock, no wait. (One read runs at a time by construction: the controls lock during a read, so a second commit can’t even be issued — that lock is covered by its own test.)
@testcase
def test_wall_reads_only_on_commit(page):
"""The wall re-reads only on commit, not on typing: typing a query fires NO wall read; the
run-search button fires exactly one, for what was typed. Reads are held, so what fires (and
doesn't) is a fact on the wire."""
open_fixtures(page)
seen = []
page.on("request", lambda r: "photovideosSample" in (r.post_data or "")
and seen.append((json.loads(r.post_data).get("variables") or {}).get("search", "")))
page.route("**/graphql", lambda r:
None if "photovideosSample" in (r.request.post_data or "") else r.fallback()) # hold every read
base = len(seen)
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzcommit", delay=0)
expect(sb).to_have_value("zzcommit") # the box shows it (the reactive tick ran)…
assert len(seen) == base, f"typing re-read the wall without a commit: {seen[base:]}" # …but no read fired
page.get_by_role("button", name="run search").click() # commit
expect(page.get_by_text("searching «zzcommit»")).to_be_visible() # now it reads (held)
assert seen[base:] == ["zzcommit"], f"commit fired the wrong reads: {seen[base:]}"
print(" PASS: wall reads only on commit")
Enter commits too, when no completion is highlighted — so a keyboard user runs a query without reaching for the button. (Enter with a suggestion highlighted applies it instead; that path is the completion test’s.)
@testcase
def test_enter_commits_search(page):
"""Enter with no completion highlighted commits the search — the wall reads it (held)."""
open_fixtures(page)
page.route("**/graphql", lambda r:
None if ("photovideosSample" in (r.request.post_data or "") and "zzenter" in (r.request.post_data or "")) else r.fallback())
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzenter", delay=0)
sb.press("Enter") # commit — nothing highlighted
expect(page.get_by_text("searching «zzenter»")).to_be_visible() # the wall read the typed query
print(" PASS: enter commits search")
Since the box can hold an unrun query, it flags itself stale until committed — a different
border colour, and (for non-colour a11y) an aria-description. Both clear on commit.
@testcase
def test_search_bar_marks_uncommitted_edit(page):
"""A query typed but not run marks the box stale — a different border colour plus an
aria-description — and committing clears both."""
open_fixtures(page)
sb = search_box(page)
clean = sb.evaluate("el => getComputedStyle(el).borderColor")
assert sb.get_attribute("aria-description") is None # committed → nothing stale
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzdirty", delay=0)
expect(sb).to_have_value("zzdirty") # the edit landed in the box
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # non-colour cue
assert sb.evaluate("el => getComputedStyle(el).borderColor") != clean, "edited box wasn't recoloured"
page.get_by_role("button", name="run search").click() # commit
expect(sb).to_be_enabled() # the read settled
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # cleared on commit
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "colour didn't revert after commit"
print(" PASS: search bar marks uncommitted edit")
The empty query is a query too: with the box cleared the cue names the whole library —
«everything» — rather than a blank. Held open at load, that empty-search read shows it.
@testcase
def test_wall_cue_names_empty_query_everything(page):
"""With no search, the in-flight cue reads «everything», not a blank term. The initial
read is held open so the empty-query label is observable on its own."""
page.route("**/graphql", lambda r: # hold the wall read; let everything else through
None if "photovideosSample" in (r.request.post_data or "") else r.fallback())
open_app(page) # fresh context → empty search → read held
expect(page.get_by_text("searching «everything»")).to_be_visible()
print(" PASS: wall cue names empty query everything")
Locking is observable as disabled: hold a read open and the search box and the state chips
report themselves disabled, so the query cannot drift from what is in flight.
@testcase
def test_query_controls_freeze_while_reading(page):
"""While a wall read is in flight the query is locked: the search box and the state chips
are disabled, so what's on screen can't drift from what's being fetched. The read is held
open, so the locked state is observed as a fact, not on a timer."""
open_fixtures(page)
page.route("**/graphql", lambda r: # hold the next wall read open
None if "photovideosSample" in (r.request.post_data or "") else r.fallback())
search_box(page).fill("zzlockcheck")
page.get_by_role("button", name="run search").click() # commit → its (held) read starts
expect(search_box(page)).to_be_disabled() # search frozen while the read is out
expect(chip(page, "todo")).to_be_disabled() # …and the state chips
print(" PASS: query controls freeze while reading")
A lock needs a key. A read that drags — a slow event: filter over the whole archive — would
otherwise trap you behind the frozen controls, so three gestures bail out: tapping the
searching «…» pill, Escape, or the back button. Any of them aborts the in-flight read and
falls back to the last query the wall actually showed (or, on the first read with nothing settled
yet, to everything): the controls free at once, the box reverts to that last good query, and the
abandoned read’s connection is aborted — not left running against the server, which for a slow
query is the whole point. The pill is the cancel button (a screen reader hears cancel search: …); Back reaches the same hatch through the exit guard that retracts the completion list, one
rung before the leave prompt; Escape through a window key listener, since the disabled box
can’t hear its own keydown. An edit’s updating… is not cancellable — its refetch is re-reading
a committed change, not a query worth abandoning.
@testcase
def test_escape_cancels_frozen_read(page):
"""Esc bails out of a read in flight: it aborts the read, unlocks the controls, and reverts
the box to the last query the wall actually showed. Only the slow read is held, so the
fall-back read settles and unlocks — and the abort is asserted, not just the unlock."""
open_fixtures(page) # settles on FIXTURE_LABEL → 3 tiles
aborted = []
page.on("requestfailed", lambda r: "/graphql" in r.url and aborted.append(1))
page.route("**/graphql", lambda r: # hold ONLY the slow query; let the fall-back through
None if ("photovideosSample" in (r.request.post_data or "") and "zzheld" in (r.request.post_data or "")) else r.fallback())
search_box(page).fill("zzheld")
page.get_by_role("button", name="run search").click() # commit → its (held) read → frozen
expect(search_box(page)).to_be_disabled()
expect(page.get_by_text("searching «zzheld»")).to_be_visible()
page.keyboard.press("Escape")
expect(search_box(page)).to_be_enabled() # unlocked
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # reverted to the last settled query
expect(tiles(page)).to_have_count(len(FIXTURES)) # those results again → no error state
wait_until(page, lambda: bool(aborted), label="held read aborted") # connection cancelled, not leaked
print(" PASS: escape cancels frozen read")
The back button reaches the same hatch — and one Back further still asks before leaving, so the history root stays intact.
@testcase
def test_back_cancels_frozen_read(page):
"""Back bails out of a frozen read like Esc, and stays in the app — a further Back still
meets the leave guard."""
open_fixtures(page)
page.route("**/graphql", lambda r:
None if ("photovideosSample" in (r.request.post_data or "") and "zzheld" in (r.request.post_data or "")) else r.fallback())
search_box(page).fill("zzheld")
page.get_by_role("button", name="run search").click() # commit → held read → frozen
expect(search_box(page)).to_be_disabled()
page.go_back() # Back bails out
expect(search_box(page)).to_be_enabled()
expect(search_box(page)).to_have_value(FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
asked = []
page.on("dialog", lambda d: (asked.append(1), d.dismiss()))
page.go_back() # a further Back reaches the leave guard
wait_until(page, lambda: bool(asked))
expect(search_box(page)).to_be_visible() # cancelled → still in Memories
print(" PASS: back cancels frozen read")
The boot edge: on the very first read there is no settled query to fall back to, so bailing out lands on an unlocked wall rather than re-locking on the same slow read.
@testcase
def test_escape_during_boot_read_unlocks(page):
"""Esc during a slow FIRST read (nothing settled yet) unlocks rather than re-locking on the
same query — the abort resolves the read to an empty wall."""
page.route("**/graphql", lambda r: # hold every wall read → the boot read hangs
None if "photovideosSample" in (r.request.post_data or "") else r.fallback())
open_app(page)
expect(search_box(page)).to_be_disabled() # frozen on the boot read
page.keyboard.press("Escape")
expect(search_box(page)).to_be_enabled() # unlocked, not re-locked
print(" PASS: escape during boot read unlocks")
And the pill itself is the cancel button: tapping it reaches the same hatch, found by its accessible name rather than a keystroke.
@testcase
def test_pill_tap_cancels_frozen_read(page):
"""Tapping the searching pill bails out like Esc/Back — the pill IS the cancel button,
reached by its accessible name."""
open_fixtures(page)
aborted = []
page.on("requestfailed", lambda r: "/graphql" in r.url and aborted.append(1))
page.route("**/graphql", lambda r:
None if ("photovideosSample" in (r.request.post_data or "") and "zzheld" in (r.request.post_data or "")) else r.fallback())
search_box(page).fill("zzheld")
page.get_by_role("button", name="run search").click() # commit → held read → frozen
expect(search_box(page)).to_be_disabled()
page.get_by_role("button", name=re.compile("cancel search")).click() # tap the pill
expect(search_box(page)).to_be_enabled() # unlocked
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # reverted to the last settled query
expect(tiles(page)).to_have_count(len(FIXTURES))
wait_until(page, lambda: bool(aborted), label="held read aborted")
print(" PASS: pill tap cancels frozen read")
Placeholders for docs with no media
Not every row has its renditions. A match without a thumbnail still gets a tile — a placeholder with an icon — so it’s reachable rather than silently dropped, and it raises no sampling notice.
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1) # the no-thumb one shows too
expect(thumb_imgs(page)).to_have_count(len(FIXTURES)) # but it isn't an image
expect(grid(page).get_by_text("🖼")).to_have_count(1) # it wears the icon, so it reads as a placeholder
expect(page.get_by_text(re.compile(r"showing a spread"))).to_have_count(0) # and no notice
Opening it, when there’s no web_cid either, shows a “no preview” placeholder instead of a
broken image.
search_for(page, NOMEDIA_LABEL) # a label only this doc carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
expect(d.get_by_text("no preview")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # nothing broken to show
expect(d.locator("video")).to_have_count(0)
Which placeholder you get turns on what the doc is. An image missing its web copy still has its thumbnail, and a stretched thumbnail is the same picture, only softer — it answers what was asked. So the lightbox enlarges it rather than refusing the doc.
d.get_by_role("button", name="close").click() # back to the wall
search_for(page, "zznwimg") # an image with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zznw-img-t") # its poster, stretched
A video in the same state is refused: the lightbox opens a clip in order to play it, and a poster cannot be played — offering the still would put a photograph where a film was asked for.
d.get_by_role("button", name="close").click()
search_for(page, "zznwvid") # a clip with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_text("no preview · video/mp4")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # and the poster is not offered in its place
Drawing our own spread
The wall’s spread is ours to draw. The frise’s photovideos_search quantises its
keep-threshold so that panning its timeline keeps the same photos on screen — but
memories never pans, and that quantisation only ever under-fills the cap: ask for the cap and
you get a few short of it. So memories samples for itself, over
the filter the frise factored out (photovideos_match): the cap rows with the smallest
cid-hash — an even, stable spread — re-ordered by date. Exactly the cap, no quantisation.
An over-cap query — the whole archive — shows exactly the cap.
@testcase
def test_sample_fills_cap(page):
"""An over-cap query shows exactly the cap — memories draws its own spread, unquantised."""
open_app(page)
chip(page, "all").click() # the whole archive ≫ the cap
expect(page.get_by_text(re.compile(r"showing a spread of 2000 from \d+"))).to_be_visible()
print(" PASS: sample fills the cap")
An even spread is one answer to the question a big match forces — which of it to show — and
often it is the wrong one. You want the start of a holiday, or the last few frames of a
run, not a taste of the whole. So the box says which: first:20 keeps the twenty earliest,
last:20 the twenty latest, sample:20 twenty taken from across the whole match, as before.
Each is also how many, so a count stands in for the wall’s own cap when you name one.
Eight photos, one a day through the first eight days of a January, make the difference plain: asked for two, the wall hands back either end of those eight.
search_for(page, "zzpick; first:2")
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-01", "2020-01-02"], f"not the earliest two: {tile_dates(page)}"
search_for(page, "zzpick; last:2")
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-07", "2020-01-08"], f"not the latest two: {tile_dates(page)}"
Ask instead for a spread of four and you get neither end but days from across the eight — with
gaps, which is what tells a spread from any run of four. However the rows were chosen
the wall reads the way it always does, oldest first, so last:2 is the last two in date
order, not handed back reversed.
search_for(page, "zzpick; sample:4")
expect(tiles(page)).to_have_count(4)
d = tile_dates(page)
assert d == sorted(d), f"a spread must still read oldest first: {d}"
days = [int(x[-2:]) for x in d]
assert max(b - a for a, b in zip(days, days[1:])) > 1, f"contiguous — that is a run, not a spread: {d}"
It’s a thin function over photovideos_match, and the LIMIT makes the count exact, with
none of the threshold’s variance; the frise
can’t use a LIMIT because it would reshuffle as the window pans, but memories holds
still. That same LIMIT is where the choice of rows lives: all a count token changes is what
it counts along — the date upward, the date downward, or the cid-hash that scatters — while
the ordering outside it stays put, which is what keeps the wall oldest-first whichever end the
rows came from. It carries the same filter arguments, so when the filter gains one —
now events_x, the -event: exclusion (eventsX to the app, snake-cased for Postgres) — the
sample inherits it and an excluded-event search samples like any other.
Applying it takes two steps together: the docs DB, then a PostGraphile restart so the new
pick arg is reflected. The app sends that arg, so a build meeting an un-restarted PostGraphile
is turned away for an unknown argument.
BEGIN;
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[]);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[], int, int);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[], int, int, text);
DROP FUNCTION IF EXISTS photovideos_sample(text, timestamptz, timestamptz, state[], text[], date, int, int, owner_type[], int, int, text, text);
CREATE OR REPLACE FUNCTION photovideos_sample(search text, since timestamptz, until timestamptz,
states state[] DEFAULT NULL, kinds text[] DEFAULT NULL,
aday date DEFAULT NULL, awin int DEFAULT 1, cap int DEFAULT 300,
owners owner_type[] DEFAULT NULL,
amonth int DEFAULT NULL, mwin int DEFAULT 0,
events text DEFAULT NULL, events_x text DEFAULT NULL,
pick text DEFAULT NULL)
RETURNS SETOF photovideo LANGUAGE sql STABLE AS $f$
SELECT * FROM (
SELECT * FROM photovideos_match(search, since, until, states, kinds, aday, awin, owners, amonth, mwin, events, events_x)
ORDER BY CASE pick WHEN 'first' THEN extract(epoch FROM date)
WHEN 'last' THEN -extract(epoch FROM date)
ELSE hashtext(cid) END
LIMIT cap
) q ORDER BY q.date
$f$;
COMMIT;
| BEGIN |
|---|
| DROP FUNCTION |
| DROP FUNCTION |
| DROP FUNCTION |
| DROP FUNCTION |
| DROP FUNCTION |
| CREATE FUNCTION |
| COMMIT |
Seeing a doc’s labels
You can’t triage what you can’t see: each tile shows the labels already on the doc, as
a caption across its foot (over a dark gradient so it reads on any photo, one line with
an ellipsis when there are many — the title attribute carries the full list on hover).
Tiles with no labels show nothing. The state stays as a small badge in the corner.
@testcase
def test_tiles_show_labels(page):
"""Each tile captions the labels already assigned to the doc."""
open_fixtures(page)
expect(grid(page).get_by_text(FIXTURE_LABEL).first).to_be_visible()
print(" PASS: tiles show labels")
Events on the wall
A thumbnail is a moment with its occasion stripped away: you see the scene, not that it was a swim, a birthday, a week in the mountains. The calendar holds the occasion, and the lightbox already names it for the open doc — here it rides onto the tile itself, so the occasion stays with the photo while you triage. That is worth doing for two reasons. You read a photo back in its context, remembering what it belonged to. And because the wall runs in date order, one occasion’s photos land in a run — so a photo whose date has drifted out of its occasion, a wrong timestamp carrying it weeks off, strands outside that run, where the eye catches it.
A run only reads as a run once the occasions are told apart at a glance, and that is colour’s job: each event takes its own contrasted hue, so a stretch of one occasion carries one colour and a break in the colour is a break in the occasion. We reach it in two steps — first the pill, then its colour. A photo taken during an event shows a small pill on its tile, captioned with the event’s summary.
@testcase
def test_tiles_show_event_pill(page):
"""A photo taken during a calendar event captions that event on its tile."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-wallpill", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
"summary": "zzWallOccasion", "owner": "konubinix", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zzwallpill", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzwallpill-t", "labels": "zzwallpill", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzwallpill")
expect(tiles(page)).to_have_count(1)
expect(grid(page).get_by_text("zzWallOccasion")).to_have_count(1) # the occasion, pilled on the tile
finally:
gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: tiles show event pill")
The events can’t ride on each photo the way its labels do: a photo’s occasions come from
the calendar, the lookup the lightbox runs one doc at a time. Per tile that would be a
query per photo, hundreds for a screen — so the wall reads the shared layer’s
events_in_window once: every event overlapping the range the wall shows, capped above the
whole calendar so the connection never truncates the window before we can use it.
const EVENTS_IN_WINDOW = `query($since:Datetime!,$until:Datetime!){
eventsInWindow(since:$since, until:$until, first:5000){ nodes{ summary starttime endtime owner } } }`;
const fetchWindowEvents = async w => (await gql(EVENTS_IN_WINDOW, w))?.eventsInWindow?.nodes ?? [];
That read follows the wall’s own window — a resource keyed on the running query’s date range, so it refetches only when the range moves and a label-only search keeps its events. In practice the key is that range serialised to a string, not the object holding it, so an unrelated re-render with the same window doesn’t refetch (object identity would).
const eventWindow = createMemo(() => { const k = JSON.parse(running().key);
return JSON.stringify({ since: k.since, until: k.until }); });
const [wallEvents] = createResource(eventWindow, w => fetchWindowEvents(JSON.parse(w)));
A photo’s own events are the ones that bracket it: the same owner — two people can hold an occasion on the same day — and its date inside the event’s span.
const docEvents = photo => { const evs = wallEvents(); if(!evs || !photo.owner) return [];
const t = new Date(photo.date).getTime();
return evs.filter(e => e.owner === photo.owner
&& new Date(e.starttime).getTime() <= t && t <= new Date(e.endtime).getTime()); };
A pill that only names an occasion marks it but doesn’t set its run apart from the neighbours. So each event is given a colour of its own, from a small palette of contrasted light pastels: adjacent occasions land on different hues, so a stretch of one occasion reads as a single colour band and the eye catches where one ends and the next begins. Far-apart occasions may reuse a hue — the palette cycles — which costs nothing, because the reading that matters is local, one run against the next. The colours aren’t fixed to particular occasions: stability across searches buys nothing here, so they are handed out afresh each render, in the order occasions first appear down the wall, keyed to each occasion’s identity so two same-named ones still differ.
@testcase
def test_event_pills_coloured_per_event(page):
"""One event's pills share a colour; a different event takes a different one — so a
run of an occasion reads as a single colour band."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
evs = [{"rowId": "zzev-cola", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
"summary": "zzColA", "owner": "konubinix", "status": "confirmed"},
{"rowId": "zzev-colb", "starttime": "2038-08-01T00:00:00Z", "endtime": "2038-08-30T23:59:59Z",
"summary": "zzColB", "owner": "konubinix", "status": "confirmed"}]
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzcol-a1", "date": "2038-06-10T12:00:00Z"}, # inside zzColA
{"cid": "https://ipfs.konubinix.eu/p/zzcol-a2", "date": "2038-06-20T12:00:00Z"}, # inside zzColA
{"cid": "https://ipfs.konubinix.eu/p/zzcol-b", "date": "2038-08-10T12:00:00Z"}] # inside zzColB
for d in docs: d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
"labels": "zzcol", "owner": "konubinix", "state": "todo"})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzcol; since:2038; until:2038")
expect(tiles(page)).to_have_count(3)
bg = "el => getComputedStyle(el).backgroundColor"
a = grid(page).get_by_text("zzColA", exact=True)
expect(a).to_have_count(2)
a0, a1 = a.nth(0).evaluate(bg), a.nth(1).evaluate(bg)
b = grid(page).get_by_text("zzColB", exact=True).evaluate(bg)
assert a0 == a1, f"same-event pills should share a colour: {a0} vs {a1}"
assert a0 != b, f"different events should differ in colour: A={a0} B={b}"
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: event pills coloured per event")
That assignment is a single pass over the shown wall:
const EVENT_HUES = ['#8ecae6', '#ffb703', '#90be6d', '#ff8fab', '#bdb2ff', '#ffd6a5', '#a0c4ff', '#caffbf'];
const eventKey = e => e.summary + '|' + e.starttime + '|' + e.owner;
const eventColour = createMemo(() => { const m = new Map(); let i = 0;
for(const p of items()) for(const e of docEvents(p))
if(!m.has(eventKey(e))) m.set(eventKey(e), EVENT_HUES[i++ % EVENT_HUES.length]);
return m; });
On the tile the pill sits along the very bottom edge, beneath the label — so the coloured strips line up tile to tile and the band carries unbroken across a run. Each is filled with its occasion’s hue, dark on the light pastel so the name still reads.
@testcase
def test_event_pill_sits_below_label(page):
"""The event pill sits along the tile's foot, beneath the label — so the colour band
carries unbroken from one tile to the next."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-foot", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
"summary": "zzFootEv", "owner": "konubinix", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zzfoot", "date": "2038-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfoot-t", "labels": "zzfootlbl", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzfootlbl; since:2038; until:2038")
expect(tiles(page)).to_have_count(1)
lbl = grid(page).get_by_text("zzfootlbl").bounding_box()
pill = grid(page).get_by_text("zzFootEv").bounding_box()
assert pill["y"] > lbl["y"], f"event pill should sit below the label: pill={pill['y']} label={lbl['y']}"
finally:
gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event pill sits below label")
<${Show} when=${() => docEvents(photo).length}>
<div class="events">
<${For} each=${() => docEvents(photo)}>${e => html`
<span class="ev-pill" style=${() => 'background:' + eventColour().get(eventKey(e))}>${() => e.summary}</span>`}<//>
</div>
<//>
.tile .events{ display:flex; flex-wrap:wrap; gap:3px; padding:4px 5px 0; pointer-events:none; }
.tile .ev-pill{ font-size:9px; line-height:1.35; padding:0 6px; border-radius:999px;
background:#cbd5e1; color:#101426; max-width:100%; overflow:hidden;
text-overflow:ellipsis; white-space:nowrap; }
An occasion rarely has a photo to itself, and occasions can overlap. A photo caught inside two at once — an afternoon out within a week away — wears a pill for each, in each event’s own colour, so both read at a glance.
@testcase
def test_overlapping_events_show_two_pills(page):
"""A photo inside two overlapping events wears a pill for each, in each event's colour."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
evs = [{"rowId": "zzev-ova", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
"summary": "zzOvA", "owner": "konubinix", "status": "confirmed"},
{"rowId": "zzev-ovb", "starttime": "2038-06-10T00:00:00Z", "endtime": "2038-06-20T23:59:59Z",
"summary": "zzOvB", "owner": "konubinix", "status": "confirmed"}]
doc = {"cid": "https://ipfs.konubinix.eu/p/zzov", "date": "2038-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzov-t", "labels": "zzov", "owner": "konubinix", "state": "todo"}
for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzov; since:2038; until:2038")
expect(tiles(page)).to_have_count(1)
a = grid(page).get_by_text("zzOvA", exact=True)
b = grid(page).get_by_text("zzOvB", exact=True)
expect(a).to_have_count(1); expect(b).to_have_count(1) # both occasions, on the one tile
bg = "el => getComputedStyle(el).backgroundColor"
assert a.evaluate(bg) != b.evaluate(bg), "overlapping events should differ in colour"
finally:
gql(DELETE, {"cid": doc["cid"]})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: overlapping events show two pills")
A photo dated outside every occasion wears no pill — the gap in the band that flags a stray date, the wrong timestamp that dropped it clear of the run it belongs to.
@testcase
def test_out_of_event_doc_has_no_pill(page):
"""A photo dated outside every occasion wears no pill — only the in-span photo does."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-out", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
"summary": "zzOutEv", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzout-in", "date": "2038-06-15T12:00:00Z"}, # inside the occasion
{"cid": "https://ipfs.konubinix.eu/p/zzout-off", "date": "2038-08-15T12:00:00Z"}] # months past it → no occasion
for d in docs: d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
"labels": "zzout", "owner": "konubinix", "state": "todo"})
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzout; since:2038; until:2038")
expect(tiles(page)).to_have_count(2)
expect(grid(page).get_by_text("zzOutEv", exact=True)).to_have_count(1) # only the in-span doc
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: out-of-event doc has no pill")
An occasion is its owner’s alone — two people can hold one on the same day — so a photo takes only its own owner’s, and a namesake belonging to someone else never colours it.
@testcase
def test_event_pill_owner_scoped(page):
"""A photo takes only its own owner's occasions — a namesake owned by another never colours it."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-scopew", "starttime": "2038-06-01T00:00:00Z", "endtime": "2038-06-30T23:59:59Z",
"summary": "zzScopeEv", "owner": "aylapomme", "status": "confirmed"} # aylapomme's alone
K = {"cid": "https://ipfs.konubinix.eu/p/zzscopew-k", "date": "2038-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscopew-k-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzscopew", "state": "todo"}
A = {"cid": "https://ipfs.konubinix.eu/p/zzscopew-a", "date": "2038-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscopew-a-t",
"owner": "aylapomme", "mimetype": "image/jpeg", "labels": "zzscopew", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in (K, A): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzscopew; since:2038; until:2038")
expect(tiles(page)).to_have_count(2)
expect(grid(page).get_by_text("zzScopeEv", exact=True)).to_have_count(1) # only aylapomme's photo
finally:
for d in (K, A): gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event pill owner scoped")
Telling a video from a photo
A video’s poster thumbnail looks like any still — on the wall you can’t tell you could
press play. The mimetype says which is which, so a video wearing a poster earns a
play badge. One with no poster at all already shows the film placeholder
(placeholder tiles), so it needs no extra mark.
The test searches a fixture set holding one video among the stills, and checks exactly one tile — the video’s — carries the badge.
@testcase
def test_video_tiles_are_marked(page):
"""A video tile wears a play badge; a photo tile doesn't — so the two are
distinguishable on the wall."""
make_fixtures() # 3 stills
gql(CREATE, {"p": VIDEO_FIXTURE}) # + one video, all carry FIXTURE_LABEL
try:
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
expect(grid(page).get_by_title("video")).to_have_count(1) # only the video tile is badged
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: video tiles are marked")
The badge is a play triangle laid over the poster — shown for a video that carries a thumbnail. It is pure decoration, so it lets pointer events fall through to the tile, leaving the tap, the double-click, and the long-press untouched.
<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
<span class="play" title="video">▶</span>
<//>
A small translucent disc, centred, with the triangle nudged right so it reads as centred to the eye.
.tile .play{ position:absolute; top:50%; left:50%; transform:translate(-50%, -50%);
width:34px; height:34px; border-radius:50%; background:#000a; color:#fff;
display:flex; align-items:center; justify-content:center;
font-size:15px; padding-left:3px; pointer-events:none; }
Searching and filtering
You narrow the wall to what you want: a free-text label search, a small query language, and the calendar as a filter.
Search by label
Typing a label narrows the wall to matching photos — the search term is a Solid
signal, and createResource re-fetches whenever it changes (no manual wiring, the
resource tracks the signal). The shared filter behind photovideosSample does the FTS.
The test types a label that exists and checks the wall shrinks to a non-empty set.
@testcase
def test_search_narrows(page):
"""Typing a label narrows the grid to matching photos."""
make_fixtures() # 3 fixtures, all carrying FIXTURE_LABEL
narrow = {"cid": "https://ipfs.konubinix.eu/p/zznarrow", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zznarrow-t", "labels": FIXTURE_LABEL + "; zznarrowonly", "state": "todo"}
gql(DELETE, {"cid": narrow["cid"]}); gql(CREATE, {"p": narrow}) # one also wears a rarer label
try:
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL) # the broad label: every fixture + the narrow doc
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
before = tiles(page).count()
search_for(page, "zznarrowonly") # the rarer label narrows to just its one doc
wait_until(page, lambda: 0 < tiles(page).count() < before)
expect(tiles(page)).to_have_count(1)
finally:
gql(DELETE, {"cid": narrow["cid"]})
print(" PASS: search narrows")
@testcase
def test_search_persists(page):
"""The search box is saved locally, surviving a reload (and the frame's reboot)."""
open_app(page)
search_box(page).fill("type:image; since:2010")
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(search_box(page)).to_have_value("type:image; since:2010")
print(" PASS: search persists")
Completing labels
Free-text labels rot into near-duplicates (cosmo, cosmos, cosmo =) unless you can see what already exists while typing. The vocabulary and the =labelCompletions function
this reads aren’t ours — they live in the frise’s labels schema (the label_vocab
table and the label_completions SQL function); this app only consumes them, through the shared LABEL_COMPLETIONS query the frise asks too.
One trap, worth carrying when chasing why a freshly-saved label fails to suggest:
photovideo is an inheritance parent, so the rows — and the trigger that keeps
label_vocab in step on every edit — live on its photo and video children. A
trigger or row check against photovideo itself comes back empty and misleads; look at
the children, where the frise’s labels schema wires the trigger up (and
rebuilds the vocabulary) — see its apply-vocab-child-triggers block.
A small Suggest dropdown reads it as you type and lets you pick an existing word — the
same component under the search box and every add-label box, so they stay consistent.
It completes the fragment under the cursor, not the whole field: the search box splits on
whitespace (its query is space-separated terms), while an add-label box completes the
segment after the last ; (its content is a ;-separated list), so balade;aure
offers aurelie and a pick swaps just that segment.
Suggest is the shared completion dropdown: mounted under any box that wants it, it
offers words for the live text and hands the picked one back to whoever mounted it. (Under
the hood the pick cancels the press that carried it, so pointing at the list never moves
the focus off the box at all; the panel then closes because the segment it was completing
is finished, not because anything was blurred.) A box that edits a known doc
can also hand it the labels already on that doc, which it drops from the offers — so
completion never proposes a label the doc already wears. Below two characters it returns
nothing — completion on one letter is just noise.
Each box reports its current list to the App and reads the highlight index back — only
one box is focused at a time, so a single shared highlight (sugNav) suffices. In the
lightbox and the frame this is also why ←=/=→ step the wall only when no field has
focus — inside the label box the arrows belong to the text.
Type a fragment of a known label, pick the first suggestion, and the box holds exactly
that word — followed by a ;, so the next term can start without reaching for it.
guess(page, "zzbal") # a fragment of one word we know
word = options(page).first.inner_text().strip()
options(page).first.click()
expect(sb).to_have_value(word + "; ") # the picked label, then a ';' for the next
print(" PASS: label completion")
Completion drives the DSL’s own tokens, not just labels. A suggestion that completes a
token — a label, a full date, a type:=/=owner: value, an event — appends a ;, so the
next term starts without your typing the separator, in the search box and the add-label
boxes alike. A suggestion that still has somewhere to go keeps the box on that token
instead, no ;: a bare key (since:, event:) about to take a value, or a partial date
the picker will drill from year to month to day. Which it is isn’t wired token by token —
the box asks the suggester whether the picked word still has a longer completion to
offer. So owner offers the key, then its values, and owner:konubinix — a closed-vocab
value with nothing longer — is done, and a ; opens the next term.
guess(page, "owner") # 2+ chars → the key is offered
expect(options(page).filter(has_text="owner:").first).to_be_visible()
sb.press_sequentially(":k", delay=20) # owner:k → its values
opt = options(page).filter(has_text="konubinix").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value("owner:konubinix; ") # a closed-vocab value is done — a ';' opens the next token
print(" PASS: owner token completes")
The list is keyboard-driven, so a hand never leaves the keys: ↑=/=↓ move a highlight
and Enter applies the highlighted word; Enter with nothing highlighted runs the box’s
own action instead — in an add-label box it adds the typed labels and Shift+Enter
removes them (the keyboard twins of the + / - buttons), while in the search box it
just leaves the live query be. What Enter takes is the word the highlight is on, which
is only the same as the first word when you stopped there — so walking past the first and
pressing it has to bring back the second.
guess(page, "zzup")
offered = [t.strip() for t in options(page).all_inner_texts()]
assert len(offered) == 2, f"this needs two to choose between, got {offered}"
sb.press("ArrowDown"); sb.press("ArrowDown") # past the first, onto the second
sb.press("Enter") # apply the highlighted one
expect(sb).to_have_value(offered[1] + "; ") # the second — taking the first is the easy bug
print(" PASS: search suggestion keyboard")
↑ reaches the list from the other end: with nothing highlighted it enters at the bottom,
on the last suggestion.
guess(page, "zzup") # two words, so there is a bottom to enter at
items = [t.strip() for t in options(page).all_inner_texts()]
assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
sb.press("ArrowUp") # from nothing selected — must enter at the bottom
sel = page.get_by_role("option", selected=True)
assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
"ArrowUp from none should select the last suggestion"
print(" PASS: suggestion up enters from none")
When the list runs longer than the dropdown can show at once, the moving highlight scrolls
it into view, so ↑=/=↓ never land on a row hidden below the fold.
@testcase
def test_completion_keeps_highlight_in_view(page):
"""A list taller than the popover scrolls as ↓ walks it, so the highlight stays visible."""
open_app(page)
box = search_box(page)
box.click() # empty box → all the DSL keys, taller than the popover
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
assert listbox.evaluate("el => el.scrollHeight > el.clientHeight + 4"), \
"setup: the menu must overflow the popover for scrolling to matter"
opts = page.get_by_role("option")
for _ in range(opts.count()): box.press("ArrowDown") # walk down to the last key
active = page.get_by_role("option", selected=True)
expect(active).to_have_count(1)
assert active.evaluate("""el => { const b = el.getBoundingClientRect(),
l = el.closest('[role=listbox]').getBoundingClientRect();
return b.top >= l.top - 1 && b.bottom <= l.bottom + 1; }"""), \
"the highlighted option is not within the visible completion area"
print(" PASS: completion keeps highlight in view")
Applying a suggestion leaves the box on a fresh, empty segment, so the list momentarily blanks. The next keystroke has to bring it back — otherwise completing one term would silently kill completion for the rest, and you would have to click out and in. What makes that work is that picking never costs the box its focus to begin with: a press on the list is stopped from taking it, so the caret stays where you were typing and the next character simply arrives.
In practice that is why what follows types at the page rather than at the box: aimed at the box, a keystroke would focus it on the way in, which is the very click-out-and-in this says you will not need. Typed at the page, the keys go wherever focus actually is, and if the pick had taken it they go nowhere.
page.keyboard.type("zzalo", delay=20) # straight on, into whatever holds the keys
expect(options(page).filter(has_text="zzalois").first).to_be_visible() # must reappear
print(" PASS: completion reopens after pick")
The match reaches inside a word, not only its start: a mid-word smo surfaces cosmo.
guess(page, "osmo") # sits inside zzcosmo, nowhere near its start
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
print(" PASS: completion matches inside label")
And it folds case and accent while offering the label exactly as written — a bare zzelod
surfaces Zzélodie, its capital and accent kept. Kept on the way in, too: what the box
takes is the word as the archive holds it, not the plain thing you typed to find it.
guess(page, "zzelod") # no capital, no accent
texts = [t.strip() for t in options(page).all_inner_texts()]
assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
options(page).filter(has_text="Zzélodie").first.click()
expect(sb).to_have_value("Zzélodie; ")
print(" PASS: completion preserves case and accent")
The vocabulary sits behind a GraphQL round-trip, and on a cold box that takes a noticeable
beat — long enough that an empty dropdown reads as broken rather than thinking. So the
popover opens while the query is still in flight, not only once it has results, and
renders a pulsing completing… row; the words replace it the moment they arrive, and on a
refine the previous segment’s list stays underneath so the panel never blanks while it
catches up.
@testcase
def test_completion_shows_in_flight_hint(page):
"""A completion query in flight shows a 'completing…' hint, so a slow round-trip never
reads as a broken box. We hold the query open so the in-flight state is observable."""
open_app(page)
# let every /graphql through except the label-completion query, which we leave unanswered
# so the resource stays loading rather than flashing in a sub-second.
page.route("**/graphql", lambda route:
route.fallback() if "labelCompletions" not in (route.request.post_data or "") else None)
search_box(page).click()
search_box(page).press_sequentially("au", delay=20) # 2+ chars → a held label-completion query
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: completion shows in-flight hint")
The popover has one collision to win. On a short screen, selecting a doc raises the fixed bottom toolbar, and the search box’s list opens downward far enough to reach it. Where the two overlap, the suggestion you can see must be the one you touch — so the completion draws above the bar, never behind it.
@testcase
def test_search_completion_sits_above_selection_toolbar(page):
"""On a short screen, selecting a doc raises the fixed bottom toolbar. The top search box's
completion opens downward and can reach the bar — where they overlap the completion must be
what the user touches, drawn above the bar, not hidden behind it."""
VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
words = [f"zzov{c}" for c in "abcdefghijkl"] # 12 words → a list tall enough to reach the bar
for w in words: gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
try:
page.set_viewport_size({"width": 360, "height": 300}) # short: the list overlaps the bottom bar
open_fixtures(page)
tiles(page).nth(0).click() # select one → the bottom toolbar appears
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20) # replace search → its completion
expect(options(page).first).to_be_visible() # completion open
expect(toolbar(page)).to_be_visible() # selection kept → bottom bar still up
tb = toolbar(page).bounding_box()
sug = page.get_by_role("listbox", name="suggestions").bounding_box()
lo, hi = max(tb["y"], sug["y"]), min(tb["y"] + tb["height"], sug["y"] + sug["height"])
assert hi > lo, f"setup: toolbar {tb} and completion {sug} don't overlap — nothing to test"
x, y = tb["x"] + tb["width"] / 2, (lo + hi) / 2
# what the user actually touches at the overlap must be the completion, not the bar behind it
hit = page.evaluate(
"([x,y]) => { const el = document.elementFromPoint(x,y);"
" return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }",
[x, y])
assert hit["list"] and not hit["bar"], f"completion is behind the toolbar at the overlap: {hit}"
print(" PASS: search completion sits above the selection toolbar")
finally:
for w in words: gql(VOCAB_DEL, {"w": w})
The search box’s completion wears a few read-only faces — none commits a query — so they
share one boot: type, read the popover, and hand back an empty box for the next. The year:
token completes like the others — typing it offers the key, then its years.
# the search box offers the year: token, then its year values, like month:/day: do
box = search_box(page)
box.click(); box.press_sequentially("year", delay=20) # 2+ chars → the key is offered
expect(options(page).filter(has_text="year:").first).to_be_visible()
box.press_sequentially(":202", delay=20) # year:202 → its year values
expect(options(page).filter(has_text=re.compile(r"year:202\d")).first).to_be_visible()
print(" PASS: year token completes")
box.fill(""); box.blur() # hand back an empty box
expect(page.get_by_role("listbox", name="suggestions")).to_have_count(0) # popover gone
The query language is only useful if you can find it. So the empty search box, the moment it takes focus, drops down the whole token menu — every prefix at once — turning the box from something you must already know into something you can discover.
# focusing the empty box offers the whole token menu (so the language is discoverable)
search_box(page).click() # empty box, just focused — no typing
expect(options(page)).to_have_count(14) # the whole menu, at once
got = sorted(t.strip() for t in options(page).all_inner_texts())
assert got == sorted(["since:", "until:", "type:", "sort:", "owner:", "month:", "day:",
"year:", "date:", "event:", "first:", "last:", "sample:",
"onthisday"]), got # every prefix present (order-free)
print(" PASS: empty box suggests all prefixes")
search_box(page).blur() # hand back closed
expect(page.get_by_role("listbox", name="suggestions")).to_have_count(0) # popover gone
The box and its list form a combobox: the input carries role=combobox and an
aria-expanded that reads true whenever the completion popover is up — while it loads (the
completing… flash) and while it lists results. The popover shows in both states, so an
aria-expanded that tracked only the results would announce collapsed over a visible
loading popover; keyed on either, it is the one place — for a screen reader and anything else
— that says whether the popover is up. Closing it is a state flip, not a timed fade: blur the
box and the list is gone at once, nothing to wait out.
# the box reports its popover via aria-expanded; closing is a state flip, no timing
sb = search_box(page)
sb.click(); sb.press_sequentially("cos")
expect(sb).to_have_attribute("aria-expanded", "true") # open popover → state true
sb.blur()
expect(sb).to_have_attribute("aria-expanded", "false") # closed → state false (what search_for waits on)
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover must be gone the instant the state flips"
print(" PASS: search completion state, no timing")
sb.fill(""); sb.blur(); expect(sb).to_have_attribute("aria-expanded", "false") # hand back an empty box
@testcase
def test_search_expanded_while_loading(page):
"""The search box reads aria-expanded=true while its completion list is loading."""
open_app(page); hold_completions(page)
expanded_while_loading(page, search_box(page))
print(" PASS: search expanded while loading")
@testcase
def test_lightbox_label_expanded_while_loading(page):
"""The lightbox add-label box reads aria-expanded=true while its list is loading."""
open_fixtures(page); open_doc(page); hold_completions(page)
expanded_while_loading(page, dialog(page).get_by_placeholder("add a label…"))
print(" PASS: lightbox label expanded while loading")
@testcase
def test_frame_label_expanded_while_loading(page):
"""The frame add-label box reads aria-expanded=true while its list is loading."""
open_fixtures(page)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
page.get_by_role("list", name="slideshow").click() # a tap reveals the frame bar
hold_completions(page)
expanded_while_loading(page, page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…"))
print(" PASS: frame label expanded while loading")
@testcase
def test_batch_label_expanded_while_loading(page):
"""The selection's add-label box reads aria-expanded=true while its list is loading."""
open_fixtures(page); select_all(page).click(); hold_completions(page)
expanded_while_loading(page, toolbar(page).get_by_placeholder("add a label…"))
print(" PASS: batch label expanded while loading")
Completion draws on two capped vocabularies, each its own query: the labels already in use
(labelCompletions), and the calendar’s event summaries (eventCompletions), scoped to a date
window.
const fetchCompletions = async word => {
if((word || '').length < 2) return [];
const d = await gql(LABEL_COMPLETIONS, { prefix: word, first: 8 });
return d?.labelCompletions?.nodes ?? [];
};
const EVENT_COMPLETIONS = `query($prefix:String!,$since:Datetime,$until:Datetime,$first:Int){
eventCompletions(prefix:$prefix, since:$since, until:$until, first:$first){ nodes } }`;
const fetchEventCompletions = async (prefix, since, until) => {
const d = await gql(EVENT_COMPLETIONS, { prefix: prefix || '', since, until, first: 8 });
return d?.eventCompletions?.nodes ?? [];
};
What the current word wants depends on where the caret sits: in the search box’s DSL an empty box
offers every key, an event: token the calendar’s summaries, a since:=/date fragment the date picker, a bare key its own name. A bare word completes a label — and, in the search box, also any event whose name it matches, offered as a ready =event: token, so the same letters reach both a
photo’s label and the occasion it was shot during. The label boxes stay labels-only: a doc takes a
label, not an event.
async function suggestFor(text, caret, dsl, present){
if(dsl && !(text || '').trim()) return DSL_KEYS;
const word = segAt(text, caret);
const ev = dsl && /^event:(.*)$/i.exec(word);
if(ev){ const q = parseQuery(text);
return (await fetchEventCompletions(ev[1], q.since, q.until)).map(s => 'event:' + s); }
if(dsl){ const d = dslSuggestions(word); if(d.length) return d; }
if(word.includes(':')) return [];
const has = (present || []).map(s => s.toLowerCase());
const drop = ws => has.length ? ws.filter(w => !has.includes(w.toLowerCase())) : ws;
if(!dsl) return drop(await fetchCompletions(word));
const q = parseQuery(text);
const [labels, events] = await Promise.all([
fetchCompletions(word),
word.length >= 2 ? fetchEventCompletions(word, q.since, q.until) : Promise.resolve([]),
]);
return [...drop(labels).slice(0, 5), ...events.map(s => 'event:' + s).slice(0, 3)];
}
Suggest is that shared popover made concrete: it recomputes its offers whenever the
text or caret moves, the caret defaulting to end-of-text so a box with no cursor of its own still
completes its last segment.
function Suggest(props){
const caret = () => props.caret == null ? (props.text || '').length : props.caret;
const [items] = createResource(() => [props.text, caret(), props.present],
([t, c, p]) => suggestFor(t, c, props.dsl, p));
createEffect(() => props.onItems?.(items() || []));
createEffect(() => props.onLoading?.(items.loading));
const active = () => props.active == null ? -1 : props.active; // a 0-arg accessor prop arrives unwrapped
let listEl;
createEffect(() => { const i = active();
if(i >= 0 && listEl) listEl.querySelectorAll('[role=option]')[i]?.scrollIntoView({ block: 'nearest' }); });
return html`
<${Show} when=${() => items.loading || (items() || []).length > 0}>
<ul class="suggest" role="listbox" aria-label="suggestions" ref=${el => listEl = el} aria-busy=${() => items.loading ? 'true' : 'false'}>
<${Show} when=${() => items.loading}>
<li class="sug-loading" aria-disabled="true">completing…</li>
<//>
<${For} each=${() => items() || []}>${(w, i) => html`
<li class=${() => 'sug' + (i() === active() ? ' active' : '')} role="option"
aria-selected=${() => i() === active() ? 'true' : 'false'}
onMouseDown=${e => { e.preventDefault(); props.onPick(w); }}>${w}</li>`}
<//>
</ul>
<//>`;
}
The screen stacks in named layers: the content wall at the base, the fixed selection toolbar
above it (z-index:20), then the transient overlays — a completion popover (25), the busy
updating… pill (30), the drag marquee (50), the lightbox (100), the frame (200). The
popover sits just above the toolbar because the two are the only pair that share the screen at
once: on a short screen the search box’s list opens downward far enough to reach the bottom bar,
and a suggestion you can see but not touch is worse than none. It stays below busy/marquee/
lightbox/frame, which never coexist with an open search completion.
.complete{ position:relative; }
/* completion-popover layer — see the stacking order in prose */
.complete .suggest{ z-index:25; }
/* a long list still scrolls, but with a thin themed bar — the chunky default one clashes
with the dark panel (scrollbar-color for Firefox/modern Chromium, ::-webkit for the rest). */
.suggest{ position:absolute; z-index:10; left:0; right:0; top:100%; margin:2px 0 0; padding:4px;
list-style:none; background:#1b1d2e; border:1px solid #3a3f5a; border-radius:6px;
max-height:240px; overflow:auto; box-shadow:0 6px 20px #0008;
scrollbar-width:thin; scrollbar-color:#4a4f6e transparent; }
.suggest::-webkit-scrollbar{ width:8px }
.suggest::-webkit-scrollbar-thumb{ background:#4a4f6e; border-radius:8px }
.suggest::-webkit-scrollbar-track{ background:transparent }
.sug{ padding:9px 10px; border-radius:4px; cursor:pointer; font-size:14px; }
.sug:hover, .sug.active{ background:#33395a; }
.sug-loading{ padding:9px 10px; font-size:14px; color:#8a8ea5; cursor:default;
animation:sug-pulse 1s ease-in-out infinite; }
@keyframes sug-pulse{ 0%,100%{ opacity:.45 } 50%{ opacity:.9 } }
A query language in the search box
The search box wears the shared query language — bare labels beside
since:=/=until:=/=type:=/=sort:=/=owner:=/=onthisday tokens, ;-separated so a label
keeps its spaces (parc des oiseaux, famille sam). (State stays on the chips for now —
folding it in would mean either retiring the chips or syncing them, a separate decision.)
What’s memories’ own is how the box carries it: completion is segment-aware, working on
the segment the cursor sits in — offering the language’s tokens when you start one and
vocabulary labels otherwise — and a picked label gets a trailing ; so the next one
starts without your typing the separator.
The free text — every segment that isn’t a typed token — is a boolean label search,
handed to Postgres websearch_to_tsquery untouched: space-separated terms must all
match, or unions them, a leading - excludes, and "…" forces a phrase. Two docs, one
tagged zza and one zzb (both zzbool), pin the operators down.
@testcase
def test_boolean_search(page):
"""Free text is a websearch query: =or= unions results, a leading =-= excludes."""
drop_fixtures()
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbool-a", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbool-a-t", "labels": "zzbool zza", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzbool-b", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbool-b-t", "labels": "zzbool zzb", "state": "todo"}]
for d in docs: gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zza or zzb")
expect(tiles(page)).to_have_count(2) # or unions the two
search_for(page, "zzbool -zza")
expect(tiles(page)).to_have_count(1) # - excludes the zza doc
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbool-b-t")
print(" PASS: boolean search")
finally:
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
The date test starts from the three fixtures (dated Jan/Feb/Mar 2020) and tightens the
bounds: since:2020-02 drops January, then adding until:2020-02 leaves only February.
@testcase
def test_date_range(page):
"""since:/until: tokens in the box bound the search to a date range."""
open_fixtures(page) # 3 fixtures: 2020-01, -02, -03
search_for(page, FIXTURE_LABEL + "; since:2020-02")
expect(tiles(page)).to_have_count(2) # January falls before the bound
search_for(page, FIXTURE_LABEL + "; since:2020-02; until:2020-02")
expect(tiles(page)).to_have_count(1) # only February is in range
print(" PASS: date range")
The default upper bound reaches the end of today, not its midnight — so a photo shot today
is on the wall like any other, with no until: token needed.
@testcase
def test_today_photos_shown_by_default(page):
"""The default window runs to the end of today, so a photo shot today is on the wall."""
from datetime import date
doc = {"cid": "https://ipfs.konubinix.eu/p/zztoday", "date": date.today().isoformat() + "T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zztoday-t",
"labels": "zztodayphoto", "state": "todo"}
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page) # default view, default window (no until: token)
search_for(page, "zztodayphoto")
expect(tiles(page)).to_have_count(1) # today's photo is in range
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: today photos shown by default")
A bound doesn’t have to be a spelled-out date. The shared query language also reads a
relative phrase — since:2 months ago, until:3 days ago, a bare lastweek — resolved against
today, so a rolling window costs no arithmetic. A ;-split segment keeps the spaces, so the whole
phrase reaches the parser intact.
@testcase
def test_relative_date_bound(page):
"""A relative phrase bounds the window — since:N months ago keeps only the recent side."""
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
recent = (now - timedelta(days=30)).strftime("%Y-%m-%dT12:00:00Z") # ~1 month ago
old = (now - timedelta(days=180)).strftime("%Y-%m-%dT12:00:00Z") # ~6 months ago
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzrel-recent", "date": recent, "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzrel-recent-t", "labels": "zzreldate", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzrel-old", "date": old, "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzrel-old-t", "labels": "zzreldate", "state": "todo"}]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzreldate")
expect(tiles(page)).to_have_count(2)
search_for(page, "zzreldate; since:2 months ago")
expect(tiles(page)).to_have_count(1) # only the ~1-month-old doc
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzrel-recent-t")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: relative date bound")
A bound can also drop its year and name just the season you mean: until:july is the July
just gone, until:07-14 the last 14th of July, until:july-14 the same in words. The year
it takes is whichever one that date last came round in, so the token keeps meaning last
without ever being retyped. Here two docs sit either side of one 14th of July, and each
spelling of it separates them the same way — the same way exactly, since both land on the
identical instant.
@testcase
def test_bare_month_and_day_bounds(page):
"""until: takes a month, or a month and a day, with no year — the most recent one."""
from datetime import date
# last July's 14th, whichever year that was: this year if it has been, else the year before
y = date.today().year - (0 if (date.today().month, date.today().day) >= (7, 14) else 1)
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzbare-before", "date": f"{y}-07-10T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-before-t", "labels": "zzbare", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzbare-after", "date": f"{y}-07-20T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-after-t", "labels": "zzbare", "state": "todo"}]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page)
for spelling in ("07-14", "july-14"):
search_for(page, "zzbare")
expect(tiles(page)).to_have_count(2) # both, unbounded
search_for(page, f"zzbare; until:{spelling}")
expect(tiles(page)).to_have_count(1) # only the 10th survives the bound
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbare-before-t")
search_for(page, "zzbare; since:july") # the whole of that July, both back
expect(tiles(page)).to_have_count(2)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: bare month and day bounds")
And you shouldn’t have to type a whole date: once a since: / until: token is open,
completion turns into a progressive date picker — years (recent first), then the months
of a chosen year — so a range is a couple of taps. Picking a partial date keeps the segment
open so you can drill on — year to month to day; picking the full day (or a relative word)
closes it with a ;, the same as a label does, and the list waits, empty, for the next token.
@testcase
def test_date_completion(page):
"""since:/until: completes year → month → day — tap a date, don't type it."""
open_app(page)
search_box(page).click()
search_box(page).fill("since:")
year = options(page).get_by_text("since:2020", exact=True)
expect(year).to_be_visible() # years are offered
year.click()
expect(search_box(page)).to_have_value("since:2020")
month = options(page).get_by_text("since:2020-06", exact=True)
expect(month).to_be_visible() # then months
month.click()
day = options(page).get_by_text("since:2020-06-15", exact=True)
expect(day).to_be_visible() # then days
day.click()
expect(search_box(page)).to_have_value("since:2020-06-15; ") # a full date is done — a ';' opens the next token
print(" PASS: date completion")
@testcase
def test_relative_dates(page):
"""Relative since: shortcuts resolve to a recent date, and complete from a prefix."""
open_fixtures(page) # fixtures are dated 2020
search_for(page, FIXTURE_LABEL + "; since:lastyear")
expect(tiles(page)).to_have_count(0) # last year is well past 2020
search_box(page).fill("since:last")
expect(options(page)).to_have_text(["since:lastweek", "since:lastmonth", "since:lastyear"])
print(" PASS: relative dates")
The fixed shortcuts cover the common cases, but sometimes the offset is specific.
since:N days ago — and weeks, months, years, singular or plural — resolves N
units back from today. The fixtures sit in 2020, so any recent offset puts the lower
bound past them (nothing shown), while a long-enough offset reaches back before them
(all shown) — which also proves the count N actually moves the bound.
@testcase
def test_relative_dates_ago(page):
"""since:N days/weeks/months/years ago resolves a bound that scales with N."""
open_fixtures(page) # fixtures are dated 2020
for token in ["7 days ago", "3 weeks ago", "6 months ago", "1 year ago"]:
search_for(page, FIXTURE_LABEL + "; since:" + token)
expect(tiles(page)).to_have_count(0) # a recent bound is past the 2020 fixtures
search_for(page, FIXTURE_LABEL + "; since:20 years ago")
expect(tiles(page)).to_have_count(len(FIXTURES)) # far enough back, they return
print(" PASS: relative dates (N ago)")
The picker only ever offers a date you could really tap: a month is one through twelve, a
day one to that month’s last. So a half-typed 2022-0 names no month and 2022-06-0 no
day, and there the picker stays silent rather than proposing a date that isn’t.
@testcase
def test_date_picker_offers_only_real_dates(page):
"""The date picker drills only real dates — an out-of-range month or day offers nothing."""
open_app(page)
box = search_box(page)
box.click()
box.fill("since:2022")
expect(options(page).get_by_text("since:2022-06", exact=True)).to_be_visible() # picker is live: real months show
for bad in ["since:2022-0", "since:2022-13"]: # a 0 month and a 13th month
box.fill(bad)
expect(options(page)).to_have_count(0) # no such month → nothing offered
box.fill("since:2022-06-0") # the 0th day of a real month
expect(options(page)).to_have_count(0)
print(" PASS: date picker offers only real dates")
onthisday is the anniversary view: today’s month-day ±1 (or onthisday:N) across
all years — what the frame shows to surface “this day in years past”. It’s a server
predicate (anniv_dist, year ignored, wrapping at the year boundary), so it samples and
bulk-edits like any other filter, and composes with since:=/=until: to bound the years.
@testcase
def test_onthisday(page):
"""onthisday matches today's month-day ±N across every year."""
from datetime import date, timedelta
t = date.today()
anchored = lambda days, yr: (t + timedelta(days=days)).replace(year=yr).isoformat() + "T12:00:00Z"
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzanniv-{i}", "date": anchored(off, yr), "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzanniv-t-{i}", "labels": "zzanniv", "state": "todo"}
for i, (off, yr) in enumerate([(0, 2010), (1, 2015), (5, 2018)])] # ±0, ±1, ±5 days
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page) # default state todo; all three are todo
search_for(page, "zzanniv; onthisday")
expect(tiles(page)).to_have_count(2) # ±0 and ±1 only
search_for(page, "zzanniv; onthisday:5")
expect(tiles(page)).to_have_count(3) # ±5 now included
search_box(page).fill("ontH") # and it completes
expect(options(page)).to_have_text(["onthisday"])
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: onthisday")
Completion follows the cursor, not the end of the line: with the caret inside an
earlier segment it completes that one. The box tracks the caret position and hands it to
Suggest, which completes the segment the caret sits in and replaces exactly that segment.
@testcase
def test_completion_at_cursor(page):
"""With the caret inside an earlier token, completion works on that token."""
open_app(page)
box = search_box(page)
box.click()
box.fill("until:; since:2026") # two date segments
box.press("Home")
for _ in range(6): box.press("ArrowRight") # caret to just after "until:"
expect(options(page).get_by_text("until:2026", exact=True)).to_be_visible() # until:, not since:
expect(options(page).get_by_text("since:2026", exact=True)).to_have_count(0)
print(" PASS: completion at cursor")
Completion knows the language: a prefix of a key offers the key, not a label.
@testcase
def test_dsl_key_completion(page):
"""Typing a key prefix offers since:/until:, and picking inserts the token."""
open_app(page)
search_box(page).click()
search_box(page).fill("sin")
expect(options(page)).to_have_text(["since:"]) # the key, not some label
options(page).first.click()
expect(search_box(page)).to_have_value("since:")
print(" PASS: dsl key completion")
The date: token completes like the rest — a prefix offers the key, and once open it
drills the same year→month→day picker as since:=/=until:.
@testcase
def test_date_token_completes(page):
"""The search box offers the date: token and drills its period like since:/until: do."""
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("date", delay=20) # 2+ chars → the key is offered
expect(options(page).filter(has_text="date:").first).to_be_visible()
box.press_sequentially(":2020", delay=20) # date:2020 → its months
expect(options(page).filter(has_text=re.compile(r"date:2020-\d\d")).first).to_be_visible()
print(" PASS: date token completes")
A type:image / type:video token filters by kind — server-side, before the sample,
via the shared functions’ kinds argument — and completes the same way.
@testcase
def test_type_filter(page):
"""type:video / type:image narrow the wall to that kind."""
make_fixtures() # 3 images
gql(CREATE, {"p": VIDEO_FIXTURE}) # + one video, all carry FIXTURE_LABEL
try:
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL + "; type:video")
expect(tiles(page)).to_have_count(1) # just the video
search_for(page, FIXTURE_LABEL + "; type:image")
expect(tiles(page)).to_have_count(len(FIXTURES)) # just the images
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: type filter")
@testcase
def test_type_completion(page):
"""type: completes to the two kinds."""
open_app(page)
search_box(page).click()
search_box(page).fill("type:")
expect(options(page)).to_have_text(["type:image", "type:video"])
print(" PASS: type completion")
@testcase
def test_sort_random(page):
"""sort:random reorders the wall by myrandom (and completes); date is the default."""
open_fixtures(page) # date order → thumb-0 first
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
search_box(page).fill(FIXTURE_LABEL + "; sort:random") # myrandom asc → thumb-1 first
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
search_box(page).fill("sort:") # and it completes
expect(options(page)).to_have_text(["sort:date", "sort:random"])
print(" PASS: sort random")
@testcase
def test_multiword_label_completion(page):
"""A spaced label completes and is inserted as one unit, then a ';' is added for the next."""
open_app(page)
sb = search_box(page)
sb.click(); sb.press_sequentially("parc des oise", delay=10) # part of a multi-word label
opt = options(page).filter(has_text="oiseaux").first
expect(opt).to_be_visible()
word = opt.inner_text().strip() # the label as written (any casing)
opt.click()
expect(sb).to_have_value(word + "; ") # whole label, not "parc des parc des oiseaux"
print(" PASS: multiword label completion")
Filtering by event
A search can ask when by name: event:NAME keeps only the photos taken during a calendar
event whose summary matches — the wall narrowed to an occasion. It is a server-side span
test, threaded through the very photovideos_* filter the other tokens use (so the sample
and its count agree), and it composes with the rest of the query — a label beside it still
narrows first.
@testcase
def test_event_token_filters_wall(page):
"""event:NAME narrows the wall to the photos whose date falls inside that event's span."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-span", "starttime": "2020-08-10T00:00:00Z", "endtime": "2020-08-20T23:59:59Z",
"summary": "zzSpanEvent", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzef-in1", "date": "2020-08-12T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzef-in1-t"},
{"cid": "https://ipfs.konubinix.eu/p/zzef-in2", "date": "2020-08-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzef-in2-t"},
{"cid": "https://ipfs.konubinix.eu/p/zzef-out", "date": "2020-08-25T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzef-out-t"}]
for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzeventf", "owner": "konubinix", "state": "todo"})
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzeventf")
expect(tiles(page)).to_have_count(3) # all three, unfiltered
search_for(page, "zzeventf; event:zzSpanEvent")
expect(tiles(page)).to_have_count(2) # only those inside the span
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzef-in1-t']")).to_have_count(1)
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzef-in2-t']")).to_have_count(1)
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzef-out-t']")).to_have_count(0) # the out-of-span one, gone
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event token filters wall")
The same token, negated, does the opposite. -event:NAME keeps every photo except those
taken during an occasion of that name — the way to say “everything from that trip, but not the
theme park itself”. A leading - is the shared query language’s one negation mark, and an
event: name has no closed vocabulary to complement, so the exclusion rides all the way to
the same span filter, which drops instead of keeps. It composes with the rest of the query
exactly as the include does — a label beside it still narrows first.
@testcase
def test_event_exclusion_filters_wall(page):
"""-event:NAME keeps every photo except those taken during that occasion."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-disney", "starttime": "2020-08-10T00:00:00Z", "endtime": "2020-08-20T23:59:59Z",
"summary": "zzDisneyland", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzx-in", "date": "2020-08-12T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzx-in-t"}, # during the event
{"cid": "https://ipfs.konubinix.eu/p/zzx-out", "date": "2020-08-25T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzx-out-t"}] # outside it
for d in docs: d.update({"mimetype": "image/jpeg", "labels": "zzexcl", "owner": "konubinix", "state": "todo"})
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzexcl")
expect(tiles(page)).to_have_count(2) # both, unfiltered
search_for(page, "zzexcl; -event:zzDisneyland")
expect(tiles(page)).to_have_count(1) # the in-span one dropped
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzx-out-t']")).to_have_count(1)
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzx-in-t']")).to_have_count(0) # excluded
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: -event: exclusion filters wall")
Three edges the span filter must hold: a cancelled event is no occasion (its span pulls in
nothing); an accented summary is matched by a plain-typed search (both sides accent-folded);
and the span is inclusive at its end, so a photo dated exactly at endtime still counts.
@testcase
def test_event_filter_edges(page):
"""event: excludes cancelled events, matches accented summaries (both sides unaccented),
and is inclusive at the span's end."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
events = [
{"rowId": "zzev-acc", "starttime": "2021-05-01T00:00:00Z", "endtime": "2021-05-31T23:59:59Z",
"summary": "zzÉdgéFest", "owner": "konubinix", "status": "confirmed"}, # accented summary
{"rowId": "zzev-canc", "starttime": "2021-09-01T00:00:00Z", "endtime": "2021-09-30T23:59:59Z",
"summary": "zzCancelledFest", "owner": "konubinix", "status": "cancelled"}] # a cancelled event
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzedge-in", "date": "2021-05-15T12:00:00Z"}, # inside the accented event
{"cid": "https://ipfs.konubinix.eu/p/zzedge-bnd", "date": "2021-05-31T23:59:59Z"}, # exactly at endtime
{"cid": "https://ipfs.konubinix.eu/p/zzedge-canc", "date": "2021-09-15T12:00:00Z"}] # inside the CANCELLED event only
for d in docs: d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
"labels": "zzedgef", "owner": "konubinix", "state": "todo"})
for e in events: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzedgef; event:zzedgefest") # accented summary, plain-typed search
expect(tiles(page)).to_have_count(2) # the in-span doc + the endtime-boundary doc
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzedge-bnd-t']")).to_have_count(1) # boundary counts (inclusive)
search_for(page, "zzedgef; event:zzCancelledFest")
expect(tiles(page)).to_have_count(0) # a cancelled event pulls in nothing
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
for e in events: gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: event filter edges (cancelled, accent, boundary)")
The filter is owner-scoped, like the lightbox’s events: event:NAME asks each photo whether
one of its own owner’s events matches, so a namesake event belonging to someone else never
pulls a photo in. Two people can hold a “Piscine” on the same day; the search keeps each
owner’s own.
@testcase
def test_event_filter_is_owner_scoped(page):
"""event:NAME scopes by each photo's own owner — a namesake event of another owner won't pull it in."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-scope", "starttime": "2020-09-10T00:00:00Z", "endtime": "2020-09-20T23:59:59Z",
"summary": "zzScopeEvent", "owner": "aylapomme", "status": "confirmed"} # only aylapomme has it
P = {"cid": "https://ipfs.konubinix.eu/p/zzscope-k", "date": "2020-09-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscope-k-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzscope", "state": "todo"}
R = {"cid": "https://ipfs.konubinix.eu/p/zzscope-a", "date": "2020-09-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscope-a-t",
"owner": "aylapomme", "mimetype": "image/jpeg", "labels": "zzscope", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in (P, R): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzscope")
expect(tiles(page)).to_have_count(2) # both, same day, same label
search_for(page, "zzscope; event:zzScopeEvent")
expect(tiles(page)).to_have_count(1) # only the aylapomme photo — its owner's event
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzscope-a-t']")).to_have_count(1)
expect(grid(page).locator("img[src='https://ipfs.konubinix.eu/p/zzscope-k-t']")).to_have_count(0) # konubinix has no such event
finally:
for d in (P, R): gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event filter is owner-scoped")
You don’t even need the event: token: a plain word matches the calendar too. Search karate
and it turns up the photos tagged karate and the ones taken during a Karate occasion that
were never tagged — the shared filter runs the free text against event summaries just as it
runs it against labels. That reach is read-only, though: a label can be lifted off a doc, an
event cannot — it is the calendar’s.
@testcase
def test_search_matches_events_like_labels(page):
"""A plain search term matches a photo by its label OR by an event it was taken during."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-karate", "starttime": "2043-06-15T09:00:00Z", "endtime": "2043-06-15T18:00:00Z",
"summary": "zzKaratefest", "owner": "konubinix", "status": "confirmed"} # a short occasion in an empty future window
by_label = {"cid": "https://ipfs.konubinix.eu/p/zzk-label", "date": "2043-01-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzk-label-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzkaratefest", "state": "todo"} # by label, outside the event span
by_event = {"cid": "https://ipfs.konubinix.eu/p/zzk-event", "date": "2043-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzk-event-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzkevfest", "state": "todo"} # no karate label — pulled in only by the event
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in (by_label, by_event): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzkaratefest; since:2043; until:2043")
expect(tiles(page)).to_have_count(2) # the tagged doc AND the doc taken during zzKaratefest
search_for(page, "zzkevfest; since:2043; until:2043") # the event doc's own label matches only it
expect(tiles(page)).to_have_count(1)
finally:
for d in (by_label, by_event): gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: search matches events like labels")
That reach shows up while you type, too. A bare word offers not just the labels that start with
it but any event whose name it matches, each handed over as a ready event: token — so kara
surfaces the label karate beside event:Open Karaté, and the same few letters lead to both a
tagged photo and the occasion another was shot during.
@testcase
def test_bare_word_completes_events(page):
"""A bare word in the search box offers a matching event, handed over as a ready event: token."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-karc", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
"summary": "zzKar Open", "owner": "konubinix", "status": "confirmed"}
during = {"cid": "https://ipfs.konubinix.eu/p/zzkc-ev", "date": "2020-06-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzkc-ev-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzduringonly", "state": "todo"} # gives the event a photo, so it completes
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": during["cid"]}); gql(CREATE, {"p": during})
try:
open_app(page); chip(page, "all").click()
box = search_box(page)
box.click(); box.press_sequentially("zzkar", delay=20) # a bare word, not an event: token
ev_opt = options(page).filter(has_text="event:zzKar Open").first
expect(ev_opt).to_be_visible() # the event, offered as a token
ev_opt.click()
expect(box).to_have_value("event:zzKar Open; ") # picked, as a token
finally:
gql(DELETE, {"cid": during["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: bare word completes events")
The box completes event: as it does the other tokens — a prefix of the key offers it.
@testcase
def test_event_token_completes_key(page):
"""Typing a prefix of the key offers event: (like owner:/year: do)."""
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("eve", delay=20) # 2+ chars → the key is offered
expect(options(page).filter(has_text="event:").first).to_be_visible()
print(" PASS: event token completes key")
Once you’re inside event:, the box offers the calendar’s own vocabulary — the event
summaries in use, matched as you type — the same dynamic completion labels get, but drawn
from the calendar. Picking one fills the token with that summary.
@testcase
def test_event_value_completes(page):
"""Inside event:, the box offers calendar event summaries; picking one fills the token."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-complete", "starttime": "2020-10-01T00:00:00Z", "endtime": "2020-10-02T23:59:59Z",
"summary": "zzPiscineComplete", "owner": "konubinix", "status": "confirmed"}
photo = {"cid": "https://ipfs.konubinix.eu/p/zzpisc-p", "date": "2020-10-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzpisc-p-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzpiscp", "state": "todo"} # so it qualifies
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
try:
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("event:zzPisc", delay=20) # inside the token, a summary prefix
opt = options(page).filter(has_text="zzPiscineComplete").first
expect(opt).to_be_visible() # the calendar summary is offered
opt.click()
expect(box).to_have_value("event:zzPiscineComplete; ") # a completed event token, then a ';' for the next
finally:
gql(DELETE, {"cid": photo["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event value completes")
The match isn’t one contiguous run. Type the words of a name in any order, separated by spaces,
and each just has to appear — so eur par and par eur both reach Europa Parc, handy when you
hold two pieces of a name but not their order. The box draws these from the shared
event_completions.
@testcase
def test_event_value_completes_multiword(page):
"""Inside event:, space-separated words match a summary in any order — eur par and par eur both find it."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-europa", "starttime": "2020-08-01T00:00:00Z", "endtime": "2020-08-02T23:59:59Z",
"summary": "zzEuropa Parc", "owner": "konubinix", "status": "confirmed"}
photo = {"cid": "https://ipfs.konubinix.eu/p/zzeuropa-p", "date": "2020-08-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzeuropa-p-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzeuropap", "state": "todo"} # so it qualifies
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
try:
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("event:eur par", delay=20) # two words, out of order
expect(options(page).filter(has_text="zzEuropa Parc").first).to_be_visible()
box.fill(""); box.press_sequentially("event:par eur", delay=20) # the same two, reversed
expect(options(page).filter(has_text="zzEuropa Parc").first).to_be_visible()
finally:
gql(DELETE, {"cid": photo["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event value completes multiword")
And the menu is scoped to the query’s own date range: with a since:=/=until: in play,
event: offers only the events overlapping that window — the occasions you could actually be
looking at — not the whole calendar’s history.
@testcase
def test_event_completion_within_window(page):
"""event: suggestions are scoped to the query's date window — an out-of-window event isn't offered."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
a = {"rowId": "zzev-win-in", "starttime": "2019-06-01T00:00:00Z", "endtime": "2019-06-02T00:00:00Z",
"summary": "zzWinIn", "owner": "konubinix", "status": "confirmed"}
b = {"rowId": "zzev-win-out", "starttime": "2021-06-01T00:00:00Z", "endtime": "2021-06-02T00:00:00Z",
"summary": "zzWinOut", "owner": "konubinix", "status": "confirmed"}
# each event gets a photo in its span, so the window (not has-photo) is what excludes zzWinOut
pics = [{"cid": "https://ipfs.konubinix.eu/p/zzwin-in-p", "date": "2019-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzwin-in-p-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzwinp", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzwin-out-p", "date": "2021-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzwin-out-p-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzwinp", "state": "todo"}]
for e in (a, b): gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
for p in pics: gql(DELETE, {"cid": p["cid"]}); gql(CREATE, {"p": p})
try:
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("since:2019; until:2019; event:zzWin", delay=15)
expect(options(page).filter(has_text="zzWinIn").first).to_be_visible() # in window, has a photo
expect(options(page).filter(has_text="zzWinOut")).to_have_count(0) # 2021, outside the window
finally:
for p in pics: gql(DELETE, {"cid": p["cid"]})
for e in (a, b): gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: event completion within window")
That listing starts from the bare event:, before you’ve typed any name — the window’s
events laid out to pick from — so you can browse the occasions as well as match one by typing.
@testcase
def test_event_lists_on_bare_key(page):
"""The bare event: (no name typed yet) already lists the window's events to pick from."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-bare", "starttime": "2033-06-01T00:00:00Z", "endtime": "2033-06-02T00:00:00Z",
"summary": "zzBareEvent", "owner": "konubinix", "status": "confirmed"} # alone in a 2033 window
photo = {"cid": "https://ipfs.konubinix.eu/p/zzbare-p", "date": "2033-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbare-p-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzbarep", "state": "todo"} # so it qualifies
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
try:
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("since:2033; until:2033; event:", delay=15) # a window, then the bare token
expect(options(page).filter(has_text="zzBareEvent").first).to_be_visible() # the window's events, already listed
finally:
gql(DELETE, {"cid": photo["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: event lists on bare key")
And the menu only offers events that would actually turn something up: an event whose span holds none of your photos is dropped, so every suggestion leads to a non-empty wall — no more picking an occasion and landing on nothing.
@testcase
def test_completion_omits_photoless_events(page):
"""event: offers only events that have a matching photo — a photoless event isn't suggested."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
has = {"rowId": "zzev-has", "starttime": "2034-06-01T00:00:00Z", "endtime": "2034-06-01T23:59:59Z",
"summary": "zzHasPhoto", "owner": "konubinix", "status": "confirmed"}
non = {"rowId": "zzev-non", "starttime": "2034-07-01T00:00:00Z", "endtime": "2034-07-01T23:59:59Z",
"summary": "zzNoPhoto", "owner": "konubinix", "status": "confirmed"}
photo = {"cid": "https://ipfs.konubinix.eu/p/zzhasphoto", "date": "2034-06-01T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzhasphoto-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzhasphoto", "state": "todo"}
for e in (has, non): gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
gql(DELETE, {"cid": photo["cid"]}); gql(CREATE, {"p": photo})
try:
open_app(page)
box = search_box(page)
box.click(); box.press_sequentially("since:2034; until:2034; event:zz", delay=15)
expect(options(page).filter(has_text="zzHasPhoto").first).to_be_visible() # has a photo → offered
expect(options(page).filter(has_text="zzNoPhoto")).to_have_count(0) # no photo → dropped
finally:
gql(DELETE, {"cid": photo["cid"]})
for e in (has, non): gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: completion omits photoless events")
The lightbox
One doc, full size — where you look closely and edit what the archive holds about it: its labels, its state, its date.
Opening a doc — the lightbox
The wall is for scanning; sometimes you need one doc up close — to actually watch a video, or to read and fix its labels.
Open one doc and the modal shows its full web_cid media (a <video controls> when the
mimetype is video, else the image), its date, and its labels as removable chips; the ✕
or Escape closes it again.
@testcase
def test_lightbox_opens_and_closes(page):
"""Double-clicking a tile opens a modal with the media and labels; close dismisses it."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
expect(d).to_be_visible()
expect(d.get_by_role("img")).to_have_count(1) # the image is shown
expect(d.locator(".lb-date")).to_have_text(page.evaluate(f"() => new Date('{FIXTURES[0]['date']}').toLocaleString('fr-FR')")) # its date
expect(d.get_by_text(FIXTURE_LABEL)).to_be_visible() # its labels are shown
d.get_by_role("button", name="close").click()
expect(d).to_be_hidden()
print(" PASS: lightbox opens and closes")
A click that lands on nothing that acts — the backdrop, the image, the empty margins — also closes it; only a control swallows the click and keeps it open.
@testcase
def test_lightbox_click_outside_content_closes(page):
"""A click on nothing that acts — the image — closes the modal; a control click keeps it."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
expect(d).to_be_visible()
sel = d.get_by_role("button", name=re.compile("select"))
sel.click() # a control acts → the modal stays
expect(sel).to_have_text(re.compile("selected")) # the click reached the button, didn't leak to close
expect(d).to_be_visible()
d.get_by_role("img").click() # the image doesn't act → the modal closes
expect(d).to_be_hidden()
print(" PASS: lightbox click outside content closes")
The open gesture itself is a double-click: a single tap stays the triage select and a long-press arms a range, so opening one doc full-size has its own gesture and the three don’t collide.
@testcase
def test_double_click_opens_lightbox(page):
"""A double-click on a tile is the open gesture — the modal comes up on that doc."""
open_fixtures(page)
tiles(page).nth(0).dblclick()
d = dialog(page)
expect(d).to_be_visible()
expect(d.get_by_role("img")).to_have_count(1)
print(" PASS: double-click opens lightbox")
That long-press is also what a touchscreen reads as a request for its own save image
callout. But the app has its own use for every press — this one starts a selection — so on
a tile it swallows the native context menu.
@testcase
def test_tile_context_menu_suppressed(page):
"""A long-press starts a selection — an owned gesture — so the app swallows the native context menu on a tile."""
open_fixtures(page)
fired = tiles(page).nth(0).evaluate(
"el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
assert fired is False, "a tile press should preventDefault on contextmenu"
print(" PASS: tile context menu suppressed")
It swallows that menu across all its own surfaces, not just tiles — the open lightbox’s media too.
@testcase
def test_context_menu_suppressed_app_wide(page):
"""The native context menu is swallowed across the app's own surfaces, not just tiles — here the lightbox media."""
open_fixtures(page)
open_doc(page)
fired = dialog(page).get_by_role("img").evaluate(
"el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
assert fired is False, "the lightbox media should suppress the native context menu"
print(" PASS: context menu suppressed app-wide")
It keeps the native menu only in the text fields, where paste-and-select still earns its place.
@testcase
def test_context_menu_allowed_in_text_field(page):
"""A text field keeps its native menu — paste and select still help there."""
open_app(page)
fired = search_box(page).evaluate(
"el => el.dispatchEvent(new MouseEvent('contextmenu', {bubbles:true, cancelable:true}))")
assert fired is True, "the search box should keep its native context menu"
print(" PASS: context menu allowed in text field")
The lightbox has a few read-only looks — none of them touches a doc — so they share one
boot: each opens a doc, checks its one thing, and hands the wall back closed for the next.
First, the device back button leaves the lightbox: opening a doc pushes a history entry, so
Back pops it — landing back on the grid — while Esc and ✕ unwind that same entry, so
explicit-close and Back stay balanced (just as they do for the frame).
# Back steps out of the lightbox to the grid, like ✕ does
open_doc(page, 0)
expect(dialog(page)).to_be_visible()
page.go_back()
expect(dialog(page)).to_be_hidden()
expect(grid(page)).to_be_visible()
print(" PASS: back button closes lightbox")
The modal media is the downscaled web_cid, so the lightbox also offers the original
file — the doc’s cid is itself the original’s /ipfs/ address — opened in a new tab for a
full-resolution look or a download. The metadata rows should leave the media every pixel
they can, so the offer rides on the date row as a small ⤢ glyph: an icon that reads as
“open full” carries it without spending a line or a word. An icon has no text to name it,
so the link labels itself original for a screen reader, and its hover title spells out
the full-resolution, new-tab behaviour.
# the lightbox links to the original doc at its /ipfs/ cid (full-res, new tab)
open_doc(page)
link = dialog(page).get_by_role("link", name="original")
expect(link).to_be_visible()
expect(link).to_have_attribute("href", FIXTURES[0]["cid"]) # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
page.keyboard.press("Escape") # hand the wall back for the next view
expect(dialog(page)).to_be_hidden()
print(" PASS: lightbox links to original")
Up close means big: the doc is what you came to see, so the dialog takes the whole band the
system leaves it and the media — photo or video alike, they share the same .lb-media box —
gets every pixel the metadata rows leave, scaled up as well as down. A cap-only
sizing (max-width=/=max-height) would shrink an oversized media but leave the
downscaled web_cid floating small in the overlay — on a tablet, most of the screen
wasted. But it is shown whole, never cropped: object-fit:contain scales the doc to
the largest size that fits the box, so a portrait photo on a landscape screen (or the
reverse) keeps every edge — the bars on the spare axis are the price of seeing all of it,
and cropping away the very thing you opened the doc to look at is the worse trade.
open_doc(page)
img = dialog(page).get_by_role("img")
box = img.bounding_box()
assert box["width"] >= VIEWPORT["width"] * 0.9, f"media width {box['width']} < 90% of viewport"
assert box["height"] >= VIEWPORT["height"] * 0.65, f"media height {box['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", "media crops instead of fitting whole"
page.keyboard.press("Escape") # hand the wall back (last view, kept uniform)
expect(dialog(page)).to_be_hidden()
print(" PASS: lightbox media fills screen")
A doc’s labels show as chips, and a chip is also a shortcut: click one and the lightbox closes, the wall re-filtered to that label.
@testcase
def test_lightbox_chip_filters(page):
"""Clicking a label chip in the lightbox sets the search to that label."""
make_fixtures()
chip_doc = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-chip", "date": "2020-05-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-chip-t",
"labels": "zzchiponly; " + FIXTURE_LABEL, "state": "todo"}
gql(CREATE, {"p": chip_doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzchiponly") # narrow to just the chip doc
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
d.get_by_role("button", name=FIXTURE_LABEL, exact=True).click() # click its other chip
expect(d).to_be_hidden() # the lightbox closes
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # the filter switched to that label
finally:
gql(DELETE, {"cid": chip_doc["cid"]})
print(" PASS: lightbox chip filters")
The box takes several labels at once, ;-separated, and skips any the doc already
carries.
box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20) # one existing + two new, typed
box.press("Enter")
expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1) # not duplicated
print(" PASS: lightbox adds several labels")
Completion targets the segment after the last ;, so a pick fills that one and keeps the
earlier labels already typed.
@testcase
def test_lightbox_completes_last_segment(page):
"""Completion targets the segment after the last ';' (and a pick keeps the earlier ones)."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zztest;cos", delay=20) # typed, as a user would
opt = options(page).filter(has_text="cosmo").first # the live segment 'cos' completes to a vocab word
expect(opt).to_be_visible()
opt.click()
expect(box).to_have_value("zztest;cosmo; ") # earlier segment kept, completed, auto-';
box.press("Enter") # commit the built list
expect(d.get_by_role("button", name="zztest", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="cosmo", exact=True)).to_be_visible()
print(" PASS: lightbox completes last segment")
It’s the same keyboard path as the search box: ↓ then Enter completes the segment
(with the auto-;), and a plain Enter commits the built list.
@testcase
def test_lightbox_label_keyboard(page):
"""In the lightbox add-label box, ↓+Enter completes (with auto-';'), then Enter commits."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos", delay=20)
expect(options(page).first).to_be_visible()
word = options(page).first.inner_text().strip()
box.press("ArrowDown"); box.press("Enter") # apply the highlight → fills "word; "
expect(box).to_have_value(word + "; ")
box.press("Enter") # nothing highlighted now → commit
expect(d.get_by_role("button", name=word, exact=True)).to_be_visible() # chip added
print(" PASS: lightbox label keyboard")
With nothing highlighted, Enter adds the typed labels and Shift+Enter removes them —
the keyboard twins of add and drop.
box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible() # added
box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0) # removed
print(" PASS: lightbox enter adds, shift-enter removes")
This box — and the frame’s add-label box — is a combobox on the same terms as the
search box: an aria-expanded that tracks its popover, and a close that is a state flip,
not a timed guess.
@testcase
def test_lightbox_label_combobox_state(page):
"""The lightbox add-label box is a combobox too: aria-expanded tracks its list, no-timing close."""
open_fixtures(page)
open_doc(page)
box = dialog(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos")
expect(box).to_have_attribute("aria-expanded", "true")
box.blur()
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: lightbox label combobox state")
And while the box has focus, ←=/=→ edit the text rather than stepping to another doc —
inside the field the arrows belong to the text, as the shared highlight set out.
@testcase
def test_label_edit_keeps_arrows_in_text(page):
"""While the add-label box is focused, ← / → edit the text — they don't step to another doc."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
img = d.get_by_role("img")
src0 = img.get_attribute("src")
box = d.get_by_placeholder("add a label…")
box.click(); box.fill("abc")
box.press("ArrowRight") # would step to the next doc if unguarded
page.wait_for_timeout(200)
expect(img).to_have_attribute("src", src0) # same doc — the arrow stayed in the field
print(" PASS: label edit keeps arrows in text")
Completion also won’t waste a row on a label the doc already wears: it drops the open doc’s current labels from its offers, so you only ever see words you could actually add.
@testcase
def test_lightbox_completion_skips_present(page):
"""The lightbox's completion never re-offers a label the open doc already has."""
make_fixtures()
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpresent", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzpresent-t", "labels": "cosmo; zzpresent", "state": "todo"}
gql(CREATE, {"p": doc})
try:
open_app(page)
search_for(page, "zzpresent") # narrow to just this doc
expect(tiles(page)).to_have_count(1)
open_doc(page)
box = dialog(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("balade", delay=20) # a label the doc lacks…
expect(options(page).filter(has_text=re.compile(r"^balade$")).first).to_be_visible() # …is offered
box.fill(""); box.press_sequentially("cosmo", delay=20) # one it already has…
expect(options(page).filter(has_text=re.compile(r"^cosmo$"))).to_have_count(0) # …is not
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: lightbox completion skips present")
One thing about that box is peculiar to the lightbox: it sits at the very foot of the modal, and nothing in the lightbox scrolls. Whatever ends up below that foot is not awkward but gone — a doc you opened in order to label it is a doc you cannot label. Which is why the modal ends where the navigation bar’s strip begins; and why the completion list opens upward, above the input, rather than dropping off the bottom of a short screen with the rows you want out of reach and the wall behind catching the scroll you would try to chase them with.
@testcase
def test_lightbox_completion_opens_upward(page):
"""On a short screen the lightbox label completion opens above the input, on screen — reachable."""
open_fixtures(page)
open_doc(page)
page.set_viewport_size({"width": 420, "height": 470}) # short: a downward list would fall off the foot
box = dialog(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos", delay=20) # a 2+-char prefix with vocabulary
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
lb = listbox.bounding_box(); ib = box.bounding_box(); vh = page.viewport_size["height"]
assert lb["y"] + lb["height"] <= ib["y"] + 1, f"the completion must open above the input: list {lb} input {ib}"
assert lb["y"] >= 0 and lb["y"] + lb["height"] <= vh + 1, f"the completion must sit on screen: {lb} vh={vh}"
print(" PASS: lightbox completion opens upward")
And the video branch: a video-mimetype doc opens as a <video> pointed at its
web_cid. (A throwaway video fixture — a fake cid — proves the element and source are
right without needing real playback.)
@testcase
def test_lightbox_video(page):
"""A video doc opens as a <video> sourced from its web_cid."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE})
try:
open_app(page)
chip(page, "all").click()
search_for(page, VIDEO_LABEL) # a label only the video carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
video = dialog(page).locator("video") # no ARIA role exists for <video>
expect(video).to_be_visible()
assert VIDEO_FIXTURE["webCid"] in (video.get_attribute("src") or ""), "wrong video src"
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: lightbox video")
Browsing without closing. Once a doc is open you move through the wall in place — the
‹ / › buttons or the keyboard arrows — stepping to the previous/next doc in the current
(filtered, dated) order, wrapping at the ends. Nothing here reads a drag across the media:
the media is the one place you want the browser’s gestures intact, which the next section
comes to.
@testcase
def test_lightbox_prev_next(page):
"""The arrows and the nav buttons step through the wall in the lightbox."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
open_doc(page)
img = dialog(page).get_by_role("img")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.press("ArrowRight")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.keyboard.press("ArrowLeft")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
dialog(page).get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: lightbox prev/next")
The lightbox also shows whether the current doc is selected and lets you toggle it, so you can build a selection while reviewing one by one; the change shows on the wall behind.
@testcase
def test_lightbox_select(page):
"""The lightbox shows selection state and toggles it; the wall reflects it."""
open_fixtures(page)
open_doc(page)
d = dialog(page)
sel = d.get_by_role("button", name=re.compile("select", re.I))
expect(sel).to_have_attribute("aria-pressed", "false")
sel.click()
expect(sel).to_have_attribute("aria-pressed", "true") # now selected
d.get_by_role("button", name="close").click()
expect(checks(page)).to_have_count(1) # the wall shows it selected
print(" PASS: lightbox select")
A Shift+wheel over the photo steps prev/next as well — plain scroll is left for the panel, so the two don’t fight.
@testcase
def test_lightbox_shift_scroll_nav(page):
"""Shift+wheel over the photo steps prev/next (plain scroll is left for the panel)."""
open_fixtures(page)
open_doc(page)
img = dialog(page).get_by_role("img")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = img.bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.keyboard.down("Shift")
page.mouse.wheel(0, 240) # shift-scroll down → next
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.wait_for_timeout(250) # clear the one-step cooldown
page.mouse.wheel(0, -240) # shift-scroll up → prev
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.up("Shift")
print(" PASS: lightbox shift-scroll nav")
Browsing to the next doc is where last-label reuse pays off: the label you just applied is offered there for a single tap, no retype.
box.fill("zzreuse"); box.press("Enter")
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # applied here
d.get_by_role("button", name="next photo").click()
reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
expect(reuse).to_be_visible() # offered on the next
reuse.click()
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # reused, no retype
print(" PASS: lightbox reuse last label")
A chip’s × is the other half of the box: the way to take a word off a photo without
typing it again. And whatever the chips show, the words have to have actually gone to the
archive — so the proof is not a chip on screen but a fresh search: the photo answering to
the word just put on it, and no longer answering to the word just taken off. A removal that
never landed looks, on screen, exactly like one that did.
box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
d.get_by_role("button", name="close").click()
search_for(page, "lbadded") # the word put on: the photo answers
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # the word taken off: it no longer does
expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
print(" PASS: lightbox edits labels")
Selection has a keyboard twin too: with no field focused, Enter toggles the open doc’s
selection — the lightbox echo of a click on the wall — so you can build a batch while
reviewing one by one, hands on the keys.
@testcase
def test_lightbox_enter_toggles_select(page):
"""In the lightbox, Enter toggles the open doc's selection (when no field is focused)."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
sel = d.get_by_role("button", name=re.compile("select", re.I))
expect(sel).to_have_attribute("aria-pressed", "false")
page.keyboard.press("Enter")
expect(sel).to_have_attribute("aria-pressed", "true") # selected
page.keyboard.press("Enter")
expect(sel).to_have_attribute("aria-pressed", "false") # toggled back off
print(" PASS: lightbox enter toggles select")
This is what earns the lightbox its name. The whole point of opening a doc full-size is to look closer, and closer than the screen means two fingers — to read a sign in the background, or to be sure whose face that is. Then, magnified, you have to move about: a zoom you cannot travel shows you the middle of the photo and nothing else. The browser does both of those perfectly on its own, so the app’s whole job on the media is to claim no gesture there and let them through.
@testcase
def test_lightbox_pinch_zooms_and_pans(page):
"""Two fingers magnify the open photo, and a finger then travels across it."""
open_fixtures(page)
open_doc(page)
box = dialog(page).get_by_role("img").bounding_box()
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
seen = lambda: page.evaluate("() => [visualViewport.scale, visualViewport.pageLeft]")
assert seen()[0] <= 1.01, "started already magnified"
pinch_in(page, cx, cy)
wait_until(page, lambda: seen()[0] > 1.5, label="the pinch reached the browser's zoom",
detail=lambda: f"scale is {seen()[0]}")
at = seen()[1]
drag_touch(page, cx + 150, cy, cx - 150, cy) # one finger, straight across the photo
wait_until(page, lambda: abs(seen()[1] - at) > 5, label="the magnified photo panned sideways",
detail=lambda: f"pageLeft went {at} → {seen()[1]}")
print(" PASS: lightbox pinch zooms and pans")
A long video shouldn’t make you scrub with the tiny native bar from across the room. So
while a video is playing, the arrows seek it — → jumps 5s on, ← 5s back — instead of
leaving the doc; only at the very end (or start) does an arrow give up and step to the next
(or previous) doc, so the wall is still one key away. A paused video, or a photo, steps as
before.
@testcase
def test_lightbox_video_arrows(page):
"""While a video plays, → seeks +5s; from the end, → rolls to the next doc."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzvarrow", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzvarrow-t", "webCid": "https://ipfs.konubinix.eu/p/zzvarrow-web",
"labels": "zzvarrow", "state": "todo"}
nxt = {"cid": "https://ipfs.konubinix.eu/p/zzvarrow-next", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzvarrow-next-t", "labels": "zzvarrow", "state": "todo"}
for d in (vid, nxt): gql(CREATE, {"p": d})
page.route("**/ipfs/zzvarrow-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page); chip(page, "all").click(); search_for(page, "zzvarrow")
expect(tiles(page)).to_have_count(2)
open_doc(page, 0) # date order → the video first
v = dialog(page).locator("video")
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }") # headless blocks unmuted autoplay
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
v.evaluate("el => el.currentTime = 0")
page.keyboard.press("ArrowRight") # → seeks +5s
wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
v.evaluate("el => el.currentTime = el.duration - 0.05") # park at the end
page.keyboard.press("ArrowRight") # → now rolls to the next doc
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzvarrow-next-t")
print(" PASS: lightbox video arrows")
finally:
page.unroute("**/ipfs/zzvarrow-web")
for d in (vid, nxt):
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
SPC is the video’s play control in the lightbox: it toggles play/pause, and once the clip
has run to its end SPC replays it from the start rather than sitting on a frozen last
frame. Driving it from the keyboard means it works whether or not the native control bar
has focus.
@testcase
def test_lightbox_video_spc(page):
"""SPC toggles a lightbox video's play/pause, and replays it from the end."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzvspc", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzvspc-t", "webCid": "https://ipfs.konubinix.eu/p/zzvspc-web",
"labels": "zzvspc", "state": "todo"}
gql(CREATE, {"p": vid})
page.route("**/ipfs/zzvspc-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page); chip(page, "all").click(); search_for(page, "zzvspc")
expect(tiles(page)).to_have_count(1) # wait for the settled result — not a stale tile mid-read
open_doc(page, 0)
v = dialog(page).locator("video")
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press(" ") # SPC pauses
wait_until(page, lambda: v.evaluate("el => el.paused"))
page.keyboard.press(" ") # SPC plays again
wait_until(page, lambda: v.evaluate("el => !el.paused"))
v.evaluate("el => el.currentTime = el.duration") # run it to the end
page.keyboard.press(" ") # SPC replays from the start
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 1
and not v.evaluate("el => el.paused"))
print(" PASS: lightbox video SPC")
finally:
page.unroute("**/ipfs/zzvspc-web")
try: gql(DELETE, {"cid": vid["cid"]})
except Exception: pass
The lightbox turns on one opened signal — the doc in view, or null — with the label-box text and
its focus beside it. A photo can fall back to its thumbnail when its web copy is missing, but a
video has nothing to show without one. Opening pushes a history entry, so the Back button leaves
the modal the way ✕ does.
const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
Adding a label goes through the optimistic applyLabels the intro described, and remembers the
last word applied so labelling the next doc is one tap away; its Shift-Enter twin removes instead
of adds.
async function applyLabels(labels){
const cid = opened().cid;
setOpened({ ...opened(), labels });
await gql(UPDATE_PHOTO, { cid, patch: { labels } });
await refetch();
}
const lbAdd = input => {
const words = splitWords(input); if(!words.length) return;
setLastLabel(words[words.length - 1]);
const cur = labelsOf(opened());
for(const w of words) if(!cur.includes(w)) cur.push(w);
setLbText('');
return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
The ‹ / › buttons, the arrows and a Shift+wheel are three ways of asking for the same
thing, so they meet in one step. A playing video takes the arrows first, though — seeking
within the clip before it steps off it. The wheel reads whichever axis Shift maps it to, and
is held to one step per burst so a fast fling doesn’t overshoot — plain scroll left free for
the panel.
const step = delta => {
const list = items(); if(!list.length || !opened()) return;
const i = list.findIndex(p => p.cid === opened().cid);
setOpened(list[((i < 0 ? 0 : i) + delta + list.length) % list.length]); setLbText('');
};
let lbVideo = null;
const seekOrStep = dir => {
const v = lbVideo;
if(v && isVideo(opened()) && !v.paused &&
(dir > 0 ? v.currentTime < v.duration - 0.25 : v.currentTime > 0.25))
v.currentTime = Math.max(0, Math.min(v.duration, v.currentTime + dir * 5));
else step(dir);
};
let wheelAt = 0;
const onWheel = e => {
if(!e.shiftKey) return;
const d = e.deltaY || e.deltaX;
const now = performance.now();
if(Math.abs(d) < 1 || now - wheelAt < 200) return;
wheelAt = now; step(d > 0 ? 1 : -1);
};
One window listener routes the keyboard while a doc is open, gathering the browsing and triage keys already met into one place. Its own rule is the guard: the whole set stands aside whenever a text field has focus, where those keys belong to the text and the label box’s suggestions.
onMount(() => {
const onKey = e => {
if(!opened()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') dismissPhoto();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); seekOrStep(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); seekOrStep(-1); }
else if(!editing && e.key === 'Delete'){ e.preventDefault(); lbDelete(e.shiftKey); }
else if(!editing && e.key === 'Enter'){ e.preventDefault(); toggle(opened().cid); }
else if(!editing && e.key === ' ' && isVideo(opened()) && lbVideo){
e.preventDefault(); const v = lbVideo;
if(v.currentTime >= v.duration - 0.25){ v.currentTime = 0; v.play(); }
else if(v.paused) v.play(); else v.pause();
}
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
<${Show} when=${() => opened()}>
<div class="lb" onClick=${e => {
if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]')) dismissPhoto(); }}>
<div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
<button class="lb-close" aria-label="close" onClick=${dismissPhoto}>✕</button>
<button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid) ? '✓ selected' : 'select'}</button>
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
<button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}>‹</button>
<button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}>›</button>
<${Show} when=${() => hasMedia(opened())}
fallback=${html`<div class="lb-media noimg">
<span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
<span class="mt">no preview · ${() => opened()?.filename || opened()?.mimetype || ''}</span></div>`}>
<${Show} when=${() => isVideo(opened())}
fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
<video class="lb-media" controls autoplay ref=${el => lbVideo = el} src=${() => IPFS + opened()?.webCid}></video>
<//>
<//>
<div class="lb-meta">
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
onBlur=${e => commitDate(e.target.value)} />
<//>
<a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
aria-label="original" title="original — full resolution, new tab">⤢</a>
</div>
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
<div class="lb-states">
<${For} each=${() => STATES}>${st => html`
<button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
onClick=${() => lbSetState(st)}>${st}</button>`}
<//>
</div>
<div class="lb-labels">
<${For} each=${() => labelsOf(opened())}>${w => html`
<span class="lb-chip"><button class="lb-chip-word"
onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
<//>
<${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
<button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
<//>
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
<${Show} when=${() => lbFocus()}>
<${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
onLoading=${setSugLoading} onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
<//>
</div>
</div>
</div>
</div>
<//>
.lb{ position:fixed; inset:0; z-index:100; background:#000d; display:flex;
padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right))
calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); }
.lb-inner{ position:relative; flex:1; display:flex; flex-direction:column; gap:10px; }
.lb-media{ flex:1; min-height:0; width:100%; object-fit:contain; border-radius:6px; background:#000; }
.lb-media.noimg{ display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:10px; color:#9aa; }
.lb-media.noimg .ph{ font-size:64px; opacity:.55; }
.lb-media.noimg .mt{ font-size:13px; }
.lb-close{ position:absolute; top:-6px; right:-6px; width:32px; height:32px; border-radius:50%;
border:none; background:#262a40; color:var(--fg); font-size:16px; cursor:pointer; z-index:2; }
.lb-nav{ position:absolute; top:50%; transform:translateY(-50%); z-index:2; width:40px; height:64px;
border:none; border-radius:8px; background:#262a40cc; color:var(--fg); font-size:28px; cursor:pointer; }
.lb-nav:hover{ background:#33395a; }
.lb-nav:active{ transform:translateY(-50%) scale(0.88); background:#3d4468; }
.lb-prev{ left:-6px; } .lb-next{ right:-6px; }
.lb-select{ position:absolute; top:-6px; left:-6px; z-index:2; padding:6px 10px; border:none;
border-radius:8px; background:#262a40; color:var(--fg); font-size:12px; cursor:pointer; }
.lb-select[aria-pressed='true']{ background:#6cf; color:#08111e; font-weight:700; }
.lb-meta{ display:flex; align-items:center; gap:8px; }
.lb-date{ font-size:13px; color:#9aa; }
.lb-orig{ color:#6cf; text-decoration:none; font-size:17px; line-height:1; }
.lb-orig:hover{ color:#9df; }
.lb-states{ display:flex; gap:6px; flex-wrap:wrap; }
.lb-st{ padding:4px 10px; border:1px solid #3a3f5a; border-radius:999px; background:#262a40;
color:var(--fg); font-size:12px; cursor:pointer; }
.lb-st[data-st]{ border-color:var(--st); color:var(--st); }
.lb-st[aria-pressed='true']{ background:var(--st,#6cf); color:#08111e; font-weight:700; }
.lb-labels{ display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.lb-labels .suggest{ top:auto; bottom:100%; margin:0 0 4px; } /* open upward from the foot */
.lb-chip{ display:inline-flex; align-items:center; gap:4px; padding:4px 6px 4px 10px; font-size:13px;
background:#262a40; border:1px solid #3a3f5a; border-radius:999px; }
.lb-chip-word{ border:none; background:none; color:inherit; font:inherit; cursor:pointer; padding:0; }
.lb-chip-word:hover{ text-decoration:underline; }
.lb-chip .x{ border:none; background:none; color:#9aa; font-size:16px; line-height:1; cursor:pointer; padding:0 2px; }
.lb-chip .x:hover{ color:#f88; }
/* one-tap reuse of the last-applied label — dashed accent so it reads as an offer, not a set tag */
.lb-reuse{ border:1px dashed #6cf; background:#16243b; color:#6cf; padding:4px 10px;
border-radius:999px; font-size:13px; cursor:pointer; }
.lb-reuse:hover{ background:#1d2f4d; }
Seeing a doc’s events
A photo sits in time, and the calendar knows what was happening then — so the lightbox
names it. The events the photo falls within — from the calendar’s eventsAt, keyed on the
doc’s date and owner — ride under the date as small chips. The calendar is Google’s,
managed elsewhere, so memories never edits the events themselves — it only surfaces the
overlap they name.
@testcase
def test_lightbox_shows_events(page):
"""The lightbox names the calendar events the photo falls within (its date, its owner)."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-piscine", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
"summary": "zzPiscine Beynost", "owner": "konubinix", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zzevphoto", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzevphoto-t", "labels": "zzevphoto", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev}) # the event the photo was taken during
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzevphoto")
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(dialog(page).get_by_text("zzPiscine Beynost")).to_be_visible() # the overlapping event, named
start = page.evaluate("() => new Date('2020-06-01T00:00:00Z').toLocaleDateString('fr-FR')")
end = page.evaluate("() => new Date('2020-06-30T23:59:59Z').toLocaleDateString('fr-FR')")
expect(dialog(page).get_by_text(start)).to_be_visible() # the span's start, beside the name
expect(dialog(page).get_by_text(end)).to_be_visible() # …and its end
finally:
gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: lightbox shows events")
On the same open doc, the event’s start and end dates read off the pill (a month-long event here, so both bounds show); each is computed in-browser so the locale and timezone match what the pill rendered.
start = page.evaluate("() => new Date('2020-06-01T00:00:00Z').toLocaleDateString('fr-FR')")
end = page.evaluate("() => new Date('2020-06-30T23:59:59Z').toLocaleDateString('fr-FR')")
expect(dialog(page).get_by_text(start)).to_be_visible() # the span's start, beside the name
expect(dialog(page).get_by_text(end)).to_be_visible() # …and its end
An occasion need not fill a day. When it is timed — here an afternoon, 09:00 to 18:00 — the hour is what places it, so its chip must show the clock, not the date alone.
@testcase
def test_lightbox_shows_timed_event_hours(page):
"""A timed occasion (not a whole day) shows its start and end hour on the pill, not just the date."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-timed", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzKarate Beynost", "owner": "konubinix", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zztimedphoto", "date": "2020-03-07T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zztimedphoto-t", "labels": "zztimedphoto", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zztimedphoto")
expect(tiles(page)).to_have_count(1)
open_doc(page)
when = page.evaluate("""() => {
const s = new Date('2020-03-07T09:00:00Z'), en = new Date('2020-03-07T18:00:00Z');
const t = x => x.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
return `${s.toLocaleDateString('fr-FR')} ${t(s)} – ${t(en)}`;
}""")
expect(dialog(page).get_by_text(when)).to_be_visible() # date + clock, computed in-browser
finally:
gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: lightbox shows timed event hours")
Owner-scoping is not cosmetic: two people’s calendars can hold events on the very same day, and a photo belongs to just one of them. So the lightbox shows only that owner’s events — a different owner’s event spanning the same instant stays hidden.
@testcase
def test_lightbox_events_are_owner_scoped(page):
"""A photo shows only its own owner's events — a same-day event of another owner stays hidden."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
mine = {"rowId": "zzev-mine", "starttime": "2020-07-01T00:00:00Z", "endtime": "2020-07-31T23:59:59Z",
"summary": "zzMine Konubinix", "owner": "konubinix", "status": "confirmed"}
other = {"rowId": "zzev-other", "starttime": "2020-07-01T00:00:00Z", "endtime": "2020-07-31T23:59:59Z",
"summary": "zzAyla Elsewhere", "owner": "aylapomme", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zzevphoto-k", "date": "2020-07-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzevphoto-k-t", "labels": "zzevphotok", "owner": "konubinix", "state": "todo"}
for e in (mine, other): gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzevphotok")
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
expect(d.get_by_text("zzMine Konubinix")).to_be_visible() # its own owner's event shows
expect(d.get_by_text("zzAyla Elsewhere")).to_have_count(0) # another owner's, same day — hidden
finally:
gql(DELETE, {"cid": doc["cid"]})
for e in (mine, other): gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: lightbox events are owner-scoped")
A pill is also a way in: the occasion it names is usually the next thing you want whole —
every photo of it — so clicking one runs event:<name> and drops you back on the wall,
now narrowed to that occasion. It is a jump, not an edit: the calendar stays untouched.
@testcase
def test_lightbox_event_pill_searches(page):
"""Clicking an event pill in the lightbox runs its event: search and lands on the filtered wall."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-pill", "starttime": "2020-06-01T00:00:00Z", "endtime": "2020-06-30T23:59:59Z",
"summary": "zzPillfest Beynost", "owner": "konubinix", "status": "confirmed"}
inside = {"cid": "https://ipfs.konubinix.eu/p/zzpill-in", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzpill-in-t", "labels": "zzpill", "owner": "konubinix", "state": "todo"}
outside = {"cid": "https://ipfs.konubinix.eu/p/zzpill-out", "date": "2020-09-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzpill-out-t", "labels": "zzpill", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in (inside, outside): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzpill")
expect(tiles(page)).to_have_count(2) # both docs, same label
grid(page).get_by_role("listitem").filter( # open the one inside the event
has=page.get_by_role("img", name="2020-06-15")).dblclick()
pill = dialog(page).get_by_role("button", name=re.compile("zzPillfest Beynost"))
expect(pill).to_be_visible()
pill.click()
expect(search_box(page)).to_have_value("event:zzPillfest Beynost") # the pill's search, committed
expect(dialog(page)).to_be_hidden() # left the lightbox for the wall
expect(tiles(page)).to_have_count(1) # narrowed to the occasion
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzpill-in-t")
finally:
for d in (inside, outside): gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: lightbox event pill searches")
The lookup rides on the open doc: a resource keyed on opened() runs fetchDocEvents, which
asks eventsAt for a doc’s date and owner — so it follows whatever doc is shown. A doc
with no owner has nothing to scope by, so the resource stays idle rather than asking. (The
wall query carries each doc’s owner for exactly this.) fetchDocEvents is written against a
doc rather than the lightbox, so any surface with a current doc can reuse it.
const EVENTS_FOR_DOC = `query($d:Datetime!,$o:OwnerType!){ eventsAt(d:$d, o:$o){ nodes{ summary starttime endtime } } }`;
const fetchDocEvents = async o => (await gql(EVENTS_FOR_DOC, { d: o.date, o: o.owner }))?.eventsAt?.nodes ?? [];
const dayBased = (s, en) => s.getUTCHours() === 0 && s.getUTCMinutes() === 0 && s.getUTCSeconds() === 0
&& en.getUTCHours() === 23 && en.getUTCMinutes() === 59 && en.getUTCSeconds() === 59;
const hhmm = t => t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
const eventWhen = e => {
const s = new Date(e.starttime), en = new Date(e.endtime);
const sd = s.toLocaleDateString('fr-FR'), ed = en.toLocaleDateString('fr-FR');
if (dayBased(s, en)) return sd === ed ? sd : `${sd} – ${ed}`;
return sd === ed ? `${sd} ${hhmm(s)} – ${hhmm(en)}` : `${sd} ${hhmm(s)} – ${ed} ${hhmm(en)}`;
};
// run the occasion's own event: search — the pill's jump into it
const searchEvent = summary => { setSearch('event:' + summary); commit(); };
const [lbEvents] = createResource(
() => { const o = opened(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
Each chip names the occasion and, beside the name, carries when it ran. A whole-day occasion
shows only its dates — collapsed to one when it lasted a single day — since its clock would read
00:00 and add nothing; a timed occasion instead shows the start and end hour, which is what
places it. Under the hood, whole-day is read off the span’s UTC bounds — 00:00:00 to 23:59:59,
a day’s last second — the inclusive end the calendar spans carry, the same bound events_at matches
on. That when is muted to a quiet
aside (.ev-when, dimmed): a photo’s occasion is Piscine, juin 2020, and the name alone
recurs, so the when disambiguates without shouting.
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event" onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
They are small rounded pills, muted against the dark panel and wrapping onto another line
when there are several. Unlike a label chip they carry no × — there is no event to edit
here — but the whole pill is a button: a pointer and a hover lift mark it as a jump into
the occasion.
.lb-events{ display:flex; flex-wrap:wrap; gap:6px; }
.lb-event{ font:inherit; font-size:12px; color:#bcd; background:#20293f; border:1px solid #34406088;
border-radius:999px; padding:3px 10px; cursor:pointer; }
.lb-event:hover{ background:#2a3450; border-color:#4a5680; }
/* the span reads as a quiet aside beside the name — the discreet part of "name + when" */
.ev-when{ margin-left:6px; opacity:.6; }
Change state from the lightbox
Triage shouldn’t need closing the photo: the lightbox carries the state buttons too, and tapping one saves it at once. But you are reading the run in order — one, then the next, then the next — and judging the doc in front of you drops it out of the filter you are working through, so it vanishes from the wall under you. The one you were going to read next is the one after it. Landing you instead on the one before — which you read a moment ago, and dealt with — costs you a step back and a step forward to get where you already were.
d.get_by_role("button", name="done", exact=True).click() # leaves the todo filter
expect(d).to_be_visible() # still open…
expect(img).to_have_attribute("src", ahead) # …on the doc that took its place
assert img.get_attribute("src") != behind, "the modal stepped backwards"
print(" PASS: judging one lands on the next, not the previous")
There is no doc without a next, because the run is a ring — the arrows already come round at its ends. So judging the one at the end is no special case at all: you carry on to the first, the same single step forward as anywhere else.
onto_last = thumb_imgs(page).nth(2).get_attribute("src") # the last of the three left
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", onto_last) # settled on it before judging it
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_visible()
expect(img).to_have_attribute("src", behind) # round to the first, not back to the end
print(" PASS: judging the last comes round to the first")
Carry on and the run empties. Each judgement hands you the next as before, right down to the last of them — and when the one you just judged was the only one left, there is nothing to show at all, so the modal gets out of the way rather than sitting on an empty wall.
d.get_by_role("button", name="done", exact=True).click()
expect(img).to_have_attribute("src", ahead) # round again, to the one still standing…
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_hidden() # …and with that one gone, nothing to show
expect(tiles(page)).to_have_count(0)
print(" PASS: the last one judged closes the run")
All of it falls out of one write and one re-anchor. The doc that was next around the ring is noted before the write, because after it that doc has moved up into the gap and its neighbour would answer instead.
async function lbPatch(patch){
const list = items(), cur = opened(); if(!cur) return;
const i = list.findIndex(p => p.cid === cur.cid);
const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
const stay = l2.find(p => p.cid === cur.cid);
setOpened(stay || (nextCid && l2.find(p => p.cid === nextCid)) || l2[0]);
});
}
const lbSetState = st => lbPatch({ state: st });
Marking for deletion with a key
Sweeping a stack down to the keepers means sending a lot of docs to delete, and reaching
for the button each time is the slow part. So in the lightbox the Delete key marks the
open doc for deletion, through the same state-setting a button press uses — so it
re-anchors and closes just as one would. Because it is destructive it asks first;
Shift+Delete is the trusting shortcut that skips the question. And while a label box has
focus, Delete is just text editing — it obeys the same field-focus guard as the arrows.
Pressing Delete raises a confirm; accepting it moves the open doc to delete.
del_btn = d.get_by_role("button", name="delete", exact=True)
expect(del_btn).to_have_attribute("aria-pressed", "false")
asked = []
page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
page.keyboard.press("Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true") # moved to delete
assert asked, "Delete should have asked to confirm"
print(" PASS: lightbox delete confirms")
Shift+Delete is the same move without the prompt — for a confident run down a stack. It
has to be tried on a doc that is not already condemned, or a key that did nothing at all
would look like a key that worked.
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false") # a fresh doc, not yet condemned
asked.clear()
page.keyboard.press("Shift+Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true")
assert not asked, "Shift+Delete should not ask to confirm"
print(" PASS: lightbox shift-delete skips confirm")
And where a key that condemns a photo would be worst — mid-word in the label box, reaching to rub out a typo — it must be nothing but text editing.
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false")
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzoops", delay=10)
box.press("ArrowLeft"); box.press("Delete") # rubbing out the last letter
expect(box).to_have_value("zzoop")
expect(del_btn).to_have_attribute("aria-pressed", "false") # the photo is untouched
print(" PASS: lightbox delete in a field is text editing")
The key routes the open doc through the lightbox’s state-setting, so it inherits the
re-anchoring whole; Shift is what forces past the confirm.
const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };
Fixing a wrong date
A doc turns up wearing a date it never earned — a WhatsApp copy stamped to the day it
was saved, a scan that carried no capture time — and the date-sorted wall files it years
from where it belongs. The correction has to be right there in the moment you catch it,
eyes on the photo and no detour to a form: the date on the lightbox’s meta row is
already what you are reading, so it is what you edit. It saves through the very
lbPatch a state button uses, so a doc that just changed date re-anchors just as one
that changed state — it slides to its rightful place on the wall and the lightbox keeps
its footing.
The edit is a small state machine, worth seeing whole before the pieces.
d.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = d.get_by_label("date", exact=True)
box.fill("2020-12-15T12:00") # push it past the other two
box.press("Enter") # save
d.get_by_role("button", name="close").click() # back to the wall
expect(tiles(page)).to_have_count(len(FIXTURES))
# the tile's alt is its day (set straight from the date, no lazy load), so the edited
# doc — now the latest — sits last on the date-sorted wall.
expect(thumb_imgs(page).nth(2)).to_have_attribute("alt", "2020-12-15")
print(" PASS: lightbox edit date re-orders")
The date on the meta row is a button; opening the picker seeds it with what the date held.
want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }",
FIXTURES[0]["date"])
expect(d.get_by_label("date", exact=True)).to_have_value(want) # seeded with the stored instant, in local time
print(" PASS: date editor seeds current value")
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); } }}
onBlur=${e => commitDate(e.target.value)} />
<//>
Under the hood the picker is a datetime-local input, which speaks the browser’s local
wall-clock while the stored date is a zoned instant — so a small formatter seeds it and
toISOString() reads it back. Only the day was ever wrong, so the seed keeps the hours
and minutes too, and a save that fixes just the year leaves the time of day standing.
const toLocalInput = iso => { if(!iso) return '';
const d = new Date(iso), p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
let cancelEdit = false;
const commitDate = v => { const skip = cancelEdit; cancelEdit = false; setEditingDate(false);
if(!skip && v) lbPatch({ date: new Date(v).toISOString() }); };
Backing out has to stay inside the lightbox: Escape would otherwise reach the modal’s
own key handler and close the whole photo, so the picker swallows the key at the input
and trips the veto before it lets go.
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); }
Whether the backing-out took has to be read off the wall behind, and not off the picker’s own row, because a date that saves can carry its photo clean out of the window the wall is showing — and the open doc then re-anchors to a neighbour. So after a save that should never have happened, the row is showing neither the old date nor the new one but a third photo’s altogether, and it cannot answer the question that was asked of it. The wall can: the photo is missing from it, and the dates it lists are the ones actually stored. And since the claim is that nothing happened, the reading waits out the span a save would have needed before believing it.
unmoved = tile_dates(page) # the wall as it stands
box = d.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(d).to_be_visible() # still open
page.wait_for_timeout(SAVE_GRACE_MS) # room for a save to land
assert tile_dates(page) == unmoved, f"the backed-out date was written anyway: {tile_dates(page)}"
print(" PASS: date edit escape cancels")
Reaching the picker should cost no more than reaching the label box does: d — for
date — opens it on the doc in view, the same reflex as l for labels.
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_be_focused()
print(" PASS: date shortcut focuses editor")
It stands aside where it would be in the way: while a text field already holds the keys, d
is a letter like any other.
d.get_by_placeholder("add a label…").click() # focus the label box
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_have_count(0) # the picker stayed shut
print(" PASS: date shortcut stands aside in a field")
And on the wall, with nothing open, it has no doc to act on — nor may it lie in wait and spring on the next one opened.
page.keyboard.press("d") # no doc open
open_doc(page, 0)
d = dialog(page)
expect(d.get_by_role("button", name="edit date")).to_be_visible() # opens in display mode…
expect(d.get_by_label("date", exact=True)).to_have_count(0) # …not the picker
print(" PASS: date shortcut ignored without a doc")
onMount(() => {
const onKey = e => {
if(e.key !== 'd' || !opened()) return; // only while a doc is open
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
e.preventDefault(); setEditingDate(true);
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
.lb-date{ background:none; border:none; padding:0; font-family:inherit; cursor:pointer; }
.lb-date:hover{ color:#ccd; text-decoration:underline; }
.lb-date-edit{ font-size:13px; color:var(--fg); background:#262a40; border:1px solid #3a3f5a;
border-radius:6px; padding:2px 6px; }
Customizable thumbnail size
Wall density is a taste call, so the tile size is adjustable — the − / + controls run the tiles from 80 to 320px in steps of forty, opening at 96 for a first visit.
page.set_viewport_size({"width": 1600, "height": 800}) # wide enough that every step re-columns the wall
open_fixtures(page)
t = tiles(page)
w0 = t.nth(0).bounding_box()["width"]
page.get_by_role("button", name="bigger thumbnails").click()
page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w0 + 8)
Density chosen once should stay chosen, so the size outlives the session — reopen the app and the wall comes back at the size you left it.
w1 = t.nth(0).bounding_box()["width"]
open_app(page)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
A hand already on the wall shouldn’t have to travel to the controls, so Ctrl+scroll over the tiles does the same: scroll up to grow them, down to shrink.
w2 = t.nth(0).bounding_box()["width"]
box = grid(page).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + 10)
page.keyboard.down("Control")
page.mouse.wheel(0, -240) # ctrl-scroll up → bigger
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w2 + 8)
That gesture is not ours to borrow quietly — Ctrl+wheel is the browser’s own page zoom, and a handler that merely listened would leave the whole app scaling around the wall instead of the tiles in it. So the wall claims the event outright before it resizes anything.
A wheel also arrives as a burst of notches where a finger meant one movement, and counting them would rocket the wall through its range. So a burst buys a single step: notches less than 120 ms apart are the same gesture, and stepping back once returns the size the burst started from.
page.wait_for_timeout(200) # a fresh gesture, clear of the notch above
w3 = t.nth(0).bounding_box()["width"]
page.keyboard.down("Control")
page.mouse.wheel(0, -240)
page.mouse.wheel(0, -240) # a second notch inside the same burst
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w3 + 8) # the burst moved the wall
page.get_by_role("button", name="smaller thumbnails").click() # …by exactly one step
wait_until(page, lambda: abs(t.nth(0).bounding_box()["width"] - w3) < 2)
const SIZE_KEY = 'memories.thumbSize';
const [thumbSize, setThumbSize] = createSignal(+localStorage.getItem(SIZE_KEY) || 96);
createEffect(() => localStorage.setItem(SIZE_KEY, thumbSize()));
const bumpSize = d => setThumbSize(s => Math.max(80, Math.min(320, s + d * 40)));
let zoomAt = 0;
const onGridWheel = e => {
if(!e.ctrlKey) return;
e.preventDefault();
const now = performance.now();
if(now - zoomAt < 120) return;
zoomAt = now; bumpSize(e.deltaY < 0 ? 1 : -1);
};
A tile at the top of that range shows a 256px thumbnail at 320 — a stretch of a quarter on
a desktop, and several-fold on a phone, whose device pixels outnumber its CSS ones by two
or three to one. On a dense wall that softness is worth what it saves, because sharpness here
is priced per tile. Sampled across the archive — 126 photos from twelve evenly spaced points
through all seventy-five thousand — a thumbnail runs 16–64 kB where the
web_cid beside it runs 102–474 kB: the same photo, five to twelve times heavier in the
second. At the default size a fold
holds some seventy tiles, and the next search draws a fresh wall of them, so buying the heavier
rendition for a contact sheet would run into hundreds of megabytes spent sharpening pictures
nobody is looking at yet.
What changes as the tiles grow is not that price but the number of tiles paying it. Three
columns of 330px fill the same fold with fewer than ten tiles where the default fits seventy,
and that drop is a factor of twelve against the five to twelve the rendition costs: a grown fold
comes in at or below what a dense one already spent. Nor is that a bargain of one screen — a
fold holds area over cell squared tiles, so the area falls out, and a phone strikes the same
trade as a desktop. What the same bytes buy, though, is incomparably
better, because at that width the upscale has stopped being a softness you can overlook: the
tile has become a picture you are looking at rather than a stamp you are picking out of a
sheet. So past that width the wall
refines: each tile on screen trades up to its web_cid, the same rendition the
lightbox and the frame show — a wall grown that wide has more in common with those
surfaces than with a contact sheet. A video’s web_cid is a film rather than a bigger
poster, so a video tile keeps the poster it has.
Where the two curves cross is a judgement call, fixed at a drawn cell of 300px — to move
by eye if the wall comes to feel either soft or heavy. Drawn, not chosen: the wall’s columns
are auto-fill tracks running to 1fr, so they stretch into whatever width is left over,
and a phone at the top of the slider draws a single column across its whole screen. The
threshold has to read the pixels the picture is actually painted at, so it is taken from the
resolved column and not from the setting.
At the default size, then, the wall buys thumbnails and nothing else — scroll it as far as you like.
page.set_viewport_size({"width": 1000, "height": 420}) # short, so the dense wall really does overflow
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
open_app(page)
search_for(page, "zzref")
expect(tiles(page)).to_have_count(80)
assert cell_px(page) <= 300, f"the default wall should be a contact sheet, drawn at {cell_px(page)}px"
page.mouse.move(500, 210)
for _ in range(10): # all the way down the wall
page.mouse.wheel(0, 400)
page.wait_for_timeout(100)
assert tiles(page).last.bounding_box()["y"] < 420, "the wall never overflowed — nothing was scrolled into reach"
assert not web(), f"the dense wall fetched {len(web())} web renditions"
Grow the tiles past the threshold and that same wall sharpens, the thumbnail staying underneath so that nothing blinks out while the fuller picture is on its way.
tiles(page).first.scroll_into_view_if_needed()
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click() # to the 320 ceiling
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
wait_until(page, lambda: any(u.endswith("zzref-web-1") for u in web()),
label="the tile on screen trades up to its web rendition",
detail=lambda: f"srcs={tile_srcs(page, 1)} web={web()}")
assert any("zzref-t-1" in (s or "") for s in tile_srcs(page, 1)), "the thumbnail left from under it"
Only what you are looking at is bought, and only if it is a photo: the tiles far below the fold keep their thumbnails, and the video keeps its poster instead of pulling a film onto the wall.
assert not [u for u in web() if u.endswith("web-79")], f"a tile far below the fold refined: {web()}"
assert not [u for u in web() if u.endswith("web-vid")], f"the video tile fetched its film: {web()}"
Refinement must never cost the wall its legibility. The thumbnail pass is what turns a screenful of blanks into pictures you can triage, and that moment is what the wall is for; the sharpening after it is a luxury. A web rendition is ten thumbnails on the wire, and issued alongside them it competes for the same bandwidth and pushes that moment back. So the two passes are ordered: nothing refines until every thumbnail the wall is waiting for has settled. Settled, not painted — an address the gateway cannot serve must not hold the whole wall soft for good.
page.route("**/ipfs/zzwait-t-0", lambda route: None) # never answered: this one is still on its way
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
page.set_viewport_size({"width": 1000, "height": 700})
open_app(page)
search_for(page, "zzwait")
expect(tiles(page)).to_have_count(12)
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwait-t-0") # asked for…
assert not [u for u in fetched() if u.endswith("zzwait-t-0")], "…and still unanswered"
wait_until(page, lambda: len([u for u in fetched() if "zzwait-t-" in u]) >= 2,
label="its neighbours' thumbnails have settled",
detail=lambda: f"settled={[u for u in fetched() if 'zzwait-t-' in u]}")
page.wait_for_timeout(600) # ample room for a refinement to fire
assert not web(), f"the wall refined with a thumbnail still in flight: {web()}"
Scrolling reopens the question: fresh tiles bring fresh thumbnails to wait for, and the wall is waiting again. A tile that has already traded up keeps what it has — handing a picture back because a neighbour arrived late would be a flicker bought with nothing — so the sharpening only ever moves forward, a screenful at a time.
page.route("**/ipfs/zzfwd-t-4", lambda route: None) # doc 4's thumbnail never arrives
sharp = lambda i: any(f"zzfwd-web-{i}" in (s or "") for s in tile_srcs(page, i))
inreach = lambda i: any(f"zzfwd-t-{i}" in (s or "") for s in tile_srcs(page, i))
page.set_viewport_size({"width": 480, "height": 700}) # one tall column at the ceiling
open_app(page)
search_for(page, "zzfwd")
expect(tiles(page)).to_have_count(8)
for _ in range(6): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
wait_until(page, lambda: sharp(0), label="the tile on screen sharpens",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
tiles(page).nth(2).scroll_into_view_if_needed()
wait_until(page, lambda: sharp(3), label="the tiles scrolled into reach sharpen too",
detail=lambda: f"srcs={tile_srcs(page, 3)}")
tiles(page).nth(4).scroll_into_view_if_needed() # doc 4 comes into reach, and never lands
wait_until(page, lambda: inreach(5), label="doc 5 has come into reach behind it",
detail=lambda: f"srcs={tile_srcs(page, 5)}")
page.wait_for_timeout(600) # ample room for doc 5 to settle and refine
assert inreach(4), f"doc 4 must be in reach to hold anything back: {tile_srcs(page, 4)}"
assert sharp(3), f"a tile handed its picture back: {tile_srcs(page, 3)}"
assert not sharp(5), f"a tile refined behind a thumbnail in flight: {tile_srcs(page, 5)}"
What a tile does give back is what it gave back before. Scrolled out of reach, it drops the fuller picture along with its thumbnail, so a wall you have left behind holds no more decoded pixels for having been sharpened.
wait_until(page, lambda: not inreach(0), label="doc 0 has been left behind",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
assert not sharp(0), f"the fuller picture outlived the thumbnail: {tile_srcs(page, 0)}"
A tile can also leave the wall altogether, when a search swaps the whole of it while a thumbnail is still on its way. The tally has to let go of whatever left with it: a wall still counting thumbnails that are no longer on it would never refine again.
search_for(page, "zzfwd2") # swapped out from under a thumbnail in flight
expect(tiles(page)).to_have_count(3)
wait_until(page, lambda: any("zzfwd2-web-0" in (s or "") for s in tile_srcs(page, 0)),
label="the wall that replaced it sharpens too",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
In code the threshold is read off the grid itself, and it takes two triggers to keep that
reading true: a ResizeObserver re-measures the resolved column whenever the wall’s own box
changes, and an effect re-measures when the slider moves the columns inside a box that has
not changed.
Under the hood, getComputedStyle is what makes the second reading current: it flushes the
pending layout, so the effect reads the columns the new setting produces rather than the ones
the last frame drew.
const REFINE_CELL = 300; // drawn px
const [cellPx, setCellPx] = createSignal(0);
const measureCell = () => gridEl && setCellPx(parseFloat(getComputedStyle(gridEl).gridTemplateColumns) || 0);
const watchCell = el => { const ro = new ResizeObserver(measureCell);
ro.observe(el); onCleanup(() => ro.disconnect()); };
createEffect(() => { thumbSize(); measureCell(); }); // the slider moves the columns, not the grid's box
const [thumbsInFlight, setThumbsInFlight] = createSignal(0);
const refining = () => cellPx() > REFINE_CELL && thumbsInFlight() === 0;
A tile’s part is to declare itself: it counts itself among what the wall is waiting for from the moment it comes into reach until its thumbnail settles, and once it has traded up it holds that claim until it leaves — whether it leaves the viewport or the wall.
const [thumbOn, setThumbOn] = createSignal(false);
const thumbSettled = () => setThumbOn(true); // painted, or failed and never coming
createEffect(() => { near(); setThumbOn(false); }); // re-shown → its source is refetched → waiting again
createEffect(() => { // one of the thumbnails the wall waits for…
if(!(near() && photo.thumbnailCid && !thumbOn())) return;
setThumbsInFlight(n => n + 1);
onCleanup(() => setThumbsInFlight(n => n - 1)); // …until it settles or leaves
});
const [sharp, setSharp] = createSignal(false);
createEffect(() => { if(!near()) setSharp(false); // gone → both layers go
else if(thumbOn() && refining()) setSharp(true); });
const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
const [webOn, setWebOn] = createSignal(false);
createEffect(() => { webSrc(); setWebOn(false); }); // a new source → fade the overlay in again
The picture is then two stacked images, as in the frame: the thumbnail carries the tile, and
the fuller rendition fades in over it once it has painted. Both are the same photo, so only
the layer beneath is named — the overlay’s alt is empty, which keeps it out of the
accessibility tree.
<${Show} when=${webSrc}>
<img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
src=${webSrc} onLoad=${() => setWebOn(true)} />
<//>
.tile .thumb.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; }
.tile .thumb.web.shown{ opacity:1; }
Two layers is also two decoded images, and decoded pixels — not bytes on the wire — are what the reach window exists to bound. Whichever way a browser unpacks a rendition, that comes to at most about twice the decoded image a dense wall already held; the annex works it out.
Frame mode
Left to itself the app becomes a photo frame: a fullscreen slideshow over whatever the wall is showing.
The frame — a fullscreen slideshow
This folds the photo-frame in: no separate app. ▶ frame turns the current wall —
whatever the query language has narrowed to — into a frame show. It is not the
lightbox: it’s a full-screen horizontal filmstrip, each doc a viewport-wide slide laid
side by side in a native scroll container, which gives it the feel of a hand-built slider:
a fluid lateral swipe whose momentum coasts across several docs, easing onto whichever one
it comes to rest nearest. It plays the wall in the order shown — chronological by
default, or the myrandom draw under sort:random (ordering is an app-wide concern, not
a frame one). The media fills the screen, videos don’t auto-play, and there’s no chrome —
a small bar (pause, interval, exit) is a tap away.
Which slide it has come to rest on is a question the strip cannot answer directly: a filmstrip has no notion of a current item, only a scroll offset. In practice the answer is arithmetic — divide the offset by a slide’s width and round to the nearest. The width has to be the one the show itself places slides by, the strip’s own rather than the viewport’s, or the reading drifts from the thing it is measuring; the measure the show places by says why the two differ. The whole chapter asks the question in three shapes: the bare position along the strip, the picture sitting there, and which of the fixtures that picture belongs to.
SLIDE_W = "el => (el.scrollWidth / (el.children.length || 1)) || 1"
ON_SLIDE = f"el => Math.round(el.scrollLeft / ({SLIDE_W})(el))"
CENTERED = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" const im = el.children[i] && el.children[i].querySelector('img');"
" return im && im.getAttribute('src'); }")
def centred_slide(strip):
src = strip.evaluate(CENTERED) or ""
return next((k for k, f in enumerate(FIXTURES) if f["thumbnailCid"] == src), None)
Left alone, the show auto-advances: a smooth scroll to the next slide every interval
(default 60s, ?ms= overrides), reading the current scroll position each tick. It runs in
date order, so the fixtures play thumb-0, thumb-1, thumb-2.
for src in ["thumb-0", "thumb-1", "thumb-2"]: # date order
wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
print(" PASS: frame auto-advances")
Touch it and the show yields: any tap or swipe stops the auto-advance, which picks back up
only after a span of quiet (?idleresume overrides) — so a slide you’ve stopped on to
look at is never pulled out from under you.
opened = strip.evaluate(ON_SLIDE) # wherever the show resumed
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened, # …and it is advancing from there
label="the show is running before we touch it")
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) # a centre tap: real interaction, no nav
page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS) # let any in-flight step settle
held = strip.evaluate(ON_SLIDE)
page.wait_for_timeout(LIVE_RESUME_MS // 2) # several tempo ticks, still inside the resume span
assert strip.evaluate(ON_SLIDE) == held, f"interaction must stop the show; it drifted {held}→{strip.evaluate(ON_SLIDE)}"
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held) # quiet long enough → resumes on its own
print(" PASS: frame pauses on interaction")
The strip loops both ways: an arrow or swipe before the first slide lands on the last, and
past the last on the first, so the show never dead-ends. A native scroller cannot wrap, so
the strip is laid out with a copy of the last doc before the first and a copy of the first
after the last: stepping off either end lands on a copy, and the settle silently jumps the
scroll to the real doc it duplicates. That padding is why the first doc sits at offset one
rather than zero, and why the last of N sits at N.
page.keyboard.press("ArrowLeft") # off the front → the last doc
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
label="a step back off the first lands on the last",
detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
page.keyboard.press("ArrowRight") # off the end → the first
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame wraps both ways")
The strip is a keyboard-focusable scroller, so an unguarded arrow would just scroll it natively. The frame claims the arrows itself, so they step the show even once focus has dropped to the page — after a centre tap, which only reveals the bar.
page.keyboard.press("ArrowRight") # focus is on the body, not a control
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.keyboard.press("ArrowLeft") # back where we started
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame arrow steps off control")
Esc, exit, or the device back button leaves the show rather than navigating away:
entering pushes a history entry and exit unwinds it via history.back, so back and
explicit-exit stay balanced (and the wake lock is released).
page.go_back()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible() # still on the app, not gone
print(" PASS: frame exits on back")
A video the viewer started is paused once it scrolls out of view — an
IntersectionObserver over the strip stops any slide video that drops below half-visible,
so sound doesn’t keep playing from a slide you’ve left.
@testcase
def test_frame_pauses_offscreen_video(page):
"""A video scrolled out of view in the frame is paused."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE}) # dated earliest → the first slide
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("el => el.scrollLeft === el.clientWidth")) # settled on slide 1 (the video)
vid = strip.locator("video").first # the centered (first) slide
vid.evaluate("v => { v.dataset.paused = '0';"
" const o = v.pause.bind(v); v.pause = () => { v.dataset.paused = '1'; return o(); }; }")
page.keyboard.press("ArrowRight") # scroll the video off-screen
wait_until(page, lambda: vid.get_attribute("data-paused") == "1")
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: frame pauses offscreen video")
The swipe is the frame’s main gesture, and across the room it has to feel like the
slider it grew out of: you fling the strip and it coasts a few docs on its own momentum.
The browser’s own scroll-snap reaches for that feel but overshoots — a quick flick is
flung clear across the wall, ten docs gone in one careless swipe, which is exactly what
makes the cabinet frame unusable from the couch.
Both checks below ride one gesture. A mouse drag carries no momentum, so the behaviour only shows under a real touch fling, driven (as for the lightbox) through Chromium’s touch pipeline: the finger flies left across the glass and lets go.
Knowing when the fling is over is the awkward part, because the strip goes still twice. Momentum runs out first, then the settle waits out its own beat of quiet before easing onto the nearest doc — so a reading taken during that lull would measure a resting place the show is about to leave. The way past it is to insist on a stillness longer than the beat: only the rest that follows the ease can be that quiet, and any motion starts the count again. The ceiling on waiting has to clear the worst case — the fling, the beat, and the ease together — and is generous, since it costs nothing when the strip settles early.
SETTLE_BEAT_MS = 150 # the show's own wait for quiet before it eases
STILL_READS = 7 # a stillness longer than that beat, in 100ms reads
SETTLE_CAP_MS = 6000 # fling + beat + ease, with room to spare
def hard_frame_flick(page, strip):
"""A hard, fast touch fling — the finger flies ~900px left in ten quick steps —
returning once the strip (and any recentre ease) comes to rest."""
box = strip.bounding_box()
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
cdp = page.context.new_cdp_session(page)
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": cx, "y": cy}]})
for k in range(1, 11):
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": cx - 90 * k, "y": cy}]})
time.sleep(0.001)
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
prev, stable, step = None, 0, 100
for _ in range(SETTLE_CAP_MS // step):
cur = strip.evaluate("el => el.scrollLeft")
stable = stable + 1 if (prev is not None and abs(cur - prev) < 1) else 0
if stable >= STILL_READS: break
prev = cur; page.wait_for_timeout(step)
First, the fling must stay controlled: a hard, fast one advances a handful and comes to rest within the first few docs, never stampedes across the whole strip — the ceiling the suite can pin. The fuller feel the frame is for — a brisk flick carrying you across several docs at once — rides real-device momentum stronger than the headless touch pipeline’s, so it’s the cabinet tablet that has the final say on it, not the suite.
Where to put that ceiling was measured rather than chosen: on this machine the overshooting build landed four slides beyond where native momentum rests, repeatably, so a ceiling set at momentum’s own reach plus a slide of margin separates the two without sitting on either.
CONTROLLED_SLIDES = 4
before = strip.evaluate(ON_SLIDE)
hard_frame_flick(page, strip)
landed = strip.evaluate(ON_SLIDE)
assert landed - before <= CONTROLLED_SLIDES, \
f"the swipe flung {landed - before} slides on — past the controlled range"
print(" PASS: frame swipe does not overshoot")
Coasting freely, momentum stops the strip wherever friction runs out — usually between two docs, leaving the show squinting at half of each. So a moment after the fling stops — a sixth of a second of quiet, enough to know it is fully spent — the strip eases onto whichever doc it came to rest nearest, settling centred on one, never straddling two; waiting for that quiet lets the fling die completely first, so the ease lands cleanly instead of wrestling momentum still trailing off.
Centred means landing on a slide boundary, and the ease lands on it exactly, so the tolerance only has to be tight enough to catch a strip that never eased at all — which rests hundreds of pixels out. A score of pixels is slack by that measure and unreachable by accident.
CENTRED_PX = 20
w = strip.evaluate(SLIDE_W); rest = strip.evaluate("el => el.scrollLeft")
off = min(rest % w, w - (rest % w)) # distance to the nearest slide boundary
assert off < CENTRED_PX, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
print(" PASS: frame swipe settles centered")
Filling the glass is measurable — the frame is watched from across the room, so a
web_cid smaller than the display must be grown to it, photo and video alike — but the
doc is shown whole: object-fit:contain scales it to the largest size that fits without
cropping, so a differently-shaped doc keeps all of itself, the black glass framing its
spare axis:
@testcase
def test_frame_media_fills_screen(page):
"""A slide's media — video or photo — spans the whole viewport, on black glass."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE}) # earliest → the first slide
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
for media in [strip.locator("video").first, strip.locator("img").first]:
box = media.bounding_box()
assert box["width"] >= VIEWPORT["width"] * 0.95, f"media width {box['width']} < 95% of viewport"
assert box["height"] >= VIEWPORT["height"] * 0.95, f"media height {box['height']} < 95% of viewport"
assert media.evaluate("el => getComputedStyle(el).objectFit") == "contain", "slide media crops instead of fitting whole"
assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", "the frame's glass must be black"
finally:
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
print(" PASS: frame media fills screen on black")
Showing the doc whole is no good if it shows up late: web_cid is heavy, so a slide is built
in two layers. The thumbnail is the base — light, and already cached from the wall you came in
through — so every slide within range paints something at once instead of sitting black. Over
it, the full web_cid leads the way you’re heading: it is carried one slide behind the centre
and three ahead, so each next step lands on a doc already whole; if it isn’t in hand yet, the
thumbnail stands in (marked, below) until it arrives. The kept-thumbnail window leans the same
way — six slides behind, fourteen ahead — and past it a doc is dropped to a blank, so a long wall
never keeps thousands of decoded images alive at once. Turn around and the lean turns with you,
so the loading always anticipates where you’re going next.
To read the bands the test jumps the strip to a chosen slide, locating it by arithmetic on
scrollWidth — total width over slide count. Two things must hold for that jump to land true.
First, the strip must be laid out: the slides reach their 100vw width a beat after they
render, and the arithmetic divides by a scrollWidth that is only right once they have — so the
test waits for scrollWidth to come within one slide of full width (a slide of slack, so a
sub-pixel settle doesn’t hang the wait), not merely for the nodes to appear. Second, entering the
frame starts its own auto-centre — an animation frame that scrolls to the opening slide; were the
test to jump before that frame fires, the auto-centre would land afterward and undo the jump — so
the test also waits until the strip has settled on that first slide. Only then does it scroll.
@testcase
def test_frame_preloads_web_window(page):
"""The load window leans toward travel: full-res reaches further ahead than behind, the
kept-thumbnail band likewise, and the lean flips when you reverse direction."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzwin-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzwin-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzwin-web-{i}", "labels": "zzwin", "state": "todo"} for i in range(40)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzwin")
expect(tiles(page)).to_have_count(40)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
# laid out at full width, not merely populated (see the note just above)
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(docs) + 2),
label="strip laid out at full width (all slides + 2 clones)",
detail=lambda: f"children={strip.evaluate('el => el.children.length')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}"
f" clientWidth={strip.evaluate('el => el.clientWidth')}")
# …and for the frame's own initial auto-centre to have landed (see the note above)
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1,
label="frame centred on its first slide before we jump",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')} scrollWidth={strip.evaluate('el => el.scrollWidth')}")
# slot k holds doc k-1; read each slot's loaded image sources (thumbnail base + web overlay)
srcs_at = "(el, k) => { const s = el.children[k]; return s ? Array.from(s.querySelectorAll('img')).map(im => im.getAttribute('src')) : []; }"
srcs = lambda k: strip.evaluate(srcs_at, k)
web = lambda k: any("zzwin-web-" in x for x in srcs(k))
thumb = lambda k: any("zzwin-t-" in x for x in srcs(k))
blank = lambda k: any(x and x.startswith("data:image/gif") for x in srcs(k))
goto = lambda n: strip.evaluate("(el, n) => el.scrollLeft = n * (el.scrollWidth / el.children.length)", n)
c = 20; goto(c) # head FORWARD to a mid slide → direction is +1
wait_until(page, lambda: web(c), # the centre arrives, full-res on it
label=f"web overlay reaches the centre (slot {c})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{c}]={srcs(c)}")
# full-res leads the way you're going: it reaches 3 ahead but only 1 behind
assert web(c + 3) and not web(c + 4), f"web should reach 3 ahead, got {srcs(c + 3)} / {srcs(c + 4)}"
assert web(c - 1) and not web(c - 2), f"web should reach only 1 behind, got {srcs(c - 1)} / {srcs(c - 2)}"
# the kept-thumbnail band leans the same way: 14 ahead, 6 behind
assert thumb(c + 14) and blank(c + 15), f"thumbnail kept 14 ahead, got {srcs(c + 14)} / {srcs(c + 15)}"
assert thumb(c - 6) and blank(c - 7), f"thumbnail kept 6 behind, got {srcs(c - 6)} / {srcs(c - 7)}"
c2 = 15; goto(c2) # reverse → head BACK → direction flips
wait_until(page, lambda: web(c2 - 3), # full-res now leads the OTHER way
label=f"web overlay leads the reversed way (slot {c2 - 3})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')} srcs[{c2 - 3}]={srcs(c2 - 3)} srcs[{c2}]={srcs(c2)}")
assert web(c2 - 3) and not web(c2 + 3), f"after reversing, web should reach 3 the new way, got {srcs(c2 - 3)} / {srcs(c2 + 3)}"
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame preloads web window")
Those bands are only as honest as the centre they measure from, and a hard fling tests that centre. Were it moved only when the strip comes to rest, a fast swipe would land deep in the blank band — a black slide, held the sixth of a second until the settle caught up. So the centre is followed live, on every scroll and not just at rest; the slide you fling onto is already in band, and requested, by the time you reach it.
This test lands its fling by the same scrollWidth arithmetic as the window above, so it too
waits for the strip to be laid out and centred before it scrolls.
@testcase
def test_frame_fling_loads_into_view(page):
"""A fast fling re-centres the bands live, so the slide you land on is requested — not the
blank gif the pre-fling centre would leave it."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzfling-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfling-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzfling-web-{i}", "labels": "zzfling", "state": "todo"} for i in range(30)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzfling")
expect(tiles(page)).to_have_count(30)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(docs) + 2),
label="strip laid out at full width",
detail=lambda: f"scrollWidth={strip.evaluate('el => el.scrollWidth')} clientWidth={strip.evaluate('el => el.clientWidth')}")
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
# fling deep into the strip, then read the landing slot two frames later — far under the
# 0.15s settle, so we observe the bands MID-fling, before any settle re-centres them
src = page.evaluate("""async (k) => {
const el = document.querySelector('.strip');
el.scrollLeft = k * (el.scrollWidth / el.children.length);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const im = el.children[k] && el.children[k].querySelector('img');
return im && im.getAttribute('src');
}""", 20)
assert src and not src.startswith("data:image/gif"), f"slide flung-to must be loaded, not blank: {src}"
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame fling loads into view")
In code each slide measures its signed distance from the centre in the travel direction — from
the strip index frameCenterIdx and the last direction frameDir — and picks each layer’s
source: the thumbnail base anywhere in the leaning window, the web_cid overlay only at its
leading edge.
const WEB_BEHIND = 1, WEB_AHEAD = 3, KEEP_BEHIND = 6, KEEP_AHEAD = 14;
const inReach = (k, behind, ahead) => { const t = (k - frameCenterIdx()) * frameDir(); // signed steps, in the way you're heading
return t >= -behind && t <= ahead; };
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD) // base layer:
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK; // thumbnail kept in the window, blank past it
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid) // overlay:
? IPFS + p.webCid : ''; // full-res leads the way you're going
A photo with nothing yet to show should say so rather than sit black — you need to read it as
loading, not broken. So until the thumbnail base paints, the slide carries a 🖼 mark — the
wall’s missing-thumbnail glyph — labelled loading so a screen reader announces it too; the
instant the thumbnail’s load fires, the mark clears and the picture stands.
@testcase
def test_frame_shows_placeholder_while_loading(page):
"""A slide whose image hasn't painted shows a 'loading' mark; it clears once the image loads."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzph-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzph-t-{i}",
"labels": "zzph", "state": "todo"} for i in range(2)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
# doc 0's thumbnail actually loads (a real 1×1 PNG); doc 1's never does
png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
page.route("**/ipfs/zzph-t-0", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzph")
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.clientWidth||1))") == 1)
slides = strip.get_by_role("listitem") # [clone, doc0(centre), doc1(neighbour), clone]
# the neighbour's thumbnail never loads, so its loading mark stays up
expect(slides.nth(2).get_by_label("loading")).to_be_visible()
# the centred doc's thumbnail loads, so its mark clears (wait for the load event)
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0)
finally:
page.unroute("**/ipfs/zzph-t-0")
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame shows placeholder while loading")
Once the thumbnail stands, a near doc’s full web_cid may still be on its way. The big icon has
no place there — the picture is already up, only sharper detail is pending — so a quieter mark
takes its place: a small pulse tucked in the corner, labelled fetching full resolution, that
reads as “this is the thumbnail; the full image is coming.” When the web_cid paints over the
base, it clears.
@testcase
def test_frame_thumbnail_shows_upgrade_mark(page):
"""Once the thumbnail paints, the big mark gives way to a subtle 'fetching full resolution
mark until the web_cid lands; when it lands, that mark clears too."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzup-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzup-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzup-web-{i}", "labels": "zzup", "state": "todo"} for i in range(2)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
for c in ("zzup-t-0", "zzup-t-1", "zzup-web-1"): # both thumbnails load; only doc1's web does
page.route(f"**/ipfs/{c}", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzup")
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
slides = strip.get_by_role("listitem") # [clone, doc0(centre), doc1, clone]
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0) # doc0's thumbnail painted → big mark gone
expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible() # doc0's web 404s → subtle mark stays
wait_until(page, lambda: slides.nth(2).get_by_label("fetching full resolution").count() == 0) # doc1's web painted → subtle gone
finally:
for c in ("zzup-t-0", "zzup-t-1", "zzup-web-1"): page.unroute(f"**/ipfs/{c}")
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame thumbnail shows upgrade mark")
A slide is a reused box: after an edit re-anchors the show (a doc leaves the filter and the strip closes the gap), a box that held one doc comes to hold another. The mark has to follow the new doc, not linger from the old — a freshly-shown doc whose image is still arriving must wear the mark even though the box it landed in had finished loading something else.
@testcase
def test_frame_placeholder_after_edit_remaps_slot(page):
"""When an edit re-uses a slide box for a different doc, the loading mark follows the new doc."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzbstale-{i}", "date": f"2020-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbstale-t-{i}",
"labels": "zzbstale", "state": "todo"} for i in range(2)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
png = _b64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
page.route("**/ipfs/zzbstale-t-0", lambda r: r.fulfill(status=200, content_type="image/png", body=png))
try:
open_app(page, "?ms=999999")
search_for(page, "zzbstale") # default chip = todo
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
slides = strip.get_by_role("listitem")
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbstale-t-0")
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0) # doc0's image loaded → mark cleared
strip.click() # reveal the bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="done", exact=True).click() # doc0 leaves todo → slot 1 reused for doc1
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbstale-t-1")
# doc1's image never loads, so its mark must be up — not inherited "loaded" from doc0
expect(slides.nth(1).get_by_label("loading")).to_be_visible()
finally:
page.unroute("**/ipfs/zzbstale-t-0")
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame placeholder after edit remaps slot")
A slot swap is not the only way a slide’s base image changes under it. As the centre moves, a slide keeps its doc but its base layer is re-picked — a blank far out, the thumbnail in range. A slide flung in from the blank band carries an already-painted blank, so on its own it would read as loaded and sit black while the thumbnail arrives. So the big mark keys on the base layer’s current source: it returns whenever that source changes — a new doc dropped in the box, or the thumbnail re-picked for the doc already there — and clears once the thumbnail paints.
@testcase
def test_frame_placeholder_returns_on_band_flip(page):
"""When the band flips a far slide from the blank gif to a real image, the loading mark
returns until it paints — the gif's finished-loading state must not leave you on black."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzbflip-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbflip-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzbflip-web-{i}", "labels": "zzbflip", "state": "todo"} for i in range(30)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzbflip")
expect(tiles(page)).to_have_count(30)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
far = strip.get_by_role("listitem").nth(20).get_by_label("loading")
expect(far).to_have_count(0) # slot 20 starts in the blank band — its gif painted, no mark
strip.evaluate("el => el.scrollLeft = 20 * (el.scrollWidth / el.children.length)") # fling onto it
expect(far).to_have_count(1) # its source flips to a real image → the mark returns until it paints
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame placeholder returns on band flip")
Assembling the slide, then: two stacked images — the thumbnail base and, when close enough, the
web_cid overlay above it — with the marks between them. Each layer re-arms its own mark when its
source changes (a new doc, a band that swaps blank↔thumbnail, or one that adds or drops the
overlay), so neither mark lingers from a source no longer shown:
const FrameSlide = (slide, k) => {
const thumbSrc = createMemo(() => thumbBand(slide(), k)); // base layer's source
const webSrc = createMemo(() => webBand(slide(), k)); // overlay's source ('' when far from centre)
const [thumbOn, setThumbOn] = createSignal(false); // the base thumbnail has painted
const [webOn, setWebOn] = createSignal(false); // the full-res overlay has painted
createEffect(() => { thumbSrc(); setThumbOn(false); }); // each layer re-arms its mark on its own source change
createEffect(() => { webSrc(); setWebOn(false); });
return html`
<div class="slide" role="listitem">
<${Show} when=${() => hasMedia(slide())}
fallback=${html`<div class="slide-media noimg">
<span class="ph">${() => isVideo(slide()) ? '🎬' : '🖼'}</span></div>`}>
<${Show} when=${() => isVideo(slide())}
fallback=${html`<div class="slide-pic">
<${Show} when=${() => !thumbOn()}>
<span class="ph load-ph" aria-label="loading">🖼</span><//>
<img class="slide-media" loading="lazy"
src=${thumbSrc} onLoad=${() => setThumbOn(true)} />
<${Show} when=${() => webSrc()}>
<img class="slide-media web" classList=${() => ({ shown: webOn() })}
loading="lazy" src=${webSrc} onLoad=${() => setWebOn(true)} />
<${Show} when=${() => thumbOn() && !webOn()}>
<span class="upgrading" aria-label="fetching full resolution"></span><//>
<//></div>`}>
<video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
<//>
<//>
</div>`;
};
Triaging from the frame. A tap reveals the control bar, and it turns the slideshow into a review station: everything on it acts on the centred doc, so you can triage without leaving the show.
An edit often pushes that doc out of the current filter — mark a todo done while
viewing todos. frameEdit handles it: if the doc leaves the set it re-anchors on the
previous doc, so the next advance lands on whatever filled the gap instead of skipping
it; if it stays, it keeps it centred.
@testcase
def test_frame_edit_reanchors(page):
"""Editing a frame doc out of the filter re-centers on the previous doc."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzedit-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzedit-t-{i}",
"labels": "zzeditframe", "state": "todo"} for i in range(3)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
search_for(page, "zzeditframe") # default state chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-0")
page.keyboard.press("ArrowRight") # centre the middle doc
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-1")
strip.click() # reveal the frame bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="done", exact=True).click() # → leaves the todo filter
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzedit-t-0") # back to previous
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame edit re-anchors")
The bar’s state buttons are each tinted their own hue — todo cyan, next amber, done green, delete red — so the one you want is easy to pick across the room.
strip.click() # a tap reveals the frame bar
expect(bar).to_be_visible()
colours = [bar.get_by_role("button", name=st, exact=True).evaluate("el => getComputedStyle(el).color")
for st in ["todo", "next", "done", "delete"]]
assert len(set(colours)) == 4, f"each state pill should have its own colour, got {colours}"
print(" PASS: frame state pills are colour coded")
It also names the centred slide’s date, and the date follows the slide as the show advances.
def shown(k): # the date the bar should be reading, as the browser renders it
return page.evaluate("d => new Date(d).toLocaleString('fr-FR')", FIXTURES[k]["date"])
i = centred_slide(strip) # whichever slide the show stopped on
expect(bar.locator(".frame-date")).to_have_text(shown(i))
page.keyboard.press("ArrowRight") # step to the next slide
j = (i + 1) % len(FIXTURES)
wait_until(page, lambda: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{j}")
expect(bar.locator(".frame-date")).to_have_text(shown(j)) # follows the slide
print(" PASS: frame shows date")
Having said its piece the bar should step back out of the way. So after a span of quiet it hides itself and the frame is clean glass again — twenty seconds by default, long enough to read the controls and act, short enough that a bar tapped up by accident doesn’t sit over the show. Under the hood the wait needs no clock of its own: the assertion polls, so the window simply elapses inside it.
expect(bar).to_be_visible() # still up from the tap that raised it
expect(bar).to_be_hidden() # and gone once the window has run out
print(" PASS: frame bar auto-hides when idle")
And “quiet” means quiet: any interaction while the bar is up — a tap, a swipe, a press on its own controls — restarts the count, so it never vanishes mid-use and leaves only once you have truly stopped. Pressing it repeatedly at less than a window’s interval therefore keeps it alive past the moment a bar that ignored the presses would already have gone; stop pressing, and it goes.
strip.click() # bring the bar back up
expect(bar).to_be_visible()
pause = bar.get_by_role("button", name=re.compile("pause|play"))
for _ in range(3):
pause.click(); page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)
page.wait_for_timeout(CABINET_UI_IDLE_MS // 3) # now well past a window since the first press
expect(bar).to_be_visible() # still up: each press restarted the count
expect(bar).to_be_hidden() # now left alone → it finally hides
print(" PASS: frame bar idle resets on use")
The centred slide’s date is editable in place — a click opens the picker, Enter or a
click away saves it through frameEdit, the same path the lightbox uses.
@testcase
def test_frame_edits_date(page):
"""The frame's date is editable in place: set a new date and it saves through frameEdit."""
strip = enter_frame(page, 999999)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
strip.click() # reveal the bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = bar.get_by_label("date", exact=True)
box.fill("2020-12-15T12:00") # move the centred doc to December
box.press("Enter") # save
want = page.evaluate("() => new Date('2020-12-15T12:00').toLocaleString('fr-FR')")
expect(bar.get_by_role("button", name="edit date")).to_have_text(want) # saved, and shown
print(" PASS: frame edits date")
The picker opens seeded with the slide’s current date, time and all, so a small correction isn’t a re-entry of the whole stamp.
@testcase
def test_frame_date_seeds(page):
"""The frame's date picker opens seeded with the centred slide's date — time and all."""
strip = enter_frame(page, 999999)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
strip.click()
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="edit date").click()
want = page.evaluate("() => { const t = new Date('2020-01-15T12:00:00Z'), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`; }")
expect(bar.get_by_label("date", exact=True)).to_have_value(want)
print(" PASS: frame date seeds")
Escape backs out of the picker without saving, and leaves you in the frame rather than
exiting the show.
@testcase
def test_frame_date_escape_cancels(page):
"""Escape backs out of the frame's date picker without saving, and stays in the frame."""
strip = enter_frame(page, 999999)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
strip.click()
bar = page.get_by_role("toolbar", name="frame actions")
before = bar.get_by_role("button", name="edit date").text_content()
bar.get_by_role("button", name="edit date").click()
box = bar.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(strip).to_be_visible() # Escape didn't exit the frame
expect(bar.get_by_role("button", name="edit date")).to_have_text(before) # date unchanged
print(" PASS: frame date escape cancels")
The red button asks before it acts: a delete is a soft, undoable mark, but across the room a stray tap shouldn’t bin a photo, so it waits for a yes — dismiss keeps the doc, accept marks it (and, leaving the filter, re-anchors like any other edit).
@testcase
def test_frame_delete_confirms(page):
"""Marking a doc for deletion in the frame asks first: dismiss keeps it, accept removes it."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdelcfm-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdelcfm-t-{i}",
"labels": "zzdelcfm", "state": "todo"} for i in range(3)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999")
search_for(page, "zzdelcfm") # default chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-0")
page.keyboard.press("ArrowRight") # centre the middle doc
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-1")
strip.click() # reveal the frame bar
delete_btn = page.get_by_role("toolbar", name="frame actions").get_by_role("button", name="delete", exact=True)
seen = []
dismiss = lambda d: (seen.append(d.message), d.dismiss())
page.on("dialog", dismiss)
delete_btn.click() # ask, then DISMISS
wait_until(page, lambda: bool(seen), label="delete asks for confirmation")
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-1", "dismiss must keep the doc"
page.remove_listener("dialog", dismiss)
page.on("dialog", lambda d: d.accept())
delete_btn.click() # ask, then ACCEPT
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzdelcfm-t-0") # left the filter, re-anchored
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: frame delete confirms")
The show is meant to run unattended, so it remembers where it was: on a reboot it resumes the slide it was last centred on, not the first. Two things make that hard to catch in the act. Coming back on the first slide is what a show with no memory does, so if it went down on the opening slide a resume and a cold start are the same picture. And a show that is playing will wander onto the right slide by itself within a few ticks, so an answer waited for is no answer at all — the slide it comes back on has to be read the moment it lands, before the first tick moves it.
In practice both ends of the reboot need pinning down. Going down, the slide is written a beat after the show settles on it, so waiting for merely something to be written would accept the slide before last. Coming back, the show is placed from what was written only once the wall’s docs are in hand, in an animation frame of its own; before they arrive it has nothing to go on and sits on the opening slide. So the reading waits for the write to name the slide we are on, and afterwards for the docs and that frame — never for the position to become the expected one, which is the very thing in question.
if centred_slide(strip) == 0: # park it away from the cold-start slide
page.keyboard.press("ArrowRight")
wait_until(page, lambda: centred_slide(strip) != 0,
label="the show steps off the slide a cold start would pick")
i = centred_slide(strip)
was = strip.evaluate(CENTERED) # the slide it is showing when it goes down
remembered = lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')")
wait_until(page, lambda: remembered() == FIXTURES[i]["cid"],
label="the slide it is on is the one written down",
detail=lambda: f"written down: {remembered()}")
open_app(page, CABINET_QS) # reboot: a plain relaunch
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs it places from are in
page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
came_back_on = strip.evaluate(CENTERED) # read at once, not waited for
assert came_back_on == was, f"resumed on {came_back_on}, not the {was} it went down on"
print(" PASS: frame resumes position")
Leaving, on the other hand, is meant to stick: exit the show deliberately and the next launch leaves you on the wall, however many times you relaunch.
page.keyboard.press("Escape") # leaving turns the memory off
expect(strip).to_be_hidden()
open_app(page, CABINET_QS)
expect(page.get_by_role("list", name="slideshow")).to_be_hidden() # stays on the wall
print(" PASS: frame mode persists")
Set it going again and the memory comes back with it. The relaunch carries no instruction to enter the frame — only the cabinet’s tempo — yet the show opens itself over the query the wall had persisted, which is what makes an unattended tablet survive a power cut. That auto-entry fires once per launch, though: exiting it stays exited, so a tablet you have walked over to and switched off doesn’t snap straight back in.
expect(tiles(page)).to_have_count(len(FIXTURES)) # the persisted query brought the wall back
page.get_by_role("button", name=re.compile("frame", re.I)).click() # set it going again → remembered
expect(page.get_by_role("list", name="slideshow")).to_be_visible()
open_app(page, CABINET_QS) # relaunch: remembered → auto-enters, no click
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
page.keyboard.press("Escape") # exit within this launch
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for a re-entry to fire
expect(strip).to_be_hidden() # once per launch — it doesn't
print(" PASS: frame autostart")
The bar carries an add-label box too, with the same vocabulary completion as everywhere else.
@testcase
def test_frame_label_completion(page):
"""The frame's add-label box completes on the existing vocabulary."""
strip = enter_frame(page, 999999)
strip.click() # reveal the frame bar
box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos", delay=20)
expect(options(page).first).to_be_visible() # vocabulary suggestions
assert "cos" in options(page).first.inner_text().strip().lower()
print(" PASS: frame label completion")
It’s a combobox on the same terms as the others — aria-expanded tracks its list, and it
closes on a state flip, not a timer.
@testcase
def test_frame_label_combobox_state(page):
"""The frame add-label box is a combobox too: aria-expanded tracks its list, no-timing close."""
strip = enter_frame(page, 999999)
strip.click() # reveal the frame bar
box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos")
expect(box).to_have_attribute("aria-expanded", "true")
box.blur()
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: frame label combobox state")
And like the lightbox, its completion drops the centred doc’s own labels, so it never offers a word the doc already wears.
@testcase
def test_frame_completion_skips_present(page):
"""Like the lightbox, the frame's completion drops the centred doc's own labels."""
make_fixtures()
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpresentframe", "date": "2020-06-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzpresentframe-t", "labels": "cosmo; zzpresentframe", "state": "todo"}
gql(CREATE, {"p": doc})
try:
open_app(page, "?ms=999999")
chip(page, "all").click()
search_for(page, "zzpresentframe") # narrow to just this doc
expect(tiles(page)).to_have_count(1)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
strip.click() # reveal the frame bar
box = page.get_by_role("toolbar", name="frame actions").get_by_placeholder("add a label…")
box.click(); box.press_sequentially("balade", delay=20) # a label the doc lacks…
expect(options(page).filter(has_text=re.compile(r"^balade$")).first).to_be_visible() # …is offered
box.fill(""); box.press_sequentially("cosmo", delay=20) # one the centred doc has…
expect(options(page).filter(has_text=re.compile(r"^cosmo$"))).to_have_count(0) # …is not
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: frame completion skips present")
The frame state and clock. The interval timer smooth-scrolls one slide on from wherever the strip currently sits, so manual swipes are respected and it wraps. A playing video holds it too: each tick checks the slides first and skips the advance while one is still rolling, so a clip you started isn’t scrolled off before it ends.
A pinch holds it hardest of all. The pinch itself is the browser’s own — spread two fingers
and it magnifies the slide, no code of ours in the loop — but the frame has to notice,
because a slide that auto-advances or snap-realigns out from under a magnified look is
useless. The browser reports the pinch through visualViewport: its scale sits at 1
unzoomed and climbs past it the moment a pinch takes hold, so scale > 1 is our reading
that one is on, watched off the viewport’s own resize and scroll.
One measure underlies all the positioning: a slide is located by its own geometry — each
slide’s offsetLeft, and the true per-slide width (the strip’s scrollWidth over every
slide) — never the viewport’s rounded clientWidth. A slide is a full viewport (100vw)
wide, a length that can sit a fraction off the integer clientWidth; across a wall of
thousands of slides that fraction compounds into whole slides adrift, so only the slides'
real geometry keeps the show landing dead-centre.
@testcase
def test_frame_video_holds_the_show(page):
"""While a slide's video is playing, the show doesn't auto-advance; when it ends, it resumes."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzfvid", "date": "2019-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvid-web",
"labels": "zzfvid", "state": "todo"}
# three stills after the video, so an un-held show marches visibly past it instead of
# wrapping the short clone-cycle back onto the video within the test window
stills = [{"cid": f"https://ipfs.konubinix.eu/p/zzfvid-{i}", "date": f"20{19 + i}-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfvid-{i}-t", "labels": "zzfvid", "state": "todo"} for i in range(1, 4)]
for d in (vid, *stills): gql(CREATE, {"p": d})
page.route("**/ipfs/zzfvid-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm", headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, "?ms=1000")
chip(page, "all").click(); search_for(page, "zzfvid")
expect(tiles(page)).to_have_count(4)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
on_slide = "el => Math.round(el.scrollLeft / (el.clientWidth || 1))"
wait_until(page, lambda: strip.evaluate(on_slide) == 1) # settled on the video (first real slide)
v = strip.locator("video").first
v.evaluate("el => { el.muted = true; el.currentTime = 0; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.wait_for_timeout(2500) # several 1s ticks pass…
assert strip.evaluate(on_slide) == 1, "a playing video must hold the show"
v.evaluate("el => { el.currentTime = el.duration; }") # let it play out
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzfvid-1-t") # show resumes onto the first still
finally:
page.unroute("**/ipfs/zzfvid-web")
for d in (vid, *stills):
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
print(" PASS: frame video holds the show")
So the auto-advance stands still while a pinch is on — lean in on a face and the show won’t step away from it, even once the after-touch idle span that also pauses it has lapsed.
@testcase
def test_frame_pinch_pauses_autoadvance(page):
"""A magnified slide (native pinch) holds the show even after the touch-idle span lapses."""
make_fixtures()
open_app(page, "?ms=500&idleresume=600") # brisk tempo, short touch-idle so only the zoom can hold
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5) # the browser magnified
page.wait_for_timeout(1600) # past the 600ms touch-idle AND several 500ms ticks
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0", "a magnified slide must not auto-advance"
print(" PASS: frame pinch pauses auto-advance")
And the settle-snap stands off while a pinch is on, so a scroll nudged mid-slide isn’t yanked back to a slide edge and out from under the magnified view.
@testcase
def test_frame_pinch_freezes_snap(page):
"""While magnified, the settle-snap stands off — a mid-slide scroll fires no realign."""
make_fixtures()
open_app(page, "?ms=999999") # no auto-advance to muddy the scroll
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # let the app read the now-live scale
# count the settle-snap's realign action; a mid-slide scroll would trigger it when not frozen
strip.evaluate("el => { window.__snaps = 0; const o = el.scrollTo.bind(el); el.scrollTo = (...a) => { window.__snaps++; return o(...a); }; }")
strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4); // 40% into slide 1: well off any boundary
el.dispatchEvent(new Event('scroll')); }""")
page.wait_for_timeout(400) # well past the ~150ms settle-snap
assert page.evaluate("() => window.__snaps") == 0, "a magnified slide's settle-snap must not realign"
print(" PASS: frame pinch freezes snap")
Everything the frame does is a reaction to a small cluster of state: whether it is open and playing, which slide has settled — and at what strip index, so the load bands know where they are — and whether a recent touch or a live pinch should hold the auto-advance. We keep the signals together so every effect below reads one live picture.
const [frame, setFrame] = createSignal(false);
const [playing, setPlaying] = createSignal(true);
const [frameUI, setFrameUI] = createSignal(false); // controls revealed on tap
const [frameLabel, setFrameLabel] = createSignal('');
const [frameLabelFocus, setFrameLabelFocus] = createSignal(false);
const [frameCenterCid, setFrameCenterCid] = createSignal(null); // the settled slide
const frameDoc = () => items().find(p => p.cid === frameCenterCid()); // the centred doc
const [frameEditingDate, setFrameEditingDate] = createSignal(false);
const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);
const [frameDir, setFrameDir] = createSignal(1); // last travel direction (+1 forward)
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
const FRAME_IDLE_MS = Number(new URLSearchParams(location.search).get('idleresume')) || 60000;
const [pokes, setPokes] = createSignal(0);
const nudge = () => setPokes(n => n + 1);
const [interacting, setInteracting] = createSignal(false);
const [zoomed, setZoomed] = createSignal(false);
The frame plays the wall in the order shown — chronological, or the sort:random draw. To loop
seamlessly it builds an infinite carousel: a clone of the last slide sits before the first and a
clone of the first after the last, so real slide 0 lands at strip index 1. Positions and indices
read the slides’ own geometry rather than a rounded clientWidth (which the settle would then
have to correct), and a step is just a smooth scroll to the neighbour.
const frameSlides = () => { const o = items();
return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
let stripEl, wakeLock = null;
const slideW = () => stripEl && stripEl.children.length ? stripEl.scrollWidth / stripEl.children.length : (stripEl?.clientWidth || 1);
const slideAt = () => Math.round((stripEl?.scrollLeft || 0) / slideW()); // nearest slide index
const slideLeft = i => { const k = stripEl && stripEl.children[i]; return k ? k.offsetLeft : i * slideW(); };
const frameGo = delta => { if(!stripEl) return;
stripEl.scrollTo({ left: slideLeft(slideAt() + delta), behavior: 'smooth' }); };
A native scroll flows freely; the frame lets it, then tidies up once it stops. While the strip moves, the load bands ride the live position and lean the way it is going. About 150ms after the last scroll, the settle re-centres on the nearest real slide — swapping a clone for its twin at the edges — and remembers the doc it came to rest on, so a reboot resumes there. A pinch that has taken hold is left alone: a realign queued before it must stand off rather than yank the magnified view back to a slide edge.
const FRAME_CID_KEY = 'memories.frame.cid';
let snapT;
const onFrameScroll = () => {
if(stripEl){ const i = slideAt(), c = frameCenterIdx();
if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
clearTimeout(snapT); snapT = setTimeout(() => {
if(!stripEl) return;
if(zoomed()) return;
const n = items().length;
let i = slideAt();
if(i <= 0){ stripEl.scrollLeft = slideLeft(n); i = n; } // leading clone(last) → real last
else if(i >= n + 1){ stripEl.scrollLeft = slideLeft(1); i = 1; } // trailing clone(first) → real first
const target = slideLeft(i);
if(Math.abs(stripEl.scrollLeft - target) > 1) stripEl.scrollTo({ left: target, behavior: 'smooth' });
const doc = items()[i - 1];
setFrameCenterIdx(i);
if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
}, 150); };
Entering the frame is a small ceremony: mark it open and playing, remember it in localStorage
so a PWA relaunch comes straight back, push a history entry so the back button leaves, and take a
wake lock so the cabinet screen stays lit. Closing is the teardown — turn the auto-enter off and
release the lock. Exit unwinds through the history entry when there is one, so the back button
and an explicit exit both leave history balanced.
const FRAME_ON_KEY = 'memories.frame.on';
async function enterFrame(){
if(!items().length) return;
setFrame(true); setPlaying(true); setFrameUI(false);
localStorage.setItem(FRAME_ON_KEY, '1');
history.pushState({ frame: true }, '');
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
}
function closeFrame(){
setFrame(false);
localStorage.setItem(FRAME_ON_KEY, '0');
try { wakeLock?.release(); } catch(e) {} wakeLock = null;
}
const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();
You can edit the centred doc without leaving the show — set its state, add or drop a label, fix
its date. An edit can push the doc out of the current filter; when it does we re-anchor on the
previous doc, so the next advance shows whatever filled the gap rather than skipping it, and
when it stays we keep it centred. Each editor below is a thin wrapper over that one frameEdit.
const frameIndex = () => Math.max(0, Math.min(items().length - 1, slideAt() - 1));
async function frameEdit(patchFor){
const list = items(); if(!list.length || !stripEl) return;
const i = frameIndex(), cur = list[i], prevCid = i > 0 ? list[i - 1].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch: patchFor(cur) });
setFrameLabel('');
await refetch();
requestAnimationFrame(() => {
const l2 = items(); if(!l2.length) { exitFrame(); return; }
const stay = l2.findIndex(p => p.cid === cur.cid);
let t = stay >= 0 ? stay : (prevCid ? l2.findIndex(p => p.cid === prevCid) : 0);
stripEl.scrollLeft = slideLeft(Math.max(0, t) + 1);
});
}
const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
return frameEdit(() => ({ state: st })); };
const frameAddWord = input => { const words = splitWords(input); if(!words.length) return;
return frameEdit(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; }); };
const frameAddLabel = () => frameAddWord(frameLabel());
const frameDropLabel = () => { const words = splitWords(frameLabel()); if(!words.length) return;
return frameEdit(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') })); };
let frameCancelDate = false;
const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false; setFrameEditingDate(false);
if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };
The cabinet tablet has no address bar: the frame launches from a PWA home-screen shortcut, which opens with no query string, so an auto-start can’t ride a URL param. Being in the frame is remembered instead — when the persisted wall loads and we were last in the frame, the show auto-starts, once only, so exiting doesn’t immediately re-enter. It opens on the slide we left off, resuming across a reboot, and falls back to the first real slide.
const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1';
let autoEntered = false;
createEffect(() => {
if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
});
createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
const saved = localStorage.getItem(FRAME_CID_KEY);
const r = saved ? items().findIndex(p => p.cid === saved) : -1;
stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1); // +1 for the leading clone
setFrameCenterIdx(r >= 0 ? r + 1 : 1); // seed the centre before any scroll
setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
}); });
A video the viewer started should not keep playing once it has scrolled away — a soundtrack from a slide nobody can see, over a photo that has nothing to do with it.
@testcase
def test_frame_video_pauses_when_it_leaves(page):
"""A video playing in the frame stops once the show has moved off its slide."""
drop_fixtures()
vid = {"cid": "https://ipfs.konubinix.eu/p/zzfvid", "date": "2020-01-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvid-web",
"labels": "zzfvid", "state": "todo"}
nxt = {"cid": "https://ipfs.konubinix.eu/p/zzfvid-next", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvid-next-t", "labels": "zzfvid", "state": "todo"}
for d in (vid, nxt): gql(CREATE, {"p": d})
page.route("**/ipfs/zzfvid-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, "?ms=999999") # the show holds still; the test moves it
search_for(page, "zzfvid") # default chip = todo
expect(tiles(page)).to_have_count(2)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
v = strip.locator("video").first # slide 1 — slide 0 is the wrap-around clone of the last
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }") # headless blocks unmuted autoplay
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
strip.evaluate("el => el.scrollLeft = el.children[2].offsetLeft") # onto the next slide
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the video paused once its slide left the screen")
print(" PASS: frame video pauses when it leaves")
finally:
page.unroute("**/ipfs/zzfvid-web")
for d in (vid, nxt):
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
And it has to keep doing so after the strip has re-flowed under it. Triage from the frame removes docs from the filter, so the boxes shuffle: a box that held a photo comes to hold the video that used to sit after it. That video is a different element from the one the show opened with, and it must be watched just the same.
@testcase
def test_frame_video_pauses_after_reflow(page):
"""A video that only lands in its slide box after an edit re-flows the strip still pauses."""
drop_fixtures()
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzfvr-a", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-a-t", "labels": "zzfvr", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzfvr-v", "date": "2020-02-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-v-t", "webCid": "https://ipfs.konubinix.eu/p/zzfvr-web",
"labels": "zzfvr", "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzfvr-c", "date": "2020-03-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfvr-c-t", "labels": "zzfvr", "state": "todo"}]
for d in docs: gql(CREATE, {"p": d})
page.route("**/ipfs/zzfvr-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, "?ms=999999")
search_for(page, "zzfvr")
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
strip.click() # reveal the bar, on the first photo
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → the strip closes the gap
expect(tiles(page)).to_have_count(2)
v = strip.locator("video").first # the video now sits in the box the photo had
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
strip.evaluate("el => el.scrollLeft = el.children[2].offsetLeft")
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the re-flowed video paused once its slide left the screen")
print(" PASS: frame video pauses after reflow")
finally:
page.unroute("**/ipfs/zzfvr-web")
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
An IntersectionObserver over the strip pauses any slide video that drops below half-visible.
It can only watch the videos it was handed, though, and it is handed them by one sweep of the
strip — so the sweep has to happen again every time the boxes are re-dealt, not just when the
show opens. Hence the observer is thrown away and rebuilt on each of those two events, which
is what on([items, frame]) says: rebuild when the docs change, and when the show opens.
createEffect(on([items, frame], () => {
if(!frame() || !stripEl) return;
const io = new IntersectionObserver(
es => es.forEach(e => { if(e.intersectionRatio < 0.5) e.target.pause(); }),
{ root: stripEl, threshold: 0.5 });
requestAnimationFrame(() => stripEl.querySelectorAll('video').forEach(v => io.observe(v)));
onCleanup(() => io.disconnect());
}));
The auto-advance is a smooth step to the next slide every interval, but it must yield. The
browser’s own pinch-zoom is watched off visualViewport, so a magnified slide holds the show; a
playing video holds it too; and a recent touch holds it — each interaction marks the show busy,
then quiet again after the idle span, and every tap or swipe on the strip bumps that timer. When
nothing holds it, the show steps and wraps at the end.
createEffect(() => { if(!frame()) return; const vv = window.visualViewport; if(!vv) return;
const read = () => setZoomed(vv.scale > 1);
read(); vv.addEventListener('resize', read); vv.addEventListener('scroll', read);
onCleanup(() => { vv.removeEventListener('resize', read); vv.removeEventListener('scroll', read); }); });
createEffect(() => {
if(!frame() || !playing() || zoomed() || interacting()) return;
const id = setInterval(() => {
if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
frameGo(1);
}, intervalMs());
onCleanup(() => clearInterval(id));
});
createEffect(() => { if(!pokes()) return;
setInteracting(true);
const id = setTimeout(() => setInteracting(false), FRAME_IDLE_MS); onCleanup(() => clearTimeout(id)); });
createEffect(() => { if(!frame() || !stripEl) return;
stripEl.addEventListener('pointerdown', nudge);
onCleanup(() => stripEl.removeEventListener('pointerdown', nudge)); });
A few listeners ride on the window for the frame’s whole life. Esc and the arrow keys work the
show from a desktop keyboard — except while the label box holds focus, where the arrows must edit
the text rather than step the slide. The back button unwinds the stack in order — out of the
frame, then out of the lightbox it may have launched from, then to the wall. And when the tab
returns to the foreground the wake lock, dropped while hidden, is taken again so the screen stays
lit.
onMount(() => {
const onKey = e => {
if(!frame()) return;
const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
if(e.key === 'Escape') exitFrame();
else if(!editing && e.key === 'ArrowRight'){ e.preventDefault(); frameGo(1); }
else if(!editing && e.key === 'ArrowLeft'){ e.preventDefault(); frameGo(-1); }
};
const onVis = async () => {
if(frame() && document.visibilityState === 'visible' && !wakeLock)
try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
};
const onPop = () => {
if(frame()) closeFrame();
const st = history.state || {};
if(opened() && !st.lb) closePhoto();
if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }
};
window.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPop);
document.addEventListener('visibilitychange', onVis);
onCleanup(() => { window.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPop);
document.removeEventListener('visibilitychange', onVis); });
});
On a desktop keyboard, ← and → step the show — the same one-slide move the side-taps
make. The frame claims that keypress wholly, so frameGo alone drives the step: a
modern browser hands keyboard focus to a scrollable region, and left to its own devices it
would answer the arrow by scrolling the strip a notch itself — jerking the doc sideways
and fighting the step the settle then has to undo. Taking the key keeps the arrow a clean
step, the same animation as a side-tap.
Those arrows want a keyboard the cabinet tablet doesn’t have, and one-handed from across the room a dependable swipe wants two hands. So the screen itself carries the same step, and where the tap lands is the whole of it.
Two of the three zones go to travel, because stepping is what you do most and it has to be reachable without looking: the outer thirds move the show, left back and right forward.
page.mouse.click(box["x"] + box["width"] * 0.92, midY) # right third → forward
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.mouse.click(box["x"] + box["width"] * 0.08, midY) # left third → back
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame tap zones step the show")
The centre third is what is left, and it has to be the bar’s: put the controls behind an edge and every reach for them would step the show first.
page.mouse.click(box["x"] + box["width"] / 2, midY) # centre third → the bar
expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
print(" PASS: frame centre tap reveals the bar")
In code the zones are the whole handler: an x against two thirds of the width, and nothing else to decide.
const onFrameTap = e => {
const w = window.innerWidth || 1;
if(e.clientX < w / 3) frameGo(-1);
else if(e.clientX > w * 2 / 3) frameGo(1);
else setFrameUI(v => !v);
};
A plain click can’t carry that step: a <video controls> in the slide swallows the tap
before any click bubbles up. So we read the strip’s raw pointers instead, watching for a
lone finger that presses and lifts in place — travelling no more than about ten pixels, past
which it is a swipe rather than a tap. A second finger is not a tap at all: it is a pinch,
and pinching now belongs to the browser’s own zoom, so the watch lets that gesture go and
never steps the show.
const TAP_SLOP = 10;
let tapFrom = null; // where a lone finger went down, while it could still be a tap
const tapPtrs = new Set();
const onTapDown = e => { tapPtrs.add(e.pointerId);
tapFrom = tapPtrs.size === 1 ? { x: e.clientX, y: e.clientY } : null; };
const onTapMove = e => { if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null; };
const onTapUp = e => { tapPtrs.delete(e.pointerId);
if(e.type === 'pointerup' && tapFrom) onFrameTap(e);
if(!tapPtrs.size) tapFrom = null; };
createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
const on = (t, h) => el.addEventListener(t, h), off = (t, h) => el.removeEventListener(t, h);
on('pointerdown', onTapDown); on('pointermove', onTapMove); on('pointerup', onTapUp); on('pointercancel', onTapUp);
onCleanup(() => { off('pointerdown', onTapDown); off('pointermove', onTapMove);
off('pointerup', onTapUp); off('pointercancel', onTapUp); }); });
The second finger is the watch’s other half: two fingers planted in an outer third and lifted must leave the show exactly where it stood — only a lone finger carries the side-step.
x = box["x"] + box["width"] * 0.92 # the right third — a lone tap here would step forward
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": x - 20, "y": midY}, {"x": x + 20, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
page.wait_for_timeout(FRAME_STEP_GRACE_MS) # long enough for a step to show
assert strip.evaluate(ON_SLIDE) == first, "a two-finger gesture must not step the show"
print(" PASS: frame two-finger gesture does not step")
The window is its own knob — ?uiidle overrides the twenty seconds — and deliberately
apart from the minute of quiet that lets the auto-advance
resume: this timer tidies away a menu, that one guards a slide you are studying, so they
pace different things and shouldn’t share a number. The count has to restart whenever you
touch the show, so it rides the same pokes() signal the frame already tracks for every
interaction.
const FRAME_UI_IDLE_MS = Number(new URLSearchParams(location.search).get('uiidle')) || 20000;
createEffect(() => { if(!frameUI()) return; pokes();
const id = setTimeout(() => setFrameUI(false), FRAME_UI_IDLE_MS); onCleanup(() => clearTimeout(id)); });
That interaction signal is fed by the strip, but the bar lays over it as a sibling, so a press on the bar’s own controls never reaches it. The bar forwards its presses there, so working the menu counts as touching the show and keeps it up:
onPointerDown=${nudge}
That stack bottoms out at the grid; one more Back would leave the app entirely — easy to do by accident on the cabinet tablet. So a root history entry sits under the grid, and popping below it (nothing open) asks first: confirm and we leave, cancel and the root is re-pushed so the wall stays put.
@testcase
def test_back_at_grid_asks_before_exit(page):
"""At the grid, with nothing open, the back button asks before leaving the app."""
open_fixtures(page)
asked = []
page.on("dialog", lambda d: (asked.append(d.message), d.dismiss())) # cancel → stay
page.go_back()
wait_until(page, lambda: bool(asked)) # the dialog is delivered async
expect(grid(page)).to_be_visible() # cancelling kept us in the app
print(" PASS: back at grid asks before exit")
The completion list under the search box is the one thing that can be showing without being a stacked overlay — yet the same reflex applies: with it up, Back should retract it, not ask whether to leave. Retracting the last thing that appeared is what the button is for, so a Back that finds the list open spends itself closing it, and only a Back with nothing showing reaches the leave prompt. And retracting the list must not spend the root guard itself: a further Back, now with nothing showing, still meets that prompt.
@testcase
def test_back_closes_completion(page):
"""With the completion list up, the back button retracts it and stays in the app."""
open_app(page)
search_box(page).click() # focus the empty box → the token menu drops down
expect(options(page).first).to_be_visible()
page.go_back() # Back retracts the list — it must not leave the app
expect(options(page)).to_have_count(0) # the list is gone
expect(search_box(page)).to_be_visible() # and we're still in Memories
print(" PASS: back closes completion")
@testcase
def test_back_after_completion_still_guards_exit(page):
"""After Back retracts the completion list, a further Back still asks before leaving."""
open_app(page)
search_box(page).click() # focus → the list drops down
expect(options(page).first).to_be_visible()
page.go_back() # first Back: retract the list
expect(options(page)).to_have_count(0)
asked = []
page.on("dialog", lambda d: (asked.append(d.message), d.dismiss()))
page.go_back() # second Back: must reach the leave guard
wait_until(page, lambda: bool(asked))
expect(search_box(page)).to_be_visible() # cancelled → still in the app
print(" PASS: back after completion still guards exit")
In practice the guard reads whether the list is up straight from the DOM — the presence
of the rendered .suggest node — rather than from a state flag: a flag holding the
last-offered items would keep its value after the box blurs and the list unmounts, and so
would claim a list that is already gone.
onMount(() => {
history.pushState({ app: true }, ''); // the root entry the back button stops on
const onExit = () => {
const st = history.state || {};
if(!frame() && !opened() && !st.lb && !st.app){ // popped below the root with nothing open
if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; } // bail out of a frozen read; stay
if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; } // retract the list; keep the root beneath
if(confirm('Leave Memories?')) history.back(); // really leave
else history.pushState({ app: true }, ''); // stay — restore the root
}
};
window.addEventListener('popstate', onExit);
onCleanup(() => window.removeEventListener('popstate', onExit));
});
The filmstrip: a natively-scrolled row of viewport-wide slides (incl. the two clones). The control bar lays over it as a sibling — outside the strip that reads taps — so a press on the bar’s own buttons is never taken for a tap on the show.
<${Show} when=${() => frame()}>
<div class="frame">
<div class="strip" role="list" aria-label="slideshow"
ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
<${Index} each=${() => frameSlides()}>${(slide, k) => FrameSlide(slide, k)}<//>
</div>
<${Show} when=${() => frameUI()}>
<div class="frame-bar" role="toolbar" aria-label="frame actions" onPointerDown=${nudge}>
<button aria-label=${() => playing() ? 'pause' : 'play'}
onClick=${() => setPlaying(p => !p)}>${() => playing() ? '⏸' : '▶'}</button>
<label>every <input class="ivl" type="number" min="2" aria-label="seconds per photo"
value=${() => Math.round(intervalMs() / 1000)}
onChange=${e => setIntervalMs(Math.max(2, +e.target.value) * 1000)} />s</label>
<${Show} when=${() => frameEditingDate()}
fallback=${html`<button class="frame-date" aria-label="edit date"
onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
<input class="frame-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
onBlur=${e => frameCommitDate(e.target.value)} />
<//>
<span class="room-link" data-state=${roomLink}>${roomLink}</span>
<${For} each=${() => frameEvents() || []}>${e => html`
<button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
${STATES.map(st => html`
<button class="st" data-st=${st} onClick=${() => frameSetState(st)}>${st}</button>`)}
<div class="complete">
<input class="frame-label" role="combobox" placeholder="add a label…" aria-label="add a label in the frame"
aria-expanded=${() => frameLabelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
onFocus=${() => setFrameLabelFocus(true)}
onBlur=${() => setFrameLabelFocus(false)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
<${Show} when=${() => frameLabelFocus()}>
<${Suggest} text=${frameLabel} present=${() => labelsOf(frameDoc())}
active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
<//>
</div>
<button aria-label="exit frame" onClick=${exitFrame}>✕ exit</button>
</div>
<//>
</div>
<//>
The date on the bar is the lightbox’s date editor moved here: a button showing the
centred slide’s date that, pressed, becomes a datetime-local seeded with what it held.
Saving routes through frameEdit, so fixing a wrong timestamp mid-show re-orders the strip
and keeps the doc under you, exactly as a state change does.
<${Show} when=${() => frameEditingDate()}
fallback=${html`<button class="frame-date" aria-label="edit date"
onClick=${() => setFrameEditingDate(true)}>${() => { const d = frameDoc();
return d?.date ? new Date(d.date).toLocaleString("fr-FR") : ''; }}</button>`}>
<input class="frame-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(frameDoc()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); } else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); } }}
onBlur=${e => frameCommitDate(e.target.value)} />
<//>
Backing out must not leave the show: Escape otherwise exits the frame, so the picker swallows
it at the input and marks the edit cancelled before it lets go.
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); frameCancelDate = true; e.target.blur(); }
The frame carries the same overlap the lightbox does: beside the date, the centred slide’s events ride on the bar, so a slideshow tells you not just when a photo was taken but what was happening then — and stepping to the next slide brings its own.
@testcase
def test_frame_shows_events(page):
"""The frame bar names the calendar events the centred slide was taken during."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-frame", "starttime": "2020-11-01T00:00:00Z", "endtime": "2020-11-30T23:59:59Z",
"summary": "zzFrameEvent", "owner": "konubinix", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zzframephoto", "date": "2020-11-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzframephoto-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzframeev", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzframeev")
expect(tiles(page)).to_have_count(1)
page.get_by_role("button", name=re.compile("frame", re.I)).click() # into the slideshow
expect(page.get_by_role("list", name="slideshow")).to_be_visible()
page.get_by_role("list", name="slideshow").click() # reveal the bar
bar = page.get_by_role("toolbar", name="frame actions")
expect(bar.get_by_text("zzFrameEvent")).to_be_visible() # the centred slide's event
start = page.evaluate("() => new Date('2020-11-01T00:00:00Z').toLocaleDateString('fr-FR')")
end = page.evaluate("() => new Date('2020-11-30T23:59:59Z').toLocaleDateString('fr-FR')")
expect(bar.get_by_text(start)).to_be_visible() # the span's start, beside the name
expect(bar.get_by_text(end)).to_be_visible() # …and its end
finally:
gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: frame shows events")
The bar’s pill carries the occasion’s when too — the same muted date aside as the lightbox, kept on the bar’s single line. On the revealed bar, the event’s start and end read off the pill.
start = page.evaluate("() => new Date('2020-11-01T00:00:00Z').toLocaleDateString('fr-FR')")
end = page.evaluate("() => new Date('2020-11-30T23:59:59Z').toLocaleDateString('fr-FR')")
expect(bar.get_by_text(start)).to_be_visible() # the span's start, beside the name
expect(bar.get_by_text(end)).to_be_visible() # …and its end
The wiring is the lightbox’s with one substitution: the resource keys on the centred
frameDoc() instead of opened(), reusing fetchDocEvents and its owner guard. The chips
carry the same name + muted when as there; and because the bar is a single line, both the
pill and its date aside must stay on it (nowrap).
const [frameEvents] = createResource(
() => { const o = frameDoc(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);
<${For} each=${() => frameEvents() || []}>${e => html`
<button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
The bar’s pill jumps like the lightbox’s: clicking it runs the occasion’s event: search
and steps out of the show — through exitFrame — onto the wall, now narrowed to that occasion.
@testcase
def test_frame_event_pill_searches(page):
"""Clicking an event pill on the frame bar runs its event: search and leaves the show for the wall."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-fpill", "starttime": "2020-11-01T00:00:00Z", "endtime": "2020-11-30T23:59:59Z",
"summary": "zzFramePill", "owner": "konubinix", "status": "confirmed"}
doc = {"cid": "https://ipfs.konubinix.eu/p/zzfpill", "date": "2020-11-15T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzfpill-t",
"owner": "konubinix", "mimetype": "image/jpeg", "labels": "zzframepill", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzframepill")
expect(tiles(page)).to_have_count(1)
page.get_by_role("button", name=re.compile("frame", re.I)).click() # into the slideshow
expect(page.get_by_role("list", name="slideshow")).to_be_visible()
page.get_by_role("list", name="slideshow").click() # reveal the bar
bar = page.get_by_role("toolbar", name="frame actions")
bar.get_by_role("button", name=re.compile("zzFramePill")).click() # jump to the occasion
expect(search_box(page)).to_have_value("event:zzFramePill") # the pill's search, committed
expect(page.get_by_role("list", name="slideshow")).to_have_count(0) # stepped out of the show
expect(tiles(page)).to_have_count(1) # back on the filtered wall
finally:
gql(DELETE, {"cid": doc["cid"]}); gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: frame event pill searches")
.frame-event{ font:inherit; font-size:12px; color:#bcd; background:#20293f; border:1px solid #34406088;
border-radius:999px; padding:3px 10px; white-space:nowrap; cursor:pointer; }
.frame-event:hover{ background:#2a3450; border-color:#4a5680; }
.frame{ position:fixed; inset:0; z-index:200; background:#000; }
.strip{ display:flex; width:100%; height:100%; overflow-x:auto; overflow-y:hidden;
scrollbar-width:none; }
.strip::-webkit-scrollbar{ display:none; }
.slide{ flex:0 0 100%; width:100vw; height:100vh;
display:flex; align-items:center; justify-content:center; }
.slide-media{ width:100vw; height:100vh; object-fit:contain; }
.slide-media.noimg{ display:flex; align-items:center; justify-content:center; color:#9aa; }
.slide-media.noimg .ph{ font-size:80px; opacity:.5; }
/* the loading mark sits centred over the slide until the image paints over it */
.slide-pic{ position:relative; width:100vw; height:100vh; display:flex; align-items:center; justify-content:center; }
.slide-pic .load-ph{ position:absolute; font-size:80px; opacity:.5; color:#9aa; }
.slide-pic .slide-media.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; } /* full-res fades in over the thumbnail */
.slide-pic .slide-media.web.shown{ opacity:1; }
/* a quiet pulse, corner-tucked, while the full-res is still on its way */
.slide-pic .upgrading{ position:absolute; bottom:16px; right:16px; width:10px; height:10px;
border-radius:50%; background:#cdd; animation:upgrading 1.3s ease-in-out infinite; }
@keyframes upgrading{ 0%,100%{ opacity:.15 } 50%{ opacity:.6 } }
.frame-bar{ position:fixed; bottom:calc(18px + env(safe-area-inset-bottom));
left:50%; transform:translateX(-50%); z-index:3;
display:flex; flex-wrap:wrap; gap:8px 12px; align-items:center; justify-content:center;
max-width:92vw; background:#11131fdd; border:1px solid #3a3f5a;
border-radius:10px; padding:8px 14px; font-size:14px; color:#cdd; }
.frame-bar button{ border:none; background:none; color:var(--fg); font-size:16px; cursor:pointer; }
.frame-bar .st{ font-size:12px; text-transform:uppercase; letter-spacing:.03em;
border:1px solid #3a3f5a; border-radius:5px; padding:3px 8px; }
[data-st="todo"]{ --st:#6cf; }
[data-st="next"]{ --st:#fb7; }
[data-st="done"]{ --st:#7d7; }
[data-st="delete"]{ --st:#f77; }
button[data-st]:active{ transform:scale(0.9); filter:brightness(1.2); }
.frame-bar .st[data-st]{ border-color:var(--st); color:var(--st); }
.frame-bar .ivl{ width:48px; background:#262a40; color:var(--fg); border:1px solid #3a3f5a;
border-radius:4px; padding:3px 5px; }
.frame-bar .frame-date{ font-size:13px; color:#9aa; background:none; border:none; padding:0;
font-family:inherit; cursor:pointer; }
.frame-bar .frame-date:hover{ color:#ccd; text-decoration:underline; }
.frame-bar .frame-date-edit{ font-size:13px; color:var(--fg); background:#262a40; border:1px solid #3a3f5a;
border-radius:5px; padding:3px 6px; }
.frame-bar .frame-label{ background:#262a40; color:var(--fg); border:1px solid #3a3f5a;
border-radius:5px; padding:4px 8px; font-size:13px; }
.frame-bar .suggest{ top:auto; bottom:100%; margin:0 0 4px; } /* open upward from the bar */
Letting go of a forgotten pinch
A pinch left untouched must not trap the frame forever — the cabinet plays across the room,
and a slide someone magnified and wandered from should find its way back to the show. But
the pinch is the browser’s, and the browser offers no way back: on the tablet’s Chromium a
script cannot zoom out. Neither a maximum-scale flip on the viewport tag nor a plain reload
clears it — both leave the magnification in place, Chromium carrying the remembered scale even
across the reload. The one move that drops it is landing on an address the browser has never
seen zoomed. So after five minutes with no touch (?zoomidle= overrides the span) the frame
navigates to its own page with a fresh throwaway query and lands at 1:1; its persistence
carries the show across the reboot — it auto-re-enters and resumes the slide it was on. Every
touch re-arms the five minutes, so only a truly forgotten pinch ever triggers the reload.
@testcase
def test_frame_pinch_resets_after_idle(page):
"""Left magnified and untouched, the frame reloads itself back to 1:1 after the idle span."""
make_fixtures()
open_app(page, "?ms=999999&zoomidle=700") # a short idle window for the test
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5)
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # register the zoom → arm the idle timer
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01, timeout=8000) # reloaded → 1:1
assert "z=" in page.url, "the reset lands on a fresh throwaway url"
print(" PASS: frame pinch resets after idle")
The countdown rides the same pokes() interaction signal the rest of the frame uses, so
every touch restarts it; only a stretch of pure quiet while zoomed reaches the navigation.
const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
createEffect(() => { if(!frame() || !zoomed()) return; pokes(); // any touch re-arms the countdown
const id = setTimeout(() => { const u = new URL(location.href);
u.searchParams.set('z', String(Date.now())); // an address the browser hasn't seen zoomed → it lands at 1:1
location.href = u.href; }, ZOOM_IDLE_MS);
onCleanup(() => clearTimeout(id)); });
Into the frame, from the lightbox
The frame launches from the wall, over whatever the query narrowed to. But the moment you most want the big show is usually when you’re already lingering on one photo in the lightbox — so the lightbox carries a ▶ frame button too, and it opens the show on the doc you’re looking at rather than back at the first slide.
Landing on a chosen slide is something the frame already knows how to do: it opens on whichever slide matches the remembered cid, the way it resumes after a reboot. So launching from the lightbox is just seeding that memory with the open doc, dropping the modal, and entering — the strip settles on that very slide.
@testcase
def test_frame_from_lightbox(page):
"""▶ frame in the lightbox enters the slideshow centered on the open doc."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
open_doc(page, 1) # open the middle doc, not the first
d = dialog(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
d.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(d).to_be_hidden() # the lightbox gives way to the frame
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: frame from lightbox")
The lightbox’s history entry stays underneath the frame’s, so the back button unwinds the whole way down: leaving the frame reopens the doc it was launched from, and leaving that returns to the wall — frame → lightbox → grid.
@testcase
def test_back_from_frame_returns_to_lightbox(page):
"""A frame launched from a doc steps back to that doc's lightbox, then to the grid."""
open_fixtures(page)
open_doc(page, 1) # lightbox on the middle doc
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
dialog(page).get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
page.go_back() # out of the frame …
expect(strip).to_be_hidden()
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") # … back to its lightbox
page.go_back() # out of the lightbox …
expect(dialog(page)).to_be_hidden()
expect(grid(page)).to_be_visible() # … back to the wall
print(" PASS: back from frame returns to lightbox")
The handler does exactly that: it writes the open doc’s cid where the frame looks for the slide to resume on, closes the modal, and enters.
const frameFromHere = () => { const cur = opened(); if(!cur) return;
localStorage.setItem(FRAME_CID_KEY, cur.cid); // the slide the frame will open on
closePhoto(); // hide the modal but leave its history entry, so Back returns to it
enterFrame(); };
The button sits at the top of the modal, between select and close.
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
.lb-frame{ position:absolute; top:-6px; left:50%; transform:translateX(-50%); z-index:2;
padding:6px 12px; border:none; border-radius:8px; background:#262a40;
color:var(--fg); font-size:12px; cursor:pointer; }
.lb-frame:hover{ background:#33395a; }
Handing the slide to a phone
The photo on the cabinet is often one you want to send to someone right then — and the conversation you would send it in is on your phone. The tablet has no way to hand a file to an app on another device, so the phone has to fetch it, and a small companion app is what does that: it holds whatever the frame is showing and passes it to the phone’s own share sheet. Everything memories owes it is a name for the photo.
That name lives in a shared document — a room on the house’s sync server, holding one entry that every frame writes and the companion reads. It is a heavier instrument than one value needs, and it is chosen for what surrounds the value rather than the value itself: the companion is a page with no server of its own, so nothing can hand it an update unless the update arrives over a connection it already holds.
Yjs holds such a document, and y-websocket carries it. Yjs has Solid’s one-instance
requirement and meets it the same way — the socket library comes in marked ?external=yjs so
that it and the map’s own yjs are the same copy. Two copies fail quietly in the worst way:
the room connects, reports itself live, and never syncs anything.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
Which room, and which server, a frame can work out for itself: the sync server answers on the same host the app was loaded from, so swapping the scheme is the whole of it. Both can be named at launch as well — every app here that joins a room takes its address that way, since the address is configuration and not a fact about where the page came from.
const frameParam = new URLSearchParams(location.search);
const SYNC_URL = frameParam.get('yws') || location.origin.replace(/^http/, 'ws') + '/ywebsocket';
const SYNC_ROOM = frameParam.get('room') || 'memories-nowshowing';
const [roomLink, setRoomLink] = createSignal('idle');
A frame names every slide it comes to rest on, unasked. Waiting for a gesture would defeat the point: you look up from the cabinet, take out your phone, and the photo has to be there already — a phone that needs you to go back and prod the tablet first is a phone you would not bother with.
Memories runs on the phones as much as on the cabinet, so several frames can be playing at once, all writing that same one entry, and the last to settle wins. There is no arbitration and no need for one: in practice the frame that keeps settling is the one that is on, which is the cabinet. Where two really are going at once, a touch breaks the tie.
A name is both of the doc’s addresses — the downscaled copy and the original, so the companion can offer you the choice the lightbox already draws — what kind of file it is, which the companion needs to build a file the share sheet will accept, and when the photo was taken, which is what the companion calls the file it hands over. Only the archive knows that date, and a frame that kept it to itself would leave the companion handing over a file it cannot name.
Under the hood the archive gives an instant back in its own offset rather than the Z it was
handed, so the two read differently as text while naming the same moment; what is checked is
therefore the instant, not its spelling.
named = watch_room(page.context, sync_url, room) # the entry, watched as the phone will
open_app(page, f"?ms=999999&uiidle=1500&yws={sync_url}&room={room}")
search_for(page, "zzshare") # default state chip = todo
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
# nothing is touched here: entering the show is the whole of it
wait_until(page, lambda: bool(named()), label="the frame names its slide unasked")
said = named()
assert said["cid"] == "https://ipfs.konubinix.eu/p/zzshare-0", f"named the wrong doc: {said}"
assert said["webCid"] == "https://ipfs.konubinix.eu/p/zzshare-web-0", f"no downscaled address: {said}"
assert said["mimetype"] == "image/jpeg", f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
[said.get("date"), SHARE_DOCS[0]["date"]]), \
f"named a different instant than the doc's {SHARE_DOCS[0]['date']}: {said}"
A name rides the settle, not the scroll — the centred slide reads true long before the debounce that ends a step — so a step only counts as over once the show has come to rest, and that is when the next name goes out. Keep browsing and the name keeps up with you.
page.keyboard.press("ArrowRight") # a step, with nothing touched
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzshare-1",
label="the name follows the slide", detail=lambda: str(named()))
Taking the room back is the one thing a touch is for. If another frame has spoken over you, touching yours puts your slide back without your having to move it.
speak_over(page.context, "https://ipfs.konubinix.eu/p/zzelsewhere") # another frame claims the room
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzelsewhere")
strip.click() # a touch, and the slide is not moved
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzshare-1",
label="a touch takes the room back", detail=lambda: str(named()))
bar = page.get_by_role("toolbar", name="frame actions")
expect(bar.get_by_text("live", exact=True)).to_be_visible() # the bar owns up to the link
The addresses go out exactly as the doc carries them, unresolved: the companion is served from elsewhere and reaches the archive by its own route, so it is the one that can turn an address into something it can fetch.
The socket opens on the first slide a frame names and lives as long as the page does — on a cabinet tablet, for days. Dropping it between shows would buy a little idle quiet and cost the next name a dial-up first, which is the wrong way round: the name is wanted the instant somebody looks up.
Naming a slide is a thing you cannot see working. The frame writes, says nothing, and looks exactly the same whether the name arrived or the socket is down — and if it is down, somebody on a sofa is looking at a phone that still shows the last photo the room heard about. The person standing at the frame is the only one who can notice, so the bar carries the link’s state in one word beside the date: idle until a slide has been named, connecting while the socket is being opened, then live or offline for as long as it holds or does not.
The word that has to be right is offline, because it is the only one that contradicts what the screen otherwise implies. A frame pointed somewhere nothing answers looks exactly like a frame whose every slide is landing. Pointing one nowhere takes a little care: the browser vetoes the low port numbers on its own, before any connection is attempted, so a frame aimed at one of those never learns anything about its link. A high port nobody is listening on is refused outright, which is the answer wanted.
@testcase
def test_the_frame_owns_up_to_a_dead_link(page):
"""A frame that cannot reach the room says so, instead of looking like it published."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdead-{i}", "date": f"2021-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdead-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzdead-web-{i}", "labels": "zzdead", "state": "todo"}
for i in range(3)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page, "?ms=999999&uiidle=999999&yws=ws://127.0.0.1:45999&room=nowhere")
search_for(page, "zzdead")
expect(tiles(page)).to_have_count(3)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
strip.click() # a touch, so the bar is up
bar = page.get_by_role("toolbar", name="frame actions")
expect(bar.get_by_text("offline", exact=True)).to_be_visible(timeout=20000)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: the frame owns up to a dead link")
Whoever reads that word is standing back from a screen on a cabinet, and is not looking for it anyway — they are looking at the photo. So it has to catch the eye when it is bad news and otherwise stay out of the way. Only one of the four states is bad news: only offline takes a colour that interrupts, the two that are on their way somewhere share a muted one, and live stays quiet, being the answer you already assumed.
<span class="room-link" data-state=${roomLink}>${roomLink}</span>
.frame-bar .room-link{ font:10px/1.6 monospace; text-transform:uppercase; letter-spacing:.08em;
color:#6a7; }
.frame-bar .room-link[data-state="offline"]{ color:#d55; }
.frame-bar .room-link[data-state="connecting"],
.frame-bar .room-link[data-state="idle"]{ color:#aa6; }
let showingRoom = null;
const nowShowing = () => {
if(!showingRoom){
const shared = new Y.Doc();
const provider = new WebsocketProvider(SYNC_URL, SYNC_ROOM, shared);
setRoomLink('connecting');
provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
showingRoom = shared.getMap('showing');
}
return showingRoom;
};
createEffect(() => {
if(!frame()) return;
pokes();
const d = frameDoc(); if(!d) return;
nowShowing().set('doc', { cid: d.cid, webCid: d.webCid, mimetype: d.mimetype, date: d.date });
});
Under the hood, reading pokes() there is what makes a touch republish. An effect re-runs when
anything it read has changed, so naming that counter alongside the doc buys the tie-breaker for
one line: the settle path comes from the doc, and the claim path from the counter the show
already bumps on every touch. Nothing has to notice which of the two happened.
Filtering by state
Triage needs a way to ask “show me only what’s still todo” — and since triage is the
whole point of the app, that’s where a first-ever visit opens. Thereafter it opens on
whatever you last chose, remembered like the search box (so the frame comes back to the
view you were working). Each photo carries a workflow state (todo / next / done / delete); a row of chips under the search box switches an all / todo / next / done
filter. The filter is a Solid signal — seeded from localStorage, defaulting to todo —
folded into the resource key alongside search, so flipping a chip re-fetches and saves.
Crucially the filter is a server argument (states), applied inside
photovideos_search before the ~2000 sample — an archive that’s almost entirely
done would otherwise leave a “todo” view nearly empty (the sample is mostly done,
so client-side filtering finds nothing). With the server filter, todo surfaces ~2000
todos spread across the whole span.
The test searches the fixtures (which span todo/next/done), confirms all three show
under all, clicks the todo chip, and asserts only the todo tile remains.
@testcase
def test_state_filter(page):
"""The state chips narrow the wall to one workflow state, server-side."""
open_fixtures(page) # starts on 'all' → all three states
chip(page, "todo").click()
expect(tiles(page)).to_have_count(1) # only the todo fixture remains
expect(grid(page).get_by_text("todo")).to_have_count(1) # and its badge says so
print(" PASS: state filter")
And the chosen chip is remembered: like the search box, the filter persists to
localStorage, so a reload — or the frame’s reboot — comes back to the state you were
last triaging, not always todo.
@testcase
def test_state_filter_persists(page):
"""The chosen state chip is saved locally, surviving a reload (and the frame's reboot)."""
open_app(page)
chip(page, "done").click() # pick a non-default state
expect(chip(page, "done")).to_have_attribute("aria-pressed", "true")
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(chip(page, "done")).to_have_attribute("aria-pressed", "true") # remembered, not back to todo
print(" PASS: state filter persists")
The chips: all plus one per state, in a labelled group so the filter buttons are
unambiguous from the like-named batch buttons. The active one carries aria-pressed,
which both styles it and names the active chip for a screen reader.
<div class="chips" role="group" aria-label="filter by state">
${['all', ...STATES].map(st => html`
<button class="chip" data-st=${st} disabled=${() => photos.loading}
aria-pressed=${() => stateFilter() === st ? 'true' : 'false'}
onClick=${() => setStateFilter(st)}>${st}</button>`)}
<button class="chip selall" aria-pressed=${() => allSelected() ? 'true' : 'false'}
onClick=${toggleAll}>${() => allSelected() ? 'clear' : 'select all'}</button>
<button class="chip frame-start" onClick=${enterFrame}>▶ frame</button>
</div>
.chips{ display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
.chip{ padding:4px 12px; font-size:12px; text-transform:uppercase; letter-spacing:.04em;
cursor:pointer; border-radius:999px; border:1px solid #3a3f5a;
background:#262a40; color:#9aa; }
.chip[data-st]{ border-color:var(--st,#3a3f5a); color:var(--st,#9aa); }
.chip[aria-pressed='true']{ background:var(--st,#6cf); color:#08111e; border-color:var(--st,#6cf); font-weight:700; }
.selall{ margin-left:auto; }
Selecting and editing in bulk
Triage goes faster in bulk — pick a run of docs and edit them together.
Selecting tiles and batch-editing
Triage goes far faster in bulk: click tiles to build a selection, then apply one change
to all of them at once —
add a label (free-text, merged into each photo’s labels) or set a state
(todo / next / done / delete, the photovideo workflow enum). Both ride the
auto-generated updatePhotovideo(input:{cid,patch}) mutation, one call per selected
cid; the batch runs under the mutating flag, so when it settles the wall re-reads the
current query and reflects the change.
But the wall is only a ~2000 sample — to retag the thousands a filter may match, the
toolbar’s all N matching toggle switches the same buttons to the server-side bulk
functions (photovideosSetState / AddLabel / RemoveLabel), which update every row the
current query matches in one statement. Touching the selection or the filter cancels the
toggle, so you can’t bulk-edit a stale scope by accident.
Selection is a Solid Set signal — toggling replaces the set so fine-grained
reactivity re-renders only the touched tiles’ outline/check. A sticky toolbar appears
only while something is selected. A plain click toggles one tile and remembers it as
the anchor, and a contiguous run from that anchor to a target tile (in the wall’s date
order) can be selected several ways — all routed through the same extendTo, so they
can’t drift apart.
A select all toggle sits at the end of the chips row (it has to live outside the
selection toolbar, which is hidden when nothing is selected): one tap selects the whole
shown wall, another clears it. Combined with the state filter it’s the fast path — e.g.
filter next, select all, batch done.
@testcase
def test_select_all(page):
"""The select-all toggle selects the whole shown wall, then clears it."""
open_fixtures(page)
select_all(page).click()
expect(checks(page)).to_have_count(len(FIXTURES)) # every tile shows its ✓
select_all(page).click()
expect(checks(page)).to_have_count(0) # second tap clears
print(" PASS: select all")
On desktop, shift-click the target tile and the whole run from the anchor to it is selected in one go.
@testcase
def test_range_select(page):
"""Shift-click selects the contiguous range between anchor and target."""
open_fixtures(page)
t = tiles(page)
t.nth(0).click() # anchor on the first tile
t.nth(2).click(modifiers=["Shift"]) # extend the selection to the third
expect(checks(page)).to_have_count(3) # the middle tile is roped in too
print(" PASS: range select")
Touch has no modifier key, so the toolbar’s ↔ range toggle stands in: arm it, tap the target, and the same run is roped in — the toggle disarming once it has.
@testcase
def test_range_select_touch(page):
"""The range toggle extends with plain taps — no shift key, for touch."""
open_fixtures(page)
t = tiles(page)
t.nth(0).click() # anchor + toolbar appears
rng = toolbar(page).get_by_role("button", name="range")
rng.click() # arm range mode (the touch route)
t.nth(2).click() # a plain tap now extends the run
expect(checks(page)).to_have_count(3)
expect(rng).to_have_attribute("aria-pressed", "false") # disarmed after extending
print(" PASS: range select (touch)")
Or long-press a tile to arm range mode anchored there — the touch gesture for multi-selection, no trip to the toolbar; the next tap completes the run (a double-click stays the open gesture, so the two don’t collide).
@testcase
def test_long_press_arms_range(page):
"""A long-press on a tile arms range mode (it becomes the anchor); the next tap
selects the run to it — the touch gesture for multi-selection, no trip to the toolbar."""
open_fixtures(page)
t = tiles(page)
box = t.nth(0).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.mouse.down(); page.wait_for_timeout(600); page.mouse.up() # hold past the long-press threshold
expect(dialog(page)).to_have_count(0) # no lightbox — the long-press starts a selection
expect(checks(page)).to_have_count(1) # the pressed tile, now the anchor
t.nth(2).click() # a plain tap completes the range
expect(checks(page)).to_have_count(len(FIXTURES))
print(" PASS: long-press arms range")
When the window is too dense the wall shows only a spread of the matches (the “showing a
spread” notice). That breaks the premise of all this: its tiles are an arbitrary handful, not
a run — and a contiguous-batch selection over them can’t mean what it looks like it means. So
the range gestures stand down: a shift-click ropes in only the tiles you touch, and the
↔ range toggle is disabled and says why. Single taps still pick tiles one by one.
@testcase
def test_spread_disables_multiselect(page):
"""On a spread the tiles are an arbitrary handful, not a run, so range selection
stands down: a shift-click ropes in only the two tiles touched, and the ↔ range toggle
is disabled and says why."""
open_app(page); chip(page, "all").click() # the whole archive → a genuine spread
expect(page.get_by_text(re.compile(r"showing a spread of \d+ from \d+"))).to_be_visible()
t = tiles(page)
t.nth(0).click() # select the first (toolbar appears)
t.nth(2).click(modifiers=["Shift"]) # shift-click a later tile
expect(checks(page)).to_have_count(2) # only the two touched — no run roped in
rng = toolbar(page).get_by_role("button", name="range")
expect(rng).to_be_disabled() # the range affordance stands down
title = rng.get_attribute("title")
assert title and ("spread" in title.lower() or "run" in title.lower()), \
f"the disabled range toggle should say why: {title!r}"
print(" PASS: spread disables multiselect")
Not every short wall is a spread, though. Ask for the earliest twenty and you are holding twenty in a row — fewer than match, but a run all the same, and a range across them means exactly what it looks like. What stands the gesture down is the spread, not the shortfall.
@testcase
def test_first_last_keep_range_select(page):
"""A first:/last: wall is short but contiguous, so range selection stays available."""
open_app(page); chip(page, "all").click()
search_for(page, "first:4") # the archive's earliest four
expect(tiles(page)).to_have_count(4)
expect(page.get_by_text(re.compile(r"showing the first 4 of \d+"))).to_be_visible()
t = tiles(page)
t.nth(0).click() # select the first (toolbar appears)
expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
t.nth(2).click(modifiers=["Shift"]) # a shift-click ropes in the run
expect(checks(page)).to_have_count(3)
print(" PASS: first/last keep range select")
Lifting the finger to hunt for the far tile is a wasted gesture, though — the finger is already on the wall. So the long-press need not end in a second tap: keep it down and slide, and the run follows it live, the tile under the finger becoming the run’s far end. It grows as the finger travels away from the anchor.
@testcase
def test_long_press_drag_extends_live(page):
"""Holding after the long-press and dragging the finger extends the run live to the
tile under it — the range grows mid-drag, before the finger ever lifts."""
open_fixtures(page)
t = tiles(page)
a = t.nth(0).bounding_box(); far = t.nth(2).bounding_box()
page.mouse.move(a["x"] + a["width"] / 2, a["y"] + a["height"] / 2)
page.mouse.down(); page.wait_for_timeout(600) # hold past the threshold → arms range at the anchor
expect(checks(page)).to_have_count(1) # just the anchor so far
page.mouse.move(far["x"] + far["width"] / 2, far["y"] + far["height"] / 2, steps=5)
expect(checks(page)).to_have_count(len(FIXTURES)) # the whole run roped in mid-drag…
expect(dialog(page)).to_have_count(0) # …and no lightbox — this is a selection gesture
page.mouse.up()
print(" PASS: long-press drag extends live")
And it shrinks as the finger comes back toward the anchor, never stranding a tail. A drag
can retreat as well as advance, where a tap only ever adds once — so it runs on the same
grow-or-shrink engine as the keyboard’s Shift-arrow run (extendRun, which recomputes the run
from the fixed anchor over the selection snapshotted when the drag began), not the one-shot
extendTo the tap routes share.
@testcase
def test_long_press_drag_shrinks_on_return(page):
"""Dragging back toward the anchor shrinks the run — like the keyboard's Shift-arrow,
it follows the finger both ways and never strands a tail."""
open_fixtures(page)
t = tiles(page)
a = t.nth(0).bounding_box(); mid = t.nth(1).bounding_box(); far = t.nth(2).bounding_box()
page.mouse.move(a["x"] + a["width"] / 2, a["y"] + a["height"] / 2)
page.mouse.down(); page.wait_for_timeout(600) # arm range at the anchor
page.mouse.move(far["x"] + far["width"] / 2, far["y"] + far["height"] / 2, steps=5)
expect(checks(page)).to_have_count(3) # grew to the whole run…
page.mouse.move(mid["x"] + mid["width"] / 2, mid["y"] + mid["height"] / 2, steps=5)
expect(checks(page)).to_have_count(2) # …then dragging back drops the far tile
page.mouse.up()
print(" PASS: long-press drag shrinks on return")
Under the hood, the run’s far end can’t be read from the move event’s target: once the
press fires it captures the pointer, so every later move reports the pressed tile, not the
one under the finger. So the far tile is hit-tested instead — document.elementFromPoint at
the finger, mapped to its doc through the grid’s tile order.
A pointer drag over the wall competes with two things the browser would sooner do with it.
On a desktop an <img> is draggable by default, so the browser tries to peel the thumbnail
off as a drag-and-drop ghost and the drag never reaches the run; the tiles’ images are
marked draggable“false”= to refuse it.
On a touch screen the wall is a scroll surface, so the browser reads a moving finger as a
pan and cancels the pointer out from under the run; while a press is armed the drag vetoes
that scroll and keeps the gesture. Those two defences answer to different inputs — a mouse
drag is no touch gesture at all, so it never triggers the touch-scroll the veto guards
against and slips past untouched — so proving the touch side takes a test on Chromium’s real
touch pipeline.
In practice the scroll veto is a touchmove listener that calls preventDefault while the
press is live; it has to be a non-passive listener, because a passive one — the default
the framework would attach — is forbidden from cancelling the scroll at all.
@testcase
def test_long_press_drag_touch(page):
"""A real finger (CDP touch, not a mouse) long-presses then drags, and the run
follows — the gesture stays the app's instead of scrolling the wall."""
n = 18
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdragt-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdragt-t-{i}",
"labels": "zzdragt", "state": "todo"} for i in range(n)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 700}) # narrow so the wall overflows (a drag can pan), tall so row 0 clears the edge-scroll margin
open_app(page); chip(page, "all").click(); search_for(page, "zzdragt")
expect(tiles(page)).to_have_count(n)
t = tiles(page)
a = t.nth(0).bounding_box(); end = t.nth(2).bounding_box() # first row, on screen, clear of both the toolbar and the edge margin
x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
x1, y1 = end["x"] + end["width"] / 2, end["y"] + end["height"] / 2
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(600) # hold past the long-press → arms range
for f in (0.25, 0.5, 0.75, 1.0): # drag across the row to tile 2
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
"touchPoints": [{"x": x0 + (x1 - x0) * f, "y": y0 + (y1 - y0) * f}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(3) # the contiguous run 0..2, not one lonely anchor
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: long-press drag (touch)")
A run shouldn’t halt at the last visible tile when the wall runs on below the fold. So a finger that comes within a ~48px margin of the bottom edge drags the wall up under itself — a steady ~14px a frame — the run growing onto each row that rises into view, down to the last tile; the top margin pulls the other way. That margin is read against the same toolbar-lifted floor the keyboard reveal measures, so the finger lands on a tile and not on the bar pinned across the foot.
@testcase
def test_long_press_drag_autoscrolls(page):
"""Holding the drag against the bottom edge scrolls the wall, so the run keeps growing
onto tiles that started below the fold — all the way to the last one."""
n = 30
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzdrags-{i}", "date": f"2021-03-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdrags-t-{i}",
"labels": "zzdrags", "state": "todo"} for i in range(n)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 480}) # the wall overflows well past the fold
open_app(page); chip(page, "all").click(); search_for(page, "zzdrags")
expect(tiles(page)).to_have_count(n)
t = tiles(page)
a = t.nth(0).bounding_box(); last = t.nth(n - 1).bounding_box()
x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
xl = last["x"] + last["width"] / 2 # the last tile's column (its row is below the fold)
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(600) # arm range at the anchor
for f in (0.5, 1.0): # drag to the bottom edge, in the last column, and hold
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x0 + (xl - x0) * f, "y": 470}]})
wait_until(page, lambda: checks(page).count() == n, timeout=6000, # the wall scrolls the rest under the finger
label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{n} selected")
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(n)
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: long-press drag auto-scrolls")
On a desktop the fastest way to grab a block is to sweep a rectangle around it. A mouse drag that starts on empty grid space — never on a tile, so the tap/double-click/ long-press gestures stay untouched — rubber-bands a box, and every tile it covers joins the selection. We drag from the empty cells past the last fixture back across the row and expect all of them checked.
@testcase
def test_marquee_selects(page):
"""A rubber-band drag from empty grid space selects the tiles it covers."""
open_fixtures(page)
g = grid(page).bounding_box()
y = g["y"] + 30
page.mouse.move(g["x"] + g["width"] - 12, y) # empty cells right of the row
page.mouse.down()
page.mouse.move(g["x"] + 12, y + 25, steps=10) # sweep left across every tile
page.mouse.up()
expect(checks(page)).to_have_count(len(FIXTURES))
print(" PASS: marquee selects")
Testing against prod, safely. These tests mutate rows, so they don’t touch real
photos: make_fixtures inserts a few clearly-marked rows (sentinel label zzbatchfix,
fake cids) before each batch test, and drop_fixtures deletes exactly those at the end
of the run. Searching the sentinel yields only the fixtures, so assertions are exact.
Taking the first one is where the toolbar arrives, and it must not cost you your place: if the grid slid down to make room for it, the tile under your finger would move away as you reached for the next one. So the measurement has to be made across that first pick, while the toolbar is still absent — once it is up, its arrival can no longer disturb anything.
t = tiles(page)
expect(toolbar(page)).to_be_hidden() # nothing picked yet, so the bar is not there
before = t.first.bounding_box()
t.nth(0).click() # the first pick — the bar arrives on this one
expect(toolbar(page)).to_be_visible()
after = t.first.bounding_box()
assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
print(" PASS: select doesn't shift grid")
From there the wall is a set you build up and pare back: each tile toggles, and the toolbar keeps the count so you know what you are about to act on.
t.nth(1).click() # a second one joins it
expect(checks(page)).to_have_count(2)
expect(toolbar(page).get_by_text("2 selected")).to_be_visible()
t.nth(0).click() # toggle one back off
expect(checks(page)).to_have_count(1)
print(" PASS: select toggles tiles")
One box serves both + label and - label, so the two have to agree about what they read out of it. Adding merges the typed words into every selected doc.
select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="add label").click()
expect(checks(page)).to_have_count(0) # selection clears once applied
search_for(page, "addedbybatch") # the fresh word now finds them all
expect(tiles(page)).to_have_count(n)
print(" PASS: batch add label")
- label strips the typed words from every selected doc instead. The wall is standing on the word being stripped, so when it is gone from all of them there is nothing left to show — which is the whole of the proof, and no chip has to be believed for it.
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)
print(" PASS: batch remove label")
Hands stay on the keys for a run this size, so Enter stands in for +. In practice the box
is a controlled input the toolbar tears down when the selection clears and rebuilds on the
next select-all, so the box reached for after a selection settles is a new one.
search_for(page, FIXTURE_LABEL) # back to the run itself
expect(tiles(page)).to_have_count(n)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Enter") # + via RET
expect(checks(page)).to_have_count(0) # applied → selection clears
search_for(page, "zzbatchret")
expect(tiles(page)).to_have_count(n) # every selected photo got it
print(" PASS: batch enter adds")
And Shift+Enter for −, so a word put on the wrong run comes off it without the hands
moving either.
select_all(page).click()
expect(checks(page)).to_have_count(n) # selection settled
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Shift+Enter") # - via S-RET
expect(tiles(page)).to_have_count(0) # the docs no longer carry the label → gone from the wall
print(" PASS: batch shift-enter removes")
Picking a word from the completion list rather than typing it out leaves a ; behind it,
ready for the next one — so that trailing separator is the state the box is normally in,
not an edge case. Removing has to read the box the way adding does and strip the word
anyway.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ") # as a picked suggestion leaves it
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0) # the trailing sep didn't defeat the match
print(" PASS: batch remove label trailing separator")
Setting a state applies it to every selected photo at once; once the edit settles and the wall re-reads, each tile’s badge shows the new value.
@testcase
def test_batch_set_state(page):
"""A state clicked on the selection is written to every selected photo."""
open_fixtures(page)
select_all(page).click()
toolbar(page).get_by_role("button", name="done").click()
# once the edit settles and the wall re-reads, every shown tile's badge reads done
expect(grid(page).get_by_text("done")).to_have_count(len(FIXTURES))
print(" PASS: batch set state")
Sometimes the whole filter is the target, not just the tiles you’ve picked — an all N matching button widens the scope, applying the state to every doc the search matches, selected or not.
@testcase
def test_batch_all_matching_state(page):
"""'all N matching' applies a state to the whole filter, not just the selection."""
open_fixtures(page)
tiles(page).nth(0).click() # select just one, to raise the toolbar
tb = toolbar(page)
tb.get_by_role("button", name=re.compile("all .* matching")).click() # widen the scope
tb.get_by_role("button", name="done").click()
# all three matching docs become done, though only one was selected
expect(grid(page).get_by_text("done")).to_have_count(len(FIXTURES))
print(" PASS: batch all-matching state")
That widening stays inside the active filter, though: an owner: scope spares every other
owner’s photos, so “all matching” never reaches past what you’re actually looking at.
@testcase
def test_bulk_all_matching_respects_owner(page):
"""'all N matching' stays within the owner filter, sparing other owners' photos."""
drop_fixtures()
docs = [{"cid": "https://ipfs.konubinix.eu/p/zzownb-k", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzownb-k-t", "labels": "zzownb", "state": "todo", "owner": "konubinix"},
{"cid": "https://ipfs.konubinix.eu/p/zzownb-a", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzownb-a-t", "labels": "zzownb", "state": "todo", "owner": "aylapomme"}]
for d in docs: gql(CREATE, {"p": d})
cnt = lambda owner, state: gql(
"query($o:[OwnerType!],$s:[State!]){ photovideosCount(search:\"zzownb\","
" since:\"2007-01-01\", until:\"2035-01-01\", owners:$o, states:$s) }",
{"o": [owner], "s": [state]})["data"]["photovideosCount"]
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzownb; owner:konubinix")
expect(tiles(page)).to_have_count(1)
tiles(page).nth(0).click() # raise the toolbar
tb = toolbar(page)
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="done").click()
wait_until(page, lambda: cnt("konubinix", "done") == 1) # the filtered owner flips
assert cnt("aylapomme", "todo") == 1, "bulk leaked across owners"
print(" PASS: bulk all-matching respects owner")
finally:
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
The same wide scope adds a label across the whole filter, not only a state.
@testcase
def test_batch_all_matching_label(page):
"""'all N matching' adds a label to the whole filter, not just the selection."""
open_fixtures(page)
tiles(page).nth(0).click()
tb = toolbar(page)
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill("bulkall")
tb.get_by_role("button", name="add label").click()
search_for(page, "bulkall") # all three carry it now
expect(tiles(page)).to_have_count(len(FIXTURES))
# the fresh read shows the committed label captioned on every matching tile
expect(grid(page).get_by_text("bulkall")).to_have_count(len(FIXTURES))
print(" PASS: batch all-matching label")
Labels split on ;, not on commas, so a label with a comma inside it is a single token —
removing it across the filter must drop the whole token, not the fragments around the
comma.
@testcase
def test_all_matching_removes_comma_label(page):
"""'all N matching' strips a label that itself contains a comma. Labels split on
';', so the comma is part of one token — the server must drop the whole label,
not the comma-fragments around it."""
comma_label = "zzleft, zzright" # one ;-token, with a comma inside it
open_fixtures(page) # 3 fixtures carry FIXTURE_LABEL
select_all(page).click()
tb = toolbar(page)
tb.get_by_placeholder("add a label…").fill(comma_label)
tb.get_by_role("button", name="add label").click() # selection path ;-joins it on
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(len(FIXTURES)) # sanity: the comma-label took
select_all(page).click() # raise the toolbar again
# the add clears the box only after its awaited mutation resolves — a late clear that, if
# it lands after the refill below, would blank it and make the remove a no-op. Waiting for
# the empty box here proves that clear has already fired, so the refill sticks.
box = tb.get_by_placeholder("add a label…")
wait_until(page, lambda: box.input_value() == "",
label="batch label box clears after the selection add",
detail=lambda: f"box={box.input_value()!r} alert={page.get_by_role('alert').count()} tiles={tiles(page).count()}")
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill(comma_label)
tb.get_by_role("button", name="remove label").click() # bulk path → server remove_label
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(0) # the whole comma-label is gone
print(" PASS: all-matching removes comma label")
Past triage you often just want the files, and there are two reasons to want them: a
web copy (the downscaled web_cid) to hand to someone now, and the orig (the doc’s
own /ipfs/ cid) to rework. The toolbar’s ↓ group saves the explicit selection — one
file per doc — at whichever of the two you ask for, and hands over no other rendition.
tiles(page).nth(photo).click() # the one worth sending
expect(checks(page)).to_have_count(1)
tb, got = toolbar(page), {}
for res in ["web", "orig"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
got[res] = (di.value.url, di.value.suggested_filename)
assert docs[photo]["webCid"] in got["web"][0], f"web → webCid: {got}"
assert docs[photo]["cid"] in got["orig"][0], f"orig → the doc's own cid: {got}"
print(" PASS: download selection")
A doc missing that rendition is passed over rather than served its original in the
rendition’s place: a file named …-web.jpg holding several megabytes of full-resolution
original is worse than no file, because nothing about it admits what it is. So the photo
that was never downscaled joins the first in the selection, and asking the pair for web
has to bring down one file, not two.
tiles(page).nth(never_downscaled).click()
expect(checks(page)).to_have_count(2)
saved = [] # armed here: only this click's files count
page.on("download", lambda d: saved.append(d.url))
toolbar(page).get_by_role("button", name="web", exact=True).click()
wait_until(page, lambda: len(saved) >= 1, label="the doc that has a web copy saves it")
page.wait_for_timeout(500) # room for a second, unwanted save to land
assert len(saved) == 1, f"expected the one web copy, got {len(saved)}: {saved}"
assert docs[photo]["webCid"] in saved[0], f"the wrong rendition came down: {saved[0]}"
print(" PASS: download skips a missing rendition")
Taking both forms of one photo — the copy to send and the original to rework — would put
two files of the same name in the download folder, and the second would silently replace
the first. Each saved file therefore carries its rendition in the name,
holiday-orig.jpg beside holiday-web.jpg. In practice nothing needs downloading again
to see this: both forms of the first photo are already in hand, and it is their names
that are read.
names = {res: name for res, (_, name) in got.items()} # the two the photo just yielded
assert len({*names.values()}) == len(names), f"same name → they collide in the folder: {names}"
for res in names:
assert res in names[res], f"rendition missing from the name: {names}"
print(" PASS: download names by resolution")
A cid carries no extension and a missing object answers text/plain, so a name from
either is unopenable. The right extension depends on the rendition: a video’s web
copy is an MP4 (an image’s stays a JPEG), while the original keeps its true type from
the DB mimetype. So a .mov original saves …-orig.mov and its web copy …-web.mp4.
The photos are put back down and the clip taken up on its own, since it is the one doc
whose two forms disagree about their type.
tiles(page).nth(photo).click()
tiles(page).nth(never_downscaled).click()
tiles(page).nth(video).click()
expect(checks(page)).to_have_count(1)
tb, ext = toolbar(page), {}
for res in ["orig", "web"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
ext[res] = di.value.suggested_filename.rsplit(".", 1)[-1]
assert ext["orig"] == "mov", f"original keeps its real type: {ext}"
assert ext["web"] == "mp4", f"a video's web copy is mp4: {ext}"
print(" PASS: download extensions by rendition")
Selecting tiles and editing them in bulk is a cluster of small machines: the picked set and the gestures that grow it, the two ways an edit reaches the docs, and the download. We lay them out one at a time; the tangle reassembles them into the component.
At the heart is the picked set — a set of cids that persists, so an accidental refresh mid-triage doesn’t drop the run — beside the flags the gestures read: whether a range is armed for the next tap, and whether an action should reach the whole matching filter rather than only the shown sample. A range only makes sense over a contiguous run, so it stands down whenever the wall is a spread.
const STATES = ['todo', 'next', 'done', 'delete'];
const SEL_KEY = 'memories.selected';
const [selected, setSelected] = createSignal(new Set(JSON.parse(localStorage.getItem(SEL_KEY) || '[]')));
createEffect(() => localStorage.setItem(SEL_KEY, JSON.stringify([...selected()])));
const [labelText, setLabelText] = createSignal('');
const [labelFocus, setLabelFocus] = createSignal(false);
const [anchor, setAnchor] = createSignal(null);
const [rangeMode, setRangeMode] = createSignal(false);
const [allMatching, setAllMatching] = createSignal(false);
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };
Select-all lives outside the toolbar — which only appears once something is picked — so it toggles the whole shown wall on or off straight from the empty state.
const shownCids = () => items().map(p => p.cid);
const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));
A range can grow two ways. extendTo takes the whole contiguous run from the anchor to a tile
in one shot — a shift-click, or an armed tap. extendRun is the continuous version the keyboard
Shift-arrow and the held-finger drag share: because it can grow or shrink as the far end moves,
it snapshots the selection once at the run’s start and recomputes from the fixed anchor each step.
const extendTo = cid => {
const list = items().map(p => p.cid);
const a = list.indexOf(anchor()), b = list.indexOf(cid);
if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
const [lo, hi] = a < b ? [a, b] : [b, a];
setSelected(s => { const n = new Set(s);
for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
};
const extendRun = cid => {
if(!canRange()) return;
const list = items();
const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
if(a < 0 || ni < 0) return;
if(!extending){ extending = true; rangeBase = new Set(selected()); }
const [lo, hi] = a < ni ? [a, ni] : [ni, a];
setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
};
A plain click toggles one tile, re-anchors there, and plants the keyboard cursor so the arrows carry on from where you clicked. It becomes a range extension only when a modifier is present — shift-click on the desktop, or the toolbar’s armed range-mode then a tap on touch — and either way the tap clears range mode. Arming a range from a tile makes it the anchor and readies a fresh run for the first drag-move to snapshot.
const onTileClick = (e, cid) => {
setCursor(cid);
if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
extendTo(cid); setRangeMode(false); return;
}
toggle(cid); setAnchor(cid);
};
const armRange = cid => {
if(!canRange()) return;
setSelected(s => { const n = new Set(s); n.add(cid); return n; });
setAnchor(cid); setRangeMode(true); extending = false;
};
The held-finger drag has to know which tile the finger is over. tileCidAt hit-tests a screen
point down to a tile and maps it through the grid’s own child order to a cid.
const tileCidAt = (x, y) => {
const tile = document.elementFromPoint(x, y)?.closest('.tile');
if(!tile || !gridEl) return null;
const i = [...gridEl.children].indexOf(tile);
return i < 0 ? null : items()[i]?.cid;
};
A long-press arms a range and then drives it live while the finger stays down. Its state is a bundle: the press timer, whether it has fired, the press origin, the captured pointer, whether the drag has moved, the last finger position, and the edge-scroll frame. Each move re-extends the run to the tile under the finger. And when the finger nears a top or bottom margin, an animation loop scrolls the wall a step (~14px) per frame and keeps extending, so a run reaches past the fold — its floor lifted clear of the pinned toolbar.
let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false, lpPos = null, lpRaf = 0;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
const EDGE = 48, SCROLL_STEP = 14;
const dragTick = () => {
lpRaf = 0;
if(!lpFired || !lpPos) return;
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
if(!dy) return; // finger left the margin → stop the loop
scrollBy(0, dy);
dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1))); // hit-test clear of the bar
lpRaf = requestAnimationFrame(dragTick);
};
The press starts a 500ms timer; survive it without moving and it fires — capturing the pointer so the held drag’s moves route here, and arming the range. A move before it fires (past ~10px) abandons it; a move after drives the drag, entering the edge-scroll when the finger sits in a margin. Releasing a moved drag lands the run and disarms range mode, while a press that never moved leaves the range armed for a following tap. The press swallows its own trailing click, and while a drag is armed the wall’s scroll is blocked so the gesture and the scroll don’t fight.
const onTileDown = (e, photo) => {
lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
lpTimer = setTimeout(() => { lpFired = true; lpCancel();
try { lpEl.setPointerCapture(lpId); } catch(_){}
armRange(photo.cid); }, 500);
};
const onTileMove = e => {
if(lpFired){
lpPos = { x: e.clientX, y: e.clientY };
dragExtendAt(e.clientX, e.clientY);
if(!lpRaf) dragTick();
return;
}
if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
};
const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; } dragStop(); lpCancel(); };
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
onMount(() => gridEl?.addEventListener('touchmove',
e => { if(lpFired) e.preventDefault(); }, { passive: false }));
Now the two ways an edit reaches the docs. patchSelected is the per-doc path: it maps the shown
photos by cid (so a label-merge can read each row’s existing labels), fires one updatePhotovideo
per selected cid — skipping any the patch-maker declines, so a caller can leave some docs
untouched (as moving onto an occasion leaves other owners’) — then clears the selection and
re-reads the wall. Labels are a ;-delimited list, so one split parses both a doc’s stored labels
and what’s typed into a box, and a; b adds two in one go.
async function patchSelected(patchFor){
setMutating(m => m + 1);
try {
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const patch = patchFor(byCid.get(cid));
if(patch) await gql(UPDATE_PHOTO, { cid, patch });
}
clearSel();
await refetch();
} finally { setMutating(m => m - 1); }
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);
applyBulk is the all-matching counterpart: it edits every doc the filter matches
server-side — beyond the ~2000-row sample — through the bulk functions, over the same query the
wall is showing. Those return only a count of the rows touched, so where the per-cid path hands
back each changed row for the cache to act on, the bulk path tags the Photovideo type instead.
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
async function applyBulk(kind, v){
setMutating(m => m + 1);
try { await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); clearSel(); await refetch(); }
finally { setMutating(m => m - 1); }
}
The three edits pick their path by the all matching flag: add-label, remove-label and set-state
each route to the bulk function when the whole filter is the target, else to patchSelected over
the ticked set. Add-label also remembers the last word applied, for one-tap reuse on the next doc.
const addLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
setLastLabel(words[words.length - 1]);
if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
else await patchSelected(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
setLabelText('');
};
const removeLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
: patchSelected(() => ({ state: st }));
Finally the download: save the picked docs as files, at the resolution asked for. Like the move edit it acts on the explicit ticked selection (there is no client-side list of the whole filter to fetch), and the browser asks once before saving several.
const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const p = byCid.get(cid); if(!p) continue;
const path = res === 'web' ? p.webCid : p.cid;
if(!path) continue;
const isVid = (p.mimetype || '').startsWith('video');
const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
const raw = p.filename || p.cid.split('/').pop() || 'download';
const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
const a = document.createElement('a');
a.href = IPFS + path;
a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
document.body.appendChild(a); a.click(); a.remove();
}
}
The rubber-band feeds that same selection set. A drag counts as a sweep only when its
pointerdown lands on the grid container itself — an empty cell — so a press that begins
on a tile still belongs to that tile’s tap, double-click or long-press; touch is left out
entirely (it has the long-press range gesture already). While the button is held, each
move grows the box and re-selects every tile it intersects, tested in client coordinates
against each tile’s rectangle. The sweep is additive — it extends the current selection
rather than replacing it, matching how clicks accrue.
const [marquee, setMarquee] = createSignal(null); // {x0,y0,x1,y1} in client coords, or null
let marqueeFrom = null;
const marqueeRect = m => ({ l: Math.min(m.x0, m.x1), r: Math.max(m.x0, m.x1),
t: Math.min(m.y0, m.y1), b: Math.max(m.y0, m.y1) });
const marqueeSelect = () => {
const m = marquee(); if(!m) return;
const r = marqueeRect(m), list = items(), kids = gridEl.children, next = new Set(selected());
for(let i = 0; i < kids.length && i < list.length; i++){
const b = kids[i].getBoundingClientRect();
if(b.left < r.r && b.right > r.l && b.top < r.b && b.bottom > r.t) next.add(list[i].cid);
}
setAllMatching(false); setSelected(next);
};
const onGridDown = e => {
if(e.pointerType === 'touch' || e.button !== 0 || e.target !== gridEl || !canRange()) return;
marqueeFrom = { x: e.clientX, y: e.clientY };
setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
gridEl.setPointerCapture?.(e.pointerId);
};
const onGridMove = e => {
if(!marqueeFrom) return;
setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
marqueeSelect();
};
const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };
The toolbar: only mounted while a selection exists, and clustered by purpose so the row
of controls reads as groups rather than a wall of buttons — scope (the count, the all
matching toggle, the ↔ range toggle), label (the input with +/- — Enter in the
input is +, Shift+Enter is -), move (an event box with a → event button, for
re-anchoring a stray onto its occasion), state (one button per state),
download (↓ then orig=/=web), then clear — with thin dividers between the
groups.
The label input is a combobox on the same terms as the search box: an aria-expanded that
tracks its popover, and a close that is a state flip rather than a timed fade — so a caller
waits on the state, never a clock.
@testcase
def test_batch_label_combobox_state(page):
"""The selection's add-label box is a combobox too: aria-expanded tracks its list, and
it closes on a state flip, no timing."""
open_fixtures(page)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("cos")
expect(box).to_have_attribute("aria-expanded", "true") # listing completions → true
box.blur()
expect(box).to_have_attribute("aria-expanded", "false") # closed → false
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: batch label combobox state")
<${Show} when=${() => selCount() > 0}>
<div class="toolbar" role="toolbar" aria-label="selection actions">
<!-- scope: what the actions apply to -->
<span class="count">${() => allMatching() ? `all ${total()} matching` : `${selCount()} selected`}</span>
<button class="allmatch" aria-pressed=${() => allMatching() ? 'true' : 'false'}
onClick=${() => setAllMatching(m => !m)}>all ${() => total()} matching</button>
<button class="range" aria-pressed=${() => rangeMode() ? 'true' : 'false'}
disabled=${() => !canRange()}
title=${() => canRange() ? undefined : 'a spread is not a run — range select is off here'}
onClick=${() => setRangeMode(m => !m)}>↔ range</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- label edit -->
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="label for the selection"
aria-expanded=${() => labelFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
onFocus=${() => setLabelFocus(true)}
onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
onBlur=${() => setLabelFocus(false)} />
<${Show} when=${() => labelFocus()}>
<${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading}
onPick=${w => setLabelText(replaceSeg(labelText(), w) + '; ')} />
<//>
</div>
<button aria-label="add label" onClick=${addLabel}>+ label</button>
<button aria-label="remove label" onClick=${removeLabel}>- label</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- move the selection onto an occasion -->
<div class="complete">
<input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
value=${() => moveText()}
onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
onFocus=${() => setMoveFocus(true)}
onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- stamp the selection with one instant -->
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</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, two people can each keep a « Piscine ». Applying it touches only a doc whose owner holds the picked occasion, leaving anyone else’s where they are.
@testcase
def test_move_is_owner_scoped(page):
"""Moving onto an occasion touches only docs whose owner holds it; another owner's is left."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-ownmove", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzOwnMove", "owner": "konubinix", "status": "confirmed"} # konubinix's occasion
K = {"cid": "https://ipfs.konubinix.eu/p/zzomv-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzomv-k-t", "owner": "konubinix"}
A = {"cid": "https://ipfs.konubinix.eu/p/zzomv-a", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzomv-a-t", "owner": "aylapomme"}
for d in (K, A): d.update({"mimetype": "image/jpeg", "labels": "zzownmove", "state": "todo"})
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in (K, A): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzownmove")
expect(tiles(page)).to_have_count(2)
select_all(page).click()
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzOwnMove", delay=20)
tb.get_by_role("option", name=re.compile("zzOwnMove")).click()
tb.get_by_role("button", name="move to event").click()
expect(checks(page)).to_have_count(0)
# konubinix's photo took the occasion's day; aylapomme's kept its stray date
expect(grid(page).get_by_role("img", name="2020-03-07")).to_have_count(1)
expect(grid(page).get_by_role("img", name="2024-11-02")).to_have_count(1)
finally:
for d in (K, A): gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move is owner-scoped")
And the list offers only the selection’s own owners’ occasions, so a namesake in someone else’s calendar — which could never apply — is never even shown, and a pick can’t land on a silent no-op.
@testcase
def test_move_offers_only_selection_owner_events(page):
"""The move box lists only occasions of the selection's own owners — a namesake owner's is not."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
evs = [{"rowId": "zzev-scmk", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzScopeKonu", "owner": "konubinix", "status": "confirmed"},
{"rowId": "zzev-scma", "starttime": "2020-03-08T09:00:00Z", "endtime": "2020-03-08T18:00:00Z",
"summary": "zzScopeAyla", "owner": "aylapomme", "status": "confirmed"}]
doc = {"cid": "https://ipfs.konubinix.eu/p/zzscm-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzscm-k-t",
"mimetype": "image/jpeg", "labels": "zzscopemove", "owner": "konubinix", "state": "todo"}
for e in evs: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzscopemove")
expect(tiles(page)).to_have_count(1)
select_all(page).click() # the one konubinix doc
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzScope", delay=20)
expect(tb.get_by_role("option", name=re.compile("zzScopeKonu"))).to_be_visible() # own owner's — offered
expect(tb.get_by_role("option", name=re.compile("zzScopeAyla"))).to_have_count(0) # another owner's — not
finally:
gql(DELETE, {"cid": doc["cid"]})
for e in evs: gql(CAL_DEL, {"id": e["rowId"]})
print(" PASS: move offers only selection-owner events")
const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
Now the list itself. As you type, the box narrows the calendar to the matching occasions: those whose summary contains the text, kept to the selection’s own owners, and ordered by the closeness above — the eight nearest shown, so a long calendar never floods the box.
const moveCandidates = () => { const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
return (moveEvents() || []).filter(e => owners.has(e.owner))
.filter(e => !q || (e.summary || '').toLowerCase().includes(q))
.sort((a, b) => eventDist(a, mean) - eventDist(b, mean) || new Date(a.starttime) - new Date(b.starttime))
.slice(0, 8); };
Picking one arms that exact occasion and closes the list; applying then gives every selected doc the occasion’s own start — its first day — through the same per-doc patch a batch label uses, so the wall re-anchors and the moved photos slide into the band.
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
setMoveText(''); setMoveTarget(null); };
In the toolbar the box is a combobox like the others, and its keyboard is theirs too: the one
shared completion handler arrows through the rows and Enter picks the highlighted occasion,
exactly as in the search and label boxes; with nothing highlighted Enter applies the armed
pick instead, the same as the → event button beside it. For that shared handler to see them,
the box hands its candidates up to the single keyboard-highlight the whole app keeps.
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
Each row names the occasion and, beside it, when it ran (the same when aside the lightbox
reads off a pill), so a recurring occasion is told apart by its date; the highlighted row is
marked as the shared index moves over it.
<div class="complete">
<input class="batch-move" role="combobox" placeholder="move to event…" aria-label="move the selection to an event"
aria-expanded=${() => moveFocus() && moveCandidates().length > 0 ? 'true' : 'false'}
value=${() => moveText()}
onInput=${e => { setMoveText(e.target.value); setMoveTarget(null); setMoveFocus(true); }}
onFocus=${() => setMoveFocus(true)}
onKeyDown=${e => sugNav(e, picked => picked ? pickMove(picked) : moveToEvent())}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
A move fires only from an explicit pick. The → event button stays disabled until a pick
arms it — so an unpicked box, however fully typed, can never fire it — and editing the name
after picking disables it again, waiting for a fresh pick.
@testcase
def test_move_button_armed_only_by_a_pick(page):
"""The → event button is disabled until a pick arms it; typed text never enables it, and editing disables it again."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-arm", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzArmEvent", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzarm-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzarm-{i}-t",
"mimetype": "image/jpeg", "labels": "zzarm", "owner": "konubinix", "state": "todo"} for i in range(3)]
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzarm")
expect(tiles(page)).to_have_count(3)
select_all(page).click()
expect(checks(page)).to_have_count(3)
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
move_btn = tb.get_by_role("button", name="move to event")
expect(move_btn).to_be_disabled() # nothing picked yet
box.click(); box.press_sequentially("zzArm", delay=20)
expect(move_btn).to_be_disabled() # typed text is not a pick
tb.get_by_role("option", name=re.compile("zzArmEvent")).click()
expect(move_btn).to_be_enabled() # a pick arms it
box.press("x") # edit the name → the pick drops
expect(move_btn).to_be_disabled() # disabled again, until a fresh pick
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move button armed only by a pick")
A pick need not be a click. Arrowing down to an occasion and pressing Enter is a pick just the
same — the keyboard route the box shares with every other completion box — so it arms the
→ event button no differently.
@testcase
def test_move_box_keyboard_picks(page):
"""Arrow keys highlight a move-box occasion and Enter picks it, arming the move — no mouse."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-kbd", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzKbdEvent", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzkbd-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzkbd-{i}-t",
"mimetype": "image/jpeg", "labels": "zzkbd", "owner": "konubinix", "state": "todo"} for i in range(3)]
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzkbd")
expect(tiles(page)).to_have_count(3)
select_all(page).click()
expect(checks(page)).to_have_count(3)
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
move_btn = tb.get_by_role("button", name="move to event")
box.click(); box.press_sequentially("zzKbd", delay=20)
opt = tb.get_by_role("option", name=re.compile("zzKbdEvent"))
expect(opt).to_be_visible() # the list is open
expect(move_btn).to_be_disabled() # nothing picked yet
box.press("ArrowDown") # highlight the first candidate
expect(opt).to_have_attribute("aria-selected", "true") # the arrow moved the shared highlight onto it
box.press("Enter") # Enter picks the highlighted one → arms
expect(move_btn).to_be_enabled()
expect(box).to_have_value("zzKbdEvent") # the pick filled the box
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move box keyboard picks")
A pick is also bound to the selection it was made against: touching the selection drops the armed pick — the box clears, ready for a fresh pick — so a stale pick can never move a set you have since changed.
@testcase
def test_move_target_resets_on_selection_change(page):
"""Changing the selection drops an armed pick — the box clears, so a stale pick can't move a new set."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-resetmove", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzResetMove", "owner": "konubinix", "status": "confirmed"}
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzrst-{i}", "date": "2024-11-02T12:00:00Z", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzrst-{i}-t",
"mimetype": "image/jpeg", "labels": "zzresetmv", "owner": "konubinix", "state": "todo"} for i in range(3)]
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzresetmv")
expect(tiles(page)).to_have_count(3)
select_all(page).click()
expect(checks(page)).to_have_count(3) # all three selected
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzReset", delay=20) # a prefix, so a pick visibly completes it
tb.get_by_role("option", name=re.compile("zzResetMove")).click()
expect(box).to_have_value("zzResetMove") # the pick registered — the occasion is armed
tiles(page).nth(0).click() # drop one tile → the selection changes
expect(box).to_have_value("") # the armed pick is dropped, the box cleared
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move target resets on selection change")
createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
The occasion you move onto usually has no photo of its own yet — assigning photos to it is
the whole point — and a plain label search carries no date window for the wall to hang events
on. Both are why the box reads the calendar itself rather than the wall’s window of events: even
a photoless occasion, on a windowless search, is there to pick. (The search box’s event:
completion, which offers only occasions that already hold a photo, would hide exactly this one.)
@testcase
def test_move_offers_photoless_event(page):
"""The move box offers an occasion with no photos yet, on a windowless search — the point is filling it."""
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
ev = {"rowId": "zzev-empty", "starttime": "2020-03-07T09:00:00Z", "endtime": "2020-03-07T18:00:00Z",
"summary": "zzEmptyOcc", "owner": "konubinix", "status": "confirmed"} # no photo falls in its span
doc = {"cid": "https://ipfs.konubinix.eu/p/zzem-k", "date": "2024-11-02T12:00:00Z", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzem-k-t",
"mimetype": "image/jpeg", "labels": "zzemptymove", "owner": "konubinix", "state": "todo"}
gql(CAL_DEL, {"id": ev["rowId"]}); gql(CAL_ADD, {"e": ev})
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
open_app(page); chip(page, "all").click()
search_for(page, "zzemptymove") # a bare label — no since:/until: window
expect(tiles(page)).to_have_count(1)
select_all(page).click()
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
box.click(); box.press_sequentially("zzEmptyOcc", delay=20)
expect(tb.get_by_role("option", name=re.compile("zzEmptyOcc"))).to_be_visible() # offered though photoless
finally:
gql(DELETE, {"cid": doc["cid"]})
gql(CAL_DEL, {"id": ev["rowId"]})
print(" PASS: move offers photoless event")
The box mirrors the batch label box’s styling; the row list reuses the shared completion look
(.suggest / .sug) and the pill’s muted when aside, and the → event button dims while
no pick is armed.
.batch-move{ width:100%; box-sizing:border-box; padding:5px 8px; font-size:14px;
background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:5px; }
.movebtn:disabled{ opacity:.45; cursor:default; }
Stamping a selection with one time
Photos that arrive re-shared — a batch off WhatsApp — carry a junk timestamp: the moment they
were forwarded, not the moment they were shot. So a pile of them scatters across the wall, each
landing on a day that never happened. There is no true instant to recover, so the useful move is
to pin the whole pile to one plausible moment — a given day, one hour — and let them sit
together. The lightbox already re-dates one doc; this does the same to every ticked doc at once,
through the same per-doc patchSelected. The picker seeds from the selection’s mean date, so
you begin in the middle of what you’re fixing and nudge to the day you mean.
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
await patchSelected(() => ({ date: new Date(v).toISOString() }));
setDatingSel(false); };
The control cannot simply sit in the toolbar. The bar overlays the wall’s foot, and a
double-tap opens a doc — its first tap selects, which is what mounts the bar. A permanently-shown
datetime-local is wide enough to wrap the bar onto a second row on a phone, and that taller
overlay swallows the double-tap’s second tap before it reaches the tile. So the picker hides
behind one compact button and opens in a popover above the bar — out of the bar’s own flow —
only when asked; closed, the bar keeps the single height the double-tap depends on.
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</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 and arrowing back toward the anchor shrinks
it again — a folder’s Shift-selection, built without the mouse. Whatever was selected
before the Shift-run is kept underneath.
page.keyboard.press("ArrowRight") # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(0) # and selects nothing
page.keyboard.press("Shift+ArrowRight") # reach on…
page.keyboard.press("Shift+ArrowRight") # …and on again
expect(checks(page)).to_have_count(3) # the whole run from the anchor
print(" PASS: grid cursor shift extends selection")
And arrowing back toward the anchor shrinks the run — the selection follows the cursor in both directions, never leaving a stranded tail.
page.keyboard.press("Shift+ArrowLeft") # back one → the far end drops off
expect(checks(page)).to_have_count(2)
page.keyboard.press("Shift+ArrowLeft") # back onto the anchor → only it remains
expect(checks(page)).to_have_count(1)
toolbar(page).get_by_role("button", name="clear").click()
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor shift reduces on return")
Down and up move by a row, not a tile, so the cursor travels the grid in two dimensions — and because the wall is responsive, a row is however many columns it is showing at that size.
tiles(page).nth(0).click() # plant the cursor at the top-left
toolbar(page).get_by_role("button", name="clear").click()
cols = grid(page).evaluate("el => getComputedStyle(el).gridTemplateColumns.split(' ').length")
page.keyboard.press("ArrowDown") # → one row down, i.e. index `cols`
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(cols))
page.keyboard.press("Escape")
print(" PASS: grid cursor down steps a row")
And Ctrl+A (⌘A on a Mac) grabs the whole shown wall at once — the keyboard twin of
the select-all toggle — so a reviewer can scoop up everything for a bulk edit without
leaving the home row.
page.keyboard.press("Control+a")
expect(checks(page)).to_have_count(KEY_N) # the whole wall at once
print(" PASS: ctrl+a selects whole wall")
The cursor is the keyboard’s focus on the wall: the arrows walk it a tile across or a
whole row up and down, clamped at the ends — a row being however many columns the
responsive grid is showing at the time. Each move brings the focused tile fully into
view, so the eye follows the cursor past the fold. A plain move drops the
anchor where it lands and ends any Shift-run; the first Shift move snapshots the current
selection, and every Shift move then sets the selection to that snapshot plus the run from
the anchor to the cursor — recomputed each step from the fixed anchor, so reversing direction
shrinks the run rather than stranding it.
const CURSOR_KEY = 'memories.cursor';
const [cursor, setCursor] = createSignal(localStorage.getItem(CURSOR_KEY));
createEffect(() => { const c = cursor(); c ? localStorage.setItem(CURSOR_KEY, c) : localStorage.removeItem(CURSOR_KEY); });
let gridEl, rangeBase = new Set(), extending = false;
const gridCols = () => gridEl ? getComputedStyle(gridEl).gridTemplateColumns.split(' ').length : 1;
const moveCursor = (delta, extend) => {
const list = items(); if(!list.length) return;
const at = list.findIndex(p => p.cid === cursor());
const ni = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + delta));
setCursor(list[ni].cid);
if(extend && anchor() !== null){ extendRun(list[ni].cid); } // grow-or-shrink the run to the cursor
else { extending = false; setAnchor(list[ni].cid); } // a plain move re-anchors and ends the run
requestAnimationFrame(scrollCursorIntoView);
};
Bringing it fully into view is more than nudging it past the edge, because the selection
toolbar is pinned over the foot of the wall and floats above it. That pin causes two
separate troubles, and a Shift-run driving the cursor downward — toolbar up, cursor
heading for the bottom — walks straight into both. A run dragged to the foot of a tall wall
must still leave its last tile whole, and above the bar.
tiles(page).nth(0).click() # start the run at the top-left
for _ in range(KEY_N): page.keyboard.press("Shift+ArrowDown") # drag it down past the fold
last = tiles(page).nth(KEY_N - 1).bounding_box()
bar = toolbar(page).bounding_box() # the run raised the toolbar
assert last["y"] >= 0, f"tile head scrolled above the fold: {last}"
assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
toolbar(page).get_by_role("button", name="clear").click()
print(" PASS: grid cursor reveals tile above toolbar")
The first trouble is position: a tile scrolled flush to the bottom sits half-hidden behind the bar. So the reveal measures the focused tile against a viewport whose bottom is lifted by the toolbar’s own height — zero until a selection summons it — and scrolls only as far as it takes to clear whichever edge the tile overruns.
Which cuts both ways, and the way back up is the one easily forgotten: a wall walked to its foot has to be walkable back to its head, and a cursor climbing out of view is as lost as one that never came into it.
for _ in range(KEY_N): page.keyboard.press("ArrowUp") # climb back out, plain moves
first = tiles(page).nth(0).bounding_box()
assert first["y"] >= 0, f"the cursor climbed above the fold and stayed there: {first}"
print(" PASS: grid cursor reveals a tile climbed back to")
const scrollCursorIntoView = () => {
const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
const r = tile.getBoundingClientRect();
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
if(r.top < 0) scrollBy(0, r.top); // head above the fold → pull it down
else if(r.bottom > floor) scrollBy(0, r.bottom - floor); // foot under the bar → push it up
};
The second is room: the page ends where the last row does, so the bottom tiles have nowhere to scroll to — no nudge lifts them past a bar the document is too short to outrun. So while a selection holds the toolbar up, we reserve a strip beneath the wall as tall as the bar, giving those rows somewhere to rise into. The strip is measured a frame after the toolbar mounts, since its wrapped height isn’t known until it has laid out.
createEffect(() => {
const shown = selCount() > 0; // tracked synchronously; the toolbar mounts on this turn
requestAnimationFrame(() => {
const bar = shown && document.querySelector('.toolbar');
document.body.style.paddingBottom = bar ? bar.getBoundingClientRect().height + 'px' : '';
});
});
The keys live on a window listener that stands aside whenever another surface owns them —
the lightbox or the frame while either is up, or any text field while it has focus — so the
cursor only ever drives the bare wall. Space toggles the focused tile’s selection and
Enter opens it in the lightbox; the Shift flag is what tells an arrow to reach rather
than step; and Ctrl=/=⌘+A selects the whole shown wall.
onMount(() => {
const onKey = e => {
if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
if(e.key === 'ArrowRight') step(1);
else if(e.key === 'ArrowLeft') step(-1);
else if(e.key === 'ArrowDown') step(gridCols());
else if(e.key === 'ArrowUp') step(-gridCols());
else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
else if(e.key === 'Enter' && cursor()){ e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){ e.preventDefault(); setSelected(new Set(shownCids())); }
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
The focus ring is amber, set apart from selection’s blue outline so a tile that is both focused and selected shows both at once.
.tile[data-cursor='1']{ box-shadow:0 0 0 3px #f9a826, 0 0 10px #f9a826aa; }
A triaging session can run long, building up a selection and a cursor position that would be
tedious to rebuild — and a refresh is easy to hit by accident. So the cursor and the run
you’ve picked persist: each is written to localStorage as it changes (the same way the
search box and the state chip already do) and read back on load, so an accidental reload
comes back to the tile you were on with the selection intact.
@testcase
def test_cursor_and_selection_survive_reload(page):
"""The picked run and the keyboard cursor outlast a reload, like the search does."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
tiles(page).nth(0).click() # pick tile 0 — the click plants the cursor there
page.keyboard.press("Shift+ArrowRight") # extend the run to tile 1; the cursor lands on it
expect(checks(page)).to_have_count(2)
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(tiles(page)).to_have_count(3) # the saved search brings the same wall back
expect(checks(page)).to_have_count(2) # the picked run survived
page.keyboard.press("Enter") # the cursor survived on tile 1 → opens it
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: cursor and selection survive reload")
The cursor follows what you’re viewing
The wall’s keyboard cursor marks where you are. But dive into a doc in the lightbox — or step into the frame — and move through the wall in place, and the cursor is left behind on the tile you started from: come back and the arrows resume there, not on the doc you actually stopped on. So the cursor rides along, tracking whatever doc you are looking at, and leaving either surface lands you back on it.
@testcase
def test_lightbox_nav_follows_cursor(page):
"""Stepping through the lightbox carries the wall cursor along: close it and the
cursor rests on the doc you stopped on, not the one you opened."""
open_fixtures(page) # 3 fixtures, thumbs -0/-1/-2 by date
open_doc(page, 0) # double-click opens the first
d = dialog(page)
d.get_by_role("button", name="next photo").click() # step to the second doc
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.keyboard.press("Escape")
expect(d).to_be_hidden()
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: lightbox nav follows cursor")
The frame is the same story: run the slideshow on a bit, leave it, and the cursor is on the slide you stopped on — ready for the arrows to carry on.
@testcase
def test_frame_nav_follows_cursor(page):
"""Moving through the frame carries the wall cursor: exit and the cursor rests on
the slide you stopped on."""
strip = enter_frame(page, 999999) # 3 fixtures, frame opens on thumb-0 (date order)
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.press("ArrowRight") # advance to the second slide
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
wait_until(page, lambda: page.evaluate( # the slide has settled (its cid committed) — as when you pause on it
"() => localStorage.getItem('memories.frame.cid')") == "https://ipfs.konubinix.eu/p/zzbatchfix-1")
page.keyboard.press("Escape") # leave the frame → back to the wall
expect(strip).to_be_hidden()
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
print(" PASS: frame nav follows cursor")
A cursor riding off-screen would be no help — you’d leave the surface and land on a wall scrolled to where you started, hunting for the tile you actually stopped on. So the wall scrolls to keep that cursor tile in view: navigate deep into a spread from the lightbox, and leaving it lands the wall on your tile, not at the top.
IN_VIEW = "el => { const r = el.getBoundingClientRect(); return r.top < window.innerHeight && r.bottom > 0; }"
@testcase
def test_wall_scrolls_to_lightbox_cursor(page):
"""Stepping the lightbox scrolls the wall to the cursor; leaving lands on that tile, in view."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}", "date": f"2020-02-{i + 1:02d}T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}-t", "webCid": f"https://ipfs.konubinix.eu/p/zzlbscroll-{i:02d}-web",
"labels": "zzlbscroll", "state": "todo"} for i in range(18)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 480}) # narrow + short: the 18-tile wall overflows
open_app(page); chip(page, "all").click()
search_for(page, "zzlbscroll")
expect(tiles(page)).to_have_count(18)
assert not tiles(page).nth(17).evaluate(IN_VIEW), "setup: the last tile must start below the fold"
open_doc(page, 0) # open the first (wall at top)
expect(dialog(page)).to_be_visible() # wait for the lightbox before driving it
page.keyboard.press("ArrowLeft") # wrap to the last doc, far down the wall
page.keyboard.press("Escape")
expect(dialog(page)).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(17).evaluate(IN_VIEW),
label="the wall scrolled the cursor tile into view")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: wall scrolls to lightbox cursor")
The frame scrolls the wall the same way: step the show far along, leave it, and the wall lands on the slide you stopped on — in view.
@testcase
def test_wall_scrolls_to_frame_cursor(page):
"""The frame does the same, through the same follow: stepping the show scrolls the wall to the slide."""
docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzfrscroll-{i:02d}", "date": f"2020-03-{i + 1:02d}T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfrscroll-{i:02d}-t", "labels": "zzfrscroll", "state": "todo"} for i in range(18)]
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 360, "height": 480})
open_app(page); chip(page, "all").click()
search_for(page, "zzfrscroll")
expect(tiles(page)).to_have_count(18)
assert not tiles(page).nth(17).evaluate(IN_VIEW), "setup: the last tile must start below the fold"
page.get_by_role("button", name=re.compile("frame", re.I)).click() # into the slideshow
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: bool(page.evaluate("() => localStorage.getItem('memories.frame.cid')")),
label="the show settled on its opening slide")
init = page.evaluate("() => localStorage.getItem('memories.frame.cid')")
page.keyboard.press("ArrowLeft") # wrap to the last slide
wait_until(page, lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')") != init,
label="the show settled on the far slide")
page.keyboard.press("Escape") # leave the show
expect(strip).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(17).evaluate(IN_VIEW),
label="the wall scrolled to the slide we stopped on")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: wall scrolls to frame cursor")
The doc you are looking at is opened() while the lightbox is up, and otherwise the
frame’s centred slide. Every move on either surface updates one of those — the ‹/›
buttons, the arrows, a swipe, a slideshow step — so a single effect mirroring whichever
is live onto the cursor — and scrolling that cursor’s tile into view on the wall behind —
covers them all. With neither surface open it has nothing to mirror and leaves the cursor
untouched, right where you last left it. One thing would silently undo that scroll: leaving
either surface rides on history.back(), and the browser’s own scroll restoration then
snaps the window back to where it stood when the surface opened. So the app turns
restoration off and owns the wall’s scroll itself — the follow is the authority on where
the wall stands.
history.scrollRestoration = 'manual'; // Back must not undo the follow (see prose)
createEffect(() => { const c = opened()?.cid || (frame() ? frameCenterCid() : null); if(!c) return;
setCursor(c);
gridEl?.children[items().findIndex(p => p.cid === c)]?.scrollIntoView({ block: 'nearest' }); });
Jumping to the label box
Selecting a run of photos and then dragging the mouse all the way to the label field
breaks the keyboard flow the cursor just built. So l — for label — jumps straight to
the add-label box: the lightbox’s when a doc is open, or the selection toolbar’s when a
batch is waiting on the wall. Hands stay on the keys — select with Shift+→, press l,
type the word; or press . to drop in the label you used last and just hit Enter.
With a doc open, l focuses its add-label box.
@testcase
def test_label_shortcut_focuses_lightbox_box(page):
"""In the lightbox, pressing l jumps focus to the add-label box, ready to type."""
open_fixtures(page)
open_doc(page, 0)
box = dialog(page).get_by_placeholder("add a label…")
expect(box).not_to_be_focused()
page.keyboard.press("l")
expect(box).to_be_focused()
expect(box).to_have_value("") # l opened the box; it didn't type into it
print(" PASS: label shortcut focuses lightbox box")
On the wall, with a selection up, l focuses the toolbar’s label box instead.
@testcase
def test_label_shortcut_focuses_batch_box(page):
"""On the wall, with a selection up, l jumps focus to the toolbar's label box."""
open_fixtures(page)
tiles(page).nth(0).click() # select one → the toolbar appears
box = toolbar(page).get_by_placeholder("add a label…")
page.keyboard.press("l")
expect(box).to_be_focused()
expect(box).to_have_value("")
print(" PASS: label shortcut focuses batch box")
. — repeat — goes one further: it fills that same box with the label applied last and
focuses it, so a run of photos can take the same tag without retyping. With a doc open,
. refills the lightbox’s box.
@testcase
def test_label_repeat_fills_lightbox_box(page):
"""In the lightbox, '.' refills the add-label box with the label applied last."""
open_fixtures(page)
open_doc(page, 0)
d = dialog(page)
box = d.get_by_placeholder("add a label…")
box.click(); box.fill("zzrep"); box.press("Enter") # apply → remembered as last
expect(d.get_by_role("button", name="zzrep", exact=True)).to_be_visible()
box.blur() # leave the field so '.' is a shortcut
page.keyboard.press(".")
expect(box).to_be_focused()
expect(box).to_have_value("zzrep") # refilled, ready to commit again
print(" PASS: label repeat fills lightbox box")
And on the wall, with a selection up, . refills the toolbar’s box the same way.
@testcase
def test_label_repeat_fills_batch_box(page):
"""On the wall, '.' refills the toolbar's label box with the label applied last."""
open_fixtures(page)
tiles(page).nth(0).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.fill("zzrep"); box.press("Enter") # apply to one → remembered, selection clears
tiles(page).nth(1).click() # select another → toolbar back
page.keyboard.press(".")
box2 = toolbar(page).get_by_placeholder("add a label…")
expect(box2).to_be_focused()
expect(box2).to_have_value("zzrep")
print(" PASS: label repeat fills batch box")
A small window listener routes l and . to whichever box is live — the open lightbox’s,
else the selection toolbar’s — and stands aside while a field already has focus or the
frame is up. l just focuses; . first drops in lastLabel, the word any earlier
apply (here or in a batch) left behind. The preventDefault keeps that keystroke from
landing in the box it just opened.
onMount(() => {
const onKey = e => {
if((e.key !== 'l' && e.key !== '.') || frame()) return; // the frame has its own bar
if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // a field already owns the key
const box = opened() ? document.querySelector('.lb .batch-label')
: selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
if(!box) return;
e.preventDefault();
if(e.key === '.' && lastLabel()) // '.' re-drops the label applied last
(opened() ? setLbText : setLabelText)(lastLabel());
box.focus();
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
Installable, fullscreen (PWA)
On a phone the wall wants the whole screen. A web-app manifest (display:fullscreen,
an SVG icon, the app’s dark theme_color) plus a service worker make it installable to
the home screen and launchable chrome-free. The worker earns its keep beyond that: it makes
launches instant and keeps the app current after a deploy — without the hard-reload a phone
makes painful. It caches only its own shell; the live data (/graphql) and the archive’s
media (/ipfs/) stay on the network.
The test can’t drive a real install, so it asserts the observable scaffolding: the
manifest is linked and declares fullscreen, and the service worker reaches ready.
@testcase
def test_pwa_installable(page):
"""The app ships a fullscreen manifest and a registered service worker."""
open_app(page)
assert page.locator("link[rel='manifest']").get_attribute("href"), "no manifest linked"
man = page.evaluate("() => fetch('manifest.json').then(r => r.json())")
assert man["display"] == "fullscreen", f"display is {man.get('display')!r}"
assert man["icons"], "manifest has no icons"
ready = page.evaluate("""() => Promise.race([
navigator.serviceWorker.ready.then(r => !!r.active),
new Promise(res => setTimeout(() => res(false), 6000))])""")
assert ready, "service worker did not become ready"
print(" PASS: pwa installable")
Its cache is keyed to the build, and on activate it drops caches left by any other build —
so after a deploy the next open finds the stale cache gone and re-fetches the fresh files. That
re-fetch reaches past the browser’s own HTTP cache (the very thing a hard-reload exists to
bypass), so the new build lands with an ordinary open — no hard-reload.
@testcase
def test_sw_drops_stale_version_cache(page):
"""On activate the worker deletes caches from other builds, so a new build isn't served stale."""
open_app(page)
page.evaluate("() => navigator.serviceWorker.ready")
# seed a cache from a different build, then unregister so the next load re-installs fresh
page.evaluate("""async () => {
await (await caches.open('memories-stale')).put('/x', new Response('x'));
for (const r of await navigator.serviceWorker.getRegistrations()) await r.unregister();
}""")
open_app(page) # re-register → fresh install/activate purges other builds
page.evaluate("() => navigator.serviceWorker.ready")
wait_until(page, lambda: "memories-stale" not in page.evaluate("() => caches.keys()"),
label="stale-version cache purged on activate")
print(" PASS: sw drops stale version cache")
Within a build it serves the shell cache-first — an instant launch that survives a flaky link.
@testcase
def test_sw_serves_from_cache(page):
"""Within a build the worker serves the shell from its cache, not the network."""
open_app(page)
page.evaluate("() => navigator.serviceWorker.ready")
ver = "memories-" + (page.locator(".build-tag").text_content() or "").strip() # the SW's cache name
page.evaluate(f"""() => caches.open({ver!r}).then(c => c.put('manifest.json',
new Response('{{\\"display\\":\\"CACHED\\"}}', {{headers:{{'content-type':'application/json'}}}})))""")
display = page.evaluate("() => fetch('manifest.json').then(r => r.json()).then(m => m.display)")
assert display == "CACHED", f"served network instead of cache: {display!r}"
print(" PASS: sw serves from cache")
{
"name": "Memories",
"short_name": "Memories",
"start_url": ".",
"scope": ".",
"display": "fullscreen",
"background_color": "#1b1d2e",
"theme_color": "#1b1d2e",
"icons": [
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#1b1d2e"/>
<g fill="#6cf">
<rect x="120" y="120" width="120" height="120" rx="14"/>
<rect x="272" y="120" width="120" height="120" rx="14"/>
<rect x="120" y="272" width="120" height="120" rx="14"/>
<rect x="272" y="272" width="120" height="120" rx="14"/>
</g>
</svg>
const CACHE = 'memories-nil';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil((async () => {
for(const k of await caches.keys()) if(k !== CACHE) await caches.delete(k);
await self.clients.claim();
})()));
self.addEventListener('fetch', e => {
const req = e.request, url = new URL(req.url);
if(req.method !== 'GET' || url.origin !== location.origin
|| url.pathname.includes('/graphql') || url.pathname.includes('/ipfs/')) return;
e.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(req);
if(hit) return hit;
const res = await fetch(req.url, { cache: 'reload' });
if(res.ok) cache.put(req, res.clone());
return res;
})());
});
Annexe
What a sharpened wall holds
The refined wall draws two pictures per tile, and a picture unpacked for drawing costs four bytes a pixel however little it weighed compressed. Measured on the same sample as the byte figures, a thumbnail is 0.045 Mpx — 256px on its long side — and a rendition 0.79 Mpx, cut to fit 1024px: some eighteen times the pixels, where it is only five to twelve times the bytes.
How many of those pixels a browser actually keeps is not settled here. It may unpack a rendition at the size the file was written, or at the size the tile draws it, and the two lead to opposite readings of what happens as the cells grow. Both, though, land on the same ceiling.
Unpacking at the file’s size, a fold of cells c wide holds area-over-c-squared tiles of 0.18 + 3.15 MB against the dense wall’s tiles of 0.18 MB at 96px, so the ratio is (96/c)² × 18.5 — 1.9 at the 300px threshold, level by 400px, and falling after that. The threshold is the worst of it, and the wall gets lighter the further past it you go.
Unpacking at the drawn size instead, the arithmetic collapses: tiles times cell area is just the fold’s area, so a fold holds its own area in pixels once per layer, whatever the cells measure. Two layers is then twice one, at every size, and growing the tiles changes nothing.
Neither reading is a bound on the other — at cells past 256px the thumbnail layer is upscaled, so drawn-size unpacking holds more for it than its file would suggest, while for the dense wall it holds less. What survives both is a ceiling: about twice what the dense wall already held. The reach window was drawn around a single fold of thumbnails, so this is that bound doubled — bounded rather than free, and what a device actually holds at that ceiling is a reading still to be taken.
Notes linking here
- 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