A Photos/Videos Organiser With Solid
Fleeting- authorship
- this note is edited using Claude/default, governed by the note how to do literate programming
- tl;dr
- Memories browses and triages docs — photos and videos — and doubles as a cabinet slideshow — a Solid app over PostGraphile, told here one story at a time.
Stories
- what
- a story is a whole use somebody opens the docs for, never one feature such as a download button.
- example: getting three docs onto a phone before dinner, sorting out a morning’s shooting, leaving the cabinet running through a good afternoon.
- what
- those stories are what Memories promises.
- therefore: Memories is checked against them.
- how
- every heading under this one takes one piece of one story and says how it is kept.
Showing docs
Sharing a few docs with someone
- what
- somebody asks for the docs from an afternoon.
- how: you search the label, 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.
- what
- it needs three docs that differ in what can be taken from them: one complete, one that was never
downscaled, and a video.
- how: their bytes are served from the app’s own origin.
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"},
]
- how
- they are told apart by where they sit, oldest first: the complete photo leads and the video closes.
- what
- what becomes of each in turn is selecting and downloading, a step at a time.
- how: the story below takes it in the order it happens: pick one and take both its renditions, check the two files can live side by side, then add the photo that has no web copy, and finally swap to the video.
- what
- then the same three docs are handed over the other way, which is not to hand them over at all:
put them up on the slideshow and let the phone in somebody’s pocket take what it wants.
- what: that is the slideshow naming what it is showing.
- what: at the end, the same slideshow is pointed somewhere nothing answers.
- how: both of the slideshow’s knobs — how long a slide stays up, and how long its control bar waits before
hiding — are wound past the end of the story rather than to any particular span.
- what: nothing here is watching the slideshow move: what is read is what the room was told and what one word on the bar says.
NEVER_MS = 999999 # past the end of this session, both times
SHARE_QS = f"?ms={NEVER_MS}&uiidle={NEVER_MS}"
DEAD_PORT = 45999
@testcase
def test_sharing_a_few_photos(page):
"""Someone asks for an afternoon's docs: search the tag, pick, and take each
at the size that suits — then put the same three up on the frame instead, where a
phone can help itself, and read off the bar whether anybody is hearing them."""
docs = send_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page)
search_for(page, SEND_LABEL)
expect(tiles(page)).to_have_count(len(docs))
photo, never_downscaled, video = 0, 1, 2 # shown oldest first
tiles(page).nth(photo).click() # the one worth sending
expect(checks(page)).to_have_count(1)
tb, got = toolbar(page), {}
for res in ["web", "orig"]:
with page.expect_download() as di:
tb.get_by_role("button", name=res, exact=True).click()
got[res] = (di.value.url, di.value.suggested_filename)
assert docs[photo]["webCid"] in got["web"][0], f"web → webCid: {got}"
assert docs[photo]["cid"] in got["orig"][0], f"orig → the doc's own cid: {got}"
print(" PASS: download selection")
names = {res: name for res, (_, name) in got.items()} # the two the photo just yielded
assert len({*names.values()}) == len(names), f"same name → they collide in the folder: {names}"
for res in names:
assert res in names[res], f"rendition missing from the name: {names}"
print(" PASS: download names by resolution")
tiles(page).nth(never_downscaled).click()
expect(checks(page)).to_have_count(2)
saved = [] # armed here: only this click's files count
page.on("download", lambda d: saved.append(d.url))
toolbar(page).get_by_role("button", name="web", exact=True).click()
wait_until(page, lambda: len(saved) >= 1, label="the doc that has a web copy saves it")
page.wait_for_timeout(500) # room for a second, unwanted save to land
assert len(saved) == 1, f"expected the one web copy, got {len(saved)}: {saved}"
assert docs[photo]["webCid"] in saved[0], f"the wrong rendition came down: {saved[0]}"
print(" PASS: download skips a missing rendition")
both = [] # its own count, apart from the one above
page.on("download", lambda d: both.append(d.url))
toolbar(page).get_by_role("button", name="orig", exact=True).click()
wait_until(page, lambda: len(both) >= 2, label="both picked docs come down",
detail=lambda: f"came down: {both}")
for k in (photo, never_downscaled):
assert any(docs[k]["cid"] in u for u in both), f"no file for {docs[k]['cid']}: {both}"
print(" PASS: download saves every pick")
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 clip's web copy is mp4: {ext}"
print(" PASS: download extensions by rendition")
# …and the same three put up on the frame
sync_url, room = require_sync(), "memories-test-" + uuid.uuid4().hex[:8]
named = watch_room(page.context, sync_url, room) # the entry, listened to as the phone will
yjs_twice = [] # what a second Yjs says when it loads
page.on("console", lambda m: "Yjs was already imported" in m.text and yjs_twice.append(m.text))
open_app(page, SHARE_QS + f"&yws={sync_url}&room={room}")
expect(tiles(page)).to_have_count(len(docs)) # still the afternoon you searched for
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
bar = page.get_by_role("toolbar", name="frame actions")
# nothing is touched here: entering the show is the whole of it
wait_until(page, lambda: bool(named()), label="the frame names its slide unasked")
said, shown = named(), docs[photo]
assert said["cid"] == shown["cid"], f"named the wrong doc: {said}"
assert said["webCid"] == shown["webCid"], f"no downscaled address: {said}"
assert said["mimetype"] == shown["mimetype"], f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
[said.get("date"), shown["date"]]), \
f"named a different instant than the doc's {shown['date']}: {said}"
print(" PASS: frame publishes what is showing")
# the show has named its slide, so the socket library and its Yjs are loaded by now
assert not yjs_twice, f"a second copy of Yjs loaded: {yjs_twice}"
print(" PASS: the slideshow loads one Yjs")
page.keyboard.press("ArrowRight") # a step, with nothing touched
wait_until(page, lambda: (named() or {}).get("cid") == docs[never_downscaled]["cid"],
label="the name follows the slide", detail=lambda: str(named()))
print(" PASS: the name follows the slide")
speak_over(page.context, "https://ipfs.konubinix.eu/p/zzelsewhere") # another frame claims the room
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzelsewhere")
strip.click() # a touch, and the slide is not moved
wait_until(page, lambda: (named() or {}).get("cid") == docs[never_downscaled]["cid"],
label="a touch takes the room back", detail=lambda: str(named()))
print(" PASS: a touch takes the room back")
wait_until(page, lambda: bar.get_by_text("live", exact=True).count() == 1,
label="the bar says the link is live",
detail=lambda: f"the bar is up: {bar.is_visible()}; it reads "
f"{bar.text_content() if bar.count() else '—'!r}")
print(" PASS: the bar says the link is live")
# and the same frame pointed where nothing answers
open_app(page, SHARE_QS + f"&yws=ws://127.0.0.1:{DEAD_PORT}&room=nowhere")
expect(strip).to_be_visible()
strip.click() # a touch
expect(bar.get_by_text("offline", exact=True)).to_be_visible()
print(" PASS: the frame owns up to a dead link")
hue = lambda st: bar.locator(".room-link").evaluate( # the colour the bar gives the word in that state
"(el, st) => { const t = el.cloneNode(true); t.dataset.state = st; el.after(t);"
" const c = getComputedStyle(t).color; t.remove(); return c; }", st)
hues = {st: hue(st) for st in ["offline", "connecting", "idle", "live"]}
assert hues["offline"] not in (hues["connecting"], hues["live"]), f"offline does not interrupt: {hues}"
assert hues["connecting"] == hues["idle"], f"the two on their way do not share a colour: {hues}"
assert hues["live"] != hues["connecting"], f"live is not apart from the waiting ones: {hues}"
print(" PASS: the room link wears a colour per state")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
unserve_bytes(*SEND_BYTES)
Handing the slide to a phone
- what
- the doc 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.
- what
- the cabinet tablet has no way to hand a file to an app on another device.
- therefore: the phone has to fetch it.
- who
- the companion, a small app on the phone, does that: it holds whatever the slideshow is showing and passes it to the phone’s own share sheet.
- rule
- everything Memories owes it is a name for the doc.
The room the name goes into
- what
- that name lives in a shared document — a room on the house’s sync server, holding one entry that
every slideshow writes and the companion reads.
- what: it is a heavier instrument than one value needs.
- what: the companion is a page with no server of its own.
- therefore: nothing can hand it an update unless the update arrives over a connection it already holds.
- how
- Yjs, a shared-document library, holds such a document, and
y-websocketcarries it over a socket.- how: Yjs, like Solid, must load as one instance, and meets it
the same way: the socket library comes in marked
?external=yjsso that it and the import map’s ownyjsare the same copy. - what: two copies fail quietly: the slideshow still names its slides, but a touch no longer takes the
room back from another device.
- what: that only shows at times, so the touch alone is no proof of one copy.
- how: Yjs says so itself: a second copy logs « Yjs was already imported » as it loads.
- how: Yjs, like Solid, must load as one instance, and meets it
the same way: the socket library comes in marked
# the show has named its slide, so the socket library and its Yjs are loaded by now
assert not yjs_twice, f"a second copy of Yjs loaded: {yjs_twice}"
print(" PASS: the slideshow loads one Yjs")
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
- how
- a slideshow works out which room and which server for itself: the sync server answers on the same host the app was loaded from, so swapping the scheme is the whole of it.
- how
- both can be named at launch as well, the way every app here that joins a room takes its address.
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');
The slideshow names its slide unasked
- rule
- the slideshow names every slide it comes to rest on, unasked.
- rule: you look up from the cabinet, take out your phone, and the doc has to be there already.
- what
- a name is both of the doc’s addresses — the downscaled copy and the original — what kind of file it
is, and when the doc was taken.
- what: only the server knows when the doc was taken, and the companion names the file it hands over
after that date.
- therefore: a slideshow that kept it to itself would leave the companion handing over a file it cannot name.
- how: In practice the server gives an instant back in its own offset rather than the
Zit was handed, so the two read differently as text while naming the same moment; what is checked is the instant, not its spelling. - what: the companion is served from elsewhere and reaches the server by its own route.
- therefore: it is the one that can turn an address into something it can fetch.
- therefore: the addresses go out exactly as the doc carries them, unresolved.
- what: only the server knows when the doc was taken, and the companion names the file it hands over
after that date.
# nothing is touched here: entering the show is the whole of it
wait_until(page, lambda: bool(named()), label="the frame names its slide unasked")
said, shown = named(), docs[photo]
assert said["cid"] == shown["cid"], f"named the wrong doc: {said}"
assert said["webCid"] == shown["webCid"], f"no downscaled address: {said}"
assert said["mimetype"] == shown["mimetype"], f"no file kind: {said}"
assert page.evaluate("([a, b]) => Date.parse(a) === Date.parse(b)",
[said.get("date"), shown["date"]]), \
f"named a different instant than the doc's {shown['date']}: {said}"
print(" PASS: frame publishes what is showing")
- rule
- keep browsing and the name keeps up with you, each time the slideshow comes to rest, not on
the scroll.
- how: the centred slide reads true long before the short wait after scrolling stops, so a step only counts as over once the slideshow has come to rest, and that is when the next name goes out.
page.keyboard.press("ArrowRight") # a step, with nothing touched
wait_until(page, lambda: (named() or {}).get("cid") == docs[never_downscaled]["cid"],
label="the name follows the slide", detail=lambda: str(named()))
print(" PASS: the name follows the slide")
- how
- the centred doc’s name is written again each time the slideshow settles.
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 });
});
Several devices, one room
- what
- Memories runs on the phones as much as on the cabinet.
- therefore: several devices can be playing the slideshow at once, all writing that same one entry.
- rule
- the last device to settle wins, with no arbitration.
- rule
- where two really are going at once, a touch breaks the tie.
- rule: taking the room back is the one thing a touch is for: if another device has spoken over you, touching yours puts your slide back without your having to move it.
speak_over(page.context, "https://ipfs.konubinix.eu/p/zzelsewhere") # another frame claims the room
wait_until(page, lambda: (named() or {}).get("cid") == "https://ipfs.konubinix.eu/p/zzelsewhere")
strip.click() # a touch, and the slide is not moved
wait_until(page, lambda: (named() or {}).get("cid") == docs[never_downscaled]["cid"],
label="a touch takes the room back", detail=lambda: str(named()))
print(" PASS: a touch takes the room back")
- how
- In practice, reading
pokes()in the naming effect — the counter the slideshow already bumps on every touch — is what makes a touch republish.- how: an effect re-runs when anything it read has changed: the settle path comes from the doc, and the claim path from the counter, so nothing has to notice which of the two happened.
The link, in one word on the control bar
- what
- naming a slide is a thing you cannot see working: the slideshow writes, says nothing, and looks exactly
the same whether the name arrived or the socket is down.
- what: if it is down, somebody on a sofa is looking at a phone that still shows the last doc the room heard about.
- what: the person standing at the device is the only one who can notice.
- rule
- the control bar carries the link’s state in one word beside the date.
- rule
- a slideshow whose names are landing says so.
wait_until(page, lambda: bar.get_by_text("live", exact=True).count() == 1,
label="the bar says the link is live",
detail=lambda: f"the bar is up: {bar.is_visible()}; it reads "
f"{bar.text_content() if bar.count() else '—'!r}")
print(" PASS: the bar says the link is live")
- how
- the room’s socket reports its status, and the word follows it.
setRoomLink('connecting');
provider.on('status', e => setRoomLink(e.status === 'connected' ? 'live' : 'offline'));
<span class="room-link" data-state=${roomLink}>${roomLink}</span>
Owning up to a dead link
- what
- the word that has to be right is offline, the only one that contradicts what the screen otherwise implies.
- what
- a slideshow pointed somewhere nothing answers looks exactly like one whose every slide is landing.
- rule
- such a slideshow says offline.
expect(bar.get_by_text("offline", exact=True)).to_be_visible()
print(" PASS: the frame owns up to a dead link")
- who
- 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 doc.
- what
- only one of the four states is bad news.
- rule
- only offline takes a colour that interrupts, the two that are on their way somewhere share a
muted one, and live stays quiet.
- how: In practice the word is in one state at a time, so the other three are read off copies of it laid beside it on the bar, each told a different state.
hue = lambda st: bar.locator(".room-link").evaluate( # the colour the bar gives the word in that state
"(el, st) => { const t = el.cloneNode(true); t.dataset.state = st; el.after(t);"
" const c = getComputedStyle(t).color; t.remove(); return c; }", st)
hues = {st: hue(st) for st in ["offline", "connecting", "idle", "live"]}
assert hues["offline"] not in (hues["connecting"], hues["live"]), f"offline does not interrupt: {hues}"
assert hues["connecting"] == hues["idle"], f"the two on their way do not share a colour: {hues}"
assert hues["live"] != hues["connecting"], f"live is not apart from the waiting ones: {hues}"
print(" PASS: the room link wears a colour per state")
.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; }
Taking the files
Which rendition comes down
- what
- past triage you often just want the files: a web copy — the downscaled one — to hand over now, and the original to rework.
- rule
- the toolbar’s
↓group saves the picked docs, one file each, at whichever of the two you ask for, and hands over no other rendition.- scope: like the move, it acts on the picked docs only, there being no list of the whole filter at hand to fetch.
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")
- rule
- a doc missing that rendition is passed over rather than served its original in the
rendition’s place.
- how: the photo never downscaled joins the first, and asking the pair for web brings 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")
- rule
- one press saves every picked doc, not only the first: the same pair, asked for their originals, brings down two files.
both = [] # its own count, apart from the one above
page.on("download", lambda d: both.append(d.url))
toolbar(page).get_by_role("button", name="orig", exact=True).click()
wait_until(page, lambda: len(both) >= 2, label="both picked docs come down",
detail=lambda: f"came down: {both}")
for k in (photo, never_downscaled):
assert any(docs[k]["cid"] in u for u in both), f"no file for {docs[k]['cid']}: {both}"
print(" PASS: download saves every pick")
What the saved files are called
- rule
- a second saved file never overwrites the first: each saved file carries its rendition in its
name,
holiday-orig.jpgbesideholiday-web.jpg.- how: In practice nothing is downloaded again: the names of the two renditions already in hand 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")
- how
- the photos are put down and the video taken up alone, as the one whose two renditions disagree about their type.
- rule
- the extension follows the rendition: a video’s web copy is an MP4, a photo’s a JPEG, and the
original keeps its true type, as the server records it.
- what: an address carries no extension and a missing object answers
text/plain, so a name taken from either is unopenable. - example: a
.movoriginal saves as…-orig.mov, and its web copy as…-web.mp4.
- what: an address carries no extension and a missing object answers
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 clip's web copy is mp4: {ext}"
print(" PASS: download extensions by rendition")
Saving through a link per doc
- what
- the browser asks once before saving several.
- how
- each picked doc is saved through a link of its own, named after the doc and the rendition asked for.
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 cabinet plays on its own
- what
- a tablet sits on the cabinet showing the docs, and nobody tends it.
- how: you set a search going and walk off; it plays through by itself.
- what: somebody passing touches the screen, gets the controls, reads what it is looking at, and leaves — and the control bar tidies itself away.
- what: relaunch it and it is still showing, on the slide it stopped on.
- rule
- its tempo and the patience of its control bar are the two knobs the slideshow runs on — a minute a
slide and twenty seconds of bar by default.
- how: In practice the tempo is read off the bar’s own field, and the patience waited out: still up two seconds short of the twenty, gone soon after.
BAR_PATIENCE_MS = 20000
open_app(page) # a launch that winds neither knob
expect(strip).to_be_visible() # the memory brings the show back up
strip.click() # the bar, and the touch its patience counts from
expect(bar.get_by_label("seconds per photo")).to_have_value("60") # a minute a slide
page.wait_for_timeout(BAR_PATIENCE_MS - 2000) # most of the twenty seconds, untouched
expect(bar).to_be_visible() # …and the bar is still up
expect(bar).to_be_hidden(timeout=4000) # gone once they have run out
print(" PASS: frame defaults to a minute a slide and twenty seconds of bar")
- how
- the cabinet story is wound to a second or two.
- what: the third number is the suite’s own: how long to leave a just-exited slideshow 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
- how
- the suite’s three fixture docs give it a short loop to play, and the slideshow opens on the wall —
the grid of thumbnails — they make.
- what: one occasion — an entry of the owner’s calendar, whose span a doc’s date can fall inside — is laid
over all three of them.
- therefore: whichever slide the slideshow happens to be resting on has something to say about itself.
- what: a second, shorter one is laid over the middle one alone.
- therefore: a step onto that slide, or off it, changes what the bar has to say.
- what: one occasion — an entry of the owner’s calendar, whose span a doc’s date can fall inside — is laid
over all three of them.
CABINET_EVENT = "zzVacancesCabinet"
CABINET_FROM, CABINET_TO = "2020-01-01T00:00:00Z", "2020-03-31T23:59:59Z"
CABINET_SHORT = "zzAnniversaireCabinet" # over February's doc alone
CABINET_EVENTS = [{"rowId": "zzcab-ev", "summary": CABINET_EVENT, "owner": "konubinix",
"status": "confirmed", "starttime": CABINET_FROM, "endtime": CABINET_TO},
{"rowId": "zzcab-ev-short", "summary": CABINET_SHORT, "owner": "konubinix",
"status": "confirmed", "starttime": "2020-02-10T00:00:00Z",
"endtime": "2020-02-20T23:59:59Z"}]
- what
- the passer-by who stops also gets an add-label box.
- rule: what it offers has to come from somewhere.
- how: the cabinet story brings two words and takes them away after.
- what: one answers a prefix and is nowhere near these slides.
- what: one is laid on every slide before the slideshow starts.
- therefore: whichever slide it is resting on already wears it.
- what: both answer to the same stem, which is what a prefix has to reach.
- how: the cabinet story brings two words and takes them away after.
- rule: what it offers has to come from somewhere.
CABINET_STEM = "zzcab"
CABINET_FREE, CABINET_WORN = "zzcabfree", "zzcabworn"
CABINET_WORDS = [CABINET_FREE, CABINET_WORN]
- what
- the slideshow says whether a picture is still arriving or never will.
- what: saying so is a promise with its own run of slides.
- how
- it wants one doc in each of the three states there is anything to say about.
- example: one whose thumbnail arrives and whose web copy never does, so the picture is up with something sharper still pending.
- example: one where nothing arrives at all.
- example: one where both arrive, so that there is a slide with nothing left to announce.
- how: the bytes they answer with are the smallest picture that is still a picture — a single pixel.
MARK_LABEL = "zzmark"
MARK_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzmark-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzmark-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzmark-web-{i}", "labels": MARK_LABEL, "state": "todo"}
for i in range(3)]
MARK_ARRIVES = ("zzmark-t-0", # the picture, but never anything sharper
"zzmark-t-2", "zzmark-web-2") # both — and nothing at all for the one between them
ONE_PIXEL = _b64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
@testcase
def test_the_cabinet_plays_on_its_own(page):
"""A tablet left on the cabinet: it advances by itself, yields its controls to a
passer-by — including a box that completes on the docs' own words — comes back
where it was after a relaunch, and says which of its pictures are still on the way."""
make_fixtures()
for f in FIXTURES: # the worn word, on every slide
gql(UPDATE, {"cid": f["cid"], "patch": {"labels": f"{FIXTURE_LABEL}; {CABINET_WORN}"}})
seed_vocab(CABINET_WORDS)
seed_events(CABINET_EVENTS)
for d in MARK_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
for c in MARK_ARRIVES:
page.route(f"**/ipfs/{c}", lambda r: r.fulfill(status=200, content_type="image/png", body=ONE_PIXEL))
try:
open_app(page, CABINET_QS)
search_for(page, FIXTURE_LABEL)
chip(page, "all").click()
expect(tiles(page)).to_have_count(len(FIXTURES))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
bar = page.get_by_role("toolbar", name="frame actions")
for src in ["thumb-0", "thumb-1", "thumb-2"]: # date order
wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
print(" PASS: frame auto-advances")
strip.click() # a tap reveals the frame bar
expect(bar).to_be_visible()
colours = [bar.get_by_role("button", name=st, exact=True).evaluate("el => getComputedStyle(el).color")
for st in ["todo", "next", "done", "delete"]]
assert len(set(colours)) == 4, f"each state pill should have its own colour, got {colours}"
print(" PASS: frame state pills are colour coded")
def shown(k): # the date the bar should be reading, as the browser renders it
return page.evaluate("d => new Date(d).toLocaleString('fr-FR')", FIXTURES[k]["date"])
i = centred_slide(strip) # whichever slide the show stopped on
expect(bar.locator(".frame-date")).to_have_text(shown(i))
page.keyboard.press("ArrowRight") # step to the next slide
j = (i + 1) % len(FIXTURES)
wait_until(page, lambda: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{j}")
expect(bar.locator(".frame-date")).to_have_text(shown(j)) # follows the slide
print(" PASS: frame shows date")
expect(bar.get_by_text(CABINET_EVENT)).to_be_visible() # whatever slide it rests on, it is that
day = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso)
expect(bar.get_by_text(day(CABINET_FROM))).to_be_visible() # the span's start, beside the name
expect(bar.get_by_text(day(CABINET_TO))).to_be_visible() # …and its end
print(" PASS: frame shows events")
expect(bar).to_be_visible() # still up from the tap that raised it
expect(bar).to_be_hidden() # and gone once the window has run out
print(" PASS: frame bar auto-hides when idle")
strip.click() # bring the bar back up
expect(bar).to_be_visible()
pause = bar.get_by_role("button", name=re.compile("pause|play"))
for _ in range(3):
pause.click(); page.wait_for_timeout(CABINET_UI_IDLE_MS // 3)
page.wait_for_timeout(CABINET_UI_IDLE_MS // 3) # now well past a window since the first press
expect(bar).to_be_visible() # still up: each press restarted the count
expect(bar).to_be_hidden() # now left alone → it finally hides
print(" PASS: frame bar idle resets on use")
expect(bar).to_be_hidden() # let go, so the tap below raises it
strip.click()
expect(bar).to_be_visible()
box = strip.bounding_box()
step_on = lambda: page.mouse.click(box["x"] + box["width"] * 0.92, box["y"] + box["height"] / 2)
feb = 1 # the one slide the short occasion covers
for _ in range(len(FIXTURES)):
if centred_slide(strip) == feb: break
k = (centred_slide(strip) + 1) % len(FIXTURES)
step_on()
wait_until(page, lambda k=k: centred_slide(strip) == k, label="a side tap steps the show on")
expect(bar.get_by_text(CABINET_SHORT)).to_be_visible() # February's doc, and its own occasion
step_on() # on to March's
wait_until(page, lambda: centred_slide(strip) == feb + 1, label="the show steps off February")
expect(bar.locator(".frame-event")).to_have_count(1) # the bar up, with one occasion left on it…
expect(bar.locator(".frame-event")).to_contain_text(CABINET_EVENT) # …the long one
print(" PASS: frame events follow the slide")
if centred_slide(strip) == 0: # park it away from the cold-start slide
page.keyboard.press("ArrowRight")
wait_until(page, lambda: centred_slide(strip) != 0,
label="the show steps off the slide a cold start would pick")
i = centred_slide(strip)
was = strip.evaluate(CENTERED) # the slide it is showing when it goes down
remembered = lambda: page.evaluate("() => localStorage.getItem('memories.frame.cid')")
wait_until(page, lambda: remembered() == FIXTURES[i]["cid"],
label="the slide it is on is the one written down",
detail=lambda: f"written down: {remembered()}")
open_app(page, CABINET_QS) # reboot: a plain relaunch
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs it places from are in
page.evaluate("() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
came_back_on = strip.evaluate(CENTERED) # read at once, not waited for
assert came_back_on == was, f"resumed on {came_back_on}, not the {was} it went down on"
print(" PASS: frame resumes position")
page.keyboard.press("Escape") # leaving turns the memory off
expect(strip).to_be_hidden()
open_app(page, CABINET_QS)
expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs an auto-entry would place from
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired
expect(page.get_by_role("list", name="slideshow")).to_be_hidden() # stays on the wall
print(" PASS: frame mode persists")
expect(tiles(page)).to_have_count(len(FIXTURES)) # the persisted query brought the wall back
page.get_by_role("button", name=re.compile("frame", re.I)).click() # set it going again → remembered
expect(page.get_by_role("list", name="slideshow")).to_be_visible()
open_app(page, CABINET_QS) # relaunch: remembered → auto-enters, no click
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
page.keyboard.press("Escape") # exit within this launch
chip(page, "todo").click() # …and the wall reads again, as it will
expect(tiles(page)).to_have_count(len(FIXTURES)) # fresh docs — the chance a re-entry would take
page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired
expect(strip).to_be_hidden() # once per launch — it doesn't
print(" PASS: frame autostart")
# somebody sets it going once more, and this time follows where it leads
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
strip.click()
expect(bar).to_be_visible()
box = bar.get_by_placeholder("add a label…")
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20)
expect(options(page).first).to_be_visible() # vocabulary suggestions
assert CABINET_STEM in options(page).first.inner_text().strip().lower()
print(" PASS: frame label completion")
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20)
expect(options(page).filter(has_text=re.compile(f"^{CABINET_FREE}$")).first).to_be_visible()
offered = [t.strip() for t in options(page).all_inner_texts()]
assert CABINET_FREE in offered, f"the list went before it could be read: {offered}"
assert CABINET_WORN not in offered, f"a word every slide already wears was offered: {offered}"
print(" PASS: frame completion skips present")
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM)
expect(box).to_have_attribute("aria-expanded", "true")
box.blur()
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: frame label combobox state")
hold_completions(page)
expanded_while_loading(page, bar.get_by_placeholder("add a label…"))
print(" PASS: frame label expanded while loading") # last: a held query stays held
bar.get_by_role("button", name=re.compile(CABINET_EVENT)).click() # follow the occasion
expect(search_box(page)).to_have_value(f"event:{CABINET_EVENT}") # the pill's search, committed
expect(strip).to_have_count(0) # and out of the show with it
print(" PASS: frame event pill searches")
# later, on a run whose pictures are still coming in
open_app(page, CABINET_QS)
expect(strip).to_be_hidden() # the pill search left the show behind
search_for(page, MARK_LABEL)
expect(tiles(page)).to_have_count(len(MARK_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
slides = strip.get_by_role("listitem") # [clone, photo0 (centre), photo1, photo2, clone]
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzmark-t-0"),
label="the show opens on the doc with a picture but nothing sharper coming")
expect(slides.nth(2).get_by_label("loading")).to_be_visible() # its thumbnail never arrives: still loading
wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0,
label="the mark clears on the slide whose thumbnail arrived")
print(" PASS: frame shows placeholder while loading")
# picture up, full-res still out
expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible()
wait_until(page, lambda: slides.nth(3).get_by_label("loading").count() == 0,
label="the picture is up on the slide where both arrive")
# …and nothing sharper is pending
expect(slides.nth(3).get_by_label("fetching full resolution")).to_have_count(0)
print(" PASS: frame thumbnail shows upgrade mark")
strip.click() # the bar, on the doc the show opened on
# it leaves todo → its box goes to the one that never arrives
bar.get_by_role("button", name="done", exact=True).click()
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzmark-t-1"),
label="the box that had finished loading now holds the doc that never will")
expect(slides.nth(1).get_by_label("loading")).to_be_visible() # the mark came with the new doc
print(" PASS: frame placeholder after edit remaps slot")
# and relaunched from its shortcut, with neither knob wound
BAR_PATIENCE_MS = 20000
open_app(page) # a launch that winds neither knob
expect(strip).to_be_visible() # the memory brings the show back up
strip.click() # the bar, and the touch its patience counts from
expect(bar.get_by_label("seconds per photo")).to_have_value("60") # a minute a slide
page.wait_for_timeout(BAR_PATIENCE_MS - 2000) # most of the twenty seconds, untouched
expect(bar).to_be_visible() # …and the bar is still up
expect(bar).to_be_hidden(timeout=4000) # gone once they have run out
print(" PASS: frame defaults to a minute a slide and twenty seconds of bar")
finally:
for c in MARK_ARRIVES: page.unroute(f"**/ipfs/{c}")
for d in MARK_DOCS: gql(DELETE, {"cid": d["cid"]})
drop_events(CABINET_EVENTS)
drop_vocab(CABINET_WORDS)
The slideshow
- what
- left to itself the app becomes a slideshow: fullscreen, over whatever the wall is showing.
- what: no separate app: ▶ frame turns the current wall, whatever the search box has narrowed it to, into a slideshow.
- what
- it plays the wall in the order shown — chronological by default, or the random draw the
search box asks for.
- scope: ordering is an app-wide concern, not a slideshow one.
- rule
- videos don’t auto-play: a video the slideshow comes to rest on waits for a press.
- how: In practice the reading waits out the step onto the video and the settle after it, the moment a slideshow that played its videos would start one.
wait_until(page, on_clip, label="the clip is centred")
page.wait_for_timeout(FRAME_STEP_GRACE_MS + SNAP_GRACE_MS) # the step over, and the settle after it
assert strip.locator("video").first.evaluate("el => el.paused"), "the clip started by itself"
print(" PASS: frame clip does not autoplay")
- what
- there’s no chrome.
- what: a small control bar (pause, interval, exit) is a tap away.
- how
- the slideshow is one layer over the wall: the strip, and the control bar laid 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 slideshow.
<${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(),
() => setFrameLabelFocus(false)); }} />
<${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>
<//>
.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 */
- what
- everything the slideshow does is a reaction to a small cluster of state: whether it is open and
playing, which slide has settled, and whether a pinch has taken hold.
- what: the settled slide comes with its place on the strip, so the slides loaded around it know where they are.
- how
- the state is kept together, so every reaction 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);
Which slide it rests on
- what
- which slide the slideshow 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.
- how
- In practice the answer is arithmetic — the offset divided by a slide’s width, rounded to the nearest.
- rule: the width is the one the slideshow itself places slides by, the strip’s own rather than the
viewport’s, or the reading drifts from the thing it is measuring.
- what: the two differ, as the measure the slideshow places by tells.
- rule: the width is the one the slideshow itself places slides by, the strip’s own rather than the
viewport’s, or the reading drifts from the thing it is measuring.
- how
- the question is asked in five shapes.
- example: the bare position along the strip.
- example: the picture sitting there.
- example: which of the suite’s standing docs that picture belongs to.
- example: whether what has come to rest is a video at all — a video’s slide holds no picture, so the shape that reads a picture’s address has nothing to say about one.
- example: how much of the screen whatever is there covers — a photo and a video answering the same question, so it asks after neither by name.
SLIDE_W = "el => (el.scrollWidth / (el.children.length || 1)) || 1"
ON_SLIDE = f"el => Math.round(el.scrollLeft / ({SLIDE_W})(el))"
CENTERED = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" const im = el.children[i] && el.children[i].querySelector('img');"
" return im && im.getAttribute('src'); }")
CENTERED_IS_CLIP = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" return !!(el.children[i] && el.children[i].querySelector('video')); }")
CENTERED_MEDIA = (f"el => {{ const w = ({SLIDE_W})(el); const i = Math.round(el.scrollLeft / w);"
" const m = el.children[i] && el.children[i].querySelector('video, img');"
" if(!m) return null; const r = m.getBoundingClientRect();"
" return { width: r.width, height: r.height,"
" fit: getComputedStyle(m).objectFit }; }")
def centred_slide(strip):
src = strip.evaluate(CENTERED) or ""
return next((k for k, f in enumerate(FIXTURES) if f["thumbnailCid"] == src), None)
Moving through the slideshow
-
Playing, and yielding to a touch
rule : the slideshow plays through by itself. - how: a
?ms=launch parameter overrides the minute a slide. - what: it runs in date order, so the suite’s standing docs 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")how : one interval timer drives it, standing down while anything holds the slideshow, and each tick smooth-scrolls one slide on from wherever the strip currently sits, so a swipe in between is respected.
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000; const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);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)); });rule : the slideshow yields the moment it is touched, and picks up again once you have gone. - how: any tap or swipe stops the auto-advance, which picks back up only after a span of quiet — a minute, which
?idleresumeoverrides. - therefore: 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") # a centre tap: real interaction, no nav page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS) # let any in-flight step settle held = strip.evaluate(ON_SLIDE) # several tempo ticks, still inside the resume span page.wait_for_timeout(LIVE_RESUME_MS // 2) 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")how : a press on the strip marks the slideshow busy, and the mark lapses once that span has passed since the last press.
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);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)); });
-
Flinging the strip
what : the swipe is the slideshow’s main gesture: you fling the strip and it coasts a few slides on its own momentum.
what : a mouse drag carries no momentum. - therefore: the behaviour only shows under a real touch fling. - how: both checks below ride one such fling, driven (as for the lightbox) through Chromium’s touch pipeline: the finger flies left across the screen and lets go.
what : knowing when the fling is over is the awkward part, because the strip goes still twice. - what: momentum runs out first, then the strip waits out its own beat of quiet before easing onto the nearest slide. - therefore: a reading taken during that lull would measure a resting place the slideshow is about to leave. - how: the test insists on a stillness longer than the beat — seven still reads, 100ms apart: only the rest that follows the ease can be that quiet, and any motion starts the count again. - how: the wait gives up after six seconds, well beyond the fling, the 150ms beat and the ease put together.
what : that first lull is where momentum alone left the strip — the resting place the ease exists to correct, and the only evidence that there was anything to correct. - what: each question asked from outside costs a round trip, and the lull lasts only a beat. - therefore: an answer from outside may describe the strip after the ease as well as before it. - how: the lull is found where it happens: the strip reports every scroll it makes, timestamped, and the beat is the one gap in that record wide enough to be the slideshow waiting. - how: whatever the strip read just before that gap is where momentum left it, and everything after is the ease.
rule : what counts as wide enough is pinned on both sides. - what: as wide as the beat, and the one gap it is looking for may fall just short of it. - what: too narrow, and an ordinary stutter in the fling passes for the slideshow’s wait. - how: the cutoff sits 30ms under the beat, at 120ms: far longer than the frame-sized gaps momentum and the ease leave, yet shorter than the beat itself.
SETTLE_BEAT_MS = 150 # the show's own wait for quiet before it eases FRAME_MS = 30 # the grace under it: wider than any gap momentum makes STILL_READS = 7 # a stillness longer than that beat, in 100ms reads SETTLE_CAP_MS = 6000 # fling + beat + ease, with room to spare CENTRED_PX = 20 # slack against a boundary FLING_TRIES = 4 # flings, until one coasts to rest off a boundary # Record every scroll the strip makes, then read the beat back out of the record. TRACE_SCROLL = """el => { const s = []; el.__scrolls = s; el.addEventListener('scroll', () => s.push([performance.now(), el.scrollLeft])); }""" COASTED_AT = """(el, beat) => { const s = el.__scrolls || []; for(let i = 1; i < s.length; i++) if(s[i][0] - s[i - 1][0] > beat) return s[i - 1][1]; return s.length ? s[s.length - 1][1] : null; }""" def hard_frame_flick(page, strip): """A hard, fast touch fling — the finger flies ~900px left in ten quick steps — returning where momentum alone left the strip, and once the ease has come to rest.""" box = strip.bounding_box() cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 strip.evaluate(TRACE_SCROLL) cdp = page.context.new_cdp_session(page) cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": cx, "y": cy}]}) for k in range(1, 11): cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": cx - 90 * k, "y": cy}]}) time.sleep(0.001) cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) prev, stable, step = None, 0, 100 for _ in range(SETTLE_CAP_MS // step): cur = strip.evaluate("el => el.scrollLeft") stable = stable + 1 if (prev is not None and abs(cur - prev) < 1) else 0 if stable >= STILL_READS: break prev = cur; page.wait_for_timeout(step) return strip.evaluate(COASTED_AT, SETTLE_BEAT_MS - FRAME_MS)
Showing each doc
-
Saying a picture is on its way
rule : the slideshow says a picture is still arriving: a doc with nothing yet to show reads as loading, not broken, rather than sit black. - how: 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. - rule: the instant the thumbnail’s load fires, the mark clears and the picture stands.expect(slides.nth(2).get_by_label("loading")).to_be_visible() # its thumbnail never arrives: still loading wait_until(page, lambda: slides.nth(1).get_by_label("loading").count() == 0, label="the mark clears on the slide whose thumbnail arrived") print(" PASS: frame shows placeholder while loading")what : once the thumbnail stands, a near doc’s web copy may still be on its way: the picture is already up, only sharper detail is pending.
how : In practice the clearing of the mark below is read on the slide where both arrive, in two steps: the picture is established first, and from there the mark’s absence is the landing. - what: the absence of a mark means nothing on its own — a slide with no picture yet has none either.
rule : a quieter mark takes the big one’s place: a small pulse tucked in the corner, labelled
fetching full resolution, that reads as “this is the thumbnail; the full image is coming.” - rule: when the web copy paints over the base, it clears.# picture up, full-res still out expect(slides.nth(1).get_by_label("fetching full resolution")).to_be_visible() wait_until(page, lambda: slides.nth(3).get_by_label("loading").count() == 0, label="the picture is up on the slide where both arrive") # …and nothing sharper is pending expect(slides.nth(3).get_by_label("fetching full resolution")).to_have_count(0) print(" PASS: frame thumbnail shows upgrade mark")
-
The marks follow what the box shows
what : a slide is a reused slot: after an edit re-anchors the slideshow (a doc leaves the filter and the strip closes the gap), a slot that held one doc comes to hold another.
rule : the mark follows the new doc, not lingering from the old: a freshly-shown doc whose image is still arriving wears the mark even though its slot had finished loading something else.
strip.click() # the bar, on the doc the show opened on # it leaves todo → its box goes to the one that never arrives bar.get_by_role("button", name="done", exact=True).click() wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzmark-t-1"), label="the box that had finished loading now holds the doc that never will") expect(slides.nth(1).get_by_label("loading")).to_be_visible() # the mark came with the new doc print(" PASS: frame placeholder after edit remaps slot")what : 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. - what: a slide flung in from the blank band carries an already-painted blank.
rule : the big mark keys on the base layer’s current source: it returns whenever that source changes — a new doc dropped in the box, or the thumbnail re-picked for the doc already there — and clears once the thumbnail paints.
far_mark = strip.get_by_role("listitem").nth(FAR).get_by_label("loading") expect(far_mark).to_have_count(0) # let go of, and its blank painted: nothing to announce goto(FAR) # fling onto it expect(far_mark).to_have_count(1) # its source turns real → the mark is back until that paints print(" PASS: frame placeholder returns on band flip")how : the slide stacks two images — the thumbnail base and, when close enough, the web copy overlay above it — with the marks between them. - how: 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 // each layer re-arms its mark on its own source change createEffect(() => { thumbSrc(); setThumbOn(false); }); 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>`; };how : the big mark sits centred over the slide until the picture paints over it, the web copy fades in over the thumbnail, and the quiet mark pulses tucked in a corner.
/* 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; } /* full-res fades in over the thumbnail */ .slide-pic .slide-media.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; } .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 } }
Triaging from the slideshow
- what
- a tap reveals the control bar, and it turns the slideshow into a review station.
- what: everything on it acts on the centred doc, so you can triage without leaving the slideshow.
-
What the bar says
rule : the bar’s state buttons are each tinted their own hue —
todocyan,nextamber,donegreen,deletered.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")how : each state button carries its state, and each state names its own hue.
[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); }rule : the bar also names the centred slide’s date, and the date follows the slide as the slideshow 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")
-
The bar steps back
rule : the control bar tidies itself away. - how: Under the hood the wait below 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")rule : “quiet” means quiet: any interaction while the bar is up — a tap, a swipe, a press on its own controls — restarts the count. - therefore: it never vanishes mid-use, and leaves only once you have truly stopped.
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")how : the bar’s window is its own knob —
?uiidleoverrides the twenty seconds — apart from the minute of quiet that lets the auto-advance resume. - what: this timer tidies away the control bar, that one guards a slide you are studying: they pace different things. - therefore: they don’t share a number.how : the count restarts whenever you touch the slideshow, riding the same interaction signal it already tracks.
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)); });what : that signal is fed by the strip, and the bar sits beside the strip rather than in it, so a press on the bar’s own controls never reaches it.
how : the bar forwards its presses there, so working the control bar counts as touching the slideshow and keeps it up.
onPointerDown=${nudge}
-
Coming back after a relaunch
rule : relaunched, the slideshow is still showing, on the slide it stopped on — the one last centred, not the first.
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")rule : only a deliberate leave of the slideshow stays left: the next launch opens on the wall.
page.keyboard.press("Escape") # leaving turns the memory off expect(strip).to_be_hidden() open_app(page, CABINET_QS) expect(tiles(page)).to_have_count(len(FIXTURES)) # the docs an auto-entry would place from page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired expect(page.get_by_role("list", name="slideshow")).to_be_hidden() # stays on the wall print(" PASS: frame mode persists")rule : set it going again and the memory comes back with it: a relaunch carrying only the cabinet’s tempo, no instruction to enter the slideshow, opens it over the query the wall had persisted. - rule: that auto-start fires once per launch: exiting it stays exited, so a tablet you have walked over to and switched off doesn’t snap straight back in. - what: standing still is not what keeps it out: auto-start waits on the wall having docs to show, so a wall that never reads again never gives it a second chance. - what: a tablet’s wall reads again on its own, as the server it is pointed at fills up. - rule: a fresh set of docs is not a fresh launch.
expect(tiles(page)).to_have_count(len(FIXTURES)) # the persisted query brought the wall back page.get_by_role("button", name=re.compile("frame", re.I)).click() # set it going again → remembered expect(page.get_by_role("list", name="slideshow")).to_be_visible() open_app(page, CABINET_QS) # relaunch: remembered → auto-enters, no click strip = page.get_by_role("list", name="slideshow") expect(strip).to_be_visible() page.keyboard.press("Escape") # exit within this launch chip(page, "todo").click() # …and the wall reads again, as it will expect(tiles(page)).to_have_count(len(FIXTURES)) # fresh docs — the chance a re-entry would take page.wait_for_timeout(FRAME_REENTRY_GRACE_MS) # long enough for one to have fired expect(strip).to_be_hidden() # once per launch — it doesn't print(" PASS: frame autostart")what : the cabinet tablet has no address bar: the slideshow launches from a home-screen shortcut, which opens with no query string, so an auto-start can’t ride a launch parameter. - how: being in the slideshow is remembered instead: when the persisted wall loads and the slideshow was last on, it auto-starts, once only, so exiting doesn’t immediately re-enter. - how: on opening, the slideshow is placed on the doc the settle last wrote down, before any scroll, 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); }); });
-
Labelling from the bar
rule : the passer-by gets an add-label box on the slideshow. - rule: it completes on the same vocabulary as everywhere else.
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20) expect(options(page).first).to_be_visible() # vocabulary suggestions assert CABINET_STEM in options(page).first.inner_text().strip().lower() print(" PASS: frame label completion")what : it is a combobox on the search box’s terms.
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM) expect(box).to_have_attribute("aria-expanded", "true") box.blur() expect(box).to_have_attribute("aria-expanded", "false") assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur" print(" PASS: frame label combobox state")rule : completion never proposes a label the centred doc already wears.
box.click(); box.fill(""); box.press_sequentially(CABINET_STEM, delay=20) expect(options(page).filter(has_text=re.compile(f"^{CABINET_FREE}$")).first).to_be_visible() offered = [t.strip() for t in options(page).all_inner_texts()] assert CABINET_FREE in offered, f"the list went before it could be read: {offered}" assert CABINET_WORN not in offered, f"a word every slide already wears was offered: {offered}" print(" PASS: frame completion skips present")how : the box hangs the shared completion list under itself, tells it what the centred doc already wears, and reads its expanded state off whether that list has anything to show.
<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(), () => setFrameLabelFocus(false)); }} /> <${Show} when=${() => frameLabelFocus()}> <${Suggest} text=${frameLabel} present=${() => labelsOf(frameDoc())} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading} onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} /> <//> </div>how : a pick fills the word in;
Enteradds the typed words to the centred doc through the same edit every control on the bar uses, never doubling a label it wears, andShift+Entertakes them off instead.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('; ') })); };
Back, and the occasions on the bar
-
The occasions on the bar
rule : the slideshow carries the same occasions the lightbox does: beside the date, the centred slide’s occasions ride on the bar, so it tells you not just when a doc was taken but what was happening then.
expect(bar.get_by_text(CABINET_EVENT)).to_be_visible() # whatever slide it rests on, it is that day = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso) expect(bar.get_by_text(day(CABINET_FROM))).to_be_visible() # the span's start, beside the name expect(bar.get_by_text(day(CABINET_TO))).to_be_visible() # …and its end print(" PASS: frame shows events")rule : stepping to the next slide brings its own. - how: In practice the steps are side taps: a key would step the slideshow too, but only a touch keeps the bar up while it does. - how: the bar is let go first, so the tap that raises it is known to raise it rather than put it away.
expect(bar).to_be_hidden() # let go, so the tap below raises it strip.click() expect(bar).to_be_visible() box = strip.bounding_box() step_on = lambda: page.mouse.click(box["x"] + box["width"] * 0.92, box["y"] + box["height"] / 2) feb = 1 # the one slide the short occasion covers for _ in range(len(FIXTURES)): if centred_slide(strip) == feb: break k = (centred_slide(strip) + 1) % len(FIXTURES) step_on() wait_until(page, lambda k=k: centred_slide(strip) == k, label="a side tap steps the show on") expect(bar.get_by_text(CABINET_SHORT)).to_be_visible() # February's doc, and its own occasion step_on() # on to March's wait_until(page, lambda: centred_slide(strip) == feb + 1, label="the show steps off February") expect(bar.locator(".frame-event")).to_have_count(1) # the bar up, with one occasion left on it… expect(bar.locator(".frame-event")).to_contain_text(CABINET_EVENT) # …the long one print(" PASS: frame events follow the slide")rule : the bar’s pill carries the occasion’s when too — its start and end, the same muted date aside as the lightbox, kept on the bar’s single line.
day = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso) expect(bar.get_by_text(day(CABINET_FROM))).to_be_visible() # the span's start, beside the name expect(bar.get_by_text(day(CABINET_TO))).to_be_visible() # …and its endhow : the wiring is the lightbox’s with one substitution: the occasions are fetched for the centred doc rather than the opened one, through the same query and its restriction to the doc’s owner.
const [frameEvents] = createResource( () => { const o = frameDoc(); return o?.owner && o?.date ? o : null; }, fetchDocEvents);how : the pills carry the same name and muted when as there; the bar being a single line, both the pill and its date aside stay on it.
<${For} each=${() => frameEvents() || []}>${e => html` <button class="frame-event" onClick=${() => { searchEvent(e.summary); exitFrame(); }}>${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></button>`} <//>.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; }rule : the bar’s pill jumps like the lightbox’s: clicking it runs the occasion’s
event:search and steps out of the slideshow onto the wall, now narrowed to that occasion.bar.get_by_role("button", name=re.compile(CABINET_EVENT)).click() # follow the occasion expect(search_box(page)).to_have_value(f"event:{CABINET_EVENT}") # the pill's search, committed expect(strip).to_have_count(0) # and out of the show with it print(" PASS: frame event pill searches")
Somebody stops to look
- what
- the slideshow is playing to an empty room and you walk up to it.
- what: 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.
- what
- sometimes that happens at a desk with arrow keys under your hands, and sometimes at the cabinet where the only instrument is a finger.
- what
- the finger is the harder half.
- rule: one instrument has to say four things.
- example: a tap near an edge — step the slideshow.
- example: a tap in the middle — show me the controls.
- example: two fingers at once — let me look closer.
- example: one finger travelling — run me forward a stretch.
- what: nothing distinguishes them but where they land, how many there are, and whether they move.
- what: read one as another and the slideshow lurches when you meant to pause it.
- rule: one instrument has to say four things.
- what
- looking is also when you notice things: a date that is plainly wrong, a doc worth condemning, one
you have finally dealt with.
- what: the slideshow is where they get put right.
- what
- the bar a tap reveals acts on the centred doc.
- what
- then you have seen what you came for and give the slideshow back.
- what: 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.
The strip and its tempos
- how
- it wants a long strip — the row of slides the slideshow runs along — twelve years, one apiece.
- what
- a strip that loops carries a copy of its last before the first and a copy of its first after the
last.
- therefore: the opening one sits one along rather than at the very start.
- how
- it also wants two tempos.
- how: while you drive it by hand, one wound so far down that nothing moves unless you move it.
- how: once you hand it back, one brisk enough to watch, with a short patience.
- how
- two further spans are the suite’s own, both of them room for something to happen.
- how: one for a step already under way to finish before a reading is taken.
- how: one in which a gesture that should have moved the slideshow would have shown it.
- how
- the leaning-in — a two-finger pinch, to look closer — runs at that same brisk tempo, but wants
two of the slideshow’s own spans wound down to reach.
- how: its after-touch patience is set short.
- how: its give-up-and-reload span is set short too, but not that short.
- rule: it has to outlast everything measured under the pinch.
SWIPE_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzswipe-{i}", "date": f"20{10+i:02d}-01-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzswipe-t-{i}",
"labels": "zzswipe", "state": "todo"} for i in range(12)]
STILL_QS = "?ms=999999" # a tempo no test will ever outwait
LIVE_MS, LIVE_RESUME_MS = 400, 2500
LIVE_QS = f"?ms={LIVE_MS}&idleresume={LIVE_RESUME_MS}"
LIVE_SETTLE_MS = 300
FRAME_STEP_GRACE_MS = 300
PINCH_HOLD_MS = 1600 # past a 600ms touch-idle, and several ticks
SNAP_GRACE_MS = 400
ZOOM_IDLE_MS = 3000 # outlasts both
PINCH_QS = f"?ms={LIVE_MS}&idleresume=600&zoomidle={ZOOM_IDLE_MS}"
The videos
- how
- the videos are a second, shorter run, laid out around one video.
- how: a photo ahead of the video: retiring it re-deals the slots on the strip, and the video comes to sit in one that held a picture.
- how: two photos behind it.
- how
- their tempo is the third the story opens.
- rule: slow enough that the video comes round without a wait worth watching.
- rule: brisk enough that a slideshow which failed to hold would have stepped twice off the video by the time the reading is taken — and once is already enough to fail it.
CLIP_LABEL = "zzclip"
CLIP_DOCS = ([{"cid": "https://ipfs.konubinix.eu/p/zzclip-0", "date": "2018-01-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzclip-t-0", "labels": CLIP_LABEL, "state": "todo"},
{"cid": "https://ipfs.konubinix.eu/p/zzclip-v", "date": "2018-02-15T12:00:00Z", "mimetype": "video/webm",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzclip-t-v", "webCid": "https://ipfs.konubinix.eu/p/zzclip-web",
"labels": CLIP_LABEL, "state": "todo"}]
+ [{"cid": f"https://ipfs.konubinix.eu/p/zzclip-{i}", "date": f"2018-0{i + 2}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzclip-t-{i}",
"labels": CLIP_LABEL, "state": "todo"} for i in (1, 2)])
CLIP_MS = 1000
CLIP_QS = f"?ms={CLIP_MS}"
CLIP_HOLD_MS = 2500 # two of those ticks, and half of a third
The long run
- how
- a third run, long.
- what
- the slideshow keeps a band of slides in hand around wherever you are and lets the rest go.
- rule: a run has to be longer than that band for there to be anything outside it to look at.
- what
- the band reaches fourteen ahead.
- how
- the run is forty.
- how
- they carry a web copy as well as a thumbnail.
- what: the slideshow keeps the two to different distances.
- how
- the one slide the band readings return to is twenty along.
BAND_LABEL = "zzband"
BAND_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzband-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzband-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzband-web-{i}", "labels": BAND_LABEL, "state": "todo"}
for i in range(40)]
FAR = 20 # the slot every band reading is taken around
Driving the slideshow by hand
Looping
- what
- a native scroller cannot wrap.
- rule
- the strip loops both ways: a step before the first slide lands on the last, and past the last on the first, so the slideshow never dead-ends.
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")
- how
- the strip is laid out as a loop carrying a copy at each end:
the first doc sits at offset one rather than zero, and the last of
Nsits atN. - what
- one measure underlies all the positioning: a slide is located by its own
geometry — each slide’s offset, and the true per-slide width, the strip’s total over every slide — never
the viewport’s rounded width.
- what: a slide is a full viewport wide, a length that can sit a fraction off the viewport’s integer width, and across a wall of thousands of slides that fraction compounds into whole slides adrift.
- therefore: only the slides’ real geometry keeps the slideshow landing dead-centre.
- how
- a step is just a smooth scroll to the neighbour of wherever the strip sits.
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' }); };
- how
- a native scroll flows freely; the slideshow lets it, then tidies up once it stops — the settle.
- how: 150ms after the last scroll, the settle re-centres on the nearest real slide; stepping off either end lands on a copy, which it silently swaps for the real slide it duplicates.
- how: it writes down the doc it came to rest on, so a relaunch resumes there.
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); };
The way out, and the arrows
- rule
- the way out of the slideshow leaves the app standing.
- how: the device back button and the bar’s ✕ exit both put the wall back.
page.go_back()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible() # still on the app, not gone
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
strip.click() # a tap to bring the bar up
page.get_by_role("button", name="exit frame").click()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible()
print(" PASS: frame exits on back")
- how
- entering the slideshow is a small ceremony.
- how: the slideshow is marked open and playing.
- how: being in it is remembered on the device, so a relaunch of the installed app comes straight back.
- how: a history entry is pushed, so the back button leaves.
- how: a wake lock is taken, so the cabinet screen stays lit.
- how
- closing is the teardown: the auto-start is turned off and the lock released.
- how
- 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();
- rule
- the arrow keys drive the slideshow.
- rule: they step it 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")
- how
- a modern browser hands keyboard focus to a scrollable region, and left to its own devices would
answer an arrow by scrolling the strip a notch itself — jerking the doc sideways and fighting the step
the settle then has to undo.
- therefore: the slideshow claims that keypress wholly, so its own one-slide step alone drives it, the same animation as a side-tap.
- how
- a few listeners ride on the window for the slideshow’s whole life.
- how:
Escand the arrow keys work the slideshow — except while the add-label box holds focus, where the arrows edit the text rather than step the slide. - how: the back button unwinds the stack in order — out of the slideshow, then out of the lightbox it may have launched from, then to the wall.
- how: when the tab returns to the foreground, the wake lock, dropped while hidden, is taken again so the screen stays lit.
- how:
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); });
});
A video or a pinch holds it
- rule
- a playing video holds the slideshow: a video you started isn’t scrolled off before it ends.
wait_until(page, on_clip, label="the clip comes round")
v = strip.locator("video").first
v.evaluate("el => { el.muted = true; el.currentTime = 0; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.wait_for_timeout(CLIP_HOLD_MS) # two ticks, and half of a third
assert on_clip(), "a playing clip must hold the show"
v.evaluate("el => { el.currentTime = el.duration; }") # let it play out
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-1"),
label="the show moves on once the clip has finished")
print(" PASS: frame clip holds the show")
- how
- each tick asks the slides first, and skips the advance while a video is still rolling.
if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
- what
- a pinch holds it hardest of all.
- what: the pinch itself is the browser’s own — spread two fingers and it magnifies the slide, no code of ours in the loop.
- rule
- the auto-advance stands still while a pinch is on — lean in on a face and the slideshow won’t step away from it, even once the after-touch idle span that also pauses it has lapsed.
page.wait_for_timeout(PINCH_HOLD_MS) # past the touch-idle, and several ticks
assert strip.evaluate(CENTERED) == held, "a magnified slide must not auto-advance"
print(" PASS: frame pinch pauses auto-advance")
- how
- the browser reports the pinch through its visual viewport, whose scale sits at 1 unzoomed and climbs past it the moment a pinch takes hold, watched off the viewport’s own resize and scroll.
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); }); });
- rule
- the settle stands off while a pinch is on, so a scroll nudged mid-slide isn’t yanked back to a slide edge and out from under the magnified view.
nudged = strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4); // 40% in: well off any boundary
el.dispatchEvent(new Event('scroll')); return el.scrollLeft; }""")
page.wait_for_timeout(SNAP_GRACE_MS) # well past the settle-snap's own beat
assert abs(strip.evaluate("el => el.scrollLeft") - nudged) <= 1, \
"a magnified slide must be left where it was nudged, not pulled to a slide edge"
print(" PASS: frame pinch freezes snap")
A fling stays controlled
- scope
- the fuller feel the slideshow is for — a brisk flick carrying you across several slides at once — is not something this note measures.
- what
- the suite pins a ceiling instead.
- what
- the ceiling guards the browser’s momentum rather than anything written here.
- what: no change to this app’s own code makes the fling overshoot without first breaking the wrap, which the story reads a moment earlier.
- therefore: it rests on the measurement alone — a guard kept for the day a browser changes its mind about how far a flick carries.
- rule
- the fling stays controlled: a hard, fast one advances a handful and comes to rest within the first few slides, never stampedes across the whole strip.
CONTROLLED_SLIDES = 4
w = strip.evaluate(SLIDE_W)
for _ in range(FLING_TRIES):
before = strip.evaluate(ON_SLIDE)
coasted = hard_frame_flick(page, strip)
landed = strip.evaluate(ON_SLIDE)
assert landed - before <= CONTROLLED_SLIDES, \
f"the swipe flung {landed - before} slides on — past the controlled range"
strayed = min(coasted % w, w - (coasted % w))
if strayed > CENTRED_PX: break # this one has something to ease
print(" PASS: frame swipe does not overshoot")
A fling lands centred
- what
- coasting freely, momentum stops the strip wherever friction runs out — usually between two slides, leaving the slideshow squinting at half of each.
- what
- the ease lands exactly on a slide boundary.
- therefore: the tolerance only has to be tight enough to catch a strip that never eased at all: 20px of slack.
- what
- where friction stops the strip is not ours to choose: now and then it stops near a boundary of its
own accord — a fling with nothing to ease, which an eased strip and an un-eased one both satisfy.
- how: the fling reports where momentum left it as well as where it ended, and one that coasted to rest already centred is flung again rather than counted.
- how: each attempt is a whole gesture, the same swipe thrown again, and answers the ceiling above on its own account.
- how: four of them is where the patience runs out: all four landing centred is no longer bad luck but
a strip snapping on its own.
- therefore: the ease is then unmeasurable rather than absent, and the check says so rather than passing over it.
- rule
- once the fling stops, the settle eases the strip onto whichever slide it came to rest nearest, centred on one, never straddling two.
rest = strip.evaluate("el => el.scrollLeft")
assert strayed > CENTRED_PX, \
f"{FLING_TRIES} flings all coasted within {strayed:.0f}px of a boundary — none could show an ease"
off = min(rest % w, w - (rest % w)) # distance to the nearest slide boundary
assert off < CENTRED_PX, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
print(" PASS: frame swipe settles centered")
Touch and the keys
-
Tap zones
what : the arrow keys want a keyboard the cabinet tablet doesn’t have, and one-handed from across the room a dependable swipe wants two hands. - therefore: the screen itself carries the same step, and where the tap lands is the whole of it.
rule : a tap near an edge steps the slideshow: the outer thirds do, 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")rule : a tap in the middle shows the control bar: the centre third is the bar’s, and a tap there does not step the slideshow.
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")how : 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 tap read off the pointers
what : a plain click can’t carry that step: a video’s own player controls, in the slide, swallow the tap before any click bubbles up.
what : the video is where that reading is confirmed, though nothing this note can break will pin it there. - what: rewiring the watch onto a plain click does break it, but the same rewiring breaks a tap on a doc, measured several movements earlier — so the red lands there and never reaches here.
rule : a side-tap steps the slideshow off a video just as off a photo.
page.keyboard.press("ArrowLeft") # back onto the clip wait_until(page, on_clip, label="back on the clip") cdp = page.context.new_cdp_session(page) # a real touch tap cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": box["x"] + box["width"] * 0.92, "y": midY}]}) cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-1"), label="the side-tap stepped the show off the clip") print(" PASS: frame tap steps over a clip")rule : two fingers planted in an outer third and lifted leave the slideshow exactly where it stood — only a lone finger carries the side-step.
# the right third — a lone tap here would step forward x = box["x"] + box["width"] * 0.92 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" cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": False}) # see below print(" PASS: frame two-finger gesture does not step")how : the touch screen is put away again once the fingers lift. - what: left on, it makes
blur()a no-op on a date-and-time field, so a later picker never closes.how : the strip’s raw pointers are read instead of clicks, 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.
how : a second finger makes a pinch, which belongs to the browser’s own zoom, so the watch lets that gesture go.
const TAP_SLOP = 10; // where a lone finger went down, while it could still be a tap let tapFrom = null; 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 slideshow owns the keys
what : the wall behind the slideshow still listens to the same keyboard, and it has uses of its own for these keys. - what: the two do not even read them the same way: the slideshow takes an arrow as an arrow and steps,
Shiftheld or not, while to the wall that same press is an instruction to start a run — picking docs out. - therefore: standing aside cannot be left to the keys themselves to sort out.rule : while the slideshow is up it has the keys: one you walked away from has not quietly chosen anything.
page.keyboard.press("Shift+ArrowRight") page.keyboard.press("Shift+ArrowRight") expect(checks(page)).to_have_count(0) # nothing chosen behind the slideshow print(" PASS: the frame owns the keys")
Letting go of a forgotten pinch
- rule
- a pinch left untouched must not trap the slideshow forever.
- rule: after five minutes zoomed with no touch, the slideshow drops the magnification and lands at 1:1.
page.wait_for_url(re.compile(r"[?&]z=")) # left alone, it gives up and reloads itself…
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01,
label="…onto an address the browser has never seen zoomed, so it lands at 1:1")
print(" PASS: frame pinch resets after idle")
- how
- in practice the test’s wait is a wait for a navigation, and is spelt as one.
- what: a reading taken of the page while it is being replaced is not a reading of anything.
- what: asking for the scale across the reload is how you get told the context has gone.
- rule
- the slideshow picks up again once you have gone, even from a slide left magnified.
- how: In practice the strip is read once it has been placed — before that it sits at its very start, on the copy of the last slide, which would pass for a step.
expect(strip).to_be_visible() # the reload came back into the show…
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > 0, label="the strip is placed")
placed = strip.evaluate(ON_SLIDE)
wait_until(page, lambda: strip.evaluate(ON_SLIDE) != placed,
label="…and plays on by itself", detail=lambda: f"still on slide {placed}")
print(" PASS: frame plays on after a forgotten pinch")
- what
- the pinch is the browser’s, and no way back from it was found.
- what: on the tablet’s Chromium, no script tried could zoom out.
- what: neither a
maximum-scaleflip on the viewport tag nor a plain reload cleared it — both left the magnification in place on the tablet.- what: so Chromium was seen to carry the remembered scale across the reload.
- what: the move found to drop it is landing on an address the browser has never seen zoomed.
- how
- the slideshow navigates to its own page with a fresh throwaway query.
- what: that reload is a relaunch, and a relaunched slideshow is still showing, on the slide it stopped on.
- rule
- every touch re-arms the five minutes.
- how: the countdown rides the same touch signal the rest of the slideshow listens to.
- therefore: only a stretch of pure quiet while zoomed — a truly forgotten pinch — reaches the navigation.
- how: In practice two touches, two thirds of the span apart, and a look two thirds of a span after the last: past the span counted from the first, short of it counted from the last.
zoomed_at = page.url
for _ in range(2): # a touch now and then, never a span apart
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.wait_for_timeout(ZOOM_IDLE_MS * 2 // 3)
assert page.url == zoomed_at, f"the touches did not hold the reload off: {page.url}"
print(" PASS: a touch re-arms the zoom countdown")
- how
?zoomidle=overrides the five minutes.
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);
// an address the browser hasn't seen zoomed → it lands at 1:1
u.searchParams.set('z', String(Date.now()));
location.href = u.href; }, ZOOM_IDLE_MS);
onCleanup(() => clearTimeout(id)); });
Each doc as you come to it
Shown whole, filling the screen
- how
- In practice the reading is taken of whatever is centred, and taken twice: on the doc the
slideshow opens on, then on the video a step along.
- what: a slide waiting off to the side is the full width of a viewport it is nowhere near.
- therefore: the first one in the strip would answer yes without ever having been about the screen.
- rule
- the media fills the screen: a web copy smaller than the display is grown to it, photo and video alike.
- rule
- the doc is still shown whole, scaled to the largest size that fits without cropping: a differently-shaped one keeps all of itself, the black of the screen framing its spare axis.
for kind in ("photo", "clip"):
m = strip.evaluate(CENTERED_MEDIA)
assert m, f"the centred {kind} shows nothing at all"
assert m["width"] == VIEWPORT["width"], \
f"the {kind} spans {m['width']}px of a {VIEWPORT['width']}px screen"
assert m["height"] == VIEWPORT["height"], \
f"the {kind} stands {m['height']}px in a {VIEWPORT['height']}px screen"
assert m["fit"] == "contain", f"the {kind} is cropped to fill rather than shown whole"
if kind == "photo":
page.keyboard.press("ArrowRight") # on to the other kind
wait_until(page, on_clip, label="the clip is centred")
assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", \
"the screen framing them must be black"
print(" PASS: frame media fills screen on black")
- how
- each slide is sized to the screen, and its media fitted inside it rather than cropped to cover it, on a black ground.
.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; }
/* full-res fades in over the thumbnail */
.slide-pic .slide-media.web{ position:absolute; inset:0; opacity:0; transition:opacity .25s; }
.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 */
Loading ahead of you
- what
- the web copy is heavy, so a slide is built in two layers: a thumbnail base, and the web copy over
it while the slide is near enough the centre — each layer kept within a band of slides around it.
- what: the thumbnail base is light, and already cached from the wall you came in through.
- therefore: every slide within range paints something at once instead of sitting black.
- what: the thumbnail base is light, and already cached from the wall you came in through.
- how
- In practice reading a band means putting the slideshow on a chosen slide, the strip being reached by
arithmetic: its total width over its slide count.
- rule: the strip must be laid out first: the slides reach their full width a beat after they render,
so the wait is for the strip to come within one slide of full width, not merely for the nodes.
- how: a slide of slack keeps a sub-pixel settle from hanging it.
- rule: the slideshow must have settled on its opening slide: entering it starts its own auto-centre, an animation frame that scrolls there, and a jump before that frame fires would be undone by it.
- rule: the strip must be laid out first: the slides reach their full width a beat after they render,
so the wait is for the strip to come within one slide of full width, not merely for the nodes.
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(BAND_DOCS) + 2),
label="the strip is laid out at full width, every slide and both clones",
detail=lambda: f"children={strip.evaluate('el => el.children.length')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}"
f" clientWidth={strip.evaluate('el => el.clientWidth')}")
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == 1,
label="the show has settled on its opening slide, so a jump will stick",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}")
- how
- two things are asked of the strip by slot number, and both are written once, since one reading
below does them from inside the page and the rest from outside.
- how: put the slideshow on a slot.
- how: read the image addresses sitting in a slot’s box — the thumbnail base, and the web copy overlay when the slide is close enough to carry one.
SLIDE_JUMP = "(el, k) => { el.scrollLeft = k * (el.scrollWidth / el.children.length); }"
SLOT_SRCS = ("(el, k) => { const s = el.children[k];"
" return s ? Array.from(s.querySelectorAll('img')).map(im => im.getAttribute('src')) : []; }")
- rule
- the web copy 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.
- what: if it isn’t in hand yet, the thumbnail stands in, marked (below), until it arrives.
- rule
- the band of slides kept in hand leans the same way — six
behind — and past it a slide is dropped to a blank.
- therefore: a long wall never keeps thousands of decoded images alive at once.
- rule
- turn around and the lean turns with you, so the loading always anticipates where you’re going next.
goto(FAR) # forward to a mid slide → heading is +1
wait_until(page, lambda: web(FAR), # the centre arrives, full-res on it
label=f"the full-res reaches the centre (slot {FAR})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{FAR}]={srcs(FAR)}")
# full-res leads the way you're going: it reaches 3 ahead but only 1 behind
assert web(FAR + 3) and not web(FAR + 4), \
f"full-res should reach 3 ahead, got {srcs(FAR + 3)} / {srcs(FAR + 4)}"
assert web(FAR - 1) and not web(FAR - 2), \
f"full-res should reach only 1 behind, got {srcs(FAR - 1)} / {srcs(FAR - 2)}"
# the kept-thumbnail band leans the same way: 14 ahead, 6 behind
assert thumb(FAR + 14) and blank(FAR + 15), f"thumbnail kept 14 ahead, got {srcs(FAR + 14)} / {srcs(FAR + 15)}"
assert thumb(FAR - 6) and blank(FAR - 7), f"thumbnail kept 6 behind, got {srcs(FAR - 6)} / {srcs(FAR - 7)}"
back = FAR - 5; goto(back) # turn round → the lean must turn with you
wait_until(page, lambda: web(back - 3),
label=f"the full-res leads the reversed way (slot {back - 3})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" srcs[{back - 3}]={srcs(back - 3)} srcs[{back}]={srcs(back)}")
assert not web(back + 3), f"after turning, full-res should not still reach the old way, got {srcs(back + 3)}"
print(" PASS: frame preloads web window")
- how
- each slide measures its signed distance from the centre in the travel direction — from the
centre’s place on the strip and the last direction of travel — and picks each layer’s source.
- how: the thumbnail base anywhere in the leaning window, the web copy 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; };
// base layer: thumbnail kept in the window, blank past it
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD)
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK;
// overlay: full-res leads the way you're going
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid)
? IPFS + p.webCid : '';
Following a fling live
- what
- those bands are only as honest as the centre they measure from, and a hard fling tests that centre.
- how
- In practice the reading is taken during the fling, from inside the page: the scroll and the look happen two animation frames apart, far under the 150ms the settle waits.
- rule
- 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.
wait_until(page, lambda: blank(FAR), # the bands are re-picked a beat after the show moves
label="the slide to be flung onto has been let go of, so the fling has something to prove",
detail=lambda: f"srcs[{FAR}]={srcs(FAR)}")
mid_fling = strip.evaluate(f"""async (el, k) => {{
({SLIDE_JUMP})(el, k);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return ({SLOT_SRCS})(el, k);
}}""", FAR)
assert mid_fling and not is_blank(mid_fling), \
f"the slide flung onto must be asked for, not blank: {mid_fling}"
print(" PASS: frame fling loads into view")
- how
- every scroll, not only the settle, re-reads which slide sits nearest the centre and which way the strip is travelling, so the bands ride the live position and lean the way it is going.
if(stripEl){ const i = slideAt(), c = frameCenterIdx();
if(i !== c){ setFrameDir(i > c ? 1 : -1); setFrameCenterIdx(i); } }
A video that leaves stops
- rule
- a video the viewer started stops once it has scrolled away — no soundtrack from a slide nobody can see, over a doc that has nothing to do with it.
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press("ArrowRight") # step the show off it
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the clip stopped once its slide left the screen")
print(" PASS: frame clip pauses when it leaves")
- what
- triage from the slideshow removes docs from the filter, so the slots shuffle: a slot that held a photo comes to hold the video that used to sit after it — a different element from the one the slideshow opened with.
- rule
- it keeps doing so after the strip has re-flowed under it: that video is watched just the same.
page.keyboard.press("ArrowLeft") # back over the clip…
wait_until(page, on_clip, label="back on the clip")
page.keyboard.press("ArrowLeft") # …to the photo ahead of it
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-0"),
label="back on the photo ahead of the clip")
strip.click() # the bar, on that photo
frame_bar = page.get_by_role("toolbar", name="frame actions")
frame_bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → the boxes close up
expect(tiles(page)).to_have_count(len(CLIP_DOCS) - 1)
wait_until(page, on_clip, label="the clip has taken the retired photo's box")
v = strip.locator("video").first # a different element from the one just watched
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press("ArrowRight")
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the re-dealt clip stopped once its slide left the screen")
print(" PASS: frame clip pauses after reflow")
- how
- a watcher over the strip pauses any slide video that drops below half-visible.
- what: it can only watch the videos it was handed, and it is handed them by one sweep of the strip.
- therefore: the sweep happens again every time the slots are re-dealt, not just when the slideshow opens.
- how: the watcher is thrown away and rebuilt when the docs change, and when the slideshow 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());
}));
Putting things right without leaving the slideshow
An edit keeps the slideshow in place
- what
- an edit often pushes that doc out of the current filter — marking a
tododonewhile viewing todos, its state being one oftodo,next,done,delete. - rule
- an edit that pushes the doc out of the slideshow leaves you somewhere sensible.
- rule: if the doc leaves the set, the slideshow re-anchors on the previous one, so the next advance lands on whatever filled the gap; if it stays, it stays centred.
bar.get_by_role("button", name="done", exact=True).click() # → out of the todo filter
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9") # the one just before it
print(" PASS: frame edit re-anchors")
- how
- every editor on the bar — its state, a label, the date — is a thin wrapper over one edit, which saves, re-reads the wall, then puts the slideshow back on the doc or on the one before the gap.
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() })); };
Fixing the date from the bar
- rule
- the centred slide’s date is editable in place: a click opens the picker,
Enteror a click away saves it, through the same edit the lightbox uses.
bar.get_by_role("button", name="edit date").click()
box = bar.get_by_label("date", exact=True)
box.fill("2019-06-15T12:00") # the summer it was really taken
box.press("Enter")
want = page.evaluate("() => new Date('2019-06-15T12:00').toLocaleString('fr-FR')")
expect(bar.get_by_role("button", name="edit date")).to_have_text(want) # saved, and shown
print(" PASS: frame edits date")
- how
- the date on the bar is the same editor as the lightbox’s: a button showing the centred slide’s date that, pressed, becomes a date-and-time field.
<${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)} />
<//>
- how
- saving goes through the same edit as a state change, so a wrong timestamp fixed mid-show re-orders the strip and keeps the doc under you.
let frameCancelDate = false;
const frameCommitDate = v => { const skip = frameCancelDate; frameCancelDate = false;
setFrameEditingDate(false);
if(!skip && v) frameEdit(() => ({ date: new Date(v).toISOString() })); };
- rule
- the picker opens seeded with the slide’s current date, time and all.
on = int(strip.evaluate(CENTERED).rsplit("-", 1)[1]) # whichever one is in front of you
bar.get_by_role("button", name="edit date").click()
want = page.evaluate("(iso) => { const t = new Date(iso), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}"
"T${p(t.getHours())}:${p(t.getMinutes())}`; }",
f"20{10 + on}-01-15T12:00:00Z") # the day it carries
expect(bar.get_by_label("date", exact=True)).to_have_value(want)
print(" PASS: frame date seeds")
- rule
Escapebacks out of the picker without saving, and leaves you in the slideshow rather than exiting it.
box = bar.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(strip).to_be_visible() # Escape backed out of the picker, not the show
page.wait_for_timeout(SAVE_GRACE_MS) # long enough that a save would have shown
expect(bar.get_by_role("button", name="edit date")).to_have_text(before) # and none did
print(" PASS: frame date escape cancels")
- how
Escapeotherwise exits the slideshow, so the picker swallows it at the field and marks the edit cancelled before it lets go.
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation();
frameCancelDate = true; e.target.blur(); }
Condemning asks first
- what
- a delete is a soft, undoable mark: it condemns the doc.
- rule
- the red button asks before it acts, waiting for a yes: dismiss keeps the doc, accept condemns it — and, leaving the filter, re-anchors like any other edit.
delete_btn = bar.get_by_role("button", name="delete", exact=True)
asked = []
dismiss = lambda d: (asked.append(d.message), d.dismiss())
page.on("dialog", dismiss)
delete_btn.click() # ask, and say no
wait_until(page, lambda: bool(asked), label="delete asks first")
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9", "saying no must keep the doc"
page.remove_listener("dialog", dismiss)
page.on("dialog", lambda d: d.accept())
delete_btn.click() # ask, and say yes
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-8") # gone, and re-anchored
print(" PASS: frame delete confirms")
- how
- a state change is the one edit, the condemning one held back until the question is answered yes.
const frameSetState = st => { if(st === 'delete' && !confirm('Mark this for deletion?')) return;
return frameEdit(() => ({ state: st })); };
The whole story
- how
- the story walked end to end, each step read in the order the headings above give it.
@testcase
def test_somebody_stops_to_look(page):
"""Taking the show over by hand — arrows and taps step it, two fingers don't, a
fling coasts under control — then putting right what you noticed while looking,
giving it back to run on, leaning in on one until the show waits for you, coming
back to a run with a clip in it, which holds the show and stops when it leaves the
screen, and to one long enough that the show must let go of most of it."""
for d in SWIPE_DOCS + CLIP_DOCS + BAND_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
page.route("**/ipfs/zzclip-web", lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm", headers={"Accept-Ranges": "bytes"}))
try:
open_app(page, STILL_QS)
search_for(page, "zzswipe")
expect(tiles(page)).to_have_count(len(SWIPE_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > 0, label="the strip settles")
first = strip.evaluate(ON_SLIDE)
box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
page.keyboard.press("ArrowLeft") # off the front → the last doc
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == len(SWIPE_DOCS),
label="a step back off the first lands on the last",
detail=lambda: f"on slide {strip.evaluate(ON_SLIDE)} of {len(SWIPE_DOCS)}")
page.keyboard.press("ArrowRight") # off the end → the first
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame wraps both ways")
page.mouse.click(box["x"] + box["width"] / 2, midY) # centre third → the bar
expect(page.get_by_role("toolbar", name="frame actions")).to_be_visible()
assert strip.evaluate(ON_SLIDE) == first, "a centre tap must not navigate"
print(" PASS: frame centre tap reveals the bar")
page.keyboard.press("ArrowRight") # focus is on the body, not a control
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.keyboard.press("ArrowLeft") # back where we started
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame arrow steps off control")
page.mouse.click(box["x"] + box["width"] * 0.92, midY) # right third → forward
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first + 1)
page.mouse.click(box["x"] + box["width"] * 0.08, midY) # left third → back
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == first)
print(" PASS: frame tap zones step the show")
# the right third — a lone tap here would step forward
x = box["x"] + box["width"] * 0.92
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"
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": False}) # see below
print(" PASS: frame two-finger gesture does not step")
page.keyboard.press("Shift+ArrowRight")
page.keyboard.press("Shift+ArrowRight")
expect(checks(page)).to_have_count(0) # nothing chosen behind the slideshow
print(" PASS: the frame owns the keys")
CONTROLLED_SLIDES = 4
w = strip.evaluate(SLIDE_W)
for _ in range(FLING_TRIES):
before = strip.evaluate(ON_SLIDE)
coasted = hard_frame_flick(page, strip)
landed = strip.evaluate(ON_SLIDE)
assert landed - before <= CONTROLLED_SLIDES, \
f"the swipe flung {landed - before} slides on — past the controlled range"
strayed = min(coasted % w, w - (coasted % w))
if strayed > CENTRED_PX: break # this one has something to ease
print(" PASS: frame swipe does not overshoot")
rest = strip.evaluate("el => el.scrollLeft")
assert strayed > CENTRED_PX, \
f"{FLING_TRIES} flings all coasted within {strayed:.0f}px of a boundary — none could show an ease"
off = min(rest % w, w - (rest % w)) # distance to the nearest slide boundary
assert off < CENTRED_PX, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
print(" PASS: frame swipe settles centered")
page.go_back()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible() # still on the app, not gone
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
strip.click() # a tap to bring the bar up
page.get_by_role("button", name="exit frame").click()
expect(strip).to_be_hidden()
expect(heading(page)).to_be_visible()
print(" PASS: frame exits on back")
# back in
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").startswith("https://ipfs.konubinix.eu/p/zzswipe-t-"))
strip.click() # the bar, and the doc it acts on
bar = page.get_by_role("toolbar", name="frame actions")
before = bar.get_by_role("button", name="edit date").text_content()
on = int(strip.evaluate(CENTERED).rsplit("-", 1)[1]) # whichever one is in front of you
bar.get_by_role("button", name="edit date").click()
want = page.evaluate("(iso) => { const t = new Date(iso), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}"
"T${p(t.getHours())}:${p(t.getMinutes())}`; }",
f"20{10 + on}-01-15T12:00:00Z") # the day it carries
expect(bar.get_by_label("date", exact=True)).to_have_value(want)
print(" PASS: frame date seeds")
box = bar.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(strip).to_be_visible() # Escape backed out of the picker, not the show
page.wait_for_timeout(SAVE_GRACE_MS) # long enough that a save would have shown
expect(bar.get_by_role("button", name="edit date")).to_have_text(before) # and none did
print(" PASS: frame date escape cancels")
bar.get_by_role("button", name="edit date").click()
box = bar.get_by_label("date", exact=True)
box.fill("2019-06-15T12:00") # the summer it was really taken
box.press("Enter")
want = page.evaluate("() => new Date('2019-06-15T12:00').toLocaleString('fr-FR')")
expect(bar.get_by_role("button", name="edit date")).to_have_text(want) # saved, and shown
print(" PASS: frame edits date")
bar.get_by_role("button", name="done", exact=True).click() # → out of the todo filter
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9") # the one just before it
print(" PASS: frame edit re-anchors")
delete_btn = bar.get_by_role("button", name="delete", exact=True)
asked = []
dismiss = lambda d: (asked.append(d.message), d.dismiss())
page.on("dialog", dismiss)
delete_btn.click() # ask, and say no
wait_until(page, lambda: bool(asked), label="delete asks first")
assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-9", "saying no must keep the doc"
page.remove_listener("dialog", dismiss)
page.on("dialog", lambda d: d.accept())
delete_btn.click() # ask, and say yes
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzswipe-t-8") # gone, and re-anchored
print(" PASS: frame delete confirms")
page.go_back() # hand the show back
expect(strip).to_be_hidden()
# …and set it going again, unattended, at a tempo worth watching
open_app(page, LIVE_QS)
expect(tiles(page)).to_have_count(len(SWIPE_DOCS) - 2) # two of them are dealt with now
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
box = strip.bounding_box()
opened = strip.evaluate(ON_SLIDE) # wherever the show resumed
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > opened, # …and it is advancing from there
label="the show is running before we touch it")
# a centre tap: real interaction, no nav
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.wait_for_timeout(LIVE_MS + LIVE_SETTLE_MS) # let any in-flight step settle
held = strip.evaluate(ON_SLIDE)
# several tempo ticks, still inside the resume span
page.wait_for_timeout(LIVE_RESUME_MS // 2)
assert strip.evaluate(ON_SLIDE) == held, \
f"interaction must stop the show; it drifted {held}→{strip.evaluate(ON_SLIDE)}"
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > held) # quiet long enough → resumes on its own
print(" PASS: frame pauses on interaction")
# and the other thing a finger says: two of them, spreading — let me look closer
open_app(page, PINCH_QS)
expect(strip).to_be_visible() # it picks the show back up by itself
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").startswith("https://ipfs.konubinix.eu/p/zzswipe-t-"))
held = strip.evaluate(CENTERED)
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5,
label="the browser magnifies")
page.wait_for_timeout(PINCH_HOLD_MS) # past the touch-idle, and several ticks
assert strip.evaluate(CENTERED) == held, "a magnified slide must not auto-advance"
print(" PASS: frame pinch pauses auto-advance")
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # the app reads the live scale
nudged = strip.evaluate("""el => { const w = el.scrollWidth / el.children.length;
el.scrollLeft = el.children[1].offsetLeft + Math.round(w * 0.4); // 40% in: well off any boundary
el.dispatchEvent(new Event('scroll')); return el.scrollLeft; }""")
page.wait_for_timeout(SNAP_GRACE_MS) # well past the settle-snap's own beat
assert abs(strip.evaluate("el => el.scrollLeft") - nudged) <= 1, \
"a magnified slide must be left where it was nudged, not pulled to a slide edge"
print(" PASS: frame pinch freezes snap")
page.wait_for_url(re.compile(r"[?&]z=")) # left alone, it gives up and reloads itself…
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") <= 1.01,
label="…onto an address the browser has never seen zoomed, so it lands at 1:1")
print(" PASS: frame pinch resets after idle")
expect(strip).to_be_visible() # the reload came back into the show…
wait_until(page, lambda: strip.evaluate(ON_SLIDE) > 0, label="the strip is placed")
placed = strip.evaluate(ON_SLIDE)
wait_until(page, lambda: strip.evaluate(ON_SLIDE) != placed,
label="…and plays on by itself", detail=lambda: f"still on slide {placed}")
print(" PASS: frame plays on after a forgotten pinch")
# leaning in once more, and this time touching it now and then
box = strip.bounding_box()
pinch_in(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
wait_until(page, lambda: page.evaluate("() => visualViewport.scale") > 1.5,
label="the browser magnifies again")
page.evaluate("() => visualViewport.dispatchEvent(new Event('resize'))") # read the live scale
zoomed_at = page.url
for _ in range(2): # a touch now and then, never a span apart
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.wait_for_timeout(ZOOM_IDLE_MS * 2 // 3)
assert page.url == zoomed_at, f"the touches did not hold the reload off: {page.url}"
print(" PASS: a touch re-arms the zoom countdown")
# come back another day, to a run with a clip in it
open_app(page, STILL_QS)
expect(strip).to_be_visible() # back in the show, where the pinch left it
page.keyboard.press("Escape") # out of it
expect(strip).to_be_hidden()
search_for(page, CLIP_LABEL)
expect(tiles(page)).to_have_count(len(CLIP_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
on_clip = lambda: strip.evaluate(CENTERED_IS_CLIP)
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-0"),
label="the show opens on the photo ahead of the clip")
for kind in ("photo", "clip"):
m = strip.evaluate(CENTERED_MEDIA)
assert m, f"the centred {kind} shows nothing at all"
assert m["width"] == VIEWPORT["width"], \
f"the {kind} spans {m['width']}px of a {VIEWPORT['width']}px screen"
assert m["height"] == VIEWPORT["height"], \
f"the {kind} stands {m['height']}px in a {VIEWPORT['height']}px screen"
assert m["fit"] == "contain", f"the {kind} is cropped to fill rather than shown whole"
if kind == "photo":
page.keyboard.press("ArrowRight") # on to the other kind
wait_until(page, on_clip, label="the clip is centred")
assert page.locator(".frame").evaluate("el => getComputedStyle(el).backgroundColor") == "rgb(0, 0, 0)", \
"the screen framing them must be black"
print(" PASS: frame media fills screen on black")
wait_until(page, on_clip, label="the clip is centred")
page.wait_for_timeout(FRAME_STEP_GRACE_MS + SNAP_GRACE_MS) # the step over, and the settle after it
assert strip.locator("video").first.evaluate("el => el.paused"), "the clip started by itself"
print(" PASS: frame clip does not autoplay")
v = strip.locator("video").first
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press("ArrowRight") # step the show off it
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the clip stopped once its slide left the screen")
print(" PASS: frame clip pauses when it leaves")
page.keyboard.press("ArrowLeft") # back onto the clip
wait_until(page, on_clip, label="back on the clip")
cdp = page.context.new_cdp_session(page) # a real touch tap
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": box["x"] + box["width"] * 0.92, "y": midY}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-1"),
label="the side-tap stepped the show off the clip")
print(" PASS: frame tap steps over a clip")
page.keyboard.press("ArrowLeft") # back over the clip…
wait_until(page, on_clip, label="back on the clip")
page.keyboard.press("ArrowLeft") # …to the photo ahead of it
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-0"),
label="back on the photo ahead of the clip")
strip.click() # the bar, on that photo
frame_bar = page.get_by_role("toolbar", name="frame actions")
frame_bar.get_by_role("button", name="done", exact=True).click() # it leaves todo → the boxes close up
expect(tiles(page)).to_have_count(len(CLIP_DOCS) - 1)
wait_until(page, on_clip, label="the clip has taken the retired photo's box")
v = strip.locator("video").first # a different element from the one just watched
v.evaluate("el => { el.muted = true; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press("ArrowRight")
wait_until(page, lambda: v.evaluate("el => el.paused"),
label="the re-dealt clip stopped once its slide left the screen")
print(" PASS: frame clip pauses after reflow")
# …and set it going
open_app(page, CLIP_QS)
expect(strip).to_be_visible()
wait_until(page, on_clip, label="the clip comes round")
v = strip.locator("video").first
v.evaluate("el => { el.muted = true; el.currentTime = 0; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.wait_for_timeout(CLIP_HOLD_MS) # two ticks, and half of a third
assert on_clip(), "a playing clip must hold the show"
v.evaluate("el => { el.currentTime = el.duration; }") # let it play out
wait_until(page, lambda: (strip.evaluate(CENTERED) or "").endswith("zzclip-t-1"),
label="the show moves on once the clip has finished")
print(" PASS: frame clip holds the show")
# and once more on a run far longer than the show can hold at once
open_app(page, STILL_QS)
expect(strip).to_be_visible()
page.keyboard.press("Escape")
expect(strip).to_be_hidden()
search_for(page, BAND_LABEL)
expect(tiles(page)).to_have_count(len(BAND_DOCS))
page.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate("(el, n) => el.children.length === n"
" && el.scrollWidth >= el.clientWidth * (n - 1)", len(BAND_DOCS) + 2),
label="the strip is laid out at full width, every slide and both clones",
detail=lambda: f"children={strip.evaluate('el => el.children.length')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}"
f" clientWidth={strip.evaluate('el => el.clientWidth')}")
wait_until(page, lambda: strip.evaluate(ON_SLIDE) == 1,
label="the show has settled on its opening slide, so a jump will stick",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')}")
srcs = lambda k: strip.evaluate(SLOT_SRCS, k) # slot k holds doc k-1: base, then overlay
is_blank = lambda ss: any(x and x.startswith("data:image/gif") for x in ss)
web = lambda k: any("zzband-web-" in x for x in srcs(k))
thumb = lambda k: any("zzband-t-" in x for x in srcs(k))
blank = lambda k: is_blank(srcs(k))
goto = lambda n: strip.evaluate(SLIDE_JUMP, n)
back_to_the_start = lambda: (goto(1), wait_until(page, lambda: strip.evaluate(ON_SLIDE) == 1,
label="back at the opening slide"))
far_mark = strip.get_by_role("listitem").nth(FAR).get_by_label("loading")
expect(far_mark).to_have_count(0) # let go of, and its blank painted: nothing to announce
goto(FAR) # fling onto it
expect(far_mark).to_have_count(1) # its source turns real → the mark is back until that paints
print(" PASS: frame placeholder returns on band flip")
back_to_the_start()
goto(FAR) # forward to a mid slide → heading is +1
wait_until(page, lambda: web(FAR), # the centre arrives, full-res on it
label=f"the full-res reaches the centre (slot {FAR})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" scrollWidth={strip.evaluate('el => el.scrollWidth')} srcs[{FAR}]={srcs(FAR)}")
# full-res leads the way you're going: it reaches 3 ahead but only 1 behind
assert web(FAR + 3) and not web(FAR + 4), \
f"full-res should reach 3 ahead, got {srcs(FAR + 3)} / {srcs(FAR + 4)}"
assert web(FAR - 1) and not web(FAR - 2), \
f"full-res should reach only 1 behind, got {srcs(FAR - 1)} / {srcs(FAR - 2)}"
# the kept-thumbnail band leans the same way: 14 ahead, 6 behind
assert thumb(FAR + 14) and blank(FAR + 15), f"thumbnail kept 14 ahead, got {srcs(FAR + 14)} / {srcs(FAR + 15)}"
assert thumb(FAR - 6) and blank(FAR - 7), f"thumbnail kept 6 behind, got {srcs(FAR - 6)} / {srcs(FAR - 7)}"
back = FAR - 5; goto(back) # turn round → the lean must turn with you
wait_until(page, lambda: web(back - 3),
label=f"the full-res leads the reversed way (slot {back - 3})",
detail=lambda: f"scrollLeft={strip.evaluate('el => el.scrollLeft')}"
f" srcs[{back - 3}]={srcs(back - 3)} srcs[{back}]={srcs(back)}")
assert not web(back + 3), f"after turning, full-res should not still reach the old way, got {srcs(back + 3)}"
print(" PASS: frame preloads web window")
back_to_the_start()
wait_until(page, lambda: blank(FAR), # the bands are re-picked a beat after the show moves
label="the slide to be flung onto has been let go of, so the fling has something to prove",
detail=lambda: f"srcs[{FAR}]={srcs(FAR)}")
mid_fling = strip.evaluate(f"""async (el, k) => {{
({SLIDE_JUMP})(el, k);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return ({SLOT_SRCS})(el, k);
}}""", FAR)
assert mid_fling and not is_blank(mid_fling), \
f"the slide flung onto must be asked for, not blank: {mid_fling}"
print(" PASS: frame fling loads into view")
finally:
page.unroute("**/ipfs/zzclip-web")
for d in SWIPE_DOCS + CLIP_DOCS + BAND_DOCS: gql(DELETE, {"cid": d["cid"]})
Sorting out a shoot
Fixing a date that came out wrong
Spotting the stray
- what
- a camera with a flat battery stamps everything with the day it was switched on, and a
scan carries the day it was scanned.
- therefore: whole batches of docs land on the server under a date that is plainly not theirs.
- what
- nothing about the picture says so, though.
- rule
- the wall runs in date order, oldest first.
- therefore: a strayed doc sits quietly among strangers, looking exactly as settled as they do.
dates = tile_dates(page) # each tile's day, as the wall lays them out
assert dates == sorted(dates), f"the wall is out of date order: {dates}"
print(" PASS: wall runs in date order")
- what
- what gives it away is the calendar.
- how: the wall marks each doc with the occasion it was taken during.
- therefore: one occasion’s docs land together as a stripe of a single colour.
- what: a doc carried weeks off by a bad timestamp lands outside that stripe, wearing no
occasion at all.
- therefore: the gap in the stripe is the thing the eye catches.
- rule: an occasion with hours in it has to say them, and one filling a whole day has none to say.
- how: the wall marks each doc with the occasion it was taken during.
- how
- from there it is a doc at a time — open the one with the gap, see what it says, correct it, and read back from its occasions that it now sits where it belongs.
- what
- that is the easy half; the hard half is not correcting.
Seeing a doc’s labels
- what
- each tile shows its labels — the free words already on the doc — as a caption across its foot.
- rule: many labels keep to one line, ending in an ellipsis, and hovering the tile shows the full list.
- how: In practice a copy of the caption, told many words, is laid beside it and measured against it.
- rule: many labels keep to one line, ending in an ellipsis, and hovering the tile shows the full list.
caption = tile_of(page, f"{FIX_YEAR}-06-02").get_by_text(FIX_LABEL)
long = caption.evaluate( # a copy of the caption, many words long
"el => { const t = el.cloneNode(true); t.textContent = Array(30).fill('zzlongword').join('; ');"
" el.after(t); const r = { one: el.getBoundingClientRect().height,"
" many: t.getBoundingClientRect().height, cut: t.scrollWidth > t.clientWidth,"
" end: getComputedStyle(t).textOverflow }; t.remove(); return r; }")
assert long["many"] == long["one"], f"many labels spill onto more lines: {long}"
assert long["cut"] and long["end"] == "ellipsis", f"the overflow is not cut on an ellipsis: {long}"
print(" PASS: many labels keep to one line")
- rule
- a tile with no labels shows nothing.
- how: In practice a doc nobody labelled is found by its day alone, and its tile’s foot takes no height.
expect(tiles(page)).to_have_count(1) # the bare doc alone
foot = tiles(page).first.locator(".foot").bounding_box()
assert foot["height"] == 0, f"a tile with no labels still wears a caption: {foot}"
print(" PASS: a tile with no labels shows nothing")
- how
- the wall read here is the run somebody is putting a date right in, every doc of which was found by
the one word they all carry.
- therefore: that word is on the wall by construction.
- rule
- the tiles say so: the run’s word reads on its tiles.
expect(grid(page).get_by_text(FIX_LABEL).first).to_be_visible()
print(" PASS: tiles show labels")
Events on the wall
- what
- 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.
- what
- the calendar holds the occasion, and the lightbox — one doc opened large over the wall — already names it for the open doc.
- what
- here the wall marks each doc with its occasion, on the tile itself, so it stays with the doc while you triage.
-
The pill
rule : a stripe only reads as one once the occasions are told apart at a glance, and that is colour’s job.
how : it is reached in two steps — first the pill, then its colour.
what : a doc taken during an occasion shows a small pill on its tile, captioned with the occasion’s name.
expect(pills_on(page, f"{FIX_YEAR}-06-02")).to_have_text([RANDO]) # the occasion, on the tile print(" PASS: tiles show event pill")how : each tile’s foot carries one pill per occasion its doc falls within.
<${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> <//>
-
One read for the whole wall
what : occasions cannot ride on each doc the way its labels do: they come from the calendar, the lookup the lightbox runs one doc at a time.
how : the wall reads the shared layer’s
events_in_windowonce: every occasion overlapping the range the wall shows.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 ?? [];how : that read follows the wall’s own window, keyed on the running search’s date range. - therefore: it reads again only when the range moves, and a search on labels alone keeps its occasions. - how: In practice the key is that range written out as text, not the object holding it, so a re-render with the same window does not read again — 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)));what : a doc’s own occasions are the ones that bracket it: the same owner, and its date inside the occasion’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 colour per occasion
what : a pill that only names an occasion marks it, but does not set its stripe apart from the neighbours.
rule : each occasion wears a colour of its own, from a palette of eight contrasted light pastels. - rule: adjacent occasions land on different hues. - rule: one occasion’s docs read as one stripe of colour, and the next occasion breaks it.
BG = "el => getComputedStyle(el).backgroundColor" hue = lambda day, name: pills_on(page, day).filter(has_text=name).evaluate(BG) first = hue(f"{FIX_YEAR}-06-02", RANDO) assert hue(f"{FIX_YEAR}-06-08", RANDO) == first, "one occasion should read as one band" assert hue(f"{FIX_YEAR}-06-22", PISCINE) != first, "the next occasion should break it" print(" PASS: event pills coloured per event")how : the colours are handed out in one pass over the shown wall, in the order occasions first appear down it. - scope: far-apart occasions may reuse a hue — the palette cycles. - rule: two occasions of the same name still take different colours. - how: In practice the namesake is hers, a December day of the same name as the June week.
namesake = hue(NAMESAKE_DAY, RANDO) # hers, of the same name, in December assert namesake != first, f"two occasions of one name share a colour: {namesake}" print(" PASS: namesake occasions coloured apart")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; });.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; }
-
The stripe across tiles
what : the stripe is a property of a row of tiles rather than of any one of them.
rule : within a tile the pill comes last, after the label, landing on the tile’s bottom edge whatever the label above it turned out to be.
rule : across tiles those edges agree: two docs of one occasion, side by side, carry their pills at the same height.
at = lambda day: tile_of(page, f"{FIX_YEAR}-{day}") lbl = at("06-02").get_by_text(FIX_LABEL).bounding_box() pill = at("06-02").locator(".ev-pill").bounding_box() assert pill["y"] > lbl["y"], f"the pill should come after the label: pill={pill['y']} label={lbl['y']}" other = at("06-08").locator(".ev-pill").bounding_box() # the same occasion, further along assert abs(other["y"] - pill["y"]) < 1, f"the band should be level: {pill['y']} vs {other['y']}" print(" PASS: event pill sits below label")
-
Overlaps, gaps and owners
what : occasions can overlap.
rule : a doc caught inside two at once — an afternoon out within a week away — wears a pill for each, in each occasion’s own colour, so both read at a glance.
both = pills_on(page, f"{FIX_YEAR}-06-05") expect(both).to_have_text([RANDO, SOMMET]) # the week, and the two days within it assert both.nth(0).evaluate(BG) != both.nth(1).evaluate(BG), "the two should be told apart" print(" PASS: overlapping events show two pills")rule : a stray doc wears no occasion, a gap in the stripe.
expect(pills_on(page, STRAY_DAY)).to_have_count(0) # the gap in the band: nothing was on that day print(" PASS: out-of-event doc has no pill")what : an occasion is its owner’s alone, and a day belongs to everybody. - example: two people out with cameras on the same afternoon each have their own account of it in their own calendar.
rule : an occasion colours only its owner’s docs. - example: on a day where both accounts exist, each doc wears one, rather than each wearing both.
shared = tile_of(page, f"{FIX_YEAR}-08-01") expect(shared).to_have_count(2) # the same day, one doc each expect(shared.nth(0).locator(".ev-pill")).to_have_count(1) # one occasion apiece… expect(shared.nth(1).locator(".ev-pill")).to_have_count(1) expect(grid(page).get_by_text(AYLAS, exact=True)).to_have_count(1) # …and it is their own expect(grid(page).get_by_text(MINE, exact=True)).to_have_count(1) print(" PASS: event pill owner scoped")
Seeing a doc’s events
- what
- a doc sits in time, and the calendar knows what was happening then.
- rule: the lightbox names it: the occasions the doc falls within ride under its date as small pills.
- what
- the calendar is Google’s, managed elsewhere.
- what
- a date is only ever wrong relative to something, and the pills are that something.
- therefore: they are where a correction is confirmed or found to be wrong again.
- how
- the pills are recomputed for where the doc now sits, without leaving it, so that the corrected doc reads back from its occasions.
expect(d.get_by_text(RANDO)).to_be_visible() # the week it was taken during…
expect(d.get_by_text(SOMMET)).to_be_visible() # …and the two days within it
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
print(" PASS: lightbox shows events")
- how
- the lookup rides on the open doc, asking the calendar for its date and owner.
- therefore: it follows whatever doc is shown.
- how: the wall’s search carries each doc’s owner for exactly this.
- rule: a doc with no owner has nothing to scope by, so nothing is asked.
asked = [] # every lookup of a doc's occasions from here on
page.on("request", lambda r: asked.append(r.post_data) if "eventsAt" in (r.post_data or "") else None)
tiles(page).first.click(click_count=2) # the bare doc, nobody's
d = dialog(page)
expect(d).to_be_visible()
page.wait_for_timeout(SAVE_GRACE_MS) # room for a lookup to go out
assert not asked, f"a doc with no owner still asked the calendar: {asked}"
print(" PASS: an ownerless doc asks for no occasions")
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);
// a whole day is a UTC day, wherever it is read
const zone = dayBased(s, en) ? { timeZone: 'UTC' } : {};
const sd = s.toLocaleDateString('fr-FR', zone), ed = en.toLocaleDateString('fr-FR', zone);
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);
-
When each occasion ran
what : the name alone would not settle it. - example: knowing a doc falls inside zzRando says nothing about whether that is a week or an afternoon.
rule : each pill carries the occasion’s own bounds beside its name, as the reader’s locale and timezone write them.
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso) expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start… expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its endwhat : an occasion need not fill a day, and its bounds are stored as instants.
rule : an occasion with hours in it says them, on the reader’s clock.
tile_of(page, f"{FIX_YEAR}-06-22").click(click_count=2) d = dialog(page) when = page.evaluate("""([a, b]) => { const s = new Date(a), en = new Date(b); const t = x => x.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'}); return `${s.toLocaleDateString('fr-FR')} ${t(s)} – ${t(en)}`; }""", [f"{FIX_YEAR}-06-22T09:00:00Z", f"{FIX_YEAR}-06-22T17:00:00Z"]) expect(d.get_by_text(when)).to_be_visible() # the day, and the hours of it d.get_by_role("button", name="close").click() back_on_the_wall(page) print(" PASS: lightbox shows timed event hours")rule : a whole-day occasion’s dates collapse to one when it lasted a single day. - how: Under the hood, whole-day is read off the span’s UTC bounds —
00:00:00to23:59:59, a day’s last second — the inclusive end the calendar spans carry. - what: it is the same boundevents_atmatches on. - how: its dates are then read as UTC days. - therefore: a clock east of UTC does not push that last second into the next day.tile_of(page, NAMESAKE_DAY).click(click_count=2) # Olivia's zzRando, a day long d = dialog(page) day = page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", f"{NAMESAKE_DAY}T00:00:00Z") pill = d.get_by_role("button", name=re.compile(f"^{RANDO}")) # once the lightbox has moved onto her doc expect(pill.locator(".ev-when")).to_have_text(day) # the one day, not that day twice d.get_by_role("button", name="close").click() back_on_the_wall(page) print(" PASS: a one-day occasion names one day")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); // a whole day is a UTC day, wherever it is read const zone = dayBased(s, en) ? { timeZone: 'UTC' } : {}; const sd = s.toLocaleDateString('fr-FR', zone), ed = en.toLocaleDateString('fr-FR', zone); if (dayBased(s, en)) return sd === ed ? sd : `${sd} – ${ed}`; return sd === ed ? `${sd} ${hhmm(s)} – ${hhmm(en)}` : `${sd} ${hhmm(s)} – ${ed} ${hhmm(en)}`; };/* the span reads as a quiet aside beside the name — the discreet part of "name + when" */ .ev-when{ margin-left:6px; opacity:.6; }
-
Only the owner’s
what : two people’s calendars can hold occasions on the very same day, and a doc belongs to just one of them.
rule : an occasion goes to its owner’s docs and nobody else’s in the lightbox too: another owner’s, spanning the same instant, stays hidden.
mine = tile_of(page, f"{FIX_YEAR}-08-01").filter(has=page.locator(f'.ev-pill:text-is("{MINE}")')) mine.click(click_count=2) # of the two that day, the one that is mine d = dialog(page) expect(d.get_by_text(MINE)).to_be_visible() # my account of that afternoon… expect(d.get_by_text(AYLAS)).to_have_count(0) # …and not Ayla's, of the same one print(" PASS: lightbox events are owner-scoped")how : the scoping is done where the occasions are read: the app passes the doc’s own owner and gets back what belongs to it. - therefore: showing too much would have to come from the server, beyond anything the app alone can break.
-
A way into the occasion
what : a pill is also a way in: clicking one asks for every doc of its occasion and gets out of the way.
what : the query it writes is the whole query, not an addition to it — any window you had narrowed to is spent.
rule : one click stands for typing it, and having asked, the doc you were looking at is no longer in front of the answer.
d.get_by_role("button", name=re.compile(MINE)).click() # the occasion in front of you expect(search_box(page)).to_have_value(f"event:{MINE}") # the search typed out for you… back_on_the_wall(page) # …and the doc stood aside for it print(" PASS: lightbox event pill searches")how : it asks the
event:search, written out in the search box — where the wall’s search is typed — and committed. - scope: where that leads is the search’s business, measured with the rest of the query language.// run the occasion's own event: search — the pill's jump into it const searchEvent = summary => { setSearch('event:' + summary); commit(); };how : the whole pill is a button: a pointer and a hover lift mark it as a jump into the occasion.
<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>.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; }
Fixing a wrong date
- rule
- the correction is right there in the moment you catch it, eyes on the doc and no detour to a form.
- how: the date on the lightbox’s meta row — its date, the original’s link beside it — is already what you are reading, so it is what you edit.
- how
- it saves through the very path a state button uses.
- therefore: a doc that just changed date re-anchors just as one that changed state.
- what
- the wall and the open doc read the same list.
- rule
- the doc takes its new place in both at once, while the lightbox keeps its footing.
moved = f"{FIX_YEAR}-06-06"
wait_until(page, lambda: moved in tile_dates(page) and STRAY_DAY not in tile_dates(page),
label="the wall moves the doc under the open lightbox", detail=lambda: str(tile_dates(page)))
assert tile_dates(page) == sorted(tile_dates(page)), f"the doc landed out of order: {tile_dates(page)}"
expect(d).to_be_visible() # …and the doc is still open
print(" PASS: the wall follows a date edit under the lightbox")
- what
- the edit is a small state machine.
- rule
- a doc whose date is corrected reads back its occasions: still open, it wears the occasions of the day you gave it.
d.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = d.get_by_label("date", exact=True)
box.fill(f"{FIX_YEAR}-06-06T12:00") # the day it was really taken
box.press("Enter") # save
expect(d.get_by_text(RANDO)).to_be_visible() # the week it was taken during…
expect(d.get_by_text(SOMMET)).to_be_visible() # …and the two days within it
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
print(" PASS: lightbox shows events")
moved = f"{FIX_YEAR}-06-06"
wait_until(page, lambda: moved in tile_dates(page) and STRAY_DAY not in tile_dates(page),
label="the wall moves the doc under the open lightbox", detail=lambda: str(tile_dates(page)))
assert tile_dates(page) == sorted(tile_dates(page)), f"the doc landed out of order: {tile_dates(page)}"
expect(d).to_be_visible() # …and the doc is still open
print(" PASS: the wall follows a date edit under the lightbox")
d.get_by_role("button", name="close").click() # back to the wall
back_on_the_wall(page)
print(" PASS: lightbox edit date rejoins its occasions")
-
The picker
what : the date on the meta row is a button, and clicking it opens the picker.
rule : the picker opens seeded with what the date held. - rule: the seed keeps the hours and minutes too, so a save that fixes just the year leaves the time of day standing.
want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');" " return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}" "T${p(t.getHours())}:${p(t.getMinutes())}`; }", f"{STRAY_DAY}T12:00:00Z") # seeded with the stored instant, in local time expect(d.get_by_label("date", exact=True)).to_have_value(want) print(" PASS: date editor seeds current value")how : the date stays a button until clicked, and the picker standing in for it saves as soon as it lets go of the keys.
<${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)} /> <//>.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; }how : In practice the picker is a
datetime-localinput, which speaks the browser’s local clock time while the stored date is a zoned instant: a small formatter seeds it, and the save converts back.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
rule : thinking better of the date picker leaves the doc as it was: backing out with
Escapeleaves it open.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")how :
Escapewould otherwise reach the lightbox’s own key handling and close the whole doc, so the picker swallows the key and vetoes the save before it lets go.else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); }
-
A key for the date
rule : reaching the picker costs no more than reaching the add-label box:
d— for date — opens it on the doc in view, the same reflex aslfor labels.page.keyboard.press("d") expect(d.get_by_label("date", exact=True)).to_be_focused() print(" PASS: date shortcut focuses editor")rule : the
dkey stays quiet while you type a label: in any text field, it 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")rule : the
dkey stays quiet on the wall with nothing open: nor may it lie in wait and spring on the next one opened.page.keyboard.press("d") # no doc open tiles(page).filter(has_not=page.locator(".ev-pill")).click(click_count=2) # the one with the gap d = dialog(page) expect(d.get_by_role("button", name="edit date")).to_be_visible() # opens in display mode… expect(d.get_by_label("date", exact=True)).to_have_count(0) # …not the picker print(" PASS: date shortcut ignored without a doc")how : the key is heard across the whole app, wherever the eye is, and gives way to any field.
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)); });
The run
- how
- the run has one hole in it.
- what: a second bare tile — a thumbnail on the wall wearing no occasion — and the gap stops
being a signal.
- therefore: the hole has to be the only one.
- what: a second bare tile — a thumbnail on the wall wearing no occasion — and the gap stops
being a signal.
- how
- two occasions a fortnight apart, three docs inside the first and two inside the
second.
- how: one doc dated a month past both — the stray, and the only doc here without an occasion.
- how: a third occasion sits inside the first, two days of a longer week.
- therefore: one doc is caught by two at once.
- how
- two more cover a single later day on which Ayla and I each took a doc — one occasion hers, one mine.
- how
- a sixth is an afternoon rather than a day, laid over a doc already inside the second.
- how
- those dates are years ahead of today, in a stretch of calendar nobody has been to yet.
- how: the occasions on the wall are read from the real calendar over the window the wall
shows.
- therefore: a window over a year somebody has lived would bring their own occasions in alongside these, and the colours would stop meaning what they are said to mean here.
- how: the occasions on the wall are read from the real calendar over the window the wall
shows.
A run named onto an occasion
- what
- typing the day in is only bearable for one doc.
- what: the dates that really go wrong go wrong by the dozen.
- example: an afternoon that came back through a messaging app, every copy stamped with the day it was saved.
- what: the dates that really go wrong go wrong by the dozen.
- what
- the whole point is that nobody remembers which day it was.
- therefore: there is no day to type.
- what: what is remembered is the occasion — the hike, the swim.
- how
- the second half of this story is a second run, named onto an occasion — moved to
its dates in one go — rather than dated.
- what: the wall above rests on the stray being the only doc wearing no occasion, and four
more bare tiles would have taken that signal away.
- therefore: the second run has its own label.
- what: the wall above rests on the stray being the only doc wearing no occasion, and four
more bare tiles would have taken that signal away.
- how
- one of the second run is somebody else’s, a day apart from the rest.
- how
- two occasions join the six.
- example: a namesake of the hike, months further off.
- example: one with no doc at all, a year past the window the wall is showing.
Moving a stray doc onto its occasion
- rule
- a run named onto an occasion moves to its dates in one go: the strays take the occasion’s own first day, sliding back into the stripe.
- how
- pick the strays up, name the occasion in the toolbar’s move box, and pick it.
- what: the toolbar is the row of buttons acting on what is picked up.
- rule: once the move is applied, nothing is picked up any more.
select_all(page).click()
box.click(); box.press_sequentially(RANDO, delay=20)
tb.get_by_role("option").first.click() # the hike itself: the nearer of the two
move_btn.click()
expect(checks(page)).to_have_count(0) # applied → the selection clears
# they wear the occasion's start day now — the wall re-anchors, and a tile's alt is its day
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-01")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers stayed where it was saved
print(" PASS: move selection to event")
-
Which occasions it offers
how : the move box lives in the toolbar, beside the add-label box. - how: it holds 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
-
An occasion with no doc yet
what : the occasion you move onto usually has no doc of its own yet — assigning docs to it is the whole point. - scope: the search box’s
event:completion offers only occasions that already hold a doc, and would hide exactly this one.rule : the move offers photoless and far-off occasions: one with no doc, a year past the window, is still there to pick.
box.fill(""); box.press_sequentially(VIDE, delay=20) expect(tb.get_by_role("option", name=re.compile(VIDE))).to_be_visible() print(" PASS: move offers a photoless event")rule : a row says when its occasion ran, so a recurring one is told apart by its date.
began = page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", f"{FIX_YEAR + 1}-03-01T00:00:00Z") expect(tb.get_by_role("option", name=re.compile(VIDE))).to_contain_text(began) print(" PASS: a move row says when its occasion ran")what : a stray’s date sits nowhere near the occasion it belongs to. - therefore: a list bounded by what the wall happens to show could never reach it.
how : the occasions are read from the whole calendar the moment the box opens, over an all-time span, capped like the wall’s own occasion read.
const [moveEvents] = createResource(moveFocus, f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
-
The nearest occasion first
rule : when the name fits several occasions, the list leads with the one nearest the strays. - example: a « Karaté » that recurs every week.
what : the order only suggests — you still pick. - therefore: a misleading « nearest » costs a glance, never a silent mis-move.
box.fill(""); box.press_sequentially(RANDO, delay=20) opts = tb.get_by_role("option") expect(opts).to_have_count(2) # the hike and its namesake both answer to it expect(opts.first).not_to_contain_text(RANDO_FAR) # the nearer of the two leads print(" PASS: move ranks by mean closeness")how : an occasion’s closeness is how far the mean of the picked-up docs’ dates sits from its span — zero when the mean falls inside, otherwise the gap to the nearer bound.
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; };
-
Only the owner’s occasions
rule : a move leaves everyone else’s docs alone: it touches only a doc whose owner holds the picked occasion. - example: the count landing on the occasion’s day above is three and not four: the fourth was hers, and the move declined it.
rule : the rule survives the widening — the toolbar’s
all matchingswitch, from what is picked up to everything the search matches — but by a different road. - how: the picked-up path declines doc by doc; the wide one pins the filter to the owner before it goes out.rule : one doc picked up, the switch on, and every doc of his the search holds moves, and still none of hers.
t.nth(0).click() # one of the three that are mine tb.get_by_role("button", name=re.compile("all .* matching")).click() box.click(); box.press_sequentially(PISCINE, delay=20) tb.get_by_role("option").first.click() box.press("Enter") # nothing highlighted: Enter applies it expect(checks(page)).to_have_count(0) print(" PASS: Enter applies the armed move") expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-20")).to_have_count(3) expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers, still where it was print(" PASS: move is owner-scoped when widened")rule : the list offers only the occasions of the picked-up docs’ own owners. - therefore: a namesake in someone else’s calendar — which could never apply — is never even shown, and a pick cannot land on a silent no-op.
box.fill(""); box.press_sequentially("zzChez", delay=20) expect(tb.get_by_role("option", name=re.compile(MINE))).to_be_visible() # mine — offered expect(tb.get_by_role("option", name=re.compile(AYLAS))).to_have_count(0) # hers — never shown print(" PASS: move offers only selection-owner events")const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
-
The list and the move
rule : a long calendar never floods the box: the eight nearest are shown. - how: In practice nine of his occasions answer to « zz », two of them kept for this alone.
box.fill(""); box.press_sequentially("zz", delay=20) expect(tb.get_by_role("option")).to_have_count(8) print(" PASS: the move list stops at eight")how : as you type, the box narrows the calendar to the occasions whose name contains the text, kept to those owners and ordered by closeness, ties going to the earlier occasion.
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); };how : picking one arms that exact occasion and closes the list.
what : the occasion could only have been offered because somebody on the wall owns it. - therefore: the widened move is a tightening and never a reach, as everything on the toolbar must be.
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); }; const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return; if(allMatching()) await applyBulk('SetDate', ev.starttime, { owners: [ev.owner] }); else await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null); setMoveText(''); setMoveTarget(null); };
-
The keyboard and the rows
rule : the box’s keyboard is the other completion boxes’: arrows move through the rows and
Enterpicks the highlighted occasion, as in the search and add-label boxes. - rule: a pick need not be a click: arrowing down to an occasion and pressingEnterarms the→ eventbutton no differently.box.fill(""); box.press_sequentially(RANDO, delay=20) opt = tb.get_by_role("option").first expect(opt).to_be_visible() # the list is open expect(move_btn).to_be_disabled() # nothing picked yet box.press("ArrowDown") # highlight the first candidate expect(opt).to_have_attribute("aria-selected", "true") # the shared highlight landed on it box.press("Enter") # Enter picks the highlighted one → arms expect(move_btn).to_be_enabled() expect(box).to_have_value(RANDO) # the pick filled the box print(" PASS: move box keyboard picks")rule : with nothing highlighted,
Enterapplies the armed pick, the same as the→ eventbutton beside it, which applies the move.how : the box hands its candidates up to the single keyboard highlight the whole app keeps.
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });what : each row names the occasion and, beside it, when it ran — the same aside the lightbox reads off a pill. - therefore: a recurring occasion is told apart by its date.
what : the highlighted row is marked as the shared highlight 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(), () => setMoveFocus(false))} 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>what : the
→ eventbutton 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; }
-
A move fires only from a pick
rule : a move fires only from an explicit pick. - rule: the
→ eventbutton stays disabled until a pick arms it. - therefore: an unpicked box, however fully typed, can never fire it. - rule: editing the name after picking disables it again, waiting for a fresh pick.box.fill(""); box.press_sequentially(RANDO, delay=20) expect(move_btn).to_be_disabled() # typed text is not a pick tb.get_by_role("option").first.click() expect(move_btn).to_be_enabled() # a pick arms it box.press("x") # edit the name → the pick drops expect(move_btn).to_be_disabled() # disabled again, until a fresh pick print(" PASS: move button armed only by a pick")rule : a pick is bound to the picks it was made against: a stale pick can never move a set you have since changed.
rule : touching the picks drops the armed pick, and the box clears, ready for a fresh pick.
t.nth(0).click() # drop one tile → the selection changes expect(box).to_have_value("") # the armed pick is dropped, the box cleared expect(move_btn).to_be_disabled() print(" PASS: move target resets on selection change")createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
The whole story
- how
- both runs and their occasions, then the story walked end to end — the stray first, the second run after.
SAVE_GRACE_MS = 800
FIX_LABEL = "zzfixdate"
FIX_YEAR = datetime.date.today().year + 8
RANDO, SOMMET, PISCINE = "zzRando", "zzSommet", "zzPiscine"
AYLAS, MINE = "zzChezAyla", "zzChezMoi" # the same day, one each
GOUTER = "zzGouter" # an afternoon, not a day
RANDO_FAR = "zzRandoLoin" # a namesake of the hike, months further off
VIDE = "zzVide" # no doc, and a year past the wall's window
NAMESAKE_DAY = f"{FIX_YEAR}-12-28" # Olivia's zzRando, a day long: no move box here holds her docs
FIX_EVENTS = [
{"rowId": "zzfix-ev-rando", "summary": RANDO, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-01T00:00:00Z", "endtime": f"{FIX_YEAR}-06-10T23:59:59Z"},
{"rowId": "zzfix-ev-sommet", "summary": SOMMET, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-05T00:00:00Z", "endtime": f"{FIX_YEAR}-06-06T23:59:59Z"},
{"rowId": "zzfix-ev-piscine", "summary": PISCINE, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-20T00:00:00Z", "endtime": f"{FIX_YEAR}-06-30T23:59:59Z"},
{"rowId": "zzfix-ev-ayla", "summary": AYLAS, "owner": "aylapomme", "status": "confirmed",
"starttime": f"{FIX_YEAR}-08-01T00:00:00Z", "endtime": f"{FIX_YEAR}-08-02T23:59:59Z"},
{"rowId": "zzfix-ev-mine", "summary": MINE, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-08-01T00:00:00Z", "endtime": f"{FIX_YEAR}-08-02T23:59:59Z"},
{"rowId": "zzfix-ev-gouter", "summary": GOUTER, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-06-22T09:00:00Z", "endtime": f"{FIX_YEAR}-06-22T17:00:00Z"},
{"rowId": "zzfix-ev-randofar", "summary": RANDO_FAR, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR}-01-05T00:00:00Z", "endtime": f"{FIX_YEAR}-01-06T23:59:59Z"},
{"rowId": "zzfix-ev-vide", "summary": VIDE, "owner": "konubinix", "status": "confirmed",
"starttime": f"{FIX_YEAR + 1}-03-01T00:00:00Z", "endtime": f"{FIX_YEAR + 1}-03-02T23:59:59Z"},
{"rowId": "zzfix-ev-namesake", "summary": RANDO, "owner": "olivia", "status": "confirmed",
"starttime": f"{NAMESAKE_DAY}T00:00:00Z", "endtime": f"{NAMESAKE_DAY}T23:59:59Z"},
] + [ # only to overfill the move list
{"rowId": f"zzfix-ev-far{k}", "summary": f"zzLoin{k}", "owner": "konubinix",
"status": "confirmed", "starttime": f"{FIX_YEAR + 1}-0{5 + k}-01T00:00:00Z",
"endtime": f"{FIX_YEAR + 1}-0{5 + k}-01T23:59:59Z"} for k in (1, 2)]
STRAY_DAY = f"{FIX_YEAR}-07-15" # a month past every occasion — the one to put right
FIX_DOCS = [("06-02", "konubinix"), ("06-05", "konubinix"), ("06-08", "konubinix"),
("06-22", "konubinix"), ("06-25", "konubinix"),
("07-15", "konubinix"), # the stray
("08-01", "konubinix"), ("08-01", "aylapomme"), # the same day, two owners
("12-28", "olivia")] # under her namesake of the hike
BARE_DAY = f"{FIX_YEAR}-07-20" # a doc nobody labelled, nor owns
BARE_DOC = {"cid": "https://ipfs.konubinix.eu/p/zzfix-bare", "date": f"{BARE_DAY}T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfix-bare-t", "state": "todo",
"owner": None} # said outright: left out, the server fills in its default
def fix_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzfix-{i}", "date": f"{FIX_YEAR}-{md}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfix-{i}-t",
"labels": FIX_LABEL, "state": "todo", "owner": owner}
for i, (md, owner) in enumerate(FIX_DOCS)]
MOVE_LABEL = "zzfixmove"
MOVE_DOCS = [("11-20", "konubinix"), ("11-20", "konubinix"), ("11-20", "konubinix"),
("11-21", "aylapomme")]
def move_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzfixmv-{i}", "date": f"{FIX_YEAR}-{md}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfixmv-{i}-t",
"labels": MOVE_LABEL, "state": "todo", "owner": owner}
for i, (md, owner) in enumerate(MOVE_DOCS)]
def tile_of(page, day):
"""The tile of the doc taken on that day."""
return tiles(page).filter(has=page.get_by_alt_text(day, exact=True))
def pills_on(page, day):
"""The occasions written across the tile of the doc taken on that day."""
return tile_of(page, day).locator(".ev-pill")
def back_on_the_wall(page):
expect(dialog(page)).to_be_hidden()
@testcase
def test_fixing_a_date(page):
"""Spotting a doc whose date has drifted — it alone wears no occasion — then opening
it, reaching for the picker and backing out, putting the date right, and reading back
off the occasions it has rejoined that it now sits where it belongs. Then the same
trouble at a scale nobody types their way out of: a pile of copies all stamped the day
they were saved, named onto the occasion they came from instead."""
docs = fix_docs() + move_docs() + [BARE_DOC]
for d_ in docs: gql(DELETE, {"cid": d_["cid"]}); gql(CREATE, {"p": d_})
seed_events(FIX_EVENTS)
try:
open_app(page); chip(page, "all").click()
search_for(page, f"{FIX_LABEL}; since:{FIX_YEAR}; until:{FIX_YEAR}")
expect(tiles(page)).to_have_count(len(FIX_DOCS))
dates = tile_dates(page) # each tile's day, as the wall lays them out
assert dates == sorted(dates), f"the wall is out of date order: {dates}"
print(" PASS: wall runs in date order")
expect(grid(page).get_by_text(FIX_LABEL).first).to_be_visible()
print(" PASS: tiles show labels")
caption = tile_of(page, f"{FIX_YEAR}-06-02").get_by_text(FIX_LABEL)
long = caption.evaluate( # a copy of the caption, many words long
"el => { const t = el.cloneNode(true); t.textContent = Array(30).fill('zzlongword').join('; ');"
" el.after(t); const r = { one: el.getBoundingClientRect().height,"
" many: t.getBoundingClientRect().height, cut: t.scrollWidth > t.clientWidth,"
" end: getComputedStyle(t).textOverflow }; t.remove(); return r; }")
assert long["many"] == long["one"], f"many labels spill onto more lines: {long}"
assert long["cut"] and long["end"] == "ellipsis", f"the overflow is not cut on an ellipsis: {long}"
print(" PASS: many labels keep to one line")
expect(pills_on(page, STRAY_DAY)).to_have_count(0) # the gap in the band: nothing was on that day
print(" PASS: out-of-event doc has no pill")
expect(pills_on(page, f"{FIX_YEAR}-06-02")).to_have_text([RANDO]) # the occasion, on the tile
print(" PASS: tiles show event pill")
BG = "el => getComputedStyle(el).backgroundColor"
hue = lambda day, name: pills_on(page, day).filter(has_text=name).evaluate(BG)
first = hue(f"{FIX_YEAR}-06-02", RANDO)
assert hue(f"{FIX_YEAR}-06-08", RANDO) == first, "one occasion should read as one band"
assert hue(f"{FIX_YEAR}-06-22", PISCINE) != first, "the next occasion should break it"
print(" PASS: event pills coloured per event")
namesake = hue(NAMESAKE_DAY, RANDO) # hers, of the same name, in December
assert namesake != first, f"two occasions of one name share a colour: {namesake}"
print(" PASS: namesake occasions coloured apart")
at = lambda day: tile_of(page, f"{FIX_YEAR}-{day}")
lbl = at("06-02").get_by_text(FIX_LABEL).bounding_box()
pill = at("06-02").locator(".ev-pill").bounding_box()
assert pill["y"] > lbl["y"], f"the pill should come after the label: pill={pill['y']} label={lbl['y']}"
other = at("06-08").locator(".ev-pill").bounding_box() # the same occasion, further along
assert abs(other["y"] - pill["y"]) < 1, f"the band should be level: {pill['y']} vs {other['y']}"
print(" PASS: event pill sits below label")
both = pills_on(page, f"{FIX_YEAR}-06-05")
expect(both).to_have_text([RANDO, SOMMET]) # the week, and the two days within it
assert both.nth(0).evaluate(BG) != both.nth(1).evaluate(BG), "the two should be told apart"
print(" PASS: overlapping events show two pills")
shared = tile_of(page, f"{FIX_YEAR}-08-01")
expect(shared).to_have_count(2) # the same day, one doc each
expect(shared.nth(0).locator(".ev-pill")).to_have_count(1) # one occasion apiece…
expect(shared.nth(1).locator(".ev-pill")).to_have_count(1)
expect(grid(page).get_by_text(AYLAS, exact=True)).to_have_count(1) # …and it is their own
expect(grid(page).get_by_text(MINE, exact=True)).to_have_count(1)
print(" PASS: event pill owner scoped")
page.keyboard.press("d") # no doc open
tiles(page).filter(has_not=page.locator(".ev-pill")).click(click_count=2) # the one with the gap
d = dialog(page)
expect(d.get_by_role("button", name="edit date")).to_be_visible() # opens in display mode…
expect(d.get_by_label("date", exact=True)).to_have_count(0) # …not the picker
print(" PASS: date shortcut ignored without a doc")
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_be_focused()
print(" PASS: date shortcut focuses editor")
want = page.evaluate("d => { const t = new Date(d), p = n => String(n).padStart(2, '0');"
" return `${t.getFullYear()}-${p(t.getMonth()+1)}-${p(t.getDate())}"
"T${p(t.getHours())}:${p(t.getMinutes())}`; }",
f"{STRAY_DAY}T12:00:00Z")
# seeded with the stored instant, in local time
expect(d.get_by_label("date", exact=True)).to_have_value(want)
print(" PASS: date editor seeds current value")
unmoved = tile_dates(page) # the wall as it stands
box = d.get_by_label("date", exact=True)
box.fill("1999-01-01T00:00")
box.press("Escape")
expect(d).to_be_visible() # still open
page.wait_for_timeout(SAVE_GRACE_MS) # room for a save to land
assert tile_dates(page) == unmoved, f"the backed-out date was written anyway: {tile_dates(page)}"
print(" PASS: date edit escape cancels")
d.get_by_placeholder("add a label…").click() # focus the label box
page.keyboard.press("d")
expect(d.get_by_label("date", exact=True)).to_have_count(0) # the picker stayed shut
print(" PASS: date shortcut stands aside in a field")
d.get_by_role("button", name="edit date").click() # the date is a button — open the picker
box = d.get_by_label("date", exact=True)
box.fill(f"{FIX_YEAR}-06-06T12:00") # the day it was really taken
box.press("Enter") # save
expect(d.get_by_text(RANDO)).to_be_visible() # the week it was taken during…
expect(d.get_by_text(SOMMET)).to_be_visible() # …and the two days within it
span = lambda iso: page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})", iso)
expect(d.get_by_text(span(f"{FIX_YEAR}-06-01T00:00:00Z"))).to_be_visible() # the week's start…
expect(d.get_by_text(span(f"{FIX_YEAR}-06-10T23:59:59Z"))).to_be_visible() # …and its end
print(" PASS: lightbox shows events")
moved = f"{FIX_YEAR}-06-06"
wait_until(page, lambda: moved in tile_dates(page) and STRAY_DAY not in tile_dates(page),
label="the wall moves the doc under the open lightbox", detail=lambda: str(tile_dates(page)))
assert tile_dates(page) == sorted(tile_dates(page)), f"the doc landed out of order: {tile_dates(page)}"
expect(d).to_be_visible() # …and the doc is still open
print(" PASS: the wall follows a date edit under the lightbox")
d.get_by_role("button", name="close").click() # back to the wall
back_on_the_wall(page)
print(" PASS: lightbox edit date rejoins its occasions")
tile_of(page, f"{FIX_YEAR}-06-22").click(click_count=2)
d = dialog(page)
when = page.evaluate("""([a, b]) => {
const s = new Date(a), en = new Date(b);
const t = x => x.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
return `${s.toLocaleDateString('fr-FR')} ${t(s)} – ${t(en)}`;
}""", [f"{FIX_YEAR}-06-22T09:00:00Z", f"{FIX_YEAR}-06-22T17:00:00Z"])
expect(d.get_by_text(when)).to_be_visible() # the day, and the hours of it
d.get_by_role("button", name="close").click()
back_on_the_wall(page)
print(" PASS: lightbox shows timed event hours")
tile_of(page, NAMESAKE_DAY).click(click_count=2) # Olivia's zzRando, a day long
d = dialog(page)
day = page.evaluate("s => new Date(s).toLocaleDateString('fr-FR', {timeZone: 'UTC'})",
f"{NAMESAKE_DAY}T00:00:00Z")
pill = d.get_by_role("button", name=re.compile(f"^{RANDO}")) # once the lightbox has moved onto her doc
expect(pill.locator(".ev-when")).to_have_text(day) # the one day, not that day twice
d.get_by_role("button", name="close").click()
back_on_the_wall(page)
print(" PASS: a one-day occasion names one day")
mine = tile_of(page, f"{FIX_YEAR}-08-01").filter(has=page.locator(f'.ev-pill:text-is("{MINE}")'))
mine.click(click_count=2) # of the two that day, the one that is mine
d = dialog(page)
expect(d.get_by_text(MINE)).to_be_visible() # my account of that afternoon…
expect(d.get_by_text(AYLAS)).to_have_count(0) # …and not Ayla's, of the same one
print(" PASS: lightbox events are owner-scoped")
d.get_by_role("button", name=re.compile(MINE)).click() # the occasion in front of you
expect(search_box(page)).to_have_value(f"event:{MINE}") # the search typed out for you…
back_on_the_wall(page) # …and the doc stood aside for it
print(" PASS: lightbox event pill searches")
search_for(page, f"{MOVE_LABEL}; since:{FIX_YEAR}; until:{FIX_YEAR}")
expect(tiles(page)).to_have_count(len(MOVE_DOCS))
t = tiles(page)
t.nth(0).click(); t.nth(1).click(); t.nth(2).click() # the three that are mine
tb = toolbar(page)
box = tb.get_by_placeholder("move to event…")
move_btn = tb.get_by_role("button", name="move to event")
box.click()
box.fill(""); box.press_sequentially(VIDE, delay=20)
expect(tb.get_by_role("option", name=re.compile(VIDE))).to_be_visible()
print(" PASS: move offers a photoless event")
began = page.evaluate("s => new Date(s).toLocaleDateString('fr-FR')", f"{FIX_YEAR + 1}-03-01T00:00:00Z")
expect(tb.get_by_role("option", name=re.compile(VIDE))).to_contain_text(began)
print(" PASS: a move row says when its occasion ran")
box.fill(""); box.press_sequentially("zz", delay=20)
expect(tb.get_by_role("option")).to_have_count(8)
print(" PASS: the move list stops at eight")
box.fill(""); box.press_sequentially("zzChez", delay=20)
expect(tb.get_by_role("option", name=re.compile(MINE))).to_be_visible() # mine — offered
expect(tb.get_by_role("option", name=re.compile(AYLAS))).to_have_count(0) # hers — never shown
print(" PASS: move offers only selection-owner events")
box.fill(""); box.press_sequentially(RANDO, delay=20)
opts = tb.get_by_role("option")
expect(opts).to_have_count(2) # the hike and its namesake both answer to it
expect(opts.first).not_to_contain_text(RANDO_FAR) # the nearer of the two leads
print(" PASS: move ranks by mean closeness")
box.fill(""); box.press_sequentially(RANDO, delay=20)
expect(move_btn).to_be_disabled() # typed text is not a pick
tb.get_by_role("option").first.click()
expect(move_btn).to_be_enabled() # a pick arms it
box.press("x") # edit the name → the pick drops
expect(move_btn).to_be_disabled() # disabled again, until a fresh pick
print(" PASS: move button armed only by a pick")
box.fill(""); box.press_sequentially(RANDO, delay=20)
opt = tb.get_by_role("option").first
expect(opt).to_be_visible() # the list is open
expect(move_btn).to_be_disabled() # nothing picked yet
box.press("ArrowDown") # highlight the first candidate
expect(opt).to_have_attribute("aria-selected", "true") # the shared highlight landed on it
box.press("Enter") # Enter picks the highlighted one → arms
expect(move_btn).to_be_enabled()
expect(box).to_have_value(RANDO) # the pick filled the box
print(" PASS: move box keyboard picks")
t.nth(0).click() # drop one tile → the selection changes
expect(box).to_have_value("") # the armed pick is dropped, the box cleared
expect(move_btn).to_be_disabled()
print(" PASS: move target resets on selection change")
select_all(page).click()
box.click(); box.press_sequentially(RANDO, delay=20)
tb.get_by_role("option").first.click() # the hike itself: the nearer of the two
move_btn.click()
expect(checks(page)).to_have_count(0) # applied → the selection clears
# they wear the occasion's start day now — the wall re-anchors, and a tile's alt is its day
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-01")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers stayed where it was saved
print(" PASS: move selection to event")
t.nth(0).click() # one of the three that are mine
tb.get_by_role("button", name=re.compile("all .* matching")).click()
box.click(); box.press_sequentially(PISCINE, delay=20)
tb.get_by_role("option").first.click()
box.press("Enter") # nothing highlighted: Enter applies it
expect(checks(page)).to_have_count(0)
print(" PASS: Enter applies the armed move")
expect(grid(page).get_by_role("img", name=f"{FIX_YEAR}-06-20")).to_have_count(3)
expect(tile_of(page, f"{FIX_YEAR}-11-21")).to_have_count(1) # hers, still where it was
print(" PASS: move is owner-scoped when widened")
search_for(page, f"date:{BARE_DAY}") # the doc nobody labelled, nor owns
expect(tiles(page)).to_have_count(1) # the bare doc alone
foot = tiles(page).first.locator(".foot").bounding_box()
assert foot["height"] == 0, f"a tile with no labels still wears a caption: {foot}"
print(" PASS: a tile with no labels shows nothing")
asked = [] # every lookup of a doc's occasions from here on
page.on("request", lambda r: asked.append(r.post_data) if "eventsAt" in (r.post_data or "") else None)
tiles(page).first.click(click_count=2) # the bare doc, nobody's
d = dialog(page)
expect(d).to_be_visible()
page.wait_for_timeout(SAVE_GRACE_MS) # room for a lookup to go out
assert not asked, f"a doc with no owner still asked the calendar: {asked}"
print(" PASS: an ownerless doc asks for no occasions")
finally:
drop_events(FIX_EVENTS)
for d_ in docs:
try: gql(DELETE, {"cid": d_["cid"]})
except Exception: pass
Putting words on a doc
- what
- labelling is really the whole of triage — going through what came in.
- what: the state chips — buttons marking where a doc stands — say how far along a doc is, but the labels are what make it findable again in ten years.
- what
- this is the story that runs most — open a doc, put the words on it, move to the next.
- what: the add-label box takes several words at once.
- what: the keyboard does add and remove without reaching for the
×on a label. - what: the word you just used is offered on the next doc for a single tap.
- what
- a doc takes two fingers as readily as any picture anywhere else.
- what
- a doc is sometimes worth putting up properly, which is what the slideshow is for.
- how: that is a tap from the doc already open, rather than a trip back to the wall to start the slideshow over.
- what: coming out of it lands back on the doc you left, not at the beginning of everything.
- what
- the add-label box offers words from the docs’ vocabulary.
- how: the story brings its own three words and takes them away after.
- example: one to complete onto the doc.
- example: one that must leave the list once the doc wears it.
- example: one to take with the keyboard.
- how: the story brings its own three words and takes them away after.
LABEL_WORDS = ["zzcalanque", "zzcigale", "zzcousin"]
@testcase
def test_putting_words_on_a_photo(page):
"""Working a run doc by doc: opening one, stepping to the next without leaving it,
handing one to the big show and stepping back out onto the doc, labelling — several
words at once, the keyboard for add and remove, the last word reused on the next, and
all of it written down — and getting close enough to read what is in one. Throughout,
the browser's own menu stays out of the way, except where the words are typed."""
seed_vocab(LABEL_WORDS)
try:
open_fixtures(page)
assert native_menu(page, tiles(page).nth(0)) is False, "a press on a tile should raise no menu"
print(" PASS: tile context menu suppressed")
assert native_menu(page, search_box(page)) is True, "the search box should keep its own menu"
print(" PASS: context menu allowed in text field")
tiles(page).nth(0).dblclick()
expect(dialog(page)).to_be_visible()
expect(dialog(page).get_by_role("img")).to_have_count(1) # the photo, full size
print(" PASS: double-click opens lightbox")
d = dialog(page)
expect(d.locator(".lb-date")).to_have_text(page.evaluate( # its date
f"() => new Date('{FIXTURES[0]['date']}').toLocaleString('fr-FR')"))
expect(d.get_by_text(FIXTURE_LABEL)).to_be_visible() # its labels are shown
d.get_by_role("button", name="close").click()
expect(d).to_be_hidden()
print(" PASS: lightbox opens and closes")
open_doc(page, 0)
img = d.get_by_role("img")
assert native_menu(page, img) is False, "a press on the open doc should raise no menu"
print(" PASS: lightbox context menu suppressed")
shown = img.bounding_box()
assert shown["width"] >= VIEWPORT["width"] * 0.9, f"media width {shown['width']} < 90% of viewport"
assert shown["height"] >= VIEWPORT["height"] * 0.65, f"media height {shown['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
"media crops instead of fitting whole"
print(" PASS: lightbox media fills screen")
link = d.locator(".lb-meta").get_by_role("link", name="original") # on the date row
expect(link).to_have_text("⤢")
expect(link).to_have_attribute("title", re.compile("full resolution.*new tab"))
expect(link).to_have_attribute("href", FIXTURES[0]["cid"]) # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
print(" PASS: lightbox links to original")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.press("ArrowRight")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.keyboard.press("ArrowLeft")
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
d.get_by_role("button", name="previous photo").click() # back where we started
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
d.get_by_role("button", name="previous photo").click() # and off the front
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2") # → the last, not a dead stop
d.get_by_role("button", name="next photo").click() # off the end again
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
print(" PASS: lightbox prev/next")
over = img.bounding_box()
page.mouse.move(over["x"] + over["width"] / 2, over["y"] + over["height"] / 2)
page.keyboard.down("Shift")
page.mouse.wheel(0, 240) # one flick down…
page.mouse.wheel(0, 240) # …and its tail, still inside the deaf window
page.wait_for_timeout(250)
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") # one doc, not two
page.mouse.wheel(0, -240) # shift-scroll up → prev
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
page.keyboard.up("Shift")
page.wait_for_timeout(250)
page.mouse.wheel(0, 240) # no Shift → the panel's scroll, not ours
page.wait_for_timeout(250)
expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
print(" PASS: lightbox shift-scroll nav")
asked = []
def note_leave(ask): asked.append(ask.message); ask.dismiss()
page.on("dialog", note_leave)
page.go_back()
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible()
page.remove_listener("dialog", note_leave)
assert not asked, f"closing a doc with Back asked to leave the app: {asked}"
print(" PASS: back button closes lightbox")
asked = []
def note_leave(ask): asked.append(ask.message); ask.dismiss()
page.on("dialog", note_leave)
for close in (lambda: page.keyboard.press("Escape"), lambda: d.get_by_role("button", name="close").click()):
open_doc(page, 0)
close()
expect(d).to_be_hidden()
n = len(asked)
page.go_back() # nothing left above the wall: this Back meets the guard
wait_until(page, lambda: len(asked) == n + 1, label="the close spent the doc's own history entry")
expect(grid(page)).to_be_visible()
page.remove_listener("dialog", note_leave)
print(" PASS: an explicit close and Back stay balanced")
middle = FIXTURES[1]
open_doc(page, 1) # not the first
expect(img).to_have_attribute("src", middle["thumbnailCid"])
d.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(d).to_be_hidden() # the lightbox gives way to the show
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == middle["thumbnailCid"],
label="the show opens on the doc you were looking at",
detail=lambda: f"it opened on {strip.evaluate(CENTERED)!r}")
print(" PASS: frame from lightbox")
page.go_back() # out of the show…
expect(strip).to_be_hidden()
expect(d.get_by_role("img")).to_have_attribute("src", middle["thumbnailCid"]) # …onto the doc it came from
page.go_back() # out of the lightbox…
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible() # …back to the wall
print(" PASS: back from frame returns to lightbox")
open_doc(page, 0)
sel = d.get_by_role("button", name=re.compile("select"))
sel.click() # a control acts → the modal stays
expect(sel).to_have_text(re.compile("selected")) # the click reached the button, didn't leak to close
expect(d).to_be_visible()
d.get_by_role("img").click() # the image doesn't act → the modal closes
expect(d).to_be_hidden()
print(" PASS: lightbox click outside content closes")
open_doc(page, 0)
expect(d).to_be_visible()
box = d.get_by_placeholder("add a label…")
hold_completions(page) # the vocabulary, left unanswered
expanded_while_loading(page, box)
print(" PASS: lightbox label expanded while loading")
page.unroute("**/graphql") # the vocabulary answers again
box.press_sequentially("zzc")
expect(box).to_have_attribute("aria-expanded", "true")
box.press("Tab") # out of the box, the ordinary way
expect(box).to_have_attribute("aria-expanded", "false")
assert page.get_by_role("listbox", name="suggestions").count() == 0, "the popover outlived the focus"
print(" PASS: lightbox label combobox state")
box.fill("")
box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20) # one existing + two new, typed
box.press("Enter")
expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1) # not duplicated
print(" PASS: lightbox adds several labels")
box.fill(""); box.press_sequentially("zztest;zzcal", delay=20) # typed, as a user would
opt = options(page).filter(has_text=re.compile(r"^zzcalanque$")).first
expect(opt).to_be_visible()
opt.click()
expect(box).to_have_value("zztest;zzcalanque; ") # earlier segment kept, completed, auto-';
box.press("Enter") # commit the built list
expect(d.get_by_role("button", name="zztest", exact=True)).to_be_visible()
expect(d.get_by_role("button", name="zzcalanque", exact=True)).to_be_visible()
print(" PASS: lightbox completes last segment")
box.press_sequentially("zzcal", delay=20)
expect(options(page).filter(has_text=re.compile(r"^zzcalanque$"))).to_have_count(0)
box.fill(""); box.press_sequentially("zzcig", delay=20)
expect(options(page).filter(has_text=re.compile(r"^zzcigale$")).first).to_be_visible()
print(" PASS: lightbox completion skips present")
fete = {"rowId": "zzlbfete-ev", "summary": "zzLbFete", "owner": "konubinix", "status": "confirmed",
"starttime": "2016-05-01T00:00:00Z", "endtime": "2016-05-02T23:59:59Z"}
inside = {"cid": "https://ipfs.konubinix.eu/p/zzlbfete-p", "date": "2016-05-01T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzlbfete-p-t", "labels": "zzlbfetep", "state": "todo", "owner": "konubinix"}
seed_events([fete]); seed_vocab(["zzlbfeuille"])
gql(DELETE, {"cid": inside["cid"]}); gql(CREATE, {"p": inside})
try:
box.fill(""); box.press_sequentially("zzlbfe", delay=20)
expect(options(page).filter(has_text="zzlbfeuille").first).to_be_visible() # the labels have answered
expect(options(page).filter(has_text="event:")).to_have_count(0) # and no occasion with them
finally:
box.fill("")
gql(DELETE, {"cid": inside["cid"]}); drop_events([fete]); drop_vocab(["zzlbfeuille"])
print(" PASS: the add-label box offers no occasion")
FRESH = "zzfraisneuf"
try:
with page.expect_response(lambda r: "/graphql" in r.url
and '"prefix":"zzfrais"' in (r.request.post_data or "")):
box.fill(""); box.press_sequentially("zzfrais", delay=20) # nothing yet to offer
seed_vocab([FRESH]) # added elsewhere, moments ago
box.fill(""); box.press_sequentially("zzfrais", delay=20) # the same fragment, asked again
expect(options(page).filter(has_text=FRESH).first).to_be_visible()
finally:
drop_vocab([FRESH])
print(" PASS: a fresh label completes at once")
box.fill(""); box.press_sequentially("zzcou", delay=20)
expect(options(page).first).to_be_visible()
box.press("ArrowDown"); box.press("Enter") # apply the highlight → fills "zzcousin; "
expect(box).to_have_value("zzcousin; ")
box.press("Enter") # nothing highlighted now → commit
expect(d.get_by_role("button", name="zzcousin", exact=True)).to_be_visible() # chip added
print(" PASS: lightbox label keyboard")
box.click(); box.press_sequentially("zzlbret", delay=10); box.press("Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_be_visible() # added
box.press_sequentially("zzlbret", delay=10); box.press("Shift+Enter")
expect(d.get_by_role("button", name="zzlbret", exact=True)).to_have_count(0) # removed
print(" PASS: lightbox enter adds, shift-enter removes")
src0 = img.get_attribute("src")
box.fill("abc")
box.press("ArrowRight") # would step to the next doc if unguarded
page.wait_for_timeout(200)
expect(img).to_have_attribute("src", src0) # same doc — the arrow stayed in the field
print(" PASS: label edit keeps arrows in text")
box.fill("zzreuse"); box.press("Enter")
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # applied here
d.get_by_role("button", name="next photo").click()
reuse = d.get_by_role("button", name="+ zzreuse", exact=True)
expect(reuse).to_be_visible() # offered on the next
reuse.click()
expect(d.get_by_role("button", name="zzreuse", exact=True)).to_be_visible() # reused, no retype
print(" PASS: lightbox reuse last label")
box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
d.get_by_role("button", name="close").click()
search_for(page, "lbadded") # the word put on: the doc answers
expect(tiles(page)).to_have_count(1)
search_for(page, FIXTURE_LABEL) # the word taken off: it no longer does
expect(tiles(page)).to_have_count(len(FIXTURES) - 1)
print(" PASS: lightbox edits labels")
open_doc(page, 0)
d.get_by_role("button", name="zzcalanque", exact=True).click()
expect(d).to_be_hidden() # the lightbox closes
expect(search_box(page)).to_have_value("zzcalanque") # the filter switched to that label
expect(tiles(page)).to_have_count(1) # the one doc wearing it
print(" PASS: lightbox chip filters")
open_doc(page, 0)
box.press_sequentially("zzcig", delay=20)
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
lb = listbox.bounding_box(); ib = box.bounding_box()
assert lb["y"] + lb["height"] <= ib["y"] + 1, \
f"the completion must open above the input: list {lb} input {ib}"
print(" PASS: lightbox completion opens upward")
area = img.bounding_box()
cx, cy = area["x"] + area["width"] / 2, area["y"] + area["height"] / 2
seen = lambda: page.evaluate("() => [visualViewport.scale, visualViewport.pageLeft]")
assert seen()[0] <= 1.01, "started already magnified"
pinch_in(page, cx, cy)
wait_until(page, lambda: seen()[0] > 1.5, label="the pinch reached the browser's zoom",
detail=lambda: f"scale is {seen()[0]}")
at = seen()[1]
drag_touch(page, cx + 150, cy, cx - 150, cy) # one finger, straight across the doc
wait_until(page, lambda: abs(seen()[1] - at) > 5, label="the magnified doc panned sideways",
detail=lambda: f"pageLeft went {at} → {seen()[1]}")
print(" PASS: lightbox pinch zooms and pans")
finally:
drop_vocab(LABEL_WORDS)
Opening a doc — the lightbox
- what
- the lightbox is where you look closely at one doc and edit
what the server holds about it: its labels, its state, its date.
- example: to actually watch a video, or to read and fix a doc’s labels.
Opening and closing
- what
- an open doc shows its web copy — a video as a video with its controls, a photo as an image — its
date, and its labels as removable chips.
- rule: the
✕orEscapecloses it again.
- rule: the
expect(d.locator(".lb-date")).to_have_text(page.evaluate( # its date
f"() => new Date('{FIXTURES[0]['date']}').toLocaleString('fr-FR')"))
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")
- rule
- a click that lands on nothing that acts — the backdrop, the image, the empty margins — closes it
too.
- rule: only a control swallows the click and keeps it open.
sel = d.get_by_role("button", name=re.compile("select"))
sel.click() # a control acts → the modal stays
expect(sel).to_have_text(re.compile("selected")) # the click reached the button, didn't leak to close
expect(d).to_be_visible()
d.get_by_role("img").click() # the image doesn't act → the modal closes
expect(d).to_be_hidden()
print(" PASS: lightbox click outside content closes")
- how
- the lightbox is one modal over the wall, and a click anywhere in it closes it unless it lands on something that acts.
<${Show} when=${() => opened()}>
<div class="lb" onClick=${e => {
if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]'))
dismissPhoto(); }}>
<div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
<button class="lb-close" aria-label="close" onClick=${dismissPhoto}>✕</button>
<button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid)
? '✓ selected' : 'select'}</button>
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
<button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}>‹</button>
<button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}>›</button>
<${Show} when=${() => hasMedia(opened())}
fallback=${html`<div class="lb-media noimg">
<span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
<span class="mt">no preview · ${() => opened()?.filename
|| opened()?.mimetype || ''}</span></div>`}>
<${Show} when=${() => isVideo(opened())}
fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
<video class="lb-media" controls autoplay ref=${el => lbVideo = el}
src=${() => IPFS + opened()?.webCid}></video>
<//>
<//>
<div class="lb-meta">
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date
? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); }
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); }
}}
onBlur=${e => commitDate(e.target.value)} />
<//>
<a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
aria-label="original" title="original — full resolution, new tab">⤢</a>
</div>
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event"
onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span
class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
<div class="lb-states">
<${For} each=${() => STATES}>${st => html`
<button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
onClick=${() => lbSetState(st)}>${st}</button>`}
<//>
</div>
<div class="lb-labels">
<${For} each=${() => labelsOf(opened())}>${w => html`
<span class="lb-chip"><button class="lb-chip-word"
onClick=${() => { setSearch(w); commit(); dismissPhoto(); }}>${w}</button><button class="x"
aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
<//>
<${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
<button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
<//>
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
onKeyDown=${e => {
if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText()),
() => setLbFocus(false)); }} />
<${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>
<//>
- what
- the open gesture itself is a double-click.
- what: a single tap stays the pick — a doc picked up, for an edit that acts on many at once — and a long-press arms a range.
- therefore: opening one doc full size has its own gesture, and the three don’t collide.
tiles(page).nth(0).dblclick()
expect(dialog(page)).to_be_visible()
expect(dialog(page).get_by_role("img")).to_have_count(1) # the photo, full size
print(" PASS: double-click opens lightbox")
- what
- the device back button leaves the lightbox, landing back on the wall.
- how: opening a doc pushes a history entry, and Back pops it.
- rule:
Escand✕unwind that same entry, so an explicit close and Back stay balanced — just as they do for the slideshow. - what: a doc that pushed nothing would still close on Back — by spending the app’s own root entry,
the one that stands between a stray thumb and leaving Memories.
- therefore: closing a doc would drag up the leaving-the-app question, over and over, all through a run.
- rule: closing a doc never raises it.
asked = []
def note_leave(ask): asked.append(ask.message); ask.dismiss()
page.on("dialog", note_leave)
page.go_back()
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible()
page.remove_listener("dialog", note_leave)
assert not asked, f"closing a doc with Back asked to leave the app: {asked}"
print(" PASS: back button closes lightbox")
asked = []
def note_leave(ask): asked.append(ask.message); ask.dismiss()
page.on("dialog", note_leave)
for close in (lambda: page.keyboard.press("Escape"), lambda: d.get_by_role("button", name="close").click()):
open_doc(page, 0)
close()
expect(d).to_be_hidden()
n = len(asked)
page.go_back() # nothing left above the wall: this Back meets the guard
wait_until(page, lambda: len(asked) == n + 1, label="the close spent the doc's own history entry")
expect(grid(page)).to_be_visible()
page.remove_listener("dialog", note_leave)
print(" PASS: an explicit close and Back stay balanced")
- how
- the lightbox turns on one thing — the doc in view, or none — with the add-label box’s text and focus beside it.
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();
The browser’s own menu stays shut
- what
- the long-press that arms a range is also how a touchscreen asks for its own
save imagecallout, and the right button asks for the same thing on a desk.- what: on a tile the app has its own use for every press — this one starts a pick.
- how: In practice, no browser will tell a test whether it went on to draw the menu; what can be read is whether the request was refused, so a listener sitting behind the app’s own reports what the app did with it.
- how: the gesture is the right button, the one a headless browser can actually make; the finger takes the same road once it has been read as a request.
def native_menu(page, target):
"""Ask for the native menu on something, and say whether the app let it through."""
page.evaluate("() => { window.__menu = null; addEventListener('contextmenu',"
" e => { window.__menu = !e.defaultPrevented; }, { once: true }); }")
target.click(button="right")
answer = page.evaluate("() => window.__menu")
assert answer is not None, "no menu was ever asked for, so nothing was answered"
return answer
- rule
- on a tile, the app turns that request down.
assert native_menu(page, tiles(page).nth(0)) is False, "a press on a tile should raise no menu"
print(" PASS: tile context menu suppressed")
- rule
- the refusal covers every surface the app draws — the open lightbox’s media as much as a tile.
assert native_menu(page, img) is False, "a press on the open doc should raise no menu"
print(" PASS: lightbox context menu suppressed")
- rule
- the text fields alone keep the native menu.
assert native_menu(page, search_box(page)) is True, "the search box should keep its own menu"
print(" PASS: context menu allowed in text field")
- how
- one listener on the window turns the request down everywhere but in a text field.
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));
});
Up close
- what
- the lightbox shows the downscaled web copy.
- rule: it also offers the original file, opened in a new tab for a full-resolution look or a download.
- how: a doc’s own address on the server is the original’s
/ipfs/address.
- rule
- the metadata rows leave the media every pixel they can.
- therefore: 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. - what: an icon has no text to name it.
- therefore: the link labels itself
originalfor a screen reader.
- therefore: the link labels itself
- rule: its hover title spells out the full-resolution, new-tab behaviour.
- therefore: the offer rides on the date row as a small
link = d.locator(".lb-meta").get_by_role("link", name="original") # on the date row
expect(link).to_have_text("⤢")
expect(link).to_have_attribute("title", re.compile("full resolution.*new tab"))
expect(link).to_have_attribute("href", FIXTURES[0]["cid"]) # the original's /ipfs/ path
expect(link).to_have_attribute("target", "_blank")
print(" PASS: lightbox links to original")
- how
- the glyph follows the date on one row.
<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>
.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; }
- rule
- up close means big: the lightbox takes the whole zone the system leaves it.
- rule
- the media — photo or video alike, they share one box — gets every pixel the metadata rows leave,
scaled up as well as down.
- rule: the date, the state chips, the label chips and the add-label box are held to about a third of the height between them; the media gets the rest — at least 90% of the width and 65% of the height.
- rule
- the media is shown whole, never cropped, at the largest size that fits its box.
- example: 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.
shown = img.bounding_box()
assert shown["width"] >= VIEWPORT["width"] * 0.9, f"media width {shown['width']} < 90% of viewport"
assert shown["height"] >= VIEWPORT["height"] * 0.65, f"media height {shown['height']} < 65% of viewport"
assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
"media crops instead of fitting whole"
print(" PASS: lightbox media fills screen")
- how
- the lightbox is a column filling the screen inside the system’s insets; the media is the one part allowed to grow, and fits its picture inside whatever it gets.
.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 */
.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; }
Putting words on it
-
A chip leads back
what : a doc’s labels show as chips, and a chip is also the shortest way back. - rule: click one and the lightbox closes with the wall re-filtered to that label.
d.get_by_role("button", name="zzcalanque", exact=True).click() expect(d).to_be_hidden() # the lightbox closes expect(search_box(page)).to_have_value("zzcalanque") # the filter switched to that label expect(tiles(page)).to_have_count(1) # the one doc wearing it print(" PASS: lightbox chip filters")how : each chip is two buttons — the word, which searches it, and its
×.<${For} each=${() => labelsOf(opened())}>${w => html` <span class="lb-chip"><button class="lb-chip-word" onClick=${() => { setSearch(w); commit(); dismissPhoto(); }}>${w}</button><button class="x" aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`} <//>
-
Words into the box
rule : the add-label box takes several words at once,
;-separated, and skips any the doc already carries.box.fill("") box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20) # one existing + two new, typed box.press("Enter") expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible() expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible() expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1) # not duplicated print(" PASS: lightbox adds several labels")
-
What the box offers
rule : completion works on the fragment under the caret here too: a pick fills the last segment and keeps the earlier labels already typed.
box.fill(""); box.press_sequentially("zztest;zzcal", delay=20) # typed, as a user would opt = options(page).filter(has_text=re.compile(r"^zzcalanque$")).first expect(opt).to_be_visible() opt.click() expect(box).to_have_value("zztest;zzcalanque; ") # earlier segment kept, completed, auto-'; box.press("Enter") # commit the built list expect(d.get_by_role("button", name="zztest", exact=True)).to_be_visible() expect(d.get_by_role("button", name="zzcalanque", exact=True)).to_be_visible() print(" PASS: lightbox completes last segment")how : the doc has just been given
zzcalanque, and does not carryzzcigale.rule : completion never proposes a label the open doc already wears.
box.press_sequentially("zzcal", delay=20) expect(options(page).filter(has_text=re.compile(r"^zzcalanque$"))).to_have_count(0) box.fill(""); box.press_sequentially("zzcig", delay=20) expect(options(page).filter(has_text=re.compile(r"^zzcigale$")).first).to_be_visible() print(" PASS: lightbox completion skips present")rule : the add-label box offers no occasion, though the search box would for the same fragment. - how: an occasion and a label share a prefix, the occasion holding a doc so the search box would offer it.
fete = {"rowId": "zzlbfete-ev", "summary": "zzLbFete", "owner": "konubinix", "status": "confirmed", "starttime": "2016-05-01T00:00:00Z", "endtime": "2016-05-02T23:59:59Z"} inside = {"cid": "https://ipfs.konubinix.eu/p/zzlbfete-p", "date": "2016-05-01T12:00:00Z", "mimetype": "image/jpeg", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzlbfete-p-t", "labels": "zzlbfetep", "state": "todo", "owner": "konubinix"} seed_events([fete]); seed_vocab(["zzlbfeuille"]) gql(DELETE, {"cid": inside["cid"]}); gql(CREATE, {"p": inside}) try: box.fill(""); box.press_sequentially("zzlbfe", delay=20) expect(options(page).filter(has_text="zzlbfeuille").first).to_be_visible() # the labels have answered expect(options(page).filter(has_text="event:")).to_have_count(0) # and no occasion with them finally: box.fill("") gql(DELETE, {"cid": inside["cid"]}); drop_events([fete]); drop_vocab(["zzlbfeuille"]) print(" PASS: the add-label box offers no occasion")rule : a label added moments ago is offered at once, on the very fragment that found nothing before it. - how: the fragment is asked once and answered empty, the word then added to the vocabulary behind the app’s back, as another app would, and the fragment asked again.
FRESH = "zzfraisneuf" try: with page.expect_response(lambda r: "/graphql" in r.url and '"prefix":"zzfrais"' in (r.request.post_data or "")): box.fill(""); box.press_sequentially("zzfrais", delay=20) # nothing yet to offer seed_vocab([FRESH]) # added elsewhere, moments ago box.fill(""); box.press_sequentially("zzfrais", delay=20) # the same fragment, asked again expect(options(page).filter(has_text=FRESH).first).to_be_visible() finally: drop_vocab([FRESH]) print(" PASS: a fresh label completes at once")
-
The keys in the box
rule : the add-label box keys like the search box:
↓thenEntercompletes the segment, with the auto-;, and a plainEntercommits the built list.box.fill(""); box.press_sequentially("zzcou", delay=20) expect(options(page).first).to_be_visible() box.press("ArrowDown"); box.press("Enter") # apply the highlight → fills "zzcousin; " expect(box).to_have_value("zzcousin; ") box.press("Enter") # nothing highlighted now → commit expect(d.get_by_role("button", name="zzcousin", exact=True)).to_be_visible() # chip added print(" PASS: lightbox label keyboard")rule : with nothing highlighted,
Enteradds the typed labels andShift+Enterremoves them — the keyboard does add and remove.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")how : adding merges the typed words into the doc’s own, and dropping filters them out of it, both ending in one write of the whole list. - how: adding also remembers the last word applied, so labelling the next doc is one tap away.
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 box at the foot of the lightbox
rule : this box — and the slideshow’s add-label box — is a combobox on the same terms as the search box. - rule: it tells a screen reader whether its list of offers is open. - rule: a close is a state flip, not a timed guess.
box.press_sequentially("zzc") expect(box).to_have_attribute("aria-expanded", "true") box.press("Tab") # out of the box, the ordinary way expect(box).to_have_attribute("aria-expanded", "false") assert page.get_by_role("listbox", name="suggestions").count() == 0, "the popover outlived the focus" print(" PASS: lightbox label combobox state")how : the box reports itself expanded only while it has focus and its offers have something to show, and the offers live only as long as that focus.
<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()), () => setLbFocus(false)); }} /> <${Show} when=${() => lbFocus()}> <${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug} onLoading=${setSugLoading} onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} /> <//> </div>rule : the arrows belong to the text inside the add-label box.
src0 = img.get_attribute("src") box.fill("abc") box.press("ArrowRight") # would step to the next doc if unguarded page.wait_for_timeout(200) expect(img).to_have_attribute("src", src0) # same doc — the arrow stayed in the field print(" PASS: label edit keeps arrows in text")how : one window listener routes the keyboard while a doc is open, gathering the browsing keys and the triage keys —
Enterto pick up,Deleteto condemn, that is to mark for deletion — into one place. - scope: the whole set stands aside whenever a text field has focus, the add-label box’s offers included.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 swallowSpc = e => { if(e.key === ' ' && opened() && e.target === lbVideo) e.preventDefault(); }; for(const t of ['keyup', 'keypress']) window.addEventListener(t, swallowSpc, true); onCleanup(() => { for(const t of ['keyup', 'keypress']) window.removeEventListener(t, swallowSpc, true); }); });what : one thing about that box is peculiar to the lightbox: it sits at the very foot of it, and nothing in the lightbox scrolls. - therefore: 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. - rule: the lightbox ends where the navigation bar’s strip begins.
rule : 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.
box.press_sequentially("zzcig", delay=20) listbox = page.get_by_role("listbox", name="suggestions") expect(listbox).to_be_visible() lb = listbox.bounding_box(); ib = box.bounding_box() assert lb["y"] + lb["height"] <= ib["y"] + 1, \ f"the completion must open above the input: list {lb} input {ib}" print(" PASS: lightbox completion opens upward")how : inside the label row, the offers hang from the top of the box instead of its bottom.
.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 */ .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; }
Browsing without closing
-
A video, and the run around it
what : the original of a video is the whole file off the camera.
rule : a video opens as a video pointed at its web copy, not at its original.
expect(v).to_be_visible() assert VIDEO_FIXTURE["webCid"] in (v.get_attribute("src") or ""), "wrong clip src" print(" PASS: lightbox clip")how : what fills the media box is chosen by kind, and a doc with nothing to show gets a placeholder naming it. - rule: what the lightbox opens instead holds when a copy is missing.
<${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> <//> <//>rule : the doc in front of you steps — the
‹/›buttons or the keyboard arrows go to the previous or next doc, in the order the wall is showing. - rule: the run wraps into a ring: past the last doc comes the first.expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0") page.keyboard.press("ArrowRight") expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") page.keyboard.press("ArrowLeft") expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0") d.get_by_role("button", name="next photo").click() expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") d.get_by_role("button", name="previous photo").click() # back where we started expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0") d.get_by_role("button", name="previous photo").click() # and off the front expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2") # → the last, not a dead stop d.get_by_role("button", name="next photo").click() # off the end again expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0") print(" PASS: lightbox prev/next")rule : a wheel over the doc steps too, but only with Shift held — the bare flick is left alone. - rule: a flick moves one doc, not a run: after a step the wheel goes deaf for a fifth of a second.
over = img.bounding_box() page.mouse.move(over["x"] + over["width"] / 2, over["y"] + over["height"] / 2) page.keyboard.down("Shift") page.mouse.wheel(0, 240) # one flick down… page.mouse.wheel(0, 240) # …and its tail, still inside the deaf window page.wait_for_timeout(250) expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1") # one doc, not two page.mouse.wheel(0, -240) # shift-scroll up → prev expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0") page.keyboard.up("Shift") page.wait_for_timeout(250) page.mouse.wheel(0, 240) # no Shift → the panel's scroll, not ours page.wait_for_timeout(250) expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0") print(" PASS: lightbox shift-scroll nav")how : the
‹/›buttons, the arrows and a Shift+wheel are three ways of asking for the same thing, so they meet in one step. - how: the wheel reads whichever axis Shift maps it to, and is held to one step per burst, 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); };rule : the lightbox shows whether the open doc is picked up, and lets you toggle it. - how: one doc is already picked up when this one is opened, so what the wall has to show is the two of them together. - rule: the lightbox picks into the same run the wall is holding: the wall, still visible around the lightbox, takes the change as it happens, and keeps it once the doc is put away.
sel.click() expect(sel).to_have_attribute("aria-pressed", "true") # now selected expect(checks(page)).to_have_count(2) # and the wall says so under the modal d.get_by_role("button", name="close").click() expect(checks(page)).to_have_count(2) # the set the wall goes on holding print(" PASS: lightbox select")
-
Working as you step
rule : the word just used is offered on the next doc.
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")what : a chip’s
×is the other half of the box: the way to take a word off a doc without typing it again.what : a removal that never landed looks, on screen, exactly like one that did.
rule : whatever the chips show, the server actually keeps them: a fresh search finds the doc by the word just put on it, and no longer by the word just taken off.
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 doc 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")how : an edit shows on the doc at once, and is written to the server behind it, the wall then read again.
async function applyLabels(labels){ const cid = opened().cid; setOpened({ ...opened(), labels }); await gql(UPDATE_PHOTO, { cid, patch: { labels } }); await refetch(); }rule : with no field focused,
Entertoggles whether the open doc is picked up — the lightbox echo of a click on the wall.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")
Closer than the screen
- what
- 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.
- example: to read a sign in the background, or to be sure whose face that is.
- rule
- magnified, you have to move about.
- what: a zoom you cannot travel shows you the middle of the doc and nothing else.
- what
- the browser does both of those perfectly on its own.
- therefore: the app puts no pinch and no drag of its own on the media.
- rule
- two fingers magnify, as on any picture, and one then travels.
area = img.bounding_box()
cx, cy = area["x"] + area["width"] / 2, area["y"] + area["height"] / 2
seen = lambda: page.evaluate("() => [visualViewport.scale, visualViewport.pageLeft]")
assert seen()[0] <= 1.01, "started already magnified"
pinch_in(page, cx, cy)
wait_until(page, lambda: seen()[0] > 1.5, label="the pinch reached the browser's zoom",
detail=lambda: f"scale is {seen()[0]}")
at = seen()[1]
drag_touch(page, cx + 150, cy, cx - 150, cy) # one finger, straight across the doc
wait_until(page, lambda: abs(seen()[1] - at) > 5, label="the magnified doc panned sideways",
detail=lambda: f"pageLeft went {at} → {seen()[1]}")
print(" PASS: lightbox pinch zooms and pans")
Into the slideshow, from the lightbox
- what
- The slideshow launches from the wall, over whatever the query narrowed to.
- how
- landing on a chosen slide is something the slideshow already does: it opens on whichever slide matches
the one it remembers, the way it resumes after a relaunch.
- therefore: launching from the lightbox is seeding that memory with the open doc, dropping the lightbox, and entering — the strip settles on that very slide.
- rule
- the lightbox carries a ▶ frame button too — a tap from the doc already open — and it opens the slideshow on the doc you’re looking at rather than back at the first slide.
d.get_by_role("button", name=re.compile("frame", re.I)).click()
expect(d).to_be_hidden() # the lightbox gives way to the show
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
wait_until(page, lambda: strip.evaluate(CENTERED) == middle["thumbnailCid"],
label="the show opens on the doc you were looking at",
detail=lambda: f"it opened on {strip.evaluate(CENTERED)!r}")
print(" PASS: frame from lightbox")
- how
- the button sits at the top of the lightbox, between the pick toggle 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; }
- rule
- the back button unwinds the whole way down — slideshow → lightbox → wall — so coming out of the slideshow lands back on the doc you left.
page.go_back() # out of the show…
expect(strip).to_be_hidden()
expect(d.get_by_role("img")).to_have_attribute("src", middle["thumbnailCid"]) # …onto the doc it came from
page.go_back() # out of the lightbox…
expect(d).to_be_hidden()
expect(grid(page)).to_be_visible() # …back to the wall
print(" PASS: back from frame returns to lightbox")
- how
- the handler writes the open doc’s address where the slideshow looks for the slide to resume on,
closes the lightbox, and enters.
- how: the lightbox’s history entry stays underneath the slideshow’s.
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
enterFrame(); };
Saying no, quickly
- what
- most of a burst is not worth keeping.
- example: eleven near-identical shots of the same moment, and one of them is the one.
- therefore: sweeping a burst down to the keepers means saying no far more often than yes.
- what
- the same keyboard is being used to type labels a second earlier.
- therefore: it has to know the difference between condemning a doc and rubbing out a letter.
- rule
- a run like this is read with nothing filtered out.
- scope: working the
todorun instead, each doc would drop off the wall as you condemned it and the run would shrink under you — which is its own story, and a different feeling. - what: here you are going along a row looking at everything.
- therefore: a doc you have just condemned stays where it is, and the next one is still next.
- how: none of them starts out condemned — whatever else they are.
- therefore: a doc reading condemned afterwards can only have been marked by the key just pressed.
- scope: working the
- what
- a card dump — everything off a camera’s card — is not all photographs.
- what: a video is the one thing on the wall that cannot be judged from a still — you have to watch some of it.
- how: the run opens on one.
@testcase
def test_saying_no_quickly(page):
"""Running down a stack marking rejects: spotting the clip at the head of it from the
wall, watching it, then asking the first time, taking Shift for the rest, and staying
out of the way while you are typing."""
make_fixtures()
gql(CREATE, {"p": VIDEO_FIXTURE})
page.route("**" + VIDEO_FIXTURE["webCid"], lambda r: r.fulfill(
status=200, body=CLIP_WEBM, content_type="video/webm",
headers={"Accept-Ranges": "bytes"}))
try:
open_app(page); chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1)
expect(grid(page).get_by_title("video")).to_have_count(1) # one of them, and only one, is badged
print(" PASS: clip tiles are marked")
open_doc(page, 0) # the clip: earliest in the run
d = dialog(page)
v = d.locator("video")
expect(v).to_be_visible()
assert VIDEO_FIXTURE["webCid"] in (v.get_attribute("src") or ""), "wrong clip src"
print(" PASS: lightbox clip")
v.evaluate("el => { el.muted = true; el.playbackRate = 0.1; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press(" ") # SPC pauses
wait_until(page, lambda: v.evaluate("el => el.paused"))
page.keyboard.press(" ") # SPC plays again
wait_until(page, lambda: v.evaluate("el => !el.paused"))
v.evaluate("el => el.currentTime = el.duration - 0.1") # running still, and all but over
page.keyboard.press(" ") # SPC replays from the start
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 1
and not v.evaluate("el => el.paused"))
print(" PASS: lightbox clip SPC")
v.focus() # the clip's own controls hold the focus
was = v.evaluate("el => el.paused")
page.keyboard.press(" ")
wait_until(page, lambda: v.evaluate("el => el.paused") != was)
page.wait_for_timeout(300) # a second toggle would land by now
assert v.evaluate("el => el.paused") != was, "SPC on the focused clip toggled it twice"
v.evaluate("el => el.blur()") # the arrows are not the clip's own to take
# the arrows want it as it was
v.evaluate("(el, was) => { if(was) el.pause(); else el.play().catch(() => {}); }", was)
wait_until(page, lambda: v.evaluate("el => el.paused") == was)
print(" PASS: SPC works with the clip's controls focused")
v.evaluate("el => el.currentTime = 0")
page.keyboard.press("ArrowRight") # → jumps 5s in
wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
page.keyboard.press("ArrowLeft") # ← jumps back to the head of it
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 0.25)
page.keyboard.press("ArrowLeft") # nothing behind it now → out of the clip
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[2]["thumbnailCid"]) # the run wraps back
page.keyboard.press("ArrowRight") # forward again, onto the clip…
page.keyboard.press("ArrowRight") # …and straight off it
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[0]["thumbnailCid"])
print(" PASS: lightbox clip arrows")
del_btn = d.get_by_role("button", name="delete", exact=True)
expect(del_btn).to_have_attribute("aria-pressed", "false")
asked = []
page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
page.keyboard.press("Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true") # moved to delete
assert asked, "Delete should have asked to confirm"
print(" PASS: lightbox delete confirms")
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false") # a fresh doc, not yet condemned
asked.clear()
page.keyboard.press("Shift+Delete")
expect(del_btn).to_have_attribute("aria-pressed", "true")
assert not asked, "Shift+Delete should not ask to confirm"
print(" PASS: lightbox shift-delete skips confirm")
d.get_by_role("button", name="next photo").click()
expect(del_btn).to_have_attribute("aria-pressed", "false")
box = d.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzoops", delay=10)
box.press("ArrowLeft"); box.press("Delete") # rubbing out the last letter
expect(box).to_have_value("zzoop")
expect(del_btn).to_have_attribute("aria-pressed", "false") # the doc is untouched
print(" PASS: lightbox delete in a field is text editing")
finally:
page.unroute("**" + VIDEO_FIXTURE["webCid"])
gql(DELETE, {"cid": VIDEO_FIXTURE["cid"]})
Telling a video from a photo
- what
- a video’s poster — the still its tile shows — looks like any photo.
- therefore: on the wall you can’t tell you could press play.
- rule
- a video wearing a poster earns a play badge.
- how: the file’s kind, as the server records it, says which is which.
- scope: a video with no poster at all already shows the video placeholder (placeholder tiles), so it needs no extra mark.
- rule
- on a wall of photos holding a single video, exactly one tile carries the badge.
expect(grid(page).get_by_title("video")).to_have_count(1) # one of them, and only one, is badged
print(" PASS: clip tiles are marked")
- how
- the badge is a play triangle laid over the poster, shown for a video that carries one.
<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
<span class="play" title="video">▶</span>
<//>
- how
- 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; }
Condemning with a key
- rule
- in the lightbox the
Deletekey condemns the open doc, marking itdelete.- rule: it then re-anchors and closes just as a button press would.
- how: it goes through the same state-setting a button press uses.
- rule
- it asks the first time — pressing
Deleteraises a confirm, and accepting it moves the open doc todelete.
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")
- rule
Shift+Deleteis the way to tell it you know what you are doing — the same move without the question.
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")
- how
Shiftis what forces past the confirm.
const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };
- rule
- condemning a doc is told from rubbing out a letter —
mid-word in the add-label box, reaching to rub out a typo,
Deleteis nothing but text editing.- how: it obeys the same guard as the arrows, which act only while no field has focus.
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 doc is untouched
print(" PASS: lightbox delete in a field is text editing")
A video under the keys
- what
- a video’s last quarter-second counts as its end, whether or not the element has got round to
saying so.
- what: a video that is all but over and one that has just stopped are the same thing to the hand on the key.
- rule
SPCis the space that starts and stops the video, whether or not the video’s own controls have focus.- rule: on a video at its end it replays from the start rather than leaving you on a frozen last frame.
v.evaluate("el => { el.muted = true; el.playbackRate = 0.1; el.play().catch(() => {}); }")
wait_until(page, lambda: v.evaluate("el => !el.paused && el.readyState >= 2"))
page.keyboard.press(" ") # SPC pauses
wait_until(page, lambda: v.evaluate("el => el.paused"))
page.keyboard.press(" ") # SPC plays again
wait_until(page, lambda: v.evaluate("el => !el.paused"))
v.evaluate("el => el.currentTime = el.duration - 0.1") # running still, and all but over
page.keyboard.press(" ") # SPC replays from the start
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 1
and not v.evaluate("el => el.paused"))
print(" PASS: lightbox clip SPC")
v.focus() # the clip's own controls hold the focus
was = v.evaluate("el => el.paused")
page.keyboard.press(" ")
wait_until(page, lambda: v.evaluate("el => el.paused") != was)
page.wait_for_timeout(300) # a second toggle would land by now
assert v.evaluate("el => el.paused") != was, "SPC on the focused clip toggled it twice"
v.evaluate("el => el.blur()") # the arrows are not the clip's own to take
# the arrows want it as it was
v.evaluate("(el, was) => { if(was) el.pause(); else el.play().catch(() => {}); }", was)
wait_until(page, lambda: v.evaluate("el => el.paused") == was)
print(" PASS: SPC works with the clip's controls focused")
- how
- the key goes to whichever video is on screen, reading that last quarter-second as the end.
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();
}
- what
- a video whose own controls hold the focus toggles itself on the space as well, on the key’s
release.
- therefore: the key’s release and its keypress are kept from a focused video, so it toggles once.
const swallowSpc = e => {
if(e.key === ' ' && opened() && e.target === lbVideo) e.preventDefault();
};
for(const t of ['keyup', 'keypress']) window.addEventListener(t, swallowSpc, true);
onCleanup(() => { for(const t of ['keyup', 'keypress']) window.removeEventListener(t, swallowSpc, true); });
- rule
- while a video is playing, the arrows jump through it —
→5s on,←5s back — instead of leaving it.- rule: the end, or the start, at which an arrow gives up and steps to the next, or previous, doc is that same quarter-second.
- rule: a paused video, or a photo, steps as before.
- rule: a video you have stepped away from and come back to is a paused video — nothing starts it but the hand — so pressing on leaves it at once.
v.evaluate("el => el.currentTime = 0")
page.keyboard.press("ArrowRight") # → jumps 5s in
wait_until(page, lambda: v.evaluate("el => el.currentTime") >= 4.5)
page.keyboard.press("ArrowLeft") # ← jumps back to the head of it
wait_until(page, lambda: v.evaluate("el => el.currentTime") < 0.25)
page.keyboard.press("ArrowLeft") # nothing behind it now → out of the clip
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[2]["thumbnailCid"]) # the run wraps back
page.keyboard.press("ArrowRight") # forward again, onto the clip…
page.keyboard.press("ArrowRight") # …and straight off it
expect(d.get_by_role("img")).to_have_attribute("src", FIXTURES[0]["thumbnailCid"])
print(" PASS: lightbox clip arrows")
- how
- a playing video takes the arrows first, seeking within it before it steps off it.
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);
};
Judging a run down to nothing
- what
- this is the story triage exists for.
- how
- a state filter on the wall —
todo, say — holds everything still waiting on you, and you open the first of them. - what
- what makes it awkward to build is that judging a doc removes it from the filter you
are reading.
- what: the run shifts under the open doc at the very moment you need it to say where to go next.
- what: a naive re-read lands you on the one you just finished with, or on the one before it that you dealt with a minute ago.
- what
- the run wraps — past its last doc comes its first again — so it is a ring.
- what
- in a ring of two, the one after and the one before are the same doc.
- therefore: a step forward cannot be told from a step back.
- therefore: the wrap has to be judged from a ring of at least three.
- what
- one is already gone by then.
- therefore: four is the smallest run that can show the difference at every stage.
- therefore: the run needs four docs at
todorather than the usual three.
RUN_N = len(FIXTURES) + 1 # the usual three, and one more to wrap on
@testcase
def test_judging_a_run_down_to_nothing(page):
"""Working a filter to the end: each judgement hands you the next doc, the last
comes round to the first, emptying the run closes it — and the docs are all still
there, under the chip you sent them to, which is where you come back to."""
make_fixtures()
fourth = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-3", "date": "2020-04-15T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-3", "labels": FIXTURE_LABEL, "state": "todo"}
gql(DELETE, {"cid": fourth["cid"]}); gql(CREATE, {"p": fourth})
for f in FIXTURES[1:]:
gql(UPDATE, {"cid": f["cid"], "patch": {"state": "todo"}}) # four todos, Jan..Apr
try:
open_app(page)
expect(chip(page, "todo")).to_have_attribute("aria-pressed", "true") # nothing chosen yet: todo
print(" PASS: a first visit opens on todo")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(RUN_N) # default filter is todo
# loaded, not still blank
expect(thumb_imgs(page).nth(3)).to_have_attribute("src", re.compile(r"/ipfs/"))
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")
held = []
def hold_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""): held.append(route)
else: route.fallback()
page.route("**/graphql", hold_writes)
d.get_by_role("button", name="todo", exact=True).click() # a verdict that keeps it in the run
wait_until(page, lambda: held, label="the verdict is on the wire")
page.keyboard.press("Escape")
expect(d).to_be_hidden()
with page.expect_response(lambda r: "updatePhotovideo" in (r.request.post_data or "")) as landed:
held[0].fallback()
assert landed.value.ok, f"the held verdict did not land: {landed.value.status}"
for _ in range(30): # the re-read after it, then a frame: 3 s of watching
assert d.count() == 0 or not d.is_visible(), "the doc shut mid-verdict came back by itself"
page.wait_for_timeout(100)
page.unroute("**/graphql", hold_writes) # only once nothing is on the wire through it
print(" PASS: a doc shut mid-verdict stays shut")
open_doc(page, 1) # back where the run was
d.get_by_role("button", name="done", exact=True).click() # leaves the todo filter
expect(d).to_be_visible() # still open…
expect(img).to_have_attribute("src", ahead) # …on the doc that took its place
assert img.get_attribute("src") != behind, "the modal stepped backwards"
print(" PASS: judging one lands on the next, not the previous")
onto_last = thumb_imgs(page).nth(2).get_attribute("src") # the last of the three left
d.get_by_role("button", name="next photo").click()
expect(img).to_have_attribute("src", onto_last) # settled on it before judging it
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_visible()
expect(img).to_have_attribute("src", behind) # round to the first, not back to the end
print(" PASS: judging the last comes round to the first")
d.get_by_role("button", name="done", exact=True).click()
expect(img).to_have_attribute("src", ahead) # round again, to the one still standing…
d.get_by_role("button", name="done", exact=True).click()
expect(d).to_be_hidden() # …and with that one gone, nothing to show
expect(tiles(page)).to_have_count(0)
print(" PASS: the last one judged closes the run")
# the run is empty because they moved, not because they went
expect(tiles(page)).to_have_count(0) # the todo chip has nothing left
chip(page, "all").click()
expect(tiles(page)).to_have_count(RUN_N) # they are all still there
chip(page, "done").click()
expect(tiles(page)).to_have_count(RUN_N) # under the verdict you gave them
expect(grid(page).get_by_text("done")).to_have_count(RUN_N) # and their badges say so
print(" PASS: state filter")
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(chip(page, "done")).to_have_attribute("aria-pressed", "true") # remembered, not back to todo
expect(tiles(page)).to_have_count(RUN_N) # and the wall it was showing
print(" PASS: state filter persists")
expect(thumb_imgs(page).nth(1)).to_have_attribute("src", re.compile(r"/ipfs/")) # loaded, not blank
ahead = thumb_imgs(page).nth(1).get_attribute("src")
open_doc(page, 0)
page.keyboard.press("Shift+Delete") # condemned: it leaves the done wall
expect(d).to_be_visible() # still open…
expect(img).to_have_attribute("src", ahead) # …on the doc that took its place
d.get_by_role("button", name="close").click()
print(" PASS: Delete re-anchors like a button")
finally:
gql(DELETE, {"cid": fourth["cid"]})
Change state from the lightbox
- rule
- judging a doc does not need closing it.
- what: the lightbox carries the state buttons too, and tapping one saves the state at once.
- what
- you are reading the run in order — one, then the next, then the next.
- rule: judging a doc in the lightbox removes it from the filter you are reading.
- rule: the lightbox stays open, the next one in front of you — never the one before, which you read a moment ago and dealt with.
- rule
- a doc you shut while its verdict is still on its way stays shut once the verdict lands.
- how: the verdict is held on the wire, the doc shut, and the verdict let go; a verdict that keeps the doc in the filter is the one that would bring it back.
held = []
def hold_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""): held.append(route)
else: route.fallback()
page.route("**/graphql", hold_writes)
d.get_by_role("button", name="todo", exact=True).click() # a verdict that keeps it in the run
wait_until(page, lambda: held, label="the verdict is on the wire")
page.keyboard.press("Escape")
expect(d).to_be_hidden()
with page.expect_response(lambda r: "updatePhotovideo" in (r.request.post_data or "")) as landed:
held[0].fallback()
assert landed.value.ok, f"the held verdict did not land: {landed.value.status}"
for _ in range(30): # the re-read after it, then a frame: 3 s of watching
assert d.count() == 0 or not d.is_visible(), "the doc shut mid-verdict came back by itself"
page.wait_for_timeout(100)
page.unroute("**/graphql", hold_writes) # only once nothing is on the wire through it
print(" PASS: a doc shut mid-verdict stays shut")
open_doc(page, 1) # back where the run was
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")
- what
- the run is a ring — the arrows already come round at its ends.
- therefore: 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")
- what
- carry on and the run empties.
- rule: when the one you just judged was the only one left, there is nothing to show at all, and the lightbox gets out of your 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")
- how
- all of it falls out of one write and one re-anchor — setting the lightbox back on a doc of the
run as it stands after the write.
- what: after the write, the doc that was next around the ring has moved up into the gap, and its
neighbour would answer instead.
- therefore: the doc that was next is noted before the write.
- how: and once the write lands, the lightbox is set again only if it still shows the doc judged.
- what: after the write, the doc that was next around the ring 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(() => {
if(opened()?.cid !== cur.cid) return; // shut, or moved on, while the verdict was out
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 });
Filtering by state
- what
- triage needs a way to ask « show me only what’s still
todo».- what: each doc carries one of the four states.
- what: the state filter is a row of buttons under the search box —
all, plus one per state — that narrows the wall to the one pressed. - rule: a first-ever visit opens on
todo.
expect(chip(page, "todo")).to_have_attribute("aria-pressed", "true") # nothing chosen yet: todo
print(" PASS: a first visit opens on todo")
- what
- the wall shows a spread — the 2000 docs (the cap) the server draws from across the docs'
whole span.
- what: when the docs are mostly
done, a filter applied after the spread would find nearly nothing. - how: the filter is applied by the server, before it draws the spread.
- what: when the docs are mostly
- scope
- where that ordering is applied is not something this note measures.
- what: a wall of four reads the same whether the filter went in before the spread or after it.
- what: a set of fixtures large enough to tell the two apart would have to be most of the docs.
- therefore: what is pinned below is the narrowing itself, and where the docs went.
- what
- a run judged down to nothing is the plainest way to see that narrowing.
- rule: the wall you were working is empty.
- rule: every doc that left it is still there under
all, and waiting under the state you sent it to, its state chip saying so.
expect(tiles(page)).to_have_count(0) # the todo chip has nothing left
chip(page, "all").click()
expect(tiles(page)).to_have_count(RUN_N) # they are all still there
chip(page, "done").click()
expect(tiles(page)).to_have_count(RUN_N) # under the verdict you gave them
expect(grid(page).get_by_text("done")).to_have_count(RUN_N) # and their badges say so
print(" PASS: state filter")
The choice is kept
- rule
- thereafter the wall opens on whatever you last chose — the choice is remembered, like the
search box.
- scope: a reload — or the slideshow’s relaunch — comes back to the state you were last triaging, and the
view you were working, not always
todo. - how: flipping a button fetches the wall again, the state travelling with the search, and stores the choice in the browser.
- scope: a reload — or the slideshow’s relaunch — comes back to the state you were last triaging, and the
view you were working, not always
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(chip(page, "done")).to_have_attribute("aria-pressed", "true") # remembered, not back to todo
expect(tiles(page)).to_have_count(RUN_N) # and the wall it was showing
print(" PASS: state filter persists")
// the 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 key that judges
- rule
- the
Deletekey judges like a button: the condemned doc leaves the run and the next one takes its place.
expect(thumb_imgs(page).nth(1)).to_have_attribute("src", re.compile(r"/ipfs/")) # loaded, not blank
ahead = thumb_imgs(page).nth(1).get_attribute("src")
open_doc(page, 0)
page.keyboard.press("Shift+Delete") # condemned: it leaves the done wall
expect(d).to_be_visible() # still open…
expect(img).to_have_attribute("src", ahead) # …on the doc that took its place
d.get_by_role("button", name="close").click()
print(" PASS: Delete re-anchors like a button")
The buttons
- rule
- a filter button cannot be mistaken for the like-named button of the toolbar.
- how: the state filter’s buttons sit in a group labelled as the filter.
- rule
- the active button reads as pressed — to the eye, and to a screen reader alike.
- how: to the eye, the pressed button fills with its state’s colour.
<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; }
Doing one thing to forty docs
- what
- some of triage is per-doc and some of it plainly is not.
- example: a whole afternoon wants the same word on it; a whole card-dump wants the same state.
- what
- the wall lets you take a run in hand and act on all of it at once.
The size of the wall
- rule
- before any of that, the wall has to be at a size you can work at.
- what
- which size that is depends on the job.
- example: picking a run out of a card-dump wants small tiles and a lot of them on screen.
- example: telling two nearly-identical docs apart wants the opposite.
- how
- the size is yours to set, from the controls or from the hand already on the wall.
Customizable thumbnail size
- rule
- the tile size is set from the controls: the − / + buttons, opening at 96 for a first visit.
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*96px")) # nothing chosen yet: 96
print(" PASS: a first visit opens at 96")
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)
- rule
- the tile size stays set: reopen the app and the wall comes back at the size you left it.
- rule: the run is still on the wall afterwards too — the query that put those docs there comes back with it.
w1 = t.nth(0).bounding_box()["width"]
open_app(page, launch)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # and the run is still the run
- how
- the size is one number kept in the browser’s own storage, so a relaunch reads back what the last session wrote, and every step is held inside the slider’s range.
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)));
- rule
- the tile size is set from the hand already on the wall: Ctrl+scroll over the tiles, 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)
- what
- a wheel arrives as a burst of notches where a finger meant one movement.
- rule: 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)
- what
- Ctrl+wheel is also the browser’s own page zoom.
- rule: over the wall the page never zooms: every notch, the ones a burst swallows too, is kept
from the browser.
- how: the browser zooms only on a wheel nobody cancelled, so each notch is read back as it leaves the page, cancelled or not.
- rule: over the wall the page never zooms: every notch, the ones a burst swallows too, is kept
from the browser.
page.evaluate("""() => { window.__ctrlWheels = [];
addEventListener('wheel', e => { if(e.ctrlKey) __ctrlWheels.push(e.defaultPrevented); },
{ passive: true }); }""")
page.wait_for_timeout(200) # a fresh gesture
w4 = t.nth(0).bounding_box()["width"]
box = grid(page).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + 10) # back over the wall, off the button
page.keyboard.down("Control")
page.mouse.wheel(0, -240)
page.mouse.wheel(0, -240) # the notch the burst swallows
page.keyboard.up("Control")
wait_until(page, lambda: page.evaluate("() => __ctrlWheels.length") >= 2)
seen = page.evaluate("() => __ctrlWheels")
assert all(seen), f"a Ctrl+wheel over the wall reached the browser's zoom: cancelled={seen}"
page.get_by_role("button", name="smaller thumbnails").click() # back to where it was
wait_until(page, lambda: abs(t.nth(0).bounding_box()["width"] - w4) < 2)
print(" PASS: ctrl-scroll over the wall never zooms the page")
- how
- one wheel handler on the wall holds both rules, timing each notch against the last one it let through.
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);
};
-
What a sharp tile costs
what : a tile at the top of that range shows a 256px thumbnail at 320 — a stretch of a quarter on a desktop, and more on a phone, whose device pixels outnumber its CSS ones.
what : sharpness here is priced per tile. - how: the price is read off the docs themselves: ten docs at each of twelve evenly spaced points through it, each one’s thumbnail and its web copy,
web_cid, weighed in kB.photos thumbnail kB web copy kB web / thumbnail 120 5–89 33–622 3.4–16.6 what : the next search draws a fresh wall of them. - therefore: buying the heavier rendition for a contact sheet multiplies what each fold costs by the ratio above, for pictures nobody is looking at yet.
what : what changes as the tiles grow is not that price but the number of tiles paying it. - what: a fold holds about its area over the cell’s square in tiles. - therefore: growing the cell from the default 96px to the slider’s top, 320px, cuts the tiles by (320 / 96)² — about eleven. - therefore: the area falls out, so a phone strikes the same trade as a desktop. - therefore: a grown fold of web copies costs between about a third of and half again as much as a dense fold of thumbnails, the measured ratio running 3.4 to 16.6 against elevenfold fewer tiles.
-
Sharpening past a width
rule : past a width the wall does the refining, to the same rendition the lightbox and the slideshow show.
what : where a grown fold of web copies starts to cost no more than a dense fold of thumbnails is a judgement call, the ratio above spanning a third to half again; it is fixed at a drawn cell of 300px — to move by eye if the wall comes to feel either soft or heavy. - what: drawn, not chosen: the wall’s columns stretch into whatever width is left over, and a phone at the top of the slider draws a single column across its whole screen. - therefore: the threshold reads the pixels the picture is actually painted at, taken from the resolved column and not from the setting. - rule: below that width the wall buys thumbnails and nothing else — scroll it as far as you like.
assert cell_px(page) <= 300, f"a contact sheet should be drawn under 300px, and this is {cell_px(page)}px" page.mouse.move(REFINE_VIEWPORT["width"] / 2, REFINE_VIEWPORT["height"] / 2) for _ in range(10): # all the way down the wall page.mouse.wheel(0, 400) page.wait_for_timeout(100) assert tiles(page).last.bounding_box()["y"] < REFINE_VIEWPORT["height"], \ "the wall never overflowed — nothing was scrolled into reach" assert not web(), f"the dense wall fetched {len(web())} web renditions" print(" PASS: a dense wall buys thumbnails and nothing else")how : in code the threshold is read off the grid itself, and two triggers keep that reading true. - how: a
ResizeObserverre-measures the resolved column whenever the wall’s own box changes. - how: an effect re-measures when the slider moves the columns inside a box that has not changed. - how: Under the hood,getComputedStylemakes that 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 that trades up
rule : grow the tiles past the threshold and that same wall sharpens, the thumbnail staying underneath so that nothing blinks out while the fuller picture is on its way.
tiles(page).first.scroll_into_view_if_needed() grow_the_wall(page) wait_until(page, lambda: any(u.endswith("zzref-web-1") for u in web()), label="the tile on screen trades up to its web rendition", detail=lambda: f"srcs={tile_srcs(page, 1)} web={web()}") assert any("zzref-t-1" in (s or "") for s in tile_srcs(page, 1)), "the thumbnail left from under it" print(" PASS: a grown wall trades up, the thumbnail staying under it")how : a tile trades up only once its own thumbnail has settled and the wall is refining. - rule: once it has traded up it holds that claim until it leaves — whether it leaves the viewport or the wall.
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 againhow : the picture is then two stacked images, as in the slideshow: the thumbnail carries the tile, and the web copy fades in over it once it has painted. - rule: both are the same doc, so only the layer beneath is named — the overlay’s
altis empty, which keeps it out of the accessibility tree.expect(tiles(page).nth(1).locator("img.thumb.web")).to_have_count(1) # the overlay is there… expect(tiles(page).nth(1).get_by_role("img")).to_have_count(1) # …and only the layer beneath is named print(" PASS: the overlay image is not named twice")what : two layers is also two decoded images, and decoded pixels — not bytes on the wire — are what the near-screen window exists to bound.
<${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; }rule : what is off screen is not bought, and a video keeps its poster — its web copy being a video rather than a bigger poster.
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 clip's tile fetched its film: {web()}" print(" PASS: a grown wall buys narrowly")
-
Thumbnails first
what : a web copy is ten thumbnails on the wire, and issued alongside them it competes for the same bandwidth and pushes back the moment a screenful of blanks turns into pictures you can triage.
rule : settled, not painted, is what a thumbnail owes — an address the docs’ file server cannot serve must not hold the whole wall soft for good.
rule : thumbnails first, web copies after: refinement never costs the wall its legibility — nothing refines until every thumbnail the wall is waiting for has settled.
expect(thumb_imgs(page).first).to_have_attribute("src", f"/ipfs/{WAIT_HELD}") # asked for… assert not [u for u in fetched() if u.endswith(WAIT_HELD)], "…and still unanswered" wait_until(page, lambda: len([u for u in fetched() if "zzwait-t-" in u]) >= 2, label="its neighbours' thumbnails have settled", detail=lambda: f"settled={[u for u in fetched() if 'zzwait-t-' in u]}") page.wait_for_timeout(REFINE_GRACE_MS) assert not web(), f"the wall refined with a thumbnail still in flight: {web()}" print(" PASS: the wall waits for its thumbnails before refining")how : 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. - how: the wall refines only past the threshold and with that count at zero.
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 againconst [thumbsInFlight, setThumbsInFlight] = createSignal(0); const refining = () => cellPx() > REFINE_CELL && thumbsInFlight() === 0;what : scrolling reopens the question: fresh tiles bring fresh thumbnails to wait for, and the wall is waiting again. - rule: the sharpening goes on past a stuck thumbnail: a tile that has already traded up keeps what it has, so the sharpening only ever moves forward, a screenful at a time.
wait_until(page, lambda: sharp(0), label="the tile on screen sharpens", detail=lambda: f"srcs={tile_srcs(page, 0)}") tiles(page).nth(2).scroll_into_view_if_needed() wait_until(page, lambda: sharp(3), label="the tiles scrolled into reach sharpen too", detail=lambda: f"srcs={tile_srcs(page, 3)}") tiles(page).nth(4).scroll_into_view_if_needed() # doc 4 comes into reach, and never lands wait_until(page, lambda: inreach(5), label="doc 5 has come into reach behind it", detail=lambda: f"srcs={tile_srcs(page, 5)}") page.wait_for_timeout(REFINE_GRACE_MS) assert inreach(4), f"doc 4 must be in reach to hold anything back: {tile_srcs(page, 4)}" assert sharp(3), f"a tile handed its picture back: {tile_srcs(page, 3)}" assert not sharp(5), f"a tile refined behind a thumbnail in flight: {tile_srcs(page, 5)}" print(" PASS: a late thumbnail holds back what arrives behind it")rule : a tile out of sight lets go of its picture — the fuller one along with its thumbnail, so a wall you have left behind holds no more decoded pixels for having been sharpened.
wait_until(page, lambda: not inreach(0), label="doc 0 has been left behind", detail=lambda: f"srcs={tile_srcs(page, 0)}") assert not sharp(0), f"the fuller picture outlived the thumbnail: {tile_srcs(page, 0)}" print(" PASS: a tile left behind drops both its pictures")what : a tile can also leave the wall altogether, when a search swaps the whole of it while a thumbnail is still on its way. - what: nothing here can pin that one on its own: the count of thumbnails waited for is let go of in one place, the same place a thumbnail settling releases it. - therefore: a break that strands the departed strands the settled too, and then nothing refines anywhere — caught by the reading that a grown wall trades up, long before this one. - scope: the check stays all the same — the user’s choice. - how: what this adds is the case the release is hardest in: everything on the wall leaving at once, while one of them was still owed an answer. - rule: the count lets go of whatever left with it, and the wall that replaced it sharpens too.
search_for(page, FWD_LABEL_AFTER) # swapped out from under a thumbnail in flight expect(tiles(page)).to_have_count(len(FWD_DOCS_AFTER)) wait_until(page, lambda: any("zzfwd2-web-0" in (s or "") for s in tile_srcs(page, 0)), label="the wall that replaced it sharpens too", detail=lambda: f"srcs={tile_srcs(page, 0)}") print(" PASS: a wall swapped out from under a thumbnail refines again")
Picking a run up
- what
- a run is also picked up from the open doc — half of it comes together while you are looking at the docs one at a time.
- how
- the open doc carries the same toggle, under the mouse and under the keys.
- rule: what it picks is the same run the wall is holding.
- what
- the same box that labels one doc labels the lot — by button or by keyboard, the two never drifting apart.
- what
- it takes words off again as readily as it puts them on.
Saying when the wall holds a slice
- what
- a match can hold more than the wall’s cap, and the
wall then shows a spread of it.
- rule: the wall names the slice it shows against
the true total of the match.
- example:
showing a spread of N from M — narrow…when the spread is the one that chose. - how: the query asks the server for that true total alongside the docs.
- example:
- rule: the wall names the slice it shows against
the true total of the match.
- rule
- the subtle part is when: the notice fires only on a genuine spread.
- what: every match is shown — one with no thumbnail as a placeholder tile — so « shown » equals what the server returned.
- therefore: the honest signal is simply whether the server dropped rows — returned fewer
docs than match.
- therefore: a thumbnail-less match never raises it.
- what: a spread that hands back a few short of what it was asked for is a different spread.
- rule: the N the line names is the cap on the nose, not roughly two thousand — this line is where the difference shows.
expect(page.get_by_text(re.compile(r"showing a spread of 2000 from \d+"))).to_be_visible()
print(" PASS: sampling notice")
- rule
- under the cap there is nothing to confess, and the line says so rather than going
quiet:
showing all N.
expect(page.get_by_text(re.compile(rf"^showing all {len(FIXTURES)}$"))).to_be_visible()
print(" PASS: count always shown")
- what
- 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.
- what: those are one end of the match, not a taste of the whole of it.
- rule: the wall says which slice it holds: it never calls an end a spread.
- what: the distinction reaches past the notice — an end is a run and 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 |
- how
- each slice is one gesture — a query typed and committed — and the wall says at once
which slice it holds and which docs.
- how: so the asking happens here, once per row of that table, and what came back is read from the same wall further on rather than asked for again.
- rule: each of the three reads as its row of the table says, the spread alone advising to narrow the search or date range.
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()
- how
- the line is written from the server’s own answer — whether it dropped rows, and which end was asked for — never from what was typed.
<${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>
<//>
-
Saying the wall is working
rule : the wall goes quiet again once a filter’s read has answered.
expect(grid(page)).to_have_attribute("aria-busy", "false") # the read answered — the region is still print(" PASS: wall goes quiet once the read answers")rule : while that read is out the wall says it is busy, so a screen reader holds its reading and anything watching waits for the settled result rather than read a wall mid-load. - how: In practice, the grid carries
aria-busy.expect(grid(page)).to_have_attribute("aria-busy", "true") # a read is out — the region is working print(" PASS: wall reports busy")rule : the busy state also covers an edit, from its first write until the edit’s re-read has landed the committed result on the wall. - how: a count of edits under way is kept around each batch edit, and the wall reads busy while a read is out or that count is above zero.
expect(grid(page)).to_have_attribute("aria-busy", "true") # busy during the WRITE phase print(" PASS: wall busy during edit")rule : a sighted user sees the wait too, read as working on a known thing, not as broken or already done: while the wall settles it dims and shows a small cue, a pill. - what:
searching «…»while a read is out, naming the query it is fetching. - what:updating…while an edit writes.BG = "el => getComputedStyle(el).backgroundColor" working = page.get_by_text("updating…") expect(working).to_be_visible() pending = working.evaluate(BG) # the colour of a wait, to tell a failure from print(" PASS: wall shows updating feedback")dim = lambda: float(grid(page).evaluate("el => getComputedStyle(el).opacity")) wait_until(page, lambda: dim() < 0.9, label="the wall dims while the edit writes", detail=lambda: f"opacity={dim()}") print(" PASS: the wall dims while working")how : one pill speaks for every wait: while a read is out it is also that read’s way out, and once an edit is given up on, the notice takes its place.
<${Show} when=${() => photos.loading || mutating()}> <${Show} when=${() => photos.loading && !mutating()} fallback=${html`<div class="wall-busy" aria-hidden="true">updating…</div>`}> <button class="wall-busy busy-cancel" onClick=${cancelRead} aria-label=${() => 'cancel search: ' + (running().label || 'everything')}>${ () => `searching «${running().label || 'everything'}»`}</button> <//> <//> <${Show} when=${() => editStalled()}> <div class="wall-busy wall-stalled" role="status">the edit did not go through — try again</div> <//>
-
An edit that never answers
what : a write can simply never come back — the connection drops between the phone and the house, and nothing answers. - rule: an edit is only credited as working while its writes keep answering. - rule: the wall gives up and says the edit did not go through: it goes quiet to the eye and to a screen reader alike, in the colour of something wrong rather than something pending.
failed = page.get_by_role("status") expect(failed).to_have_text("the edit did not go through — try again") expect(page.get_by_text("updating…")).to_have_count(0) # it stopped claiming to work… expect(grid(page)).to_have_attribute("aria-busy", "false") # …to everyone, not just the eye assert failed.evaluate(BG) != pending, "a failed edit wears the colour of one still going" print(" PASS: a write that never answers is given up on")what : giving up is a guess, though, and the wifi does come back: a write written off can land minutes later. - rule: the notice is taken back when the write turns up after all: it is not a verdict, and goes the moment the edit it was about finishes.
for r in dropped: r.fallback() # the write we had written off goes out after all expect(page.get_by_role("status")).to_have_count(0) # the app stops saying it failed… expect(checks(page)).to_have_count(0) # …the edit landed expect(grid(page)).to_have_attribute("aria-busy", "false") print(" PASS: a write that lands late takes the notice back")what : slow is not the same as dead, and a run of forty is slow by construction. - how: the two are told apart by a connection that answers late but always — every gap under the limit, the whole edit far over it. - rule: the clock is re-armed by every write that lands, not by the edit as a whole: what is being waited on is one answer. - therefore: an edit whose writes each come back inside the fifteen seconds runs as long as it likes.
for i in range(len(FIXTURES)): wait_until(page, lambda: len(stalled) > i, label="the next write went out") page.wait_for_timeout(700) # late, but inside the second expect(page.get_by_text("the edit did not go through — try again")).to_have_count(0) stalled[i].fallback() # …and it answers expect(checks(page)).to_have_count(0) # the whole edit landed: the selection cleared… expect(grid(page)).to_have_attribute("aria-busy", "false") # …and the wall has read it back print(" PASS: a slow edit is waited out")what : the third way is the plainest: the server answers, and the answer is no. - rule: a refusal is an answer, so the wall goes quiet at once — but it is not the edit going through, so it wears the same notice as a silence given up on. - rule: only a write that worked retracts the notice; an error arriving after the clock has run out changes nothing about whether the edit happened. - rule: the refusal stops the edit where it stands, rather than carrying on down the run. - rule: the word stays in the box, ready to try again.
wait_until(page, lambda: len(refused) > 0, label="the first write went out") page.wait_for_timeout(500) # room for the rest of the run, were it to go on assert len(refused) == 1, f"the run went on past its refusal: {len(refused)} writes" print(" PASS: a refusal stops the run where it stands")expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again") expect(page.get_by_text("updating…")).to_have_count(0) expect(grid(page)).to_have_attribute("aria-busy", "false") expect(tb.get_by_placeholder("add a label…")).to_have_value("zzrefused") # ready to try again print(" PASS: a refused edit says so")how : the error after the clock is a write held past the give-up and then refused.
wait_until(page, lambda: len(held) > 0, label="the write went out") expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again") # given up on for r in held: r.fulfill(status=500, body="no", content_type="text/plain") # …then refused page.wait_for_timeout(300) expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again") # still so expect(grid(page)).to_have_attribute("aria-busy", "false") # and still quiet print(" PASS: an error after the give-up changes nothing")how : one wrapper around every edit holds that count of edits under way, the clock and the notice together.
const [editStalled, setEditStalled] = createSignal(false); const EDIT_SILENCE_MS = Number(new URLSearchParams(location.search).get('editms')) || 15000; async function editing(run){ setMutating(m => m + 1); setEditStalled(false); let counted = true, timer = null; const giveUp = () => { if(!counted) return; counted = false; setMutating(m => m - 1); setEditStalled(true); }; const tick = () => { clearTimeout(timer); timer = setTimeout(giveUp, EDIT_SILENCE_MS); }; tick(); try { await run(tick); setEditStalled(false); } catch(e){ setEditStalled(true); throw e; } finally { clearTimeout(timer); if(counted){ counted = false; setMutating(m => m - 1); } } }
Selecting tiles and batch-editing
- what
- triage goes far faster in bulk: pick tiles, then apply one change to all of them at once.
- example: a word merged into each doc’s labels, or a state set on each.
- what
- the wall holds at most its cap, while a filter may match thousands.
- how: the reach switch — the toolbar’s all N matching button — turns the same buttons to the server’s bulk edits, which change every doc the query matches in one statement.
- rule: touching the picks or the filter turns the switch off, so no edit reaches a stale scope.
- what
- a plain click toggles one tile and makes it the anchor — the tile a run is counted from.
- what: the run from the anchor to a target tile, in the wall’s date order, can be picked several ways.
- what
- the picks survive a reload, so a refresh mid-triage keeps the run in hand.
- how
- the picks are one signal holding a set; each pick replaces the set, and only the touched tiles
redraw.
- how: beside them sit the flags the gestures read: whether a range is armed for the next tap, and whether the reach switch is on.
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);
// a new filter, a new reach
createEffect(on([committed, stateFilter], () => setAllMatching(false), { defer: true }));
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;
}); };
-
Many ways to one run
-
Picking the whole wall
what : a
select alltoggle closes the state filter’s row: one tap picks the whole wall shown, another clears it. - scope: it lives outside the toolbar, which is hidden while nothing is picked. - example: with the state filter, it is the fast path: filternext, select all, setdone.select_all(page).click() expect(checks(page)).to_have_count(n) # every tile shows its ✓ select_all(page).click() expect(checks(page)).to_have_count(0) # second tap clears print(" PASS: select all")how : the toggle reads the wall shown: a tap picks every tile on it, unless all of them are picked already, and then it clears.
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()));
-
From the anchor to a tile
what : on a desktop, a shift-click on the target tile picks the whole run from the anchor to it.
t.nth(0).click() # anchor on the first tile t.nth(n - 1).click(modifiers=["Shift"]) # extend the selection to the last expect(checks(page)).to_have_count(n) # the ones between are roped in too print(" PASS: range select")how : the run is added in one shot, every tile from the anchor to the target joining the picks.
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)])); };what : on touch, which has no modifier key, the toolbar’s
↔ rangetoggle stands in: arm it, tap the target, and the same run is picked. - rule: the toggle disarms once the run is picked.t.nth(0).click() # anchor + toolbar appears rng = toolbar(page).get_by_role("button", name="range") rng.click() # arm range mode (the touch route) t.nth(n - 1).click() # a plain tap now extends the run expect(checks(page)).to_have_count(n) expect(rng).to_have_attribute("aria-pressed", "false") # disarmed after extending print(" PASS: range select (touch)")what : a long-press on a tile arms the range anchored there, with no trip to the toolbar, and the next tap completes the run. - rule: the long-press opens no lightbox; a double-click stays the gesture that opens a doc.
press_and_hold(page, t.nth(0)); page.mouse.up() # held past the threshold, then let go expect(dialog(page)).to_have_count(0) # no lightbox — the long-press starts a selection expect(checks(page)).to_have_count(1) # the pressed tile, now the anchor t.nth(n - 1).click() # a plain tap completes the range expect(checks(page)).to_have_count(n) print(" PASS: long-press arms range")how : a click extends the range only with shift on a desktop, or with the range armed on touch, and either way it disarms the range.
how : a plain click also plants the keyboard cursor on its tile, as the keys and the mouse agreeing asks.
how : arming a range from a tile makes it the anchor and readies a fresh run for the first drag move.
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 drag
rule : the long-press need not end in a second tap: kept down and slid, it grows the run live, the tile under the finger becoming its far end.
press_and_hold(page, t.nth(0)) # held past the threshold → armed at the anchor expect(checks(page)).to_have_count(1) # just the anchor so far drag_onto(page, t.nth(n - 1)) expect(checks(page)).to_have_count(n) # the whole run roped in mid-drag… expect(dialog(page)).to_have_count(0) # …and no lightbox — this is a selection gesture page.mouse.up() print(" PASS: long-press drag extends live")how : the press starts a 500ms timer; held still past it, the press fires, capturing the pointer and arming the range. - how: a move of more than about 10px before it fires abandons it; a move after drives the drag.
how : letting go of a drag that moved lands the run and disarms the range; a press that never moved leaves the range armed for a following tap.
how : the press swallows its own trailing click, so a long-press opens nothing.
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 }));rule : the run shrinks as the finger comes back toward the anchor, never stranding a tail. - scope: a tap only ever adds once, so the tap routes keep the one-shot extension.
press_and_hold(page, t.nth(0)) # armed at the anchor again drag_onto(page, t.nth(n - 1)) expect(checks(page)).to_have_count(n) # grew to the whole run… drag_onto(page, t.nth(n - 2)) expect(checks(page)).to_have_count(n - 1) # …then coming back drops the far tile page.mouse.up() print(" PASS: long-press drag shrinks on return")how : it runs on the same grow-or-shrink mechanism as the keyboard’s Shift-arrow run: the run is recomputed from the fixed anchor over the picks held when the drag began.
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)])); };how : the far tile is the one found under the finger, through the wall’s own tile order, not the move’s own target. - what: once the press fires it captures the pointer, so every later move reports the pressed tile.
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; };
-
The held drag under a real finger
what : a drag over the wall competes with two things the browser would sooner do with it. - rule: on a desktop, a thumbnail must not peel off as a drag-and-drop ghost: the tiles’ images refuse to be dragged.
assert not thumb_imgs(page).first.evaluate("el => el.draggable"), "a thumbnail peels off as a ghost" print(" PASS: no drag ghost")rule : on a touch screen, a moving finger must not pan the wall while a press is armed: the drag vetoes the scroll.
what : a mouse drag never sets off the touch scroll, so it slips past the veto untouched.
rule : under a real finger, a held drag from the anchor onto its neighbour picks both.
a = t.nth(0).bounding_box(); end = t.nth(1).bounding_box() # its neighbour, wherever the columns fall x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2 x1, y1 = end["x"] + end["width"] / 2, end["y"] + end["height"] / 2 cdp = page.context.new_cdp_session(page) cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1}) cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]}) page.wait_for_timeout(LONG_PRESS_MS) # hold past the long-press → arms range for f in (0.25, 0.5, 0.75, 1.0): # drag off the anchor onto the next tile cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x0 + (x1 - x0) * f, "y": y0 + (y1 - y0) * f}]}) cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) expect(checks(page)).to_have_count(2) # the run followed the finger, not one lonely anchor print(" PASS: long-press drag (touch)")how : In practice the veto is a
touchmovelistener cancelling the scroll while the press is live; it must be non-passive, since a passive one — the framework’s default — may not cancel it.onMount(() => gridEl?.addEventListener('touchmove', e => { if(lpFired) e.preventDefault(); }, { passive: false }));
-
The held drag below the fold
what : the wall may run on below the fold, past the last visible tile.
rule : a held drag kept at the bottom edge picks the run down to the last tile of the wall.
m = len(DRAG_DOCS) last = t.nth(m - 1).bounding_box() xl = last["x"] + last["width"] / 2 # the last tile's column; its row is below the fold cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]}) page.wait_for_timeout(LONG_PRESS_MS) # arm range at the anchor for f in (0.5, 1.0): # drag to the bottom edge, in the last column, and hold cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x0 + (xl - x0) * f, "y": DRAG_VIEWPORT["height"] - 10}]}) wait_until(page, lambda: checks(page).count() == m, # the wall scrolls the rest under the finger label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{m} selected") cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []}) expect(checks(page)).to_have_count(m) print(" PASS: long-press drag auto-scrolls")how : a finger within about 48px of the bottom edge drags the wall up under itself, about 14px a frame, the run growing onto each row that rises into view; the top margin pulls the other way. - how: the margin is read against the floor lifted above the toolbar that the keyboard reveal measures, so the finger lands on a tile and not on the 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 first pick
how : the wall is measured across the first pick, while the toolbar is still absent.
rule : on the first pick, the toolbar arrives with nothing on the wall moving.
t = tiles(page) expect(toolbar(page)).to_be_hidden() # nothing picked yet 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")how : the toolbar is fixed across the foot of the screen, overlaying the wall rather than taking room in it.
/* a fixed bottom action bar, overlaying the wall */ .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)); }rule : from there each tile toggles, and the toolbar keeps the count of what is picked.
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")
-
-
Words on the run
rule : one add-label box serves both
+ labeland- label, and the two read it the same way.what :
+ labelmerges the typed words into every picked doc, and the picks clear once it is applied.select_all(page).click() tb.get_by_placeholder("add a label…").fill("addedbybatch") tb.get_by_role("button", name="add label").click() expect(checks(page)).to_have_count(0) # selection clears once applied search_for(page, "addedbybatch") # the fresh word now finds them all expect(tiles(page)).to_have_count(n) print(" PASS: batch add label")how : with the reach switch off, an edit takes the per-doc path: each picked doc is written in turn, then the picks clear and the wall re-reads its query. - how: it reads each doc’s labels from the wall, so a word can be merged into them. - how: a caller may leave some docs untouched, as moving onto an occasion leaves other owners'.
what : labels are a
;-separated list, so one split reads a doc’s stored labels and what is typed into a box alike. - example:a; badds two words in one go.async function patchSelected(patchFor){ return editing(async tick => { const byCid = new Map(items().map(p => [p.cid, p])); for(const cid of selected()){ const patch = patchFor(byCid.get(cid)); if(patch){ await gql(UPDATE_PHOTO, { cid, patch }); tick(); } } clearSel(); await refetch(); }); } const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean); const splitLabels = p => splitWords(p.labels);rule :
- labelstrips the typed words from every picked doc: words come off as readily as they go on. - how: the wall stands on the word being stripped, so once it is gone from all of them nothing is left to show.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")how : adding and removing a word each take their path by the reach switch, as do the date stamp and moving onto an occasion, the move sent as a dated bulk edit pinned to the occasion’s owner. - what: adding a word 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 }));
-
Typing into the add-label box
rule :
Enterin the add-label box stands in for+, so the button and the keyboard never drift apart. - how: In practice the toolbar tears the box down when the picks clear and builds it again on the next select all, so the box reached for once the picks settle 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 doc got it print(" PASS: batch enter adds")what :
Shift+Enterstands in for-.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")what : picking a word from the box’s offers leaves a
;behind it, ready for the next word. - therefore: that trailing separator is the box’s usual state, not an edge case. - rule: removing reads the box the way adding does, and strips 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")
-
Ticked or all matching
-
A state, picked or matching
rule : a pick touched after the switch turns it off again: the reach goes back to the picks.
search_for(page, FIXTURE_LABEL) t.nth(0).click() reach = tb.get_by_role("button", name=re.compile("all .* matching")) reach.click() expect(reach).to_have_attribute("aria-pressed", "true") # the whole search, off one tick t.nth(1).click() # the picks change… expect(reach).to_have_attribute("aria-pressed", "false") # …and the reach is the picks again expect(tb.get_by_text("2 selected")).to_be_visible() clear_sel.click() print(" PASS: all matching resets when the picks change")rule : a filter changed after the switch turns it off too: the reach was the old search’s.
t.nth(0).click() reach.click() expect(reach).to_have_attribute("aria-pressed", "true") search_for(page, FIXTURE_LABEL + "; date:2020") # the filter changes, the pick stays… expect(reach).to_have_attribute("aria-pressed", "false") # …and the reach is the pick again clear_sel.click() # the next search puts the run back print(" PASS: all matching resets when the filter changes")rule : one pick and the switch, and a state reaches every doc matched.
search_for(page, FIXTURE_LABEL) expect(tiles(page)).to_have_count(n) t.nth(0).click() # just one, to raise the bar tb.get_by_role("button", name=re.compile("all .* matching")).click() # widen the scope tb.get_by_role("button", name="done").click() expect(grid(page).get_by_text("done")).to_have_count(n) # all of them, off one tick print(" PASS: batch all-matching state")rule : with the switch off, a state reaches the picked docs and stops there; the others keep the state they had.
t.nth(0).click(); t.nth(1).click() # two of the three tb.get_by_role("button", name="next", exact=True).click() expect(grid(page).get_by_text("next")).to_have_count(2) # the ticked ones move… expect(grid(page).get_by_text("done")).to_have_count(1) # …and the third keeps what it had print(" PASS: batch set state")how : the state buttons take their path by the reach switch too.
const setStateFor = st => allMatching() ? applyBulk('SetState', st) : patchSelected(() => ({ state: st }));
-
The reach stays inside the filter
rule : the reach stops at the other’s docs: an
owner:filter spares every other owner’s docs. - how: it is seen on a wall two people share — one doc each, one word between them — with the filter narrowed to his.OWN_LABEL = "zzownb" OWN_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzownb-{who}", "date": f"2020-0{i + 1}-15T12:00:00Z", "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzownb-{who}-t", "labels": OWN_LABEL, "state": "todo", "owner": who} for i, who in enumerate(["konubinix", "aylapomme"])] OWN_COUNT = ("query($o:[OwnerType!],$s:[State!]){ photovideosCount(search:\"" + OWN_LABEL + "\"," " since:\"2007-01-01\", until:\"2035-01-01\", owners:$o, states:$s) }") def own_count(owner, state): """Ask the server itself, since hers is the one doc the wall is not showing.""" return gql(OWN_COUNT, {"o": [owner], "s": [state]})["data"]["photovideosCount"]rule : his doc takes the state, and hers keeps
todo.tb.get_by_role("button", name=re.compile("all .* matching")).click() tb.get_by_role("button", name="done").click() wait_until(page, lambda: own_count("konubinix", "done") == 1, label="his doc took the verdict") assert own_count("aylapomme", "todo") == 1, "the widening reached past the owner filter" print(" PASS: bulk all-matching respects owner")how : with the switch on, an edit hands the server the very filter the wall shows, state filter included, and reaches past the slice. - how: the bulk edits return only a count, so rather than act on each changed doc, the cache marks every doc it holds as stale.
rule : a caller may narrow the filter it goes out with — the move pins it to one owner — never widen it. - rule: what the person typed is the outer bound of anything the toolbar does.
const filterVars = () => ({ ...photoVars(parseQuery(search())), states: stateFilter() === 'all' ? null : [stateFilter()] }); const BULK_ARG = { SetState: ['State', 'toState'], SetDate: ['Datetime', 'toWhen'], AddLabel: ['String', 'label'], RemoveLabel: ['String', 'label'] }; const BULK = k => { const [ty, arg] = BULK_ARG[k]; return `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${ty}!){ photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${arg}:$v}){ result } }`; }; async function applyBulk(kind, v, over){ return editing(async tick => { await gql(BULK(kind), { ...filterVars(), ...over, v }, PV_CTX); tick(); clearSel(); await refetch(); }); }
-
A word across the filter
rule : the same reach adds a word across the whole filter, not only a state.
t.nth(0).click() tb.get_by_role("button", name=re.compile("all .* matching")).click() tb.get_by_placeholder("add a label…").fill("zzbulkall") tb.get_by_role("button", name="add label").click() search_for(page, "zzbulkall") # all of them carry it now expect(tiles(page)).to_have_count(n) expect(grid(page).get_by_text("zzbulkall")).to_have_count(n) # and the wall reads it back print(" PASS: batch all-matching label")what : a comma does not split a label, so a label with a comma inside it is one word. - rule: removing it across the filter drops the whole word, not the pieces around the comma.
COMMA = "zzleft, zzright" # one ;-token, with a comma inside it search_for(page, FIXTURE_LABEL) select_all(page).click() tb.get_by_placeholder("add a label…").fill(COMMA) tb.get_by_role("button", name="add label").click() # the ticked path ;-joins it on expect(checks(page)).to_have_count(0) # applied search_for(page, "zzleft") expect(tiles(page)).to_have_count(n) # it went on as one word select_all(page).click() tb.get_by_role("button", name=re.compile("all .* matching")).click() tb.get_by_placeholder("add a label…").fill(COMMA) tb.get_by_role("button", name="remove label").click() # the wide path, through the server search_for(page, "zzleft") expect(tiles(page)).to_have_count(0) # the whole word came off, not its halves print(" PASS: all-matching removes comma label")
-
-
Sweeping a rectangle
rule : on a desktop, a mouse drag that starts on empty grid space rubber-bands a rectangle; a drag that starts on a tile stays that tile’s tap, double-click or long-press.
how : the sweep starts in the empty cells past the last tile and runs back across the row.
what : every tile the rectangle covers joins the picks.
g = grid(page).bounding_box() y = g["y"] + 30 page.mouse.move(g["x"] + g["width"] - 12, y) # empty cells right of the row page.mouse.down() page.mouse.move(g["x"] + 12, y + 25, steps=10) # sweep left across every tile page.mouse.up() expect(checks(page)).to_have_count(n) print(" PASS: marquee selects")rule : the sweep is additive: it adds to the picks rather than replacing them, as clicks do.
toolbar(page).get_by_role("button", name="clear", exact=True).click() t.nth(0).click() # one picked by hand, outside the sweep last = t.nth(n - 1).bounding_box() page.mouse.move(g["x"] + g["width"] - 12, y) page.mouse.down() page.mouse.move(last["x"] + last["width"] / 2, y + 25, steps=10) # over the last tile only page.mouse.up() expect(checks(page)).to_have_count(2) # the hand's pick stayed, the sweep's joined it print(" PASS: a sweep adds to the picks")how : the rectangle is redrawn on each move while the button is held, every tile it meets picked there and then; touch never starts one, having the held drag already.
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
what : the add-label box is a combobox on the search box’s terms.
how : the run lends the box a word of its own to find.
COMBO_WORDS = ["zzcombolabel"]rule : typing opens the box’s offers and leaving the box closes them, the box saying which.
box = tb.get_by_placeholder("add a label…") box.click(); box.press_sequentially("zzcombo") expect(box).to_have_attribute("aria-expanded", "true") # listing completions → true box.blur() expect(box).to_have_attribute("aria-expanded", "false") # closed → false assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur" print(" PASS: batch label combobox state")how : the toolbar is mounted only while something is picked, and clustered by purpose — what the run reaches, its words, its occasion, its state, then taking it away — with thin dividers between the groups.
<${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(), () => setLabelFocus(false)); }} 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(), () => setMoveFocus(false))} 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> <//>how : from the foot of the screen, the toolbar’s offers open upward.
/* a fixed bottom action bar, overlaying the wall */ .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; }-
Above the toolbar
what : on a short screen, picking up a doc raises the toolbar at the bottom, and the search box’s list opens downward far enough to reach it.
how : a dozen words sharing a prefix make the list long enough to get down there on a screen that short.
OVERLAP_WORDS = [f"zzov{c}" for c in "abcdefghijkl"] OVERLAP_VIEWPORT = {"width": 360, "height": 300}rule : the completion offers sit on top of the toolbar and take the touch.
sb = search_box(page) sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20) expect(options(page).first).to_be_visible() # the list is up expect(tb).to_be_visible() # and the pick still holds the bar up under it bar = tb.bounding_box() sug = page.get_by_role("listbox", name="suggestions").bounding_box() lo, hi = max(bar["y"], sug["y"]), min(bar["y"] + bar["height"], sug["y"] + sug["height"]) assert hi > lo, f"setup: bar {bar} and list {sug} do not meet — there is nothing to see" hit = page.evaluate( # what a finger at the overlap would actually land on "([x,y]) => { const el = document.elementFromPoint(x,y);" " return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }", [bar["x"] + bar["width"] / 2, (lo + hi) / 2]) assert hit["list"] and not hit["bar"], f"the bar is in front of the list where they meet: {hit}" print(" PASS: search completion sits above the selection toolbar")how : the screen stacks in layers: the wall at the base, the toolbar above it, then the transient overlays, each over the one it may appear on. - rule: the list sits just above the toolbar, the two being the only pair that share the screen at once.
.complete{ position:relative; } /* completion-popover layer — see “Above the toolbar” */ .complete .suggest{ z-index:25; } /* a thin themed scrollbar (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 } }
-
How far an edit reaches
- what
- a word is not the only thing a run wants in one go; a state and a date are the other two.
- what
- all three face the same question of reach: sometimes what you picked, sometimes every doc the search matches.
- how
- the toolbar carries a switch between the two.
- rule: the words, the state and the date all obey it.
- rule
- on a wall two people share, every doc the search matches stops at the other’s docs.
Stamping a selection with one time
- what
- docs that arrive re-shared — a batch off WhatsApp — carry the moment they were forwarded, not
the moment they were shot.
- therefore: a run of them scatters across the wall, each landing on a day that never happened.
- what: there is no true instant to recover.
- rule
- the whole run is pinned to one plausible moment — a given day, one hour — and sits together.
- what: The lightbox already re-dates one doc; this re-dates every picked doc at once, one write per doc, the way the run’s other edits go out.
- rule
- the stamp reaches every picked doc, whatever day each started on, and clears the picks as it
lands, so the toolbar goes.
- how: docs spread over a year, two of them picked and stamped to a midday in another year — noon, so no timezone drags the instant onto an adjacent day.
- rule: the picked ones turn up together under the stamped day, and the one left alone keeps its day.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n) # three days months apart
t.nth(0).click(); t.nth(1).click() # two of them
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2018-06-15T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the bar goes
search_for(page, FIXTURE_LABEL + "; date:2018-06-15") # the ticked ones land together…
expect(tiles(page)).to_have_count(2)
search_for(page, FIXTURE_LABEL + "; date:2020") # …and the third keeps the day it had
expect(tiles(page)).to_have_count(1)
print(" PASS: batch date stamps the selection")
- rule
- the date stamp obeys the reach switch: the whole match goes through the server’s own restamp, not a write per doc.
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
t.nth(0).click() # one tick, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2016-03-04T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the bar goes
search_for(page, FIXTURE_LABEL + "; date:2016-03-04") # every one of them, off one tick
expect(tiles(page)).to_have_count(n)
print(" PASS: batch date all-matching")
- how
- one stamp serves both reaches, taking the server’s restamp or the per-doc path as the switch
says.
- rule: the picker opens on the mean date of the picked docs.
- how: the first and last of three docs a month apart, mid-January and mid-March, meet mid-February.
- rule: the picker opens on the mean date of the picked docs.
search_for(page, FIXTURE_LABEL + "; date:2020") # the run, as dated before any stamp
expect(tiles(page)).to_have_count(n)
t.nth(0).click(); t.nth(n - 1).click() # the two ends of the run
tb.get_by_role("button", name="set date").click()
expect(tb.get_by_label("date for the selection")).to_have_value(re.compile(r"^2020-02-14T"))
tb.get_by_role("button", name="set date").click() # put the picker away untouched
clear_sel.click()
print(" PASS: the date picker opens on the mean date")
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
const when = new Date(v).toISOString();
if(allMatching()) await applyBulk('SetDate', when);
else await patchSelected(() => ({ date: when }));
setDatingSel(false); };
- what
- a double-tap opens a doc, and its first tap picks, which brings the toolbar up.
- what: a date field always shown would wrap the toolbar onto a second row on a phone, and that taller toolbar swallows the double-tap’s second tap before it reaches the tile.
- therefore: the picker hides behind one compact button and opens in a popover above the toolbar only when asked, so the closed toolbar keeps the single row the double-tap needs.
<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>
- how
- the picker mirrors the toolbar’s other controls: its popover opens upward like the label and
move offers, and
→ datedims 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; }
When the wifi picks its moment
- what
- a run this size is also where the house’s wifi picks its moment.
- how
- the box that labels the lot is met three times before it is used in anger.
- example: once with a line that has gone dead, where the wall says it is working in both the ways it has, then gives up and says the edit did not go through, then takes that back when the write turns up after all.
- example: once with a line that is merely slow, where the edit is waited out to the end.
- example: once with a server that answers and refuses.
- how
- it is opened with the edit’s patience wound down, so the giving-up comes after a second rather than fifteen.
A run on a phone
- what
- the held drag — press and hold a tile, then drag across the others to pick them up — has the browser’s own scroll to fight, and that fight only starts under a finger.
- what
- the run only has a fold — the screen’s bottom edge — to grow past on a wall taller than the screen.
- what
- neither is true of the wide window the rest of the story works at, or of three docs.
- therefore
- the last of it belongs on a phone: a narrow window and a wall of sixty.
- how
- the wall is wound down to a contact sheet — its smallest tiles.
- how
- the finger it uses is Chromium’s own rather than a mouse pretending.
- what
- the veto that keeps the drag from scrolling answers to touch and would let a mouse straight through.
DRAG_LABEL = "zzdrag"
DRAG_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzdrag-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzdrag-t-{i}",
"labels": DRAG_LABEL, "state": "todo"} for i in range(60)]
DRAG_VIEWPORT = {"width": 360, "height": 700}
Three more walls
A wide short wall
- rule
- the refining — each tile on screen trading its thumbnail up for its web copy
once the wall is grown — wants a third wall again, and a wide short one.
- rule: what is off screen is not bought — not fetched at all — the far end of the wall included.
- rule: a video keeps its poster rather than pulling a whole video onto a contact sheet.
- how
- eighty of them.
- what
- the size runs from eighty to three hundred and twenty, each press of the size buttons moving it
forty, held at either end.
- therefore: six presses cross it either way.
REFINE_LABEL = "zzref"
REFINE_DOCS = ([{"cid": "https://ipfs.konubinix.eu/p/zzref-vid", "date": "2020-12-31T12:00:00Z", "mimetype": "video/mp4",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzref-t-vid", "webCid": "https://ipfs.konubinix.eu/p/zzref-web-vid",
"labels": REFINE_LABEL, "state": "todo"}]
+ [{"cid": f"https://ipfs.konubinix.eu/p/zzref-{i}", "date": f"2021-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzref-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzref-web-{i}", "labels": REFINE_LABEL, "state": "todo"}
for i in range(1, 80)])
REFINE_VIEWPORT = {"width": 1000, "height": 420}
SIZE_STEPS = 6 # 80 to 320 in forties: the whole slider
REFINE_GRACE_MS = 600 # ample room for a refinement to have fired
def grow_the_wall(page):
"""Take the slider all the way up, and wait for the columns to be drawn past 300px."""
for _ in range(SIZE_STEPS): page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: cell_px(page) > 300, label="cells drawn past the 300px threshold",
detail=lambda: f"cell={cell_px(page)}px")
- how
- a wall wound down sits at eighty, a press more leaves it there, and a press up moves it forty.
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*80px")) # wound right down
page.get_by_role("button", name="smaller thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*80px")) # held at the floor
page.get_by_role("button", name="bigger thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*120px")) # one press, forty
page.get_by_role("button", name="smaller thumbnails").click() # back to the contact sheet
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*80px"))
print(" PASS: size steps of forty, held at eighty")
- how
- a wall grown right up sits at three hundred and twenty, and a press more leaves it there.
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*320px")) # grown right up
page.get_by_role("button", name="bigger thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*320px")) # held at the ceiling
print(" PASS: size held at three hundred and twenty")
A wall with one thumbnail stuck
- rule
- the ordering between the two passes — thumbnails first, web copies after — wants
a wall with one thumbnail that never lands, and room on screen for the others to settle
around it.
- how: a dozen, on a window tall enough to hold a grown screenful of them.
WAIT_LABEL = "zzwait"
WAIT_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzwait-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzwait-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzwait-web-{i}", "labels": WAIT_LABEL, "state": "todo"} for i in range(12)]
WAIT_HELD = "zzwait-t-0" # the one the gateway never answers for
WAIT_VIEWPORT = {"width": 1000, "height": 700}
A narrow wall
- rule
- the moving-forward — the sharpening going on past a stuck thumbnail — wants the
opposite shape: a narrow window.
- how: grown tiles fall into one tall column, and scrolling brings them into reach one at a time.
- how: the stuck one sits far enough down the column that several have already sharpened above it.
- how
- a second, smaller wall waits under another word, for the search that swaps the first out from under it.
FWD_LABEL, FWD_LABEL_AFTER = "zzfwd", "zzfwd2"
FWD_DOCS = [{"cid": f"https://ipfs.konubinix.eu/p/zzfwd-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfwd-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzfwd-web-{i}", "labels": FWD_LABEL, "state": "todo"} for i in range(8)]
FWD_DOCS_AFTER = [{"cid": f"https://ipfs.konubinix.eu/p/zzfwd2-{i}", "date": f"2021-02-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzfwd2-t-{i}",
"webCid": f"https://ipfs.konubinix.eu/p/zzfwd2-web-{i}", "labels": FWD_LABEL_AFTER, "state": "todo"}
for i in range(3)]
FWD_HELD = "zzfwd-t-4"
FWD_VIEWPORT = {"width": 480, "height": 700} # one tall column once the tiles are grown
The whole story
- how
- the story walked end to end, each step read in the order the headings above give it.
@testcase
def test_doing_one_thing_to_forty_photos(page):
"""Taking a run in hand: set the wall to a size you can work at, pick the run up without
the wall moving — off the wall and from inside an open doc — watch one edit die on a
bad connection — given up on, then taken back — one waited out on a slow one and one
refused outright, then put a word on all of it and take it off again, by button and by
key — and a verdict, and a day, each across the ticked set and across the whole
search, which stops where the search does when the wall is shared — then the same
run on a screen so short the box's list comes down over the bar, and a run of sixty
picked up on a phone, by a finger that drags and a
finger that drags past the fold, and a wall of eighty at both ends of the size
slider, buying nothing while it is a contact sheet and only what you can see once
it is not — and two more walls with a thumbnail stuck on the wire, to see the
sharpening wait for it and then go on without ever going back."""
page.set_viewport_size({"width": 1600, "height": 800}) # wide enough that every step re-columns
launch = "?editms=1000"
open_fixtures(page, launch)
t = tiles(page)
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*96px")) # nothing chosen yet: 96
print(" PASS: a first visit opens at 96")
w0 = t.nth(0).bounding_box()["width"]
page.get_by_role("button", name="bigger thumbnails").click()
page.get_by_role("button", name="bigger thumbnails").click()
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w0 + 8)
w1 = t.nth(0).bounding_box()["width"]
open_app(page, launch)
wait_until(page, lambda: abs(tiles(page).nth(0).bounding_box()["width"] - w1) < 2)
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # and the run is still the run
w2 = t.nth(0).bounding_box()["width"]
box = grid(page).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + 10)
page.keyboard.down("Control")
page.mouse.wheel(0, -240) # ctrl-scroll up → bigger
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w2 + 8)
page.wait_for_timeout(200) # a fresh gesture, clear of the notch above
w3 = t.nth(0).bounding_box()["width"]
page.keyboard.down("Control")
page.mouse.wheel(0, -240)
page.mouse.wheel(0, -240) # a second notch inside the same burst
page.keyboard.up("Control")
wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w3 + 8) # the burst moved the wall
page.get_by_role("button", name="smaller thumbnails").click() # …by exactly one step
wait_until(page, lambda: abs(t.nth(0).bounding_box()["width"] - w3) < 2)
print(" PASS: thumbnail size adjustable, persisted, ctrl-scrollable one step per burst")
page.evaluate("""() => { window.__ctrlWheels = [];
addEventListener('wheel', e => { if(e.ctrlKey) __ctrlWheels.push(e.defaultPrevented); },
{ passive: true }); }""")
page.wait_for_timeout(200) # a fresh gesture
w4 = t.nth(0).bounding_box()["width"]
box = grid(page).bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + 10) # back over the wall, off the button
page.keyboard.down("Control")
page.mouse.wheel(0, -240)
page.mouse.wheel(0, -240) # the notch the burst swallows
page.keyboard.up("Control")
wait_until(page, lambda: page.evaluate("() => __ctrlWheels.length") >= 2)
seen = page.evaluate("() => __ctrlWheels")
assert all(seen), f"a Ctrl+wheel over the wall reached the browser's zoom: cancelled={seen}"
page.get_by_role("button", name="smaller thumbnails").click() # back to where it was
wait_until(page, lambda: abs(t.nth(0).bounding_box()["width"] - w4) < 2)
print(" PASS: ctrl-scroll over the wall never zooms the page")
assert not thumb_imgs(page).first.evaluate("el => el.draggable"), "a thumbnail peels off as a ghost"
print(" PASS: no drag ghost")
expect(tiles(page)).to_have_count(len(FIXTURES)) # the run is still the run
n = tiles(page).count()
t = tiles(page)
expect(toolbar(page)).to_be_hidden() # nothing picked yet
before = t.first.bounding_box()
t.nth(0).click() # the first pick — the bar arrives on this one
expect(toolbar(page)).to_be_visible()
after = t.first.bounding_box()
assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
print(" PASS: select doesn't shift grid")
t.nth(1).click() # a second one joins it
expect(checks(page)).to_have_count(2)
expect(toolbar(page).get_by_text("2 selected")).to_be_visible()
t.nth(0).click() # toggle one back off
expect(checks(page)).to_have_count(1)
print(" PASS: select toggles tiles")
# the run gets picked up whole, six ways, each starting from nothing picked
clear_sel = toolbar(page).get_by_role("button", name="clear", exact=True)
clear_sel.click()
select_all(page).click()
expect(checks(page)).to_have_count(n) # every tile shows its ✓
select_all(page).click()
expect(checks(page)).to_have_count(0) # second tap clears
print(" PASS: select all")
t.nth(0).click() # anchor on the first tile
t.nth(n - 1).click(modifiers=["Shift"]) # extend the selection to the last
expect(checks(page)).to_have_count(n) # the ones between are roped in too
print(" PASS: range select")
clear_sel.click()
t.nth(0).click() # anchor + toolbar appears
rng = toolbar(page).get_by_role("button", name="range")
rng.click() # arm range mode (the touch route)
t.nth(n - 1).click() # a plain tap now extends the run
expect(checks(page)).to_have_count(n)
expect(rng).to_have_attribute("aria-pressed", "false") # disarmed after extending
print(" PASS: range select (touch)")
clear_sel.click()
press_and_hold(page, t.nth(0)); page.mouse.up() # held past the threshold, then let go
expect(dialog(page)).to_have_count(0) # no lightbox — the long-press starts a selection
expect(checks(page)).to_have_count(1) # the pressed tile, now the anchor
t.nth(n - 1).click() # a plain tap completes the range
expect(checks(page)).to_have_count(n)
print(" PASS: long-press arms range")
clear_sel.click()
press_and_hold(page, t.nth(0)) # held past the threshold → armed at the anchor
expect(checks(page)).to_have_count(1) # just the anchor so far
drag_onto(page, t.nth(n - 1))
expect(checks(page)).to_have_count(n) # the whole run roped in mid-drag…
expect(dialog(page)).to_have_count(0) # …and no lightbox — this is a selection gesture
page.mouse.up()
print(" PASS: long-press drag extends live")
clear_sel.click()
press_and_hold(page, t.nth(0)) # armed at the anchor again
drag_onto(page, t.nth(n - 1))
expect(checks(page)).to_have_count(n) # grew to the whole run…
drag_onto(page, t.nth(n - 2))
expect(checks(page)).to_have_count(n - 1) # …then coming back drops the far tile
page.mouse.up()
print(" PASS: long-press drag shrinks on return")
clear_sel.click()
g = grid(page).bounding_box()
y = g["y"] + 30
page.mouse.move(g["x"] + g["width"] - 12, y) # empty cells right of the row
page.mouse.down()
page.mouse.move(g["x"] + 12, y + 25, steps=10) # sweep left across every tile
page.mouse.up()
expect(checks(page)).to_have_count(n)
print(" PASS: marquee selects")
toolbar(page).get_by_role("button", name="clear", exact=True).click()
t.nth(0).click() # one picked by hand, outside the sweep
last = t.nth(n - 1).bounding_box()
page.mouse.move(g["x"] + g["width"] - 12, y)
page.mouse.down()
page.mouse.move(last["x"] + last["width"] / 2, y + 25, steps=10) # over the last tile only
page.mouse.up()
expect(checks(page)).to_have_count(2) # the hand's pick stayed, the sweep's joined it
print(" PASS: a sweep adds to the picks")
clear_sel.click()
t.nth(1).click() # back to the one pick the run carries on from
open_doc(page, 0)
d = dialog(page)
sel = d.get_by_role("button", name=re.compile("select", re.I))
expect(sel).to_have_attribute("aria-pressed", "false")
page.keyboard.press("Enter")
expect(sel).to_have_attribute("aria-pressed", "true") # selected
page.keyboard.press("Enter")
expect(sel).to_have_attribute("aria-pressed", "false") # toggled back off
print(" PASS: lightbox enter toggles select")
sel.click()
expect(sel).to_have_attribute("aria-pressed", "true") # now selected
expect(checks(page)).to_have_count(2) # and the wall says so under the modal
d.get_by_role("button", name="close").click()
expect(checks(page)).to_have_count(2) # the set the wall goes on holding
print(" PASS: lightbox select")
tb = toolbar(page)
dropped, dead = [], [True]
def drop_writes(route):
if dead[0] and "updatePhotovideo" in (route.request.post_data or ""): # the house's wifi
dropped.append(route); return
route.fallback()
page.route("**/graphql", drop_writes)
tb.get_by_placeholder("add a label…").fill("zzdropped")
tb.get_by_role("button", name="add label").click()
expect(grid(page)).to_have_attribute("aria-busy", "true") # busy during the WRITE phase
print(" PASS: wall busy during edit")
BG = "el => getComputedStyle(el).backgroundColor"
working = page.get_by_text("updating…")
expect(working).to_be_visible()
pending = working.evaluate(BG) # the colour of a wait, to tell a failure from
print(" PASS: wall shows updating feedback")
dim = lambda: float(grid(page).evaluate("el => getComputedStyle(el).opacity"))
wait_until(page, lambda: dim() < 0.9, label="the wall dims while the edit writes",
detail=lambda: f"opacity={dim()}")
print(" PASS: the wall dims while working")
failed = page.get_by_role("status")
expect(failed).to_have_text("the edit did not go through — try again")
expect(page.get_by_text("updating…")).to_have_count(0) # it stopped claiming to work…
expect(grid(page)).to_have_attribute("aria-busy", "false") # …to everyone, not just the eye
assert failed.evaluate(BG) != pending, "a failed edit wears the colour of one still going"
print(" PASS: a write that never answers is given up on")
dead[0] = False # the connection comes back
for r in dropped: r.fallback() # the write we had written off goes out after all
expect(page.get_by_role("status")).to_have_count(0) # the app stops saying it failed…
expect(checks(page)).to_have_count(0) # …the edit landed
expect(grid(page)).to_have_attribute("aria-busy", "false")
print(" PASS: a write that lands late takes the notice back")
# and once more, on a line that is merely slow
stalled = []
def stall_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""): stalled.append(route)
else: route.fallback()
page.route("**/graphql", stall_writes)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("zzpatient")
tb.get_by_role("button", name="add label").click()
for i in range(len(FIXTURES)):
wait_until(page, lambda: len(stalled) > i, label="the next write went out")
page.wait_for_timeout(700) # late, but inside the second
expect(page.get_by_text("the edit did not go through — try again")).to_have_count(0)
stalled[i].fallback() # …and it answers
expect(checks(page)).to_have_count(0) # the whole edit landed: the selection cleared…
expect(grid(page)).to_have_attribute("aria-busy", "false") # …and the wall has read it back
print(" PASS: a slow edit is waited out")
page.unroute("**/graphql", stall_writes)
# and once more, on a server that answers and refuses
refused = []
def refuse_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""):
refused.append(route.request)
route.fulfill(status=500, body="no", content_type="text/plain")
else: route.fallback()
page.route("**/graphql", refuse_writes)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("zzrefused")
tb.get_by_role("button", name="add label").click()
wait_until(page, lambda: len(refused) > 0, label="the first write went out")
page.wait_for_timeout(500) # room for the rest of the run, were it to go on
assert len(refused) == 1, f"the run went on past its refusal: {len(refused)} writes"
print(" PASS: a refusal stops the run where it stands")
expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again")
expect(page.get_by_text("updating…")).to_have_count(0)
expect(grid(page)).to_have_attribute("aria-busy", "false")
expect(tb.get_by_placeholder("add a label…")).to_have_value("zzrefused") # ready to try again
print(" PASS: a refused edit says so")
page.unroute("**/graphql", refuse_writes)
# and once more, a write held past the clock and refused after it
held = []
def hold_writes(route):
if "updatePhotovideo" in (route.request.post_data or ""): held.append(route)
else: route.fallback()
page.route("**/graphql", hold_writes)
tb.get_by_role("button", name="add label").click() # the run is still in hand, the word still there
wait_until(page, lambda: len(held) > 0, label="the write went out")
expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again") # given up on
for r in held: r.fulfill(status=500, body="no", content_type="text/plain") # …then refused
page.wait_for_timeout(300)
expect(page.get_by_role("status")).to_have_text("the edit did not go through — try again") # still so
expect(grid(page)).to_have_attribute("aria-busy", "false") # and still quiet
print(" PASS: an error after the give-up changes nothing")
page.unroute("**/graphql", hold_writes)
select_all(page).click() # a refused run is still in hand: drop it
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="add label").click()
expect(checks(page)).to_have_count(0) # selection clears once applied
search_for(page, "addedbybatch") # the fresh word now finds them all
expect(tiles(page)).to_have_count(n)
print(" PASS: batch add label")
select_all(page).click()
tb.get_by_placeholder("add a label…").fill("addedbybatch")
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0)
print(" PASS: batch remove label")
search_for(page, FIXTURE_LABEL) # back to the run itself
expect(tiles(page)).to_have_count(n)
select_all(page).click()
box = toolbar(page).get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzbatchret")
expect(box).to_have_value("zzbatchret") # the word is in the live box
box.press("Enter") # + via RET
expect(checks(page)).to_have_count(0) # applied → selection clears
search_for(page, "zzbatchret")
expect(tiles(page)).to_have_count(n) # every selected doc 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)
t.nth(0).click()
reach = tb.get_by_role("button", name=re.compile("all .* matching"))
reach.click()
expect(reach).to_have_attribute("aria-pressed", "true") # the whole search, off one tick
t.nth(1).click() # the picks change…
expect(reach).to_have_attribute("aria-pressed", "false") # …and the reach is the picks again
expect(tb.get_by_text("2 selected")).to_be_visible()
clear_sel.click()
print(" PASS: all matching resets when the picks change")
t.nth(0).click()
reach.click()
expect(reach).to_have_attribute("aria-pressed", "true")
search_for(page, FIXTURE_LABEL + "; date:2020") # the filter changes, the pick stays…
expect(reach).to_have_attribute("aria-pressed", "false") # …and the reach is the pick again
clear_sel.click() # the next search puts the run back
print(" PASS: all matching resets when the filter changes")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
t.nth(0).click() # just one, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click() # widen the scope
tb.get_by_role("button", name="done").click()
expect(grid(page).get_by_text("done")).to_have_count(n) # all of them, off one tick
print(" PASS: batch all-matching state")
t.nth(0).click(); t.nth(1).click() # two of the three
tb.get_by_role("button", name="next", exact=True).click()
expect(grid(page).get_by_text("next")).to_have_count(2) # the ticked ones move…
expect(grid(page).get_by_text("done")).to_have_count(1) # …and the third keeps what it had
print(" PASS: batch set state")
t.nth(0).click()
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill("zzbulkall")
tb.get_by_role("button", name="add label").click()
search_for(page, "zzbulkall") # all of them carry it now
expect(tiles(page)).to_have_count(n)
expect(grid(page).get_by_text("zzbulkall")).to_have_count(n) # and the wall reads it back
print(" PASS: batch all-matching label")
COMMA = "zzleft, zzright" # one ;-token, with a comma inside it
search_for(page, FIXTURE_LABEL)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(COMMA)
tb.get_by_role("button", name="add label").click() # the ticked path ;-joins it on
expect(checks(page)).to_have_count(0) # applied
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(n) # it went on as one word
select_all(page).click()
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_placeholder("add a label…").fill(COMMA)
tb.get_by_role("button", name="remove label").click() # the wide path, through the server
search_for(page, "zzleft")
expect(tiles(page)).to_have_count(0) # the whole word came off, not its halves
print(" PASS: all-matching removes comma label")
search_for(page, FIXTURE_LABEL + "; date:2020") # the run, as dated before any stamp
expect(tiles(page)).to_have_count(n)
t.nth(0).click(); t.nth(n - 1).click() # the two ends of the run
tb.get_by_role("button", name="set date").click()
expect(tb.get_by_label("date for the selection")).to_have_value(re.compile(r"^2020-02-14T"))
tb.get_by_role("button", name="set date").click() # put the picker away untouched
clear_sel.click()
print(" PASS: the date picker opens on the mean date")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n) # three days months apart
t.nth(0).click(); t.nth(1).click() # two of them
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2018-06-15T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the bar goes
search_for(page, FIXTURE_LABEL + "; date:2018-06-15") # the ticked ones land together…
expect(tiles(page)).to_have_count(2)
search_for(page, FIXTURE_LABEL + "; date:2020") # …and the third keeps the day it had
expect(tiles(page)).to_have_count(1)
print(" PASS: batch date stamps the selection")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
t.nth(0).click() # one tick, to raise the bar
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="set date").click()
tb.get_by_label("date for the selection").fill("2016-03-04T12:00")
tb.get_by_role("button", name="apply date").click()
expect(toolbar(page)).to_be_hidden() # the bar goes
search_for(page, FIXTURE_LABEL + "; date:2016-03-04") # every one of them, off one tick
expect(tiles(page)).to_have_count(n)
print(" PASS: batch date all-matching")
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(n)
select_all(page).click()
tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL + "; ") # as a picked suggestion leaves it
tb.get_by_role("button", name="remove label").click()
expect(tiles(page)).to_have_count(0) # the trailing sep didn't defeat the match
print(" PASS: batch remove label trailing separator")
search_for(page, "zzbulkall") # the run, by the word that reached all of it
seed_vocab(COMBO_WORDS)
try:
select_all(page).click()
box = tb.get_by_placeholder("add a label…")
box.click(); box.press_sequentially("zzcombo")
expect(box).to_have_attribute("aria-expanded", "true") # listing completions → true
box.blur()
expect(box).to_have_attribute("aria-expanded", "false") # closed → false
assert page.get_by_role("listbox", name="suggestions").count() == 0, "popover lingered after blur"
print(" PASS: batch label combobox state")
finally:
drop_vocab(COMBO_WORDS)
clear_sel.click() # put the run down by hand
select_all(page).click()
hold_completions(page)
expanded_while_loading(page, tb.get_by_placeholder("add a label…"))
print(" PASS: batch label expanded while loading")
# …and the same widening on a wall two people share, where it must stop at his
page.unroute("**/graphql") # the vocabulary answers again
for d in OWN_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
open_app(page); chip(page, "all").click()
search_for(page, f"{OWN_LABEL}; owner:konubinix")
expect(tiles(page)).to_have_count(1) # his, of the two
tiles(page).nth(0).click() # one pick, to bring the bar up
tb.get_by_role("button", name=re.compile("all .* matching")).click()
tb.get_by_role("button", name="done").click()
wait_until(page, lambda: own_count("konubinix", "done") == 1,
label="his doc took the verdict")
assert own_count("aylapomme", "todo") == 1, "the widening reached past the owner filter"
print(" PASS: bulk all-matching respects owner")
finally:
for d in OWN_DOCS:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
# …and the same run on a screen too short to hold both the bar and the box's list
seed_vocab(OVERLAP_WORDS)
try:
page.set_viewport_size(OVERLAP_VIEWPORT)
open_fixtures(page)
tiles(page).nth(0).click() # one pick → the bar comes up
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzov", delay=20)
expect(options(page).first).to_be_visible() # the list is up
expect(tb).to_be_visible() # and the pick still holds the bar up under it
bar = tb.bounding_box()
sug = page.get_by_role("listbox", name="suggestions").bounding_box()
lo, hi = max(bar["y"], sug["y"]), min(bar["y"] + bar["height"], sug["y"] + sug["height"])
assert hi > lo, f"setup: bar {bar} and list {sug} do not meet — there is nothing to see"
hit = page.evaluate( # what a finger at the overlap would actually land on
"([x,y]) => { const el = document.elementFromPoint(x,y);"
" return { list: !!el?.closest('[role=\"listbox\"]'), bar: !!el?.closest('[role=\"toolbar\"]') }; }",
[bar["x"] + bar["width"] / 2, (lo + hi) / 2])
assert hit["list"] and not hit["bar"], f"the bar is in front of the list where they meet: {hit}"
print(" PASS: search completion sits above the selection toolbar")
finally:
drop_vocab(OVERLAP_WORDS)
# …and a run of sixty, on the phone these two gestures were designed for
for d in DRAG_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size(DRAG_VIEWPORT)
page.unroute("**/graphql") # the vocabulary answers again
open_app(page)
for _ in range(SIZE_STEPS): # down to a contact sheet: several tiles to a row
page.get_by_role("button", name="smaller thumbnails").click()
chip(page, "all").click(); search_for(page, DRAG_LABEL)
expect(tiles(page)).to_have_count(len(DRAG_DOCS))
t = tiles(page)
a = t.nth(0).bounding_box(); end = t.nth(1).bounding_box() # its neighbour, wherever the columns fall
x0, y0 = a["x"] + a["width"] / 2, a["y"] + a["height"] / 2
x1, y1 = end["x"] + end["width"] / 2, end["y"] + end["height"] / 2
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(LONG_PRESS_MS) # hold past the long-press → arms range
for f in (0.25, 0.5, 0.75, 1.0): # drag off the anchor onto the next tile
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
"touchPoints": [{"x": x0 + (x1 - x0) * f, "y": y0 + (y1 - y0) * f}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(2) # the run followed the finger, not one lonely anchor
print(" PASS: long-press drag (touch)")
toolbar(page).get_by_role("button", name="clear", exact=True).click()
m = len(DRAG_DOCS)
last = t.nth(m - 1).bounding_box()
xl = last["x"] + last["width"] / 2 # the last tile's column; its row is below the fold
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x0, "y": y0}]})
page.wait_for_timeout(LONG_PRESS_MS) # arm range at the anchor
for f in (0.5, 1.0): # drag to the bottom edge, in the last column, and hold
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
"touchPoints": [{"x": x0 + (xl - x0) * f, "y": DRAG_VIEWPORT["height"] - 10}]})
wait_until(page, lambda: checks(page).count() == m, # the wall scrolls the rest under the finger
label="autoscroll ropes in the whole wall", detail=lambda: f"{checks(page).count()}/{m} selected")
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
expect(checks(page)).to_have_count(m)
print(" PASS: long-press drag auto-scrolls")
finally:
for d in DRAG_DOCS: gql(DELETE, {"cid": d["cid"]})
# …and a wall of eighty
for d in REFINE_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size(REFINE_VIEWPORT)
open_app(page)
for _ in range(SIZE_STEPS): # wind it down to a contact sheet
page.get_by_role("button", name="smaller thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*80px")) # wound right down
page.get_by_role("button", name="smaller thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*80px")) # held at the floor
page.get_by_role("button", name="bigger thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*120px")) # one press, forty
page.get_by_role("button", name="smaller thumbnails").click() # back to the contact sheet
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*80px"))
print(" PASS: size steps of forty, held at eighty")
fetched = watch_fetches(page) # from here on, everything the wall asks for
web = lambda: [u for u in fetched() if "-web-" in u]
search_for(page, REFINE_LABEL)
expect(tiles(page)).to_have_count(len(REFINE_DOCS))
assert cell_px(page) <= 300, f"a contact sheet should be drawn under 300px, and this is {cell_px(page)}px"
page.mouse.move(REFINE_VIEWPORT["width"] / 2, REFINE_VIEWPORT["height"] / 2)
for _ in range(10): # all the way down the wall
page.mouse.wheel(0, 400)
page.wait_for_timeout(100)
assert tiles(page).last.bounding_box()["y"] < REFINE_VIEWPORT["height"], \
"the wall never overflowed — nothing was scrolled into reach"
assert not web(), f"the dense wall fetched {len(web())} web renditions"
print(" PASS: a dense wall buys thumbnails and nothing else")
tiles(page).first.scroll_into_view_if_needed()
grow_the_wall(page)
wait_until(page, lambda: any(u.endswith("zzref-web-1") for u in web()),
label="the tile on screen trades up to its web rendition",
detail=lambda: f"srcs={tile_srcs(page, 1)} web={web()}")
assert any("zzref-t-1" in (s or "") for s in tile_srcs(page, 1)), "the thumbnail left from under it"
print(" PASS: a grown wall trades up, the thumbnail staying under it")
expect(tiles(page).nth(1).locator("img.thumb.web")).to_have_count(1) # the overlay is there…
expect(tiles(page).nth(1).get_by_role("img")).to_have_count(1) # …and only the layer beneath is named
print(" PASS: the overlay image is not named twice")
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 clip's tile fetched its film: {web()}"
print(" PASS: a grown wall buys narrowly")
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*320px")) # grown right up
page.get_by_role("button", name="bigger thumbnails").click()
expect(grid(page)).to_have_attribute("style", re.compile(r"--tile:\s*320px")) # held at the ceiling
print(" PASS: size held at three hundred and twenty")
finally:
for d in REFINE_DOCS: gql(DELETE, {"cid": d["cid"]})
# …and a wall with one thumbnail the gateway never answers for
for d in WAIT_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
page.route(f"**/ipfs/{WAIT_HELD}", lambda route: None) # left hanging, never answered
try:
page.set_viewport_size(WAIT_VIEWPORT)
fetched = watch_fetches(page)
web = lambda: [u for u in fetched() if "-web-" in u]
open_app(page)
search_for(page, WAIT_LABEL)
expect(tiles(page)).to_have_count(len(WAIT_DOCS))
grow_the_wall(page)
expect(thumb_imgs(page).first).to_have_attribute("src", f"/ipfs/{WAIT_HELD}") # asked for…
assert not [u for u in fetched() if u.endswith(WAIT_HELD)], "…and still unanswered"
wait_until(page, lambda: len([u for u in fetched() if "zzwait-t-" in u]) >= 2,
label="its neighbours' thumbnails have settled",
detail=lambda: f"settled={[u for u in fetched() if 'zzwait-t-' in u]}")
page.wait_for_timeout(REFINE_GRACE_MS)
assert not web(), f"the wall refined with a thumbnail still in flight: {web()}"
print(" PASS: the wall waits for its thumbnails before refining")
finally:
page.unroute(f"**/ipfs/{WAIT_HELD}")
for d in WAIT_DOCS: gql(DELETE, {"cid": d["cid"]})
# …and the same trouble down a column, where some of it has already sharpened
for d in FWD_DOCS + FWD_DOCS_AFTER: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
page.route(f"**/ipfs/{FWD_HELD}", lambda route: None)
try:
sharp = lambda i: any(f"zzfwd-web-{i}" in (s or "") for s in tile_srcs(page, i))
inreach = lambda i: any(f"zzfwd-t-{i}" in (s or "") for s in tile_srcs(page, i))
page.set_viewport_size(FWD_VIEWPORT)
open_app(page)
search_for(page, FWD_LABEL)
expect(tiles(page)).to_have_count(len(FWD_DOCS))
grow_the_wall(page)
wait_until(page, lambda: sharp(0), label="the tile on screen sharpens",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
tiles(page).nth(2).scroll_into_view_if_needed()
wait_until(page, lambda: sharp(3), label="the tiles scrolled into reach sharpen too",
detail=lambda: f"srcs={tile_srcs(page, 3)}")
tiles(page).nth(4).scroll_into_view_if_needed() # doc 4 comes into reach, and never lands
wait_until(page, lambda: inreach(5), label="doc 5 has come into reach behind it",
detail=lambda: f"srcs={tile_srcs(page, 5)}")
page.wait_for_timeout(REFINE_GRACE_MS)
assert inreach(4), f"doc 4 must be in reach to hold anything back: {tile_srcs(page, 4)}"
assert sharp(3), f"a tile handed its picture back: {tile_srcs(page, 3)}"
assert not sharp(5), f"a tile refined behind a thumbnail in flight: {tile_srcs(page, 5)}"
print(" PASS: a late thumbnail holds back what arrives behind it")
wait_until(page, lambda: not inreach(0), label="doc 0 has been left behind",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
assert not sharp(0), f"the fuller picture outlived the thumbnail: {tile_srcs(page, 0)}"
print(" PASS: a tile left behind drops both its pictures")
search_for(page, FWD_LABEL_AFTER) # swapped out from under a thumbnail in flight
expect(tiles(page)).to_have_count(len(FWD_DOCS_AFTER))
wait_until(page, lambda: any("zzfwd2-web-0" in (s or "") for s in tile_srcs(page, 0)),
label="the wall that replaced it sharpens too",
detail=lambda: f"srcs={tile_srcs(page, 0)}")
print(" PASS: a wall swapped out from under a thumbnail refines again")
finally:
page.unroute(f"**/ipfs/{FWD_HELD}")
for d in FWD_DOCS + FWD_DOCS_AFTER: gql(DELETE, {"cid": d["cid"]})
Finding a doc
Hunting for a doc you half-remember
- what
- you know the doc exists and you know almost nothing that would find it.
- example: it was summer, at the Fête de la musique, one of mine, not Ayla’s.
- example: five or six years back, and it might have been a video.
- example: somebody typed a word on it at the time and you could not swear which.
- what: that is the whole of what you have.
- rule
- the search box takes guesses rather than facts, and takes them a piece at a time.
- what: each guess you add cuts the wall down, and a guess you withdraw costs you only itself.
- what: the guesses combine.
- therefore: a hunt is a funnel rather than a single lucky query.
- what: some of the pieces are not on the doc at all: an occasion is the calendar’s.
- what: the calendar remembers when things were even where nobody wrote it down.
- rule: an occasion is found however its accents and capitals are typed.
- how
- the run below holds six docs, five in June and spread over three years, one taken in March.
- how: one of the Junes is somebody else’s, and one of them a video rather than a photo.
- how: those years are counted back from today rather than written down.
- how: each also carries a word somebody typed onto it at the time —
zzsummerorzzholidayfor the June ones,zzsnowfor the March. - how: the calendar holds three occasions over these docs.
- how: the first is real and mine, running from the 18th of that June to the exact
instant of the doc on the 25th.
- therefore: it covers a doc of mine and one of Ayla’s, and ends on a boundary rather than safely past it.
- how: it is spelt with accents.
- how: the second was called off, and sits over the June of the year after, where it covers a doc of mine.
- how: the third is over the March one and has no accent or capital to fold.
- how: the first is real and mine, running from the 18th of that June to the exact
instant of the doc on the 25th.
- what
- all of that is a hunt where the year is one of the things remembered.
- what: the harder shape is the one where it is not: before the 14th of July, around
this time of year.
- what: both name a position in the year and leave the year itself open.
- how: more docs, with labels of their own.
- example: one pair straddling the last 14th of July.
- example: three sharing a few days of the calendar across three widely separated years.
- example: one taken today.
- what: the harder shape is the one where it is not: before the 14th of July, around
this time of year.
HUNT_LABEL = "zzhunt"
HUNT_BACK = 5 # "five or six years back", from today
HUNT_YEAR = datetime.date.today().year - HUNT_BACK
HUNT_DOCS = [
# (year, month-day, owner, the word it got, what it is, its draw)
(HUNT_YEAR - 1, "06-15", "konubinix", "zzsummer", "image/jpeg", 0.9),
(HUNT_YEAR, "06-15", "konubinix", "zzholiday", "image/jpeg", 0.8),
(HUNT_YEAR + 1, "06-10", "konubinix", "zzsummer", "image/jpeg", 0.1),
(HUNT_YEAR, "03-20", "konubinix", "zzsnow", "image/jpeg", 0.6),
(HUNT_YEAR, "06-20", "aylapomme", "zzsummer", "image/jpeg", 0.5),
(HUNT_YEAR, "06-25", "konubinix", "zzholiday", "video/mp4", 0.4),
]
def hunt_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}", "date": f"{y}-{md}T12:00:00Z", "mimetype": kind,
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzhunt-{i}-t", "labels": f"{HUNT_LABEL} {word}",
"state": "todo", "owner": owner, "myrandom": draw}
for i, (y, md, owner, word, kind, draw) in enumerate(HUNT_DOCS)]
CAL_ADD = "mutation($e:CalendarEventInput!){ createCalendarEvent(input:{calendarEvent:$e}){ clientMutationId } }"
CAL_DEL = "mutation($id:String!){ deleteCalendarEvent(input:{rowId:$id}){ clientMutationId } }"
HUNT_EVENT = "zzFêteDeLaMusique" # as the calendar spells it
HUNT_EVENT_TYPED = "zzfetedelamusique" # as somebody in a hurry types it
PLAIN_EVENT = "zzBrocante" # an occasion with nothing to fold
HUNT_EVENTS = [
# the occasion, ending at the very instant of the last doc of mine inside it
{"rowId": "zzhunt-ev", "summary": HUNT_EVENT, "owner": "konubinix", "status": "confirmed",
"starttime": f"{HUNT_YEAR}-06-18T00:00:00Z", "endtime": f"{HUNT_YEAR}-06-25T12:00:00Z"},
# and one that was called off, over the June of the year after
{"rowId": "zzhunt-ev-off", "summary": "zzAnnulé", "owner": "konubinix", "status": "cancelled",
"starttime": f"{HUNT_YEAR + 1}-06-09T00:00:00Z", "endtime": f"{HUNT_YEAR + 1}-06-11T23:59:59Z"},
# a third, plainly spelt, over the March one
{"rowId": "zzhunt-ev-plain", "summary": PLAIN_EVENT, "owner": "konubinix", "status": "confirmed",
"starttime": f"{HUNT_YEAR}-03-19T00:00:00Z", "endtime": f"{HUNT_YEAR}-03-21T23:59:59Z"},
]
# the guesses that carry no year at all, each with a set of its own
WHEN_JULY = "zzhuntjuly" # two docs either side of one 14th of July
WHEN_LAST = "zzhuntlast" # either side of the 31st of December last gone
WHEN_ANNIV = "zzhuntanniv" # the same few days of the year, three years apart
WHEN_TODAY = "zzhunttoday" # one taken today, for the bound nobody ever types
def july_year():
t = datetime.date.today() # the 14th of July last gone: this year's if it has been
return t.year - (0 if (t.month, t.day) >= (7, 14) else 1)
def when_docs():
t, y = datetime.date.today(), july_year()
out = [{"cid": "https://ipfs.konubinix.eu/p/zzwhen-before", "date": f"{y}-07-10T12:00:00Z", "labels": WHEN_JULY},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-after", "date": f"{y}-07-20T12:00:00Z", "labels": WHEN_JULY},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-yearago", "date": f"{t.year - 1}-12-30T12:00:00Z", "labels": WHEN_LAST},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-sincethen", "date": f"{t.year}-01-05T12:00:00Z", "labels": WHEN_LAST},
{"cid": "https://ipfs.konubinix.eu/p/zzwhen-today", "date": t.isoformat() + "T12:00:00Z", "labels": WHEN_TODAY}]
for i, (off, yr) in enumerate([(0, 2010), (1, 2015), (5, 2018)]):
when = (t + datetime.timedelta(days=off)).replace(year=yr)
out.append({"cid": f"https://ipfs.konubinix.eu/p/zzwhen-anniv-{i}", "date": when.isoformat() + "T12:00:00Z",
"labels": WHEN_ANNIV})
for d in out:
d.update({"mimetype": "image/jpeg", "thumbnailCid": d["cid"] + "-t",
"state": "todo", "owner": "konubinix"})
return out
def seed_events(events):
for e in events: gql(CAL_DEL, {"id": e["rowId"]}); gql(CAL_ADD, {"e": e})
def drop_events(events):
for e in events:
try: gql(CAL_DEL, {"id": e["rowId"]})
except Exception: pass
- what
- asking is always asking afresh: the box holds one query and committing it — sending
it off as the search — replaces whatever stood there.
- therefore: a guess is added by re-stating the question with the new piece in it.
def hunt_query(*tokens):
return "; ".join([HUNT_LABEL, *tokens])
def narrow(page, *tokens):
search_for(page, hunt_query(*tokens))
@testcase
def test_hunting_for_a_photo(page):
"""Finding a doc from what memory kept: nothing at all, so the wall is shuffled; then
a word, an occasion, whose it was, what kind of thing, what month, what year, what day
— up to and including today — each guess cutting the wall down, and one taken back
giving its share straight back."""
docs = hunt_docs() + when_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
seed_events(HUNT_EVENTS)
try:
open_app(page); chip(page, "all").click()
narrow(page)
expect(tiles(page)).to_have_count(len(HUNT_DOCS)) # nothing guessed yet
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-0-t") # by date, oldest first
search_box(page).fill(hunt_query("sort:random"))
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-2-t") # by the draw: the latest date
print(" PASS: sort random")
narrow(page, "zzsummer zzholiday")
expect(tiles(page)).to_have_count(0) # both words at once: nothing carries both
narrow(page, "zzsummer")
expect(tiles(page)).to_have_count(3) # only what got that word
narrow(page, "zzsummer or zzholiday")
expect(tiles(page)).to_have_count(5) # either word will do — every June
narrow(page, "-zzholiday")
expect(tiles(page)).to_have_count(4) # whatever it was, it was not that
print(" PASS: a word half-remembered, or ruled out")
narrow(page, f"event:{HUNT_EVENT_TYPED}") # typed flat; the calendar's is accented
expect(tiles(page)).to_have_count(1) # mine inside it — not Ayla's, standing right there
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-25") # the one on the last instant
narrow(page, f"-event:{HUNT_EVENT_TYPED}") # everything but that occasion
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1)
narrow(page, "event:zzannule")
expect(tiles(page)).to_have_count(0) # nothing was taken during it
print(" PASS: filter by occasion")
search_for(page, PLAIN_EVENT.lower()) # no token, no label beside it, just the name
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-03-20") # never labelled with it
print(" PASS: a bare word reaches the calendar")
narrow(page, "type:video")
expect(tiles(page)).to_have_count(1) # the one that moves
narrow(page, "type:image")
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1) # and everything that doesn't
print(" PASS: filter by kind")
narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5) # everyone else's set aside
expect(page.get_by_text("showing all 5")).to_be_visible() # …and the tally set them aside too
print(" PASS: filter by owner")
narrow(page, "owner:konubinix", "owner:aylapomme")
expect(tiles(page)).to_have_count(len(HUNT_DOCS)) # hers and mine, together
print(" PASS: owner repeated takes several")
theirs = [{"cid": f"https://ipfs.konubinix.eu/p/zzowner-{who}", "date": "2019-04-01T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzowner-{who}-t", "labels": "zzowners", "state": "todo", "owner": who}
for who in ("olivia", "gabriel")]
for d in theirs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
for d in theirs:
search_for(page, f"zzowners; owner:{d['owner']}")
expect(tiles(page)).to_have_count(1) # hers or his alone, not both
expect(thumb_imgs(page)).to_have_attribute("src", re.compile(re.escape(d["thumbnailCid"])))
finally:
for d in theirs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: every owner answers to its name")
narrow(page, "owner:konubinix", "month:6")
expect(tiles(page)).to_have_count(4) # every June, whatever the year
narrow(page, "owner:konubinix", "month:march") # named, not numbered
expect(tiles(page)).to_have_count(1)
print(" PASS: filter by month")
for recent in ["1 week ago", "1 month ago", "7 days ago", "6 months ago",
"1 year ago", "3 weeks ago", "lastyear", "1 day ago"]:
narrow(page, "owner:konubinix", f"since:{recent}")
expect(tiles(page)).to_have_count(0) # nothing of mine is that recent
narrow(page, "owner:konubinix", f"since:{HUNT_BACK * 4} years ago")
expect(tiles(page)).to_have_count(5) # far enough back to reach every one of mine
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_BACK} years ago")
kept = thumb_imgs(page).evaluate_all("els => els.map(e => e.alt)")
assert f"{HUNT_YEAR + 1}-06-10" in kept, f"the June after the reach should have survived it: {kept}"
assert f"{HUNT_YEAR - 1}-06-15" not in kept, f"the June before the reach should not have: {kept}"
print(" PASS: a distance back stands in for a date")
NO_READ_MS = 500 # every read in this set is back well inside it
narrow(page, "owner:konubinix", "since:1 year ago")
asked = len(SETTLED_READS)
search_box(page).fill(hunt_query("owner:konubinix", "since:lastyear"))
page.get_by_role("button", name="run search").click() # asked, with no answer to wait for
page.wait_for_timeout(NO_READ_MS)
assert len(SETTLED_READS) == asked, "since:lastyear went and asked again for a year back"
print(" PASS: one bound does not read twice")
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(3) # the June before that year is gone
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}", f"until:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(2) # and so is the one after it
print(" PASS: bounded from both ends")
NO_READ_MS = 500 # every read in this set is back well inside it
asked = len(SETTLED_READS)
search_box(page).fill(hunt_query("owner:konubinix", "month:6", f"year:{HUNT_YEAR}"))
page.get_by_role("button", name="run search").click() # asked, with no answer to wait for
page.wait_for_timeout(NO_READ_MS)
assert len(SETTLED_READS) == asked, "year: went and asked for the answer already on screen"
expect(tiles(page)).to_have_count(2) # and the pair's Junes never moved
print(" PASS: filter by year")
narrow(page, "owner:konubinix", "month:6") # the year guess withdrawn
expect(tiles(page)).to_have_count(4) # its Junes returned, and no more
print(" PASS: a withdrawn guess costs only itself")
narrow(page, "owner:konubinix", f"date:{HUNT_YEAR}-06") # the month and the year, in one token
expect(tiles(page)).to_have_count(2) # the same two the pair had left
narrow(page, "owner:konubinix", f"date:{HUNT_YEAR}-06-15") # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-15") # the one asked for
print(" PASS: filter by date")
search_for(page, WHEN_TODAY) # no until: token
expect(tiles(page)).to_have_count(1) # this morning's doc is inside it
print(" PASS: today docs shown by default")
for named in ("lastweek", "lastmonth"):
search_for(page, f"{WHEN_TODAY}; since:{named}")
expect(tiles(page)).to_have_count(1) # today is within the reach
search_for(page, f"{WHEN_TODAY}; until:{named}")
expect(tiles(page)).to_have_count(0) # and the reach ended before today
print(" PASS: lastweek and lastmonth bound the wall")
for spelling in ("07-14", "july-14"):
search_for(page, WHEN_JULY)
expect(tiles(page)).to_have_count(2) # both, with nothing said about when
search_for(page, f"{WHEN_JULY}; until:{spelling}")
expect(tiles(page)).to_have_count(1) # only the 10th is inside the bound
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-before-t")
search_for(page, f"{WHEN_JULY}; since:july") # the whole of that July, both back
expect(tiles(page)).to_have_count(2)
search_for(page, WHEN_LAST)
expect(tiles(page)).to_have_count(2)
search_for(page, f"{WHEN_LAST}; until:12-31") # the one last gone, not the one to come
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-yearago-t")
print(" PASS: bare month and day bounds")
search_for(page, f"{WHEN_ANNIV}; onthisday")
expect(tiles(page)).to_have_count(2) # today's day and the one after it
search_for(page, f"{WHEN_ANNIV}; onthisday:5")
expect(tiles(page)).to_have_count(3) # and the one five days out
print(" PASS: onthisday")
search_for(page, f"{WHEN_ANNIV}; since:2014; onthisday:5")
expect(tiles(page)).to_have_count(2) # 2010 left behind the bound
search_for(page, f"{WHEN_ANNIV}; since:2014; until:2016; onthisday:5")
expect(tiles(page)).to_have_count(1) # and 2018 past the other
print(" PASS: onthisday within since and until")
t = datetime.date.today()
if t.month >= 7: there, reach = datetime.date(2012, 1, 2), (datetime.date(t.year + 1, 1, 2) - t).days
else: there, reach = datetime.date(2012, 12, 30), (t - datetime.date(t.year - 1, 12, 30)).days
wrap = {"cid": "https://ipfs.konubinix.eu/p/zzwrap", "date": there.isoformat() + "T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzwrap-t", "labels": "zzwrap", "state": "todo", "owner": "konubinix"}
gql(DELETE, {"cid": wrap["cid"]}); gql(CREATE, {"p": wrap})
try:
search_for(page, f"zzwrap; onthisday:{reach}")
expect(tiles(page)).to_have_count(1) # reached across the year end
search_for(page, f"zzwrap; onthisday:{reach - 2}")
expect(tiles(page)).to_have_count(0) # and only just
finally:
gql(DELETE, {"cid": wrap["cid"]})
print(" PASS: onthisday wraps the year end")
narrow(page, "zzhuntnothing") # a word no doc wears
expect(page.get_by_text("No photos.", exact=True)).to_be_visible()
expect(tiles(page)).to_have_count(0)
print(" PASS: an empty match says so")
old = {"cid": "https://ipfs.konubinix.eu/p/zzfloor-2006", "date": "2006-06-01T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfloor-2006-t", "labels": "zzfloor", "state": "todo", "owner": "konubinix"}
gql(DELETE, {"cid": old["cid"]}); gql(CREATE, {"p": old})
try:
search_for(page, "zzfloor")
expect(tiles(page)).to_have_count(0) # older than the docs are taken to be
search_for(page, "zzfloor; since:2005")
expect(tiles(page)).to_have_count(1) # there all along, below the floor
finally:
gql(DELETE, {"cid": old["cid"]})
print(" PASS: the window floors at 2007")
finally:
drop_events(HUNT_EVENTS)
for d in docs:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
A grid of thumbnails
- what
- the wall is the home surface — a grid of thumbnails of the docs, every doc the search
box’s query matches, drawn only as far as the eye reaches.
- how: Memories draws its own even spread, up to the cap, over the frise’s shared filter (see Drawing our own spread).
- rule: one lazily-loaded tile per doc, and a placeholder while the fetch is in flight.
- rule: when nothing matches, the wall says so in a word,
No photos., and shows no tile.
narrow(page, "zzhuntnothing") # a word no doc wears
expect(page.get_by_text("No photos.", exact=True)).to_be_visible()
expect(tiles(page)).to_have_count(0)
print(" PASS: an empty match says so")
What the wall asks the server
- what
- the window spans all the dated docs, floored at 2007 like the frise.
- how: the server draws a spread of it.
- how: a doc dated before the floor stays off the wall until a
since:reaches past it.
old = {"cid": "https://ipfs.konubinix.eu/p/zzfloor-2006", "date": "2006-06-01T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzfloor-2006-t", "labels": "zzfloor", "state": "todo", "owner": "konubinix"}
gql(DELETE, {"cid": old["cid"]}); gql(CREATE, {"p": old})
try:
search_for(page, "zzfloor")
expect(tiles(page)).to_have_count(0) # older than the docs are taken to be
search_for(page, "zzfloor; since:2005")
expect(tiles(page)).to_have_count(1) # there all along, below the floor
finally:
gql(DELETE, {"cid": old["cid"]})
print(" PASS: the window floors at 2007")
- rule
- a tile holds its thumbnail only while the thumbnail is worth holding.
- what: a wall this long cannot keep them all, and letting them go has a heading of its own.
- rule
- a tile carries its doc’s date as its name, for a screen reader and for the eye alike.
- how
- each tile is drawn from its own doc, asking for the thumbnail only as it nears the screen and showing a placeholder of its kind where the doc has none.
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>`;
- what
- the server filter is not Memories’ own: turning the parsed query into the server’s question is the
same in every app.
- how: it lives in the shared data module, with the GraphQL declarations and arguments that carry it and the
PhotoCorefield set. - what: Memories adds only its own: a filter on state, a cap — the shared
SAMPLE_CAP— the choice of which rows fill it, and each doc’s state and random draw.
- how: it lives in the shared data module, with the GraphQL declarations and arguments that carry it and the
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 };
};
- what
- the search box speaks a tiny query language: bare words search the labels, and each token — a keyword
piece such as
owner:oronthisday— adds one guess.- example:
since:=/=until:bound a date range;type:=/=sort:=/=owner:filter;onthisdayopens the anniversary view. - example:
month:=/=day:keep a recurring month or day across every year, andyear:pins a single calendar year. - example:
first:=/=last:=/=sample:say how many to bring back.
- example:
- what
- that language is not Memories’ own: every doc app addresses the shared contract with it.
- how: it lives once in the data layer’s query-language module, and Memories imports its parser together with the completion primitives the box needs.
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';
Whose it was
- what
- the docs are more than one person’s.
- rule
- the wall and its count agree on every token: the count is the same question as the wall, asked for a
number instead of a page.
- how: a token threads through to the very filter the frise owns, for both.
- example: a token that reached only the wall would show five docs and say six.
- rule
owner:NAMEnarrows the wall — and its count — to a chosen owner, repeatable for several.
narrow(page, "owner:konubinix")
expect(tiles(page)).to_have_count(5) # everyone else's set aside
expect(page.get_by_text("showing all 5")).to_be_visible() # …and the tally set them aside too
print(" PASS: filter by owner")
- how
- a second
owner:adds Ayla’s June to mine rather than taking mine away.
narrow(page, "owner:konubinix", "owner:aylapomme")
expect(tiles(page)).to_have_count(len(HUNT_DOCS)) # hers and mine, together
print(" PASS: owner repeated takes several")
- rule
- every owner the server knows answers to its name, Olivia and Gabriel as much as the family.
- how: one doc each, under a label of their own, so the hunt’s counts stay as they were.
theirs = [{"cid": f"https://ipfs.konubinix.eu/p/zzowner-{who}", "date": "2019-04-01T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zzowner-{who}-t", "labels": "zzowners", "state": "todo", "owner": who}
for who in ("olivia", "gabriel")]
for d in theirs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
for d in theirs:
search_for(page, f"zzowners; owner:{d['owner']}")
expect(tiles(page)).to_have_count(1) # hers or his alone, not both
expect(thumb_imgs(page)).to_have_attribute("src", re.compile(re.escape(d["thumbnailCid"])))
finally:
for d in theirs: gql(DELETE, {"cid": d["cid"]})
print(" PASS: every owner answers to its name")
What was typed on it
- what
- not every guess is a token: anything else in the query is a word guess, handed to Postgres’s web-search parser as written.
- what
- words are where memory is least reliable of all: you know one was typed on and not which.
- rule
- the free text is a boolean search.
- rule: terms side by side must all match.
- example: naming both words of a pair side by side asks for a doc carrying both, and finds none.
- example: guessing one costs you the docs labelled with the other.
- rule:
ortakes either.- example: offering either takes them all.
- rule: a leading
-rules one out.- what: that is a guess about what the doc is not, which is often the surer memory.
- rule: terms side by side must all match.
narrow(page, "zzsummer zzholiday")
expect(tiles(page)).to_have_count(0) # both words at once: nothing carries both
narrow(page, "zzsummer")
expect(tiles(page)).to_have_count(3) # only what got that word
narrow(page, "zzsummer or zzholiday")
expect(tiles(page)).to_have_count(5) # either word will do — every June
narrow(page, "-zzholiday")
expect(tiles(page)).to_have_count(4) # whatever it was, it was not that
print(" PASS: a word half-remembered, or ruled out")
The occasion it was
- what
- the occasion is the guess that costs nothing to make: the calendar already knows when the Fête de la musique was.
- rule
event:NAMEkeeps the docs taken inside the span of an occasion so named.- rule: it is the doc’s own owner’s calendar that is consulted.
- example: Ayla standing beside me all afternoon does not put her docs among mine.
- rule: the span includes its final instant.
- example: an occasion that ends at six and a doc taken at six are the same afternoon.
- rule: an occasion called off claims no doc.
- how: the accents and capitals are taken off both sides.
- example: a calendar writes Fête and a hunt types fete.
- rule: it is the doc’s own owner’s calendar that is consulted.
- rule
- a leading
-rules an occasion out as it does a word.- example: whatever else that afternoon was, it was not the one everybody else was photographing.
narrow(page, f"event:{HUNT_EVENT_TYPED}") # typed flat; the calendar's is accented
expect(tiles(page)).to_have_count(1) # mine inside it — not Ayla's, standing right there
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-25") # the one on the last instant
narrow(page, f"-event:{HUNT_EVENT_TYPED}") # everything but that occasion
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1)
narrow(page, "event:zzannule")
expect(tiles(page)).to_have_count(0) # nothing was taken during it
print(" PASS: filter by occasion")
- rule
- a plain word is run against the calendar as well as against the labels.
- therefore: the name of an occasion finds the docs taken during it even where nobody thought to type it on.
- scope: the free text is put to the labels as one question and to the calendar as another.
- therefore: a doc answering half of each answers neither.
- how: the name goes in alone, without the scoping word this hunt has leant on throughout.
- scope: the accents are folded on the token’s path but not on this one.
- therefore: a name spelt with any is not found this way at all, and is one to reach for the token for.
- how: the occasion asked for here is spelt plainly.
search_for(page, PLAIN_EVENT.lower()) # no token, no label beside it, just the name
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-03-20") # never labelled with it
print(" PASS: a bare word reaches the calendar")
What kind, and what month
- what
- one thing about it you will not have misremembered is whether it moved.
- rule
type:videoandtype:imagesplit the docs on that, and between them account for everything.- how: the split happens server-side and before the spread, as for every other token.
- what: it is the cheapest guess there is: it cannot be half-right.
narrow(page, "type:video")
expect(tiles(page)).to_have_count(1) # the one that moves
narrow(page, "type:image")
expect(tiles(page)).to_have_count(len(HUNT_DOCS) - 1) # and everything that doesn't
print(" PASS: filter by kind")
- rule
month:keeps a month across every year.- what: Junes from three different years standing together tell it apart from a date range.
- what: a range wide enough to reach the last of them must swallow every March and December on the way, and this one holds none.
- rule: the month can be named as readily as numbered.
- example:
marchis how one says it.
- example:
- what: Junes from three different years standing together tell it apart from a date range.
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")
How far back
- what
- memory does not hand over a year, though; it hands over a distance — five or six years back.
- rule
- the box takes a distance as readily as a date, though only as one end of the window.
- example:
since:N years agosays the earliest the doc could be and leaves the near end open: everything from there to today comes back. - what: the units are
days,weeks,monthsandyears, one or many of them. - what: named distances —
lastweek,lastmonth,lastyear— cover the ones common enough to have a word. - rule: all of it resolves against today.
- therefore: a rolling window costs no arithmetic and no rewriting as the years pass.
- rule: the wall will not read twice for one bound: a query that resolves to the one already in force is the
query already on screen.
- what: several spellings say the same thing — a year back is a year back, however it is spelt.
- how: the spellings are tried in an order where no two neighbours mean the same distance.
- how: where a distance lands is read off the Junes it keeps.
- example: a distance shorter than the one being remembered leaves nothing; one longer than any of these docs reaches past every one of mine.
- what: reaching back the remembered distance itself falls partway through the middle year — before that
June or after it, depending on the time of year the hunt happens.
- therefore: the middle year is the one thing this cannot ask about.
- how: the Junes either side of it are checked: the later one is inside that reach whenever the question is put, the earlier one outside it.
- example:
for recent in ["1 week ago", "1 month ago", "7 days ago", "6 months ago",
"1 year ago", "3 weeks ago", "lastyear", "1 day ago"]:
narrow(page, "owner:konubinix", f"since:{recent}")
expect(tiles(page)).to_have_count(0) # nothing of mine is that recent
narrow(page, "owner:konubinix", f"since:{HUNT_BACK * 4} years ago")
expect(tiles(page)).to_have_count(5) # far enough back to reach every one of mine
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_BACK} years ago")
kept = thumb_imgs(page).evaluate_all("els => els.map(e => e.alt)")
assert f"{HUNT_YEAR + 1}-06-10" in kept, f"the June after the reach should have survived it: {kept}"
assert f"{HUNT_YEAR - 1}-06-15" not in kept, f"the June before the reach should not have: {kept}"
print(" PASS: a distance back stands in for a date")
- how
- the named distances are read off the doc taken today: inside a reach that starts back there, outside one that ends there.
for named in ("lastweek", "lastmonth"):
search_for(page, f"{WHEN_TODAY}; since:{named}")
expect(tiles(page)).to_have_count(1) # today is within the reach
search_for(page, f"{WHEN_TODAY}; until:{named}")
expect(tiles(page)).to_have_count(0) # and the reach ended before today
print(" PASS: lastweek and lastmonth bound the wall")
- how
- a year back spelt twice over, the second spelling committed, leaves the server unasked.
NO_READ_MS = 500 # every read in this set is back well inside it
narrow(page, "owner:konubinix", "since:1 year ago")
asked = len(SETTLED_READS)
search_box(page).fill(hunt_query("owner:konubinix", "since:lastyear"))
page.get_by_role("button", name="run search").click() # asked, with no answer to wait for
page.wait_for_timeout(NO_READ_MS)
assert len(SETTLED_READS) == asked, "since:lastyear went and asked again for a year back"
print(" PASS: one bound does not read twice")
Both ends, and the year between them
- rule
until:closes the far end, independently ofsince:, naming the latest the doc could be.- what: a guess at both is a year named without ever saying its number.
- example: laid over the Junes, the lower bound drops the earliest of them and the upper drops the latest.
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(3) # the June before that year is gone
narrow(page, "owner:konubinix", "month:6", f"since:{HUNT_YEAR}", f"until:{HUNT_YEAR}")
expect(tiles(page)).to_have_count(2) # and so is the one after it
print(" PASS: bounded from both ends")
- rule
year:is that pair said once — not merely agreeing with it on what to leave on the wall, but the same query.- what: a whole year is the commonest guess of all.
- therefore: swapping the pair for the token does not ask the server again, what would be asked being what was asked a moment ago.
- what: a token that only happened to agree would still have had to go and find out.
- how: the wall shows it by doing nothing.
NO_READ_MS = 500 # every read in this set is back well inside it
asked = len(SETTLED_READS)
search_box(page).fill(hunt_query("owner:konubinix", "month:6", f"year:{HUNT_YEAR}"))
page.get_by_role("button", name="run search").click() # asked, with no answer to wait for
page.wait_for_timeout(NO_READ_MS)
assert len(SETTLED_READS) == asked, "year: went and asked for the answer already on screen"
expect(tiles(page)).to_have_count(2) # and the pair's Junes never moved
print(" PASS: filter by year")
- rule
- a withdrawn guess costs only itself.
- example: 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")
- what
- a whole year is the smallest period
year:can name, though, and a hunt does not stop there.- what: a month, or a day, would still mean spelling the pair out twice with the same date in both.
- rule:
date:is the pair in one token at any granularity — a year, a year and a month, a year and a month and a day.- rule: each is snapped to the period’s edges, exactly its
since:=/=until:form. - example: one token says what the month and the year said together, then carries on where the pair runs out, down to the day itself.
- rule: each is snapped to the period’s edges, exactly its
narrow(page, "owner:konubinix", f"date:{HUNT_YEAR}-06") # the month and the year, in one token
expect(tiles(page)).to_have_count(2) # the same two the pair had left
narrow(page, "owner:konubinix", f"date:{HUNT_YEAR}-06-15") # and down to the day
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("alt", f"{HUNT_YEAR}-06-15") # the one asked for
print(" PASS: filter by date")
Search by label
- rule
- a label in the search box narrows the wall to the docs that carry it.
- how: In practice, the wall’s read tracks the query, so a new query re-reads with no manual wiring.
- how: the shared filter does the full-text search.
- how: every guess in a hunt is that one act repeated, so the narrowing is read there.
- what
- the query is still on the wall afterwards: reopening the app,
or relaunching the slideshow, comes back to the same question.
- how: that is read where the wall is reopened, beside the size it also keeps.
A query language in the search box
- what
- the search box wears the query language a hunt is made of.
- rule: its pieces are
;-separated, so a label keeps its spaces.- example:
parc des oiseaux,famille sam.
- example:
- scope: state stays on the state filter for now — folding it in would mean either retiring the filter or syncing the two, a separate decision.
- rule: its pieces are
- what
- what this heading adds is how the box carries it.
- what: completion works on the fragment under the caret.
- what: it offers the language’s tokens when you start one, and vocabulary labels otherwise.
- what: a pick ends with the separator for the next one.
- what: completion works on the fragment under the caret.
- what
- the free text — every segment that is not a token — is
a boolean search.
- therefore: its operators are Postgres’s, and arrive without our having to write any of them.
Where a bound falls
- rule
- an open stretch of dates ends today, and includes it: the default upper bound reaches the end of today, not its midnight.
search_for(page, WHEN_TODAY) # no until: token
expect(tiles(page)).to_have_count(1) # this morning's doc is inside it
print(" PASS: today docs shown by default")
- rule
- a bound can drop its year and name just the season you mean.
- example:
until:julyis the July just gone,until:07-14the last 14th of July,until:july-14the same in words. - how: two docs sit either side of one 14th of July, and each spelling separates them the same way — exactly, since both land on the identical instant.
- example:
- rule
- the year a bound takes is whichever one that date last came round in, so the token keeps meaning last
without ever being retyped.
- what: a July shows that only for the part of the year that July has already been.
- what: the 31st of December never has been, whatever day you ask on.
- therefore: a second pair straddling the last one is where that half is read.
for spelling in ("07-14", "july-14"):
search_for(page, WHEN_JULY)
expect(tiles(page)).to_have_count(2) # both, with nothing said about when
search_for(page, f"{WHEN_JULY}; until:{spelling}")
expect(tiles(page)).to_have_count(1) # only the 10th is inside the bound
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-before-t")
search_for(page, f"{WHEN_JULY}; since:july") # the whole of that July, both back
expect(tiles(page)).to_have_count(2)
search_for(page, WHEN_LAST)
expect(tiles(page)).to_have_count(2)
search_for(page, f"{WHEN_LAST}; until:12-31") # the one last gone, not the one to come
expect(tiles(page)).to_have_count(1)
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzwhen-yearago-t")
print(" PASS: bare month and day bounds")
This day in years past
- what
onthisdayis the anniversary view: today’s month-day ±1, or ±N withonthisday:N, across all years.- rule
onthisdayis decided by the server, year ignored and wrapping at the year boundary.- therefore: it spreads and bulk-edits like any other filter.
- rule: it composes with
since:=/=until:to bound the years.
search_for(page, f"{WHEN_ANNIV}; onthisday")
expect(tiles(page)).to_have_count(2) # today's day and the one after it
search_for(page, f"{WHEN_ANNIV}; onthisday:5")
expect(tiles(page)).to_have_count(3) # and the one five days out
print(" PASS: onthisday")
- how
- the three anniversaries are 2010, 2015 and 2018; bounds either side of 2015 keep only the years between.
search_for(page, f"{WHEN_ANNIV}; since:2014; onthisday:5")
expect(tiles(page)).to_have_count(2) # 2010 left behind the bound
search_for(page, f"{WHEN_ANNIV}; since:2014; until:2016; onthisday:5")
expect(tiles(page)).to_have_count(1) # and 2018 past the other
print(" PASS: onthisday within since and until")
- how
- a doc of its own sits just across the year end from today, reached only by wrapping round it.
- how: its window is cut two days short once, to show the reach is what lets it in.
t = datetime.date.today()
if t.month >= 7: there, reach = datetime.date(2012, 1, 2), (datetime.date(t.year + 1, 1, 2) - t).days
else: there, reach = datetime.date(2012, 12, 30), (t - datetime.date(t.year - 1, 12, 30)).days
wrap = {"cid": "https://ipfs.konubinix.eu/p/zzwrap", "date": there.isoformat() + "T12:00:00Z", "mimetype": "image/jpeg",
"thumbnailCid": "https://ipfs.konubinix.eu/p/zzwrap-t", "labels": "zzwrap", "state": "todo", "owner": "konubinix"}
gql(DELETE, {"cid": wrap["cid"]}); gql(CREATE, {"p": wrap})
try:
search_for(page, f"zzwrap; onthisday:{reach}")
expect(tiles(page)).to_have_count(1) # reached across the year end
search_for(page, f"zzwrap; onthisday:{reach - 2}")
expect(tiles(page)).to_have_count(0) # and only just
finally:
gql(DELETE, {"cid": wrap["cid"]})
print(" PASS: onthisday wraps the year end")
An order
- what
- an order is a guess of a sort too.
- rule
sort:dateis what you get without asking, oldest first.- rule
sort:randomhands the wall to the server’s own draw, one per doc.- rule:
sort:randomshuffles the wall. - how: the hunt’s docs carry draws of their own, the lowest of them on the latest date.
- therefore: asking for the shuffle brings up the doc the dates had put last.
- rule:
- rule
- the shuffle takes hold the moment it is typed, without being run.
- how: In practice, the order is the app’s own doing over docs it already holds, not a question the server is asked.
- therefore: it is the only token here that needs no commit.
- how: its check types into the box rather than committing through it.
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-0-t") # by date, oldest first
search_box(page).fill(hunt_query("sort:random"))
expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzhunt-2-t") # by the draw: the latest date
print(" PASS: sort random")
Letting the box do the typing
What the box offers
- what
- the words on the docs are your own, and you will not remember them.
- example: was it
baladeorrando? Was Élodie spelt with the accent?
- example: was it
- rule
- the box offers rather than waits.
- scope: the same holds for everything else that can go in the box: the language’s own
tokens — words the search itself understands, like
since:anduntil:—, the handful of values a token will take, the days of a year.
- scope: the same holds for everything else that can go in the box: the language’s own
tokens — words the search itself understands, like
- what
- there is one more vocabulary, and it is not the box’s at all: the names of the
occasions in the calendar, which nobody ever chose for the docs.
- what: they complete like the rest.
- how
- two of these vocabularies need setting up, and the tokens, the values a token will take and the days of a year do not — the box carries those with it or works them out.
The labels and the occasions
- how
- eight labels, covering four ways a label can be hard to reach and two properties
the list itself has to have.
- how: two are the shapes named above, a word with its distinctive part buried in the middle and a word spelt with a capital and an accent.
- how: a third is several words long, a label being allowed spaces.
- how: a fourth carries the beginning of one of the language’s own tokens inside it.
- therefore: a label and a token really do compete for the same fragment.
- how: the last four are there for the list: a pair, and two to be taken in turn.
VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
HALF_KNOWN = ["zzcosmo", "Zzélodie", "zzupa", "zzupb", "zzbalade", "zzalois",
"zzparc des oiseaux", "zzsinge"]
def seed_vocab(words):
for w in words: gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
def drop_vocab(words):
for w in words:
try: gql(VOCAB_DEL, {"w": w})
except Exception: pass
- how
- four occasions.
- how: these are put years ahead of today, in a stretch nobody has been to yet, where the only occasions are the ones placed here.
- what
- the list is scoped to the stretch of time the query is already about, and it comes
back eight rows at most.
- what: a real year of somebody’s calendar holds more than eight occasions.
- therefore: a window over a real year of somebody’s life fills all eight with their own occasions and leaves no room for the ones placed here.
- therefore: every guess at an occasion below begins by naming the window.
- how
- three of the four have a doc taken during them, and the fourth deliberately has
none.
- how: one of the three is named in several words.
- how
- the last sits two years further out with a doc of its own.
- therefore: what leaves it out of a list is the window and not the want of a doc.
EV_YEAR = datetime.date.today().year + 7 # far enough ahead that the calendar is empty there
EV_FAR = EV_YEAR + 2 # and outside any window naming EV_YEAR
EV_WINDOW = f"since:{EV_YEAR}; until:{EV_YEAR}; " # the stretch every guess below is about
EV_KERMESSE, EV_BROCANTE = "zzKermesse", "zzBrocante du Parc"
EV_PHOTOLESS, EV_ELSEWHERE = "zzSansPhoto", "zzHorsFenetre"
def _ev(row, summary, y, mo, has_photo=True):
e = {"rowId": row, "summary": summary, "owner": "konubinix", "status": "confirmed",
"starttime": f"{y}-{mo}-01T00:00:00Z", "endtime": f"{y}-{mo}-02T23:59:59Z"}
p = has_photo and {"cid": f"/ipfs/{row}-p", "date": f"{y}-{mo}-01T12:00:00Z",
"thumbnailCid": f"/ipfs/{row}-p-t", "owner": "konubinix",
"mimetype": "image/jpeg", "labels": "zzevp", "state": "todo"}
return e, p
OCCASIONS = [_ev("zzev-kerm", EV_KERMESSE, EV_YEAR, "06"),
_ev("zzev-broc", EV_BROCANTE, EV_YEAR, "07"),
_ev("zzev-none", EV_PHOTOLESS, EV_YEAR, "08", has_photo=False),
_ev("zzev-far", EV_ELSEWHERE, EV_FAR, "06")]
def seed_occasions():
seed_events([e for e, _ in OCCASIONS])
for _, p in OCCASIONS:
if p: gql(DELETE, {"cid": p["cid"]}); gql(CREATE, {"p": p})
def drop_occasions():
drop_events([e for e, _ in OCCASIONS])
for _, p in OCCASIONS:
if p:
try: gql(DELETE, {"cid": p["cid"]})
except Exception: pass
Typing a guess
- what
- a guess that comes to nothing is rubbed out and another tried.
- therefore: nearly every step below starts its guess over rather than adding to it.
- how: the one that does not is the one asking whether the list survives a word being
accepted.
- what: that question only exists if you carry straight on from the word you accepted.
- how
- in practice, a guess begins by waiting for the box to be live.
- what: while a committed search is still being answered, the controls that build the query are locked, the box among them.
- what: a locked box still takes the letters but drops the arrows and the
Enterthat follow.- what: that reads afterwards as an offer that refused to go in for no reason at all.
- how: nothing in this story commits a search, but whatever ran before it might have.
- therefore: the wait belongs at the start of every guess rather than at the end of something else.
def type_into(page, fragment):
"""Start the term over and type it in, keystroke by keystroke as a hand would."""
sb = search_box(page)
expect(sb).to_be_enabled()
sb.fill(""); sb.click()
sb.press_sequentially(fragment, delay=20)
def guess(page, fragment):
type_into(page, fragment)
expect(options(page).first).to_be_visible()
Completing labels
- what
- as you type a label, the box offers rather than waits: it offers the labels already in use — the vocabulary.
- what
- the vocabulary and the query completing a fragment against it are not this app’s.
- how: they live in the frise’s labels schema; this app only asks the shared
LABEL_COMPLETIONSquery the frise asks too. - rule: below two characters the list offers nothing.
- how: with the vocabulary held unanswered, one character leaves no
completing…row behind — nothing was even asked — while a second one brings it up.
- how: with the vocabulary held unanswered, one character leaves no
- how: they live in the frise’s labels schema; this app only asks the shared
sb.fill(""); sb.click(); sb.press_sequentially("a", delay=20) # one character, the vocabulary still held
expect(page.get_by_text("completing")).to_have_count(0) # nothing asked, so nothing pending
expect(options(page)).to_have_count(0) # and nothing offered
sb.press_sequentially("u", delay=20) # a second character: now it asks
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: one character offers nothing")
- rule
- the list completes the fragment under the caret, not the whole field.
- how: the search box splits on
;, its query being;-separated terms. - how: an add-label box completes the segment after the last
;, its content being a;-separated list. - example:
balade;aureoffersaurelie, and a pick swaps just that segment.
- how: the search box splits on
- rule
- completion never proposes a label the doc already wears.
- how: a box that edits a known doc hands the list the labels already on it, and the list drops them.
- rule
- the add-label boxes stay labels-only: a doc takes a label, not an occasion.
- what
- only one box has the focus at a time.
- therefore: a single highlight, shared by all the boxes, suffices.
- how: each box reports its current list to the app and reads the shared highlight back.
- rule: in the lightbox and in the slideshow,
←=/=→step the wall only when no field has focus: inside the add-label box the arrows belong to the text.
- rule
Escapecloses an open list and nothing else: the text and the focus stay, and typing on brings the list back.- how: each box hands the keys it shares with the others the way to close its own list; the key goes no further, so neither the search box’s own clearing nor the lightbox or the slideshow hears it.
guess(page, "zzbal")
sb.press("Escape")
expect(options(page)).to_have_count(0) # the list is gone…
expect(sb).to_be_focused() # …the box keeps the hand…
expect(sb).to_have_value("zzbal") # …and the words typed
sb.press("Backspace") # typing on
expect(options(page).first).to_be_visible() # brings it back
print(" PASS: escape closes completion")
else if(e.key === 'Escape' && close && (n || sugLoading())){
e.preventDefault(); e.stopPropagation(); close(); reportSug([]); setSugLoading(false); }
A pick finishes the term
-
Which pick closes the term
what : a term is one piece of a box’s text, the pieces being separated by
;.rule : a word accepted ends with the separator for the next one: pick the first offer for a fragment of a known label, and the box holds exactly that word before 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")what : in the search box a term may also be a token of the query language, such as
since:,owner:orevent:, then its value.rule : the
;follows any pick that completes a term — a label, a full date, atype:orowner:value, an occasion — in the search box and the add-label boxes alike.rule : a pick that still has somewhere to go keeps the box on that term, with no
;. - example: a bare token (since:,event:) about to take a value. - example: a partial date, to be drilled from year to month to day. - how: which it is is not wired term by term: the box asks the list whether the picked word still has a longer completion to offer. - example:owneroffers the token, then its values, andowner: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() # a closed-vocab value is done — a ';' opens the next token expect(sb).to_have_value("owner:konubinix; ") print(" PASS: owner token completes")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); };
-
A label with spaces
what : a label may hold spaces, its distinctive word often the last. - rule: the fragment is matched against the whole label. - rule: a pick puts the whole label in the box, replacing what was typed rather than appended to it. - rule: it is one word as far as the box is concerned, and finishes like one.
guess(page, "parc des oise") # part of a label that has spaces opt = options(page).filter(has_text="oiseaux").first expect(opt).to_be_visible() whole = opt.inner_text().strip() # the label as it is written down opt.click() expect(sb).to_have_value(whole + "; ") # all of it, and not twice over print(" PASS: a label with spaces completes as one word")
-
The term under the caret
rule : completion works on the term under the caret: put the caret back inside an earlier term, and that term is the one completed and replaced.
year = datetime.date.today().year sb.fill(f"until:; since:{year}") # two date terms, the later one filled in sb.click(); sb.press("Home") for _ in range(len("until:")): sb.press("ArrowRight") # back into the first, just past its colon expect(options(page).get_by_text(f"until:{year}", exact=True)).to_be_visible() expect(options(page).get_by_text(f"since:{year}", exact=True)).to_have_count(0) print(" PASS: the list follows the caret, not the line")
-
Asking the vocabularies
how : the app asks that query for eight words at most, and does not ask at all for a shorter fragment.
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 ?? []; };how : one function reads what the fragment under the caret is reaching for — a token, a token’s value, a date, an occasion or a label — and asks the vocabularies that can answer it. - how: the search box mixes at most five labels and three occasions.
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)]; }how : the list offers words for the live text and hands the picked one back to the box it sits under. - how: 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. - how: Under the hood, the pick cancels the press that carried it, so pointing at the list never moves the focus off the box; the list closes because its segment is finished, not because anything was blurred.
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; 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 query language’s own words
- rule
- where a fragment could be a token or a label, the list does not offer both: a fragment that begins a
token offers that token and nothing else, even when a label would have matched it too.
- how: the vocabulary seeded for the check holds a label carrying the same fragment.
guess(page, "sin") # begins "since:", and sits inside a label
expect(options(page)).to_have_text(["since:"]) # the key, alone
options(page).first.click()
expect(sb).to_have_value("since:") # a key still wants its value
print(" PASS: a key prefix offers the key and nothing else")
- rule
- a closed vocabulary small enough to show whole is shown whole, rather than waiting to be narrowed.
- example:
type:has two values, and offers both.
- example:
guess(page, "type:")
expect(options(page)).to_have_text(["type:image", "type:video"])
print(" PASS: a two-value token shows both")
- rule
- a date is tapped, not typed: an open
since:offers the years, a chosen year its months, a chosen month its days — the date drill, which turns a range into a few taps.- rule: a part of a date has further to go, so picking it leaves the term open.
- rule: a day has not, so picking it closes the term, and the list stands empty for whatever comes next.
year = datetime.date.today().year - 1 # inside the span the picker offers
guess(page, "since:")
pick = lambda text: options(page).get_by_text(text, exact=True)
expect(pick(f"since:{year}")).to_be_visible() # the years
pick(f"since:{year}").click()
expect(sb).to_have_value(f"since:{year}") # a year has further to go — still open
expect(pick(f"since:{year}-06")).to_be_visible() # …and what it goes to is months
pick(f"since:{year}-06").click()
expect(pick(f"since:{year}-06-15")).to_be_visible() # then days
pick(f"since:{year}-06-15").click()
expect(sb).to_have_value(f"since:{year}-06-15; ") # a whole date is done, like any finished word
expect(options(page)).to_have_count(0) # and the list has nothing left to say
print(" PASS: a date is tapped, not typed")
- rule
- the date drill only offers a date that could be tapped — a month is one to twelve, a day one to that
month’s last.
- therefore: a figure naming none of them leaves it silent rather than inventive.
- how: each impossible figure is typed after a possible one that differs from it by a digit: the same term, the same typing, the list filling for one and not the other.
for possible, impossible in [(f"since:{year}-1", f"since:{year}-0"), # no 0th month
(f"since:{year}-12", f"since:{year}-13"), # nor a 13th
(f"since:{year}-06-1", f"since:{year}-06-0"), # no 0th day
(f"since:{year}-06-30", f"since:{year}-06-31")]: # nor a 31st of June
guess(page, possible) # the list fills for the one that exists…
type_into(page, impossible)
expect(options(page)).to_have_count(0) # …and stays away for the one that doesn't
print(" PASS: the picker offers only dates that exist")
- rule
date:is the same date drill reached by a different word.
guess(page, "date") # 2+ chars → the key is offered
expect(options(page).filter(has_text="date:").first).to_be_visible()
sb.press_sequentially(f":{year}", delay=20)
expect(options(page).get_by_text(f"date:{year}-06", exact=True)).to_be_visible() # drilling, as before
print(" PASS: date: drills the same picker")
- what
- the rolling bounds are named:
lastweek,lastmonth,lastyear.- rule: the part they share is enough to bring all three up.
- rule: one of them is a whole bound on its own, with nothing further to drill into, so picking it closes the term like any other finished value.
guess(page, "since:last") # a bound can be a word rather than a date
expect(options(page)).to_have_text(["since:lastweek", "since:lastmonth", "since:lastyear"])
options(page).last.click()
expect(sb).to_have_value("since:lastyear; ") # a whole bound in one word — done
print(" PASS: a rolling bound completes from its prefix")
Occasions in the box
- what
- the occasions are a vocabulary like the labels, except that nobody typed them here: they are the
calendar’s, written by whoever set the meeting up.
- rule: inside
event:, a fragment offers the occasions’ names, and picking one puts it in the calendar’s spelling and closes the term.
- rule: inside
guess(page, EV_WINDOW + "event:zzkerm") # a prefix of an occasion's name
opt = options(page).filter(has_text=EV_KERMESSE).first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # the calendar's spelling, and done
print(" PASS: an occasion completes from the calendar")
- rule
- the pieces of a fragment are matched separately and anywhere in an occasion’s name, rather than as one
run from its start — in either order.
- how: the app sends the segment as typed, and would look the same however the words were matched at the other end.
- therefore: this behaviour is the occasion query’s to keep or lose.
for pieces in ("brocante parc", "parc brocante"): # either order finds it
guess(page, EV_WINDOW + f"event:{pieces}")
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: an occasion's name matches in pieces, any order")
- rule
- a bare word is put to the calendar as well as to the labels, and an occasion it matches comes back
already wearing
event:.- therefore: you never have to know the token exists to use it.
guess(page, EV_WINDOW + "zzkerm") # no token typed, and none needed
opt = options(page).filter(has_text=f"event:{EV_KERMESSE}").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # handed back as the token
print(" PASS: a bare word offers an occasion as a token")
- what
- the calendar is far larger than the docs, and mostly about afternoons no camera came to.
- rule
- an occasion is offered only if picking it would show a doc.
- rule: what is offered is held to the window too — the stretch of time the query already names.
guess(page, EV_WINDOW + "event:zz")
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_ELSEWHERE)).to_have_count(0) # has a doc; wrong window
expect(options(page).filter(has_text=EV_PHOTOLESS)).to_have_count(0) # right window; no doc
print(" PASS: only occasions worth picking are offered")
- how
- the occasions come from a query of their own, eight at most, and handed the window.
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 ?? [];
};
- rule
- with nothing typed after
event:, the list is the whole of what the window holds — a way of browsing the occasions, not only of matching one.
guess(page, EV_WINDOW + "event:") # the bare key, nothing typed
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: the bare key lists the window's occasions")
Driving the list from the keys
- rule
- completion works without the hand leaving the keys:
↑=/=↓move a highlight, andEnterapplies the highlighted word.- rule:
Enterwith nothing highlighted runs the box’s own action instead.- example: in an add-label box it adds the typed labels and
Shift+Enterremoves them — the keyboard twins of the+/-buttons. - example: in the search box it just leaves the live query be.
- example: in an add-label box it adds the typed labels and
- rule:
Entertakes the word the highlight is on, which is only the first word when you stopped there: walking past the first and pressing it brings back the second.
- rule:
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
wait_until(page, lambda: page.evaluate(HIGHLIGHTED) == offered[1],
label="two downs highlight the second suggestion")
sb.press("Enter") # apply the highlighted one
expect(sb).to_have_value(offered[1] + "; ") # the second
print(" PASS: search suggestion keyboard")
- rule
↑reaches the list from the other end: with nothing highlighted it enters at the bottom, on the last offer.
guess(page, "zzup") # two words
items = [t.strip() for t in options(page).all_inner_texts()]
assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
sb.press("ArrowUp") # from nothing selected — must enter at the bottom
wait_until(page, lambda: page.evaluate(HIGHLIGHTED) == "zzupb",
label="ArrowUp from none highlights the last suggestion",
detail=lambda: f"offered {options(page).all_inner_texts()}, "
f"highlighted {page.evaluate(HIGHLIGHTED)!r}, box {sb.input_value()!r}")
print(" PASS: suggestion up enters from none")
// keyboard-driving the suggestion list of the focused box
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, close) => {
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); }
else if(e.key === 'Escape' && close && (n || sugLoading())){
e.preventDefault(); e.stopPropagation(); close(); reportSug([]); setSugLoading(false); }
};
- how
- In practice the highlight is read out of the page in one go: a completion answering for letters typed a
moment ago re-renders the list, so a row asked first for its count and then for its text can be gone.
- how: the wait carries what it wanted and what it found.
HIGHLIGHTED = """() => { const o = document.querySelector(
'[role=listbox][aria-label=suggestions] [role=option][aria-selected=true]');
return o ? o.textContent.trim() : null; }"""
- rule
- the highlighted offer stays in view however long the list runs.
sb.fill(""); sb.click() # empty box → all the DSL keys, taller than the popover
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
assert listbox.evaluate("el => el.scrollHeight > el.clientHeight + 4"), \
"the menu must overflow the popover for scrolling to matter"
for _ in range(options(page).count()): sb.press("ArrowDown") # walk down to the last key
active = page.get_by_role("option", selected=True)
expect(active).to_have_count(1)
assert active.evaluate("""el => { const b = el.getBoundingClientRect(),
l = el.closest('[role=listbox]').getBoundingClientRect();
return b.top >= l.top - 1 && b.bottom <= l.bottom + 1; }"""), \
"the highlighted option is not within the visible completion area"
print(" PASS: completion keeps highlight in view")
- how
- the list watches the highlight and scrolls the row it lands on the least distance into view.
let listEl;
createEffect(() => { const i = active();
if(i >= 0 && listEl) listEl.querySelectorAll('[role=option]')[i]?.scrollIntoView({ block: 'nearest' }); });
- rule
- a pick never costs the box its focus: it leaves the box on a fresh, empty segment, the caret where you were typing and the list blank, without asking you to click out and back in.
- rule
- the list of offers comes back the moment you carry on typing.
- how: In practice the check types at the page rather than at the box: aimed at the box, a keystroke would focus it on the way in; typed at the page, the keys go wherever focus is, and nowhere if the pick took it.
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")
What a fragment matches
- rule
- a fragment may sit in the middle of the word: a mid-word
smosurfacescosmo.- how: the app hands the fragment over whole, and the vocabulary query is what says a match may begin anywhere.
- therefore: this promise is the server’s: no change to the code here can take it away, and the way to break it is to narrow that query, which lives in another note.
guess(page, "osmo") # sits inside zzcosmo, nowhere near its start
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
print(" PASS: completion matches inside label")
- rule
- a fragment may come without the capital or the accent: the match folds both, while the list
offers the label exactly as written.
- example: a bare
zzelodsurfacesZzélodie, its capital and accent kept.
- example: a bare
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 list on screen
-
While the server thinks
what : the vocabulary sits behind a GraphQL round-trip, a noticeable beat on a cold server.
rule : the list says so while offers are on their way: it opens while the query is still in flight, not only once it has results, and shows a pulsing
completing…row.hold_completions(page) # the vocabulary query, left unanswered sb.fill(""); sb.click(); sb.press_sequentially("au", delay=20) expect(page.get_by_text("completing")).to_be_visible() print(" PASS: completion shows in-flight hint")how : the list draws that row whenever the vocabulary has yet to answer, beside any words it already holds.
<${Show} when=${() => items.loading}> <li class="sug-loading" aria-disabled="true">completing…</li> <//>rule : the words replace that row the moment they arrive. - rule: on a refine, the previous segment’s list stays underneath, so the panel never blanks while it catches up. - how: the vocabulary is held, then let go, under a fragment, then under one letter more.
held = [] def hold(route): if "labelCompletions" in (route.request.post_data or ""): held.append(route) else: route.fallback() def let_go(): while held: held.pop(0).fallback() def asked(word): # the fragment's question has reached the hold wait_until(page, lambda: any(f'"prefix":"{word}"' in (r.request.post_data or "") for r in held), label=f"the vocabulary was asked for {word!r}") page.route("**/graphql", hold) try: sb.fill(""); sb.click(); sb.press_sequentially("zzcos", delay=20) asked("zzcos") # asked, not yet answered let_go() expect(options(page).filter(has_text="zzcosmo").first).to_be_visible() expect(page.get_by_text("completing")).to_have_count(0) # the words took its place print(" PASS: the completing row gives way to the words") sb.press_sequentially("m", delay=20) # a refine, held in its turn asked("zzcosm") expect(options(page).filter(has_text="zzcosmo").first).to_be_visible() # the old offer still there print(" PASS: old offers stay under a refining word") finally: page.unroute("**/graphql", hold); let_go()rule : a list that is only thinking is up, and the box says so while it thinks: the one place reporting whether the list is showing never announces
collapsedover a list plainly on screen. - rule: a screen reader is told what a sighted reader sees. - how: thecompleting…row is still on screen from just above, the query unanswered for good.expect(sb).to_have_attribute("aria-expanded", "true") print(" PASS: search expanded while loading")
-
Finding the language
rule : the
year:token completes like the others: typing it offers the token, then its years.guess(page, "year") # 2+ chars → the key is offered expect(options(page).filter(has_text="year:").first).to_be_visible() sb.press_sequentially(":202", delay=20) # year:202 → its year values expect(options(page).filter(has_text=re.compile(r"year:202\d")).first).to_be_visible() print(" PASS: year token completes")rule : the query language can be found from the box itself: the empty search box, the moment it takes focus, drops down the whole menu of tokens — every prefix at once.
sb.fill(""); sb.click() # empty box, just focused — no typing expect(options(page)).to_have_count(14) # the whole menu, at once got = sorted(x.strip() for x in options(page).all_inner_texts()) assert got == sorted(["since:", "until:", "type:", "sort:", "owner:", "month:", "day:", "year:", "date:", "event:", "first:", "last:", "sample:", "onthisday"]), got # every prefix present (order-free) print(" PASS: empty box suggests all prefixes")
-
Open and closed, said aloud
what : the box and its list form a combobox: the box says so, and its
aria-expandedreadstruewhenever the list is up.rule : closing is a state flip, not a timed fade: blur the box and the list is gone at once.
expect(sb).to_have_attribute("role", "combobox") # the box says what it is… sb.fill(""); sb.click(); sb.press_sequentially(HALF_KNOWN[0][:4]) expect(sb).to_have_attribute("aria-expanded", "true") # …and open popover → state true sb.blur() expect(sb).to_have_attribute("aria-expanded", "false") # closed → state false (what search_for waits on) assert page.get_by_role("listbox", name="suggestions").count() == 0, \ "popover must be gone the instant the state flips" print(" PASS: search completion state, no timing")rule : reporting itself open while it thinks is not the search box’s own arrangement: the three add-label boxes — the lightbox’s, the slideshow’s and the toolbar’s — each do it on a query that has not answered. - how: each is caught on its first word: after one list has arrived, it stays put while the next is fetched.
example : the lightbox’s, where somebody is labelling a doc, the vocabulary held quiet under the first thing they type.
hold_completions(page) # the vocabulary, left unanswered expanded_while_loading(page, box) print(" PASS: lightbox label expanded while loading")example : the slideshow’s, on the tablet, where the bar has just been woken by a passer-by.
hold_completions(page) expanded_while_loading(page, bar.get_by_placeholder("add a label…")) print(" PASS: frame label expanded while loading")example : the toolbar’s, where somebody is labelling a run.
select_all(page).click() hold_completions(page) expanded_while_loading(page, tb.get_by_placeholder("add a label…")) print(" PASS: batch label expanded while loading")
Back, while the list is open
-
Back asks before leaving
what : the stack Back unwinds bottoms out at the wall; one more Back would leave the app entirely — easy to do by accident on the cabinet tablet.
how : a root history entry sits under the wall.
rule : going back asks before it leaves the app: confirm and the app is left, cancel and the root is re-pushed so the wall stays put.
page.go_back() wait_until(page, lambda: len(asked) == 1, label="going back with nothing up asks first") expect(grid(page)).to_be_visible() # cancelling kept us in the app print(" PASS: back at grid asks before exit")what : 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. - what: retracting the last thing that appeared is what the button is for.
rule : going back closes the open completion list first: only a Back with nothing showing reaches the leave prompt.
page.go_back() # Back retracts the list — it must not leave the app expect(options(page)).to_have_count(0) # the list is gone expect(sb).to_be_visible() # and we're still in Memories assert len(asked) == 1, f"retracting the list should have asked nothing: {asked}" print(" PASS: back closes completion")rule : retracting the list does not spend the root guard itself: a further Back, now with nothing showing, still meets that prompt.
page.go_back() # a further Back: must reach the leave guard wait_until(page, lambda: len(asked) == 2, label="the guard was still there to meet") expect(sb).to_be_visible() # cancelled → still in the app print(" PASS: back after completion still guards exit")
-
The guard lets go when told
rule : going back leaves when told to: say yes and the app lets go, back to whatever the tab held before it.
leaving.append(True) # this time the prompt is answered yes page.go_back() wait_until(page, lambda: heading(page).count() == 0, label="the app let go of the tab", detail=lambda: f"still showing {page.url}") print(" PASS: back confirmed leaves")how : In practice the guard reads whether the list is up straight from the page — the rendered list being there — rather than from a state flag. - what: a flag holding the last-offered words keeps 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 // bail out of a frozen read; stay if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; } // retract the list; keep the root beneath if(document.querySelector('.suggest')){ setSearchFocus(false); history.pushState({ app: true }, ''); return; } 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 whole story
- how
- the story walked end to end, each step read in the order the headings above give it.
@testcase
def test_letting_the_box_do_the_typing(page):
"""Building a query out of what the box knows better than the hand: a word remembered
only in the middle, one whose spelling is a guess, the language's own keys, a date
nobody wants to spell, and the name of an occasion nobody here chose — with the list
staying alongside throughout, reachable by the keys however long it runs, and saying so
even while it is still thinking — and then backing out of it all, one rung at a time,
the last rung asking before it lets go, and letting go when it is told to."""
seed_vocab(HALF_KNOWN)
seed_occasions()
try:
open_app(page)
sb = search_box(page)
guess(page, "osmo") # sits inside zzcosmo, nowhere near its start
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
print(" PASS: completion matches inside label")
guess(page, "zzelod") # no capital, no accent
texts = [t.strip() for t in options(page).all_inner_texts()]
assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
options(page).filter(has_text="Zzélodie").first.click()
expect(sb).to_have_value("Zzélodie; ")
print(" PASS: completion preserves case and accent")
guess(page, "zzup") # two words
items = [t.strip() for t in options(page).all_inner_texts()]
assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
sb.press("ArrowUp") # from nothing selected — must enter at the bottom
wait_until(page, lambda: page.evaluate(HIGHLIGHTED) == "zzupb",
label="ArrowUp from none highlights the last suggestion",
detail=lambda: f"offered {options(page).all_inner_texts()}, "
f"highlighted {page.evaluate(HIGHLIGHTED)!r}, box {sb.input_value()!r}")
print(" PASS: suggestion up enters from none")
guess(page, "zzbal") # a fragment of one word we know
word = options(page).first.inner_text().strip()
options(page).first.click()
expect(sb).to_have_value(word + "; ") # the picked label, then a ';' for the next
print(" PASS: label completion")
page.keyboard.type("zzalo", delay=20) # straight on, into whatever holds the keys
expect(options(page).filter(has_text="zzalois").first).to_be_visible() # must reappear
print(" PASS: completion reopens after pick")
guess(page, "zzbal")
sb.press("Escape")
expect(options(page)).to_have_count(0) # the list is gone…
expect(sb).to_be_focused() # …the box keeps the hand…
expect(sb).to_have_value("zzbal") # …and the words typed
sb.press("Backspace") # typing on
expect(options(page).first).to_be_visible() # brings it back
print(" PASS: escape closes completion")
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
wait_until(page, lambda: page.evaluate(HIGHLIGHTED) == offered[1],
label="two downs highlight the second suggestion")
sb.press("Enter") # apply the highlighted one
expect(sb).to_have_value(offered[1] + "; ") # the second
print(" PASS: search suggestion keyboard")
guess(page, "parc des oise") # part of a label that has spaces
opt = options(page).filter(has_text="oiseaux").first
expect(opt).to_be_visible()
whole = opt.inner_text().strip() # the label as it is written down
opt.click()
expect(sb).to_have_value(whole + "; ") # all of it, and not twice over
print(" PASS: a label with spaces completes as one word")
year = datetime.date.today().year
sb.fill(f"until:; since:{year}") # two date terms, the later one filled in
sb.click(); sb.press("Home")
for _ in range(len("until:")): sb.press("ArrowRight") # back into the first, just past its colon
expect(options(page).get_by_text(f"until:{year}", exact=True)).to_be_visible()
expect(options(page).get_by_text(f"since:{year}", exact=True)).to_have_count(0)
print(" PASS: the list follows the caret, not the line")
guess(page, "sin") # begins "since:", and sits inside a label
expect(options(page)).to_have_text(["since:"]) # the key, alone
options(page).first.click()
expect(sb).to_have_value("since:") # a key still wants its value
print(" PASS: a key prefix offers the key and nothing else")
guess(page, "type:")
expect(options(page)).to_have_text(["type:image", "type:video"])
print(" PASS: a two-value token shows both")
year = datetime.date.today().year - 1 # inside the span the picker offers
guess(page, "since:")
pick = lambda text: options(page).get_by_text(text, exact=True)
expect(pick(f"since:{year}")).to_be_visible() # the years
pick(f"since:{year}").click()
expect(sb).to_have_value(f"since:{year}") # a year has further to go — still open
expect(pick(f"since:{year}-06")).to_be_visible() # …and what it goes to is months
pick(f"since:{year}-06").click()
expect(pick(f"since:{year}-06-15")).to_be_visible() # then days
pick(f"since:{year}-06-15").click()
expect(sb).to_have_value(f"since:{year}-06-15; ") # a whole date is done, like any finished word
expect(options(page)).to_have_count(0) # and the list has nothing left to say
print(" PASS: a date is tapped, not typed")
for possible, impossible in [(f"since:{year}-1", f"since:{year}-0"), # no 0th month
(f"since:{year}-12", f"since:{year}-13"), # nor a 13th
(f"since:{year}-06-1", f"since:{year}-06-0"), # no 0th day
(f"since:{year}-06-30", f"since:{year}-06-31")]: # nor a 31st of June
guess(page, possible) # the list fills for the one that exists…
type_into(page, impossible)
expect(options(page)).to_have_count(0) # …and stays away for the one that doesn't
print(" PASS: the picker offers only dates that exist")
guess(page, "date") # 2+ chars → the key is offered
expect(options(page).filter(has_text="date:").first).to_be_visible()
sb.press_sequentially(f":{year}", delay=20)
expect(options(page).get_by_text(f"date:{year}-06", exact=True)).to_be_visible() # drilling, as before
print(" PASS: date: drills the same picker")
guess(page, "owner") # 2+ chars → the key is offered
expect(options(page).filter(has_text="owner:").first).to_be_visible()
sb.press_sequentially(":k", delay=20) # owner:k → its values
opt = options(page).filter(has_text="konubinix").first
expect(opt).to_be_visible()
opt.click()
# a closed-vocab value is done — a ';' opens the next token
expect(sb).to_have_value("owner:konubinix; ")
print(" PASS: owner token completes")
guess(page, "since:last") # a bound can be a word rather than a date
expect(options(page)).to_have_text(["since:lastweek", "since:lastmonth", "since:lastyear"])
options(page).last.click()
expect(sb).to_have_value("since:lastyear; ") # a whole bound in one word — done
print(" PASS: a rolling bound completes from its prefix")
guess(page, EV_WINDOW + "event:zzkerm") # a prefix of an occasion's name
opt = options(page).filter(has_text=EV_KERMESSE).first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # the calendar's spelling, and done
print(" PASS: an occasion completes from the calendar")
for pieces in ("brocante parc", "parc brocante"): # either order finds it
guess(page, EV_WINDOW + f"event:{pieces}")
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: an occasion's name matches in pieces, any order")
guess(page, EV_WINDOW + "zzkerm") # no token typed, and none needed
opt = options(page).filter(has_text=f"event:{EV_KERMESSE}").first
expect(opt).to_be_visible()
opt.click()
expect(sb).to_have_value(EV_WINDOW + f"event:{EV_KERMESSE}; ") # handed back as the token
print(" PASS: a bare word offers an occasion as a token")
guess(page, EV_WINDOW + "event:zz")
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_ELSEWHERE)).to_have_count(0) # has a doc; wrong window
expect(options(page).filter(has_text=EV_PHOTOLESS)).to_have_count(0) # right window; no doc
print(" PASS: only occasions worth picking are offered")
guess(page, EV_WINDOW + "event:") # the bare key, nothing typed
expect(options(page).filter(has_text=EV_KERMESSE).first).to_be_visible()
expect(options(page).filter(has_text=EV_BROCANTE).first).to_be_visible()
print(" PASS: the bare key lists the window's occasions")
guess(page, "year") # 2+ chars → the key is offered
expect(options(page).filter(has_text="year:").first).to_be_visible()
sb.press_sequentially(":202", delay=20) # year:202 → its year values
expect(options(page).filter(has_text=re.compile(r"year:202\d")).first).to_be_visible()
print(" PASS: year token completes")
sb.fill(""); sb.click() # empty box, just focused — no typing
expect(options(page)).to_have_count(14) # the whole menu, at once
got = sorted(x.strip() for x in options(page).all_inner_texts())
assert got == sorted(["since:", "until:", "type:", "sort:", "owner:", "month:", "day:",
"year:", "date:", "event:", "first:", "last:", "sample:",
"onthisday"]), got # every prefix present (order-free)
print(" PASS: empty box suggests all prefixes")
sb.fill(""); sb.click() # empty box → all the DSL keys, taller than the popover
listbox = page.get_by_role("listbox", name="suggestions")
expect(listbox).to_be_visible()
assert listbox.evaluate("el => el.scrollHeight > el.clientHeight + 4"), \
"the menu must overflow the popover for scrolling to matter"
for _ in range(options(page).count()): sb.press("ArrowDown") # walk down to the last key
active = page.get_by_role("option", selected=True)
expect(active).to_have_count(1)
assert active.evaluate("""el => { const b = el.getBoundingClientRect(),
l = el.closest('[role=listbox]').getBoundingClientRect();
return b.top >= l.top - 1 && b.bottom <= l.bottom + 1; }"""), \
"the highlighted option is not within the visible completion area"
print(" PASS: completion keeps highlight in view")
expect(sb).to_have_attribute("role", "combobox") # the box says what it is…
sb.fill(""); sb.click(); sb.press_sequentially(HALF_KNOWN[0][:4])
expect(sb).to_have_attribute("aria-expanded", "true") # …and open popover → state true
sb.blur()
expect(sb).to_have_attribute("aria-expanded", "false") # closed → state false (what search_for waits on)
assert page.get_by_role("listbox", name="suggestions").count() == 0, \
"popover must be gone the instant the state flips"
print(" PASS: search completion state, no timing")
held = []
def hold(route):
if "labelCompletions" in (route.request.post_data or ""): held.append(route)
else: route.fallback()
def let_go():
while held: held.pop(0).fallback()
def asked(word): # the fragment's question has reached the hold
wait_until(page, lambda: any(f'"prefix":"{word}"' in (r.request.post_data or "") for r in held),
label=f"the vocabulary was asked for {word!r}")
page.route("**/graphql", hold)
try:
sb.fill(""); sb.click(); sb.press_sequentially("zzcos", delay=20)
asked("zzcos") # asked, not yet answered
let_go()
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible()
expect(page.get_by_text("completing")).to_have_count(0) # the words took its place
print(" PASS: the completing row gives way to the words")
sb.press_sequentially("m", delay=20) # a refine, held in its turn
asked("zzcosm")
expect(options(page).filter(has_text="zzcosmo").first).to_be_visible() # the old offer still there
print(" PASS: old offers stay under a refining word")
finally:
page.unroute("**/graphql", hold); let_go()
hold_completions(page) # the vocabulary query, left unanswered
sb.fill(""); sb.click(); sb.press_sequentially("au", delay=20)
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: completion shows in-flight hint")
sb.fill(""); sb.click(); sb.press_sequentially("a", delay=20) # one character, the vocabulary still held
expect(page.get_by_text("completing")).to_have_count(0) # nothing asked, so nothing pending
expect(options(page)).to_have_count(0) # and nothing offered
sb.press_sequentially("u", delay=20) # a second character: now it asks
expect(page.get_by_text("completing")).to_be_visible()
print(" PASS: one character offers nothing")
expect(sb).to_have_attribute("aria-expanded", "true")
print(" PASS: search expanded while loading")
# and the way out, which is a ladder rather than a door
page.unroute("**/graphql") # the vocabulary answers again
asked, leaving = [], [] # answered no until the last reading says otherwise
page.on("dialog", lambda dlg: (asked.append(dlg.message),
dlg.accept() if leaving else dlg.dismiss()))
sb.fill(""); sb.blur() # nothing typed, nothing showing
expect(options(page)).to_have_count(0)
page.go_back()
wait_until(page, lambda: len(asked) == 1, label="going back with nothing up asks first")
expect(grid(page)).to_be_visible() # cancelling kept us in the app
print(" PASS: back at grid asks before exit")
sb.click() # the empty box drops its menu again
expect(options(page).first).to_be_visible()
page.go_back() # Back retracts the list — it must not leave the app
expect(options(page)).to_have_count(0) # the list is gone
expect(sb).to_be_visible() # and we're still in Memories
assert len(asked) == 1, f"retracting the list should have asked nothing: {asked}"
print(" PASS: back closes completion")
page.go_back() # a further Back: must reach the leave guard
wait_until(page, lambda: len(asked) == 2, label="the guard was still there to meet")
expect(sb).to_be_visible() # cancelled → still in the app
print(" PASS: back after completion still guards exit")
leaving.append(True) # this time the prompt is answered yes
page.go_back()
wait_until(page, lambda: heading(page).count() == 0,
label="the app let go of the tab",
detail=lambda: f"still showing {page.url}")
print(" PASS: back confirmed leaves")
finally:
drop_occasions()
drop_vocab(HALF_KNOWN)
Working the wall without the mouse
- what
- the keys add no gesture: each is one the mouse already has.
- how
- the story wants a wall too big for the window — enough docs for several rows, on a screen short enough that the last of them sits below the fold and behind the toolbar that picking brings up.
KEY_N = 24
def key_docs():
return [{"cid": f"https://ipfs.konubinix.eu/p/zzkey-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzkey-t-{i}",
"labels": "zzkey", "state": "todo"} for i in range(KEY_N)]
def key_thumb(i):
return f"https://ipfs.konubinix.eu/p/zzkey-t-{i}"
FIRST_WORD, SECOND_WORD = "zzkeyfirst", "zzkeysecond"
- how
- the last of the story leaves the wall for the two surfaces that work one doc
or video at a time — the lightbox and the slideshow — and
comes back to it each time.
- how: before each trip, it puts the wall back where it started, with the last tile out of sight again.
- what
- a slide arrives under the eye before the slideshow has decided it has arrived.
- how: the picture is centred while the step is still settling, and only the settling hands the slide on to anything outside the strip — the slideshow’s row of slides.
- therefore: leaving the slideshow is the other thing that has to be timed.
- what: the slideshow calls a step over once the settle has fired.
- therefore: a quarter of a second of stillness is past it.
- how: stillness is read from the strip’s own scroll position, held rather than merely sampled.
def back_to_the_top(page):
"""Send the wall back to the top, and wait until its far tile is out of sight again."""
page.evaluate("() => scrollTo(0, 0)")
wait_until(page, lambda: not tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the far tile is below the fold, so a scroll to it would show")
STILL_MS = 250 # past the show's own settle, whoever is watching
def show_at_rest(page, strip):
"""Wait until the strip has held one position for longer than the show's settle."""
held = [None, 0.0] # where it is, and when it first got there
def resting():
at, now = strip.evaluate("el => Math.round(el.scrollLeft)"), time.time()
if at != held[0]: held[0], held[1] = at, now
return (now - held[1]) * 1000 >= STILL_MS
wait_until(page, resting,
label="the show came to rest on the slide it stepped to",
detail=lambda: f"it was last at {held[0]}")
@testcase
def test_working_the_wall_without_the_mouse(page):
"""A pass over a card-dump from the keys: walk the wall, open one, pick one, drag a
run to the foot of it, climb back out, take the lot, reach a label box and the last
word twice over, turn back to the search box, carry the cursor out to the lightbox
and the show and back with the wall following it both ways — and lose the tab on all
of it, to find the run and the cursor where you left them."""
docs = key_docs()
for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
try:
page.set_viewport_size({"width": 420, "height": 600}) # narrow and short: it overflows
open_app(page)
search_for(page, "zzkey")
expect(tiles(page)).to_have_count(KEY_N)
d = dialog(page)
page.keyboard.press("ArrowRight") # the first arrow focuses the first tile
page.keyboard.press("ArrowRight") # → the second
page.keyboard.press("Enter") # Enter opens the focused doc
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
print(" PASS: grid cursor opens focused doc")
expect(checks(page)).to_have_count(0) # nothing picked by walking about
page.keyboard.press(" ") # Space picks the focused one
expect(checks(page)).to_have_count(1)
page.keyboard.press(" ") # Space again unpicks it
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor space toggles selection")
tiles(page).nth(5).click() # reach for the mouse for one tile
page.keyboard.press("ArrowRight") # the arrows carry on from there
page.keyboard.press("Enter") # open the now-focused tile
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(6))
page.keyboard.press("Escape")
toolbar(page).get_by_role("button", name="clear").click() # drop what the click picked up
expect(checks(page)).to_have_count(0)
print(" PASS: cursor starts at last clicked tile")
page.keyboard.press(" ") # one picked already, before any run
expect(checks(page)).to_have_count(1)
page.keyboard.press("ArrowRight") # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(1) # and selects nothing of its own
page.keyboard.press("Shift+ArrowRight") # reach on…
page.keyboard.press("Shift+ArrowRight") # …and on again
expect(checks(page)).to_have_count(4) # the run from the anchor, over the one kept
print(" PASS: grid cursor shift extends selection")
page.keyboard.press("Shift+ArrowLeft") # back one → the far end drops off
expect(checks(page)).to_have_count(3)
page.keyboard.press("Shift+ArrowLeft") # back onto the anchor → the run is just it
expect(checks(page)).to_have_count(2) # and the one kept underneath is still there
toolbar(page).get_by_role("button", name="clear").click()
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor shift reduces on return")
tiles(page).nth(0).click() # plant the cursor at the top-left
toolbar(page).get_by_role("button", name="clear").click()
cols = grid(page).evaluate("el => getComputedStyle(el).gridTemplateColumns.split(' ').length")
page.keyboard.press("ArrowDown"); page.keyboard.press("ArrowDown") # two rows down
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(2 * cols))
page.keyboard.press("Escape")
page.keyboard.press("ArrowUp") # back up by a row
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(cols))
page.keyboard.press("Escape")
print(" PASS: grid cursor steps a row, both ways")
tiles(page).nth(0).click() # start the run at the top-left
for _ in range(KEY_N): page.keyboard.press("Shift+ArrowDown") # drag it down past the fold
last = tiles(page).nth(KEY_N - 1).bounding_box()
bar = toolbar(page).bounding_box() # the run raised the toolbar
assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
toolbar(page).get_by_role("button", name="clear").click()
print(" PASS: grid cursor reveals tile above toolbar")
for _ in range(KEY_N): page.keyboard.press("ArrowUp") # climb back out, plain moves
wait_until(page, lambda: tiles(page).nth(0).bounding_box()["y"] >= 0,
label="the cursor climbed above the fold and stayed there",
detail=lambda: f"tile 0 at {tiles(page).nth(0).bounding_box()}; " + str(page.evaluate(
"() => ({ scrollY, padding: document.body.style.paddingBottom,"
" focused: document.activeElement?.getAttribute('aria-label') })")))
print(" PASS: grid cursor reveals a tile climbed back to")
open_doc(page, 0)
page.keyboard.press("Shift+ArrowRight") # a wall gesture, aimed at the open doc
page.keyboard.press("Escape")
expect(checks(page)).to_have_count(0) # nothing was gathered behind it
print(" PASS: an open doc owns the keys")
page.keyboard.press("Control+a")
expect(checks(page)).to_have_count(KEY_N) # the whole wall at once
print(" PASS: ctrl+a selects whole wall")
# the two keys that reach a label box without the hand leaving the keys
toolbar(page).get_by_role("button", name="clear").click()
tiles(page).nth(0).click() # one picked
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).not_to_be_focused()
page.keyboard.press("l")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value("")
print(" PASS: label shortcut focuses batch box")
tb_box.fill(FIRST_WORD); tb_box.press("Enter") # applied to the one picked → remembered, selection clears
tiles(page).nth(1).click() # pick another
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).to_have_value("") # the bar lets the word go once the write lands
tb_box.blur() # leave the field
page.keyboard.press(".")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value(FIRST_WORD)
print(" PASS: label repeat fills batch box")
sb = search_box(page); before = sb.input_value()
sb.click(); sb.press("End")
page.keyboard.press("l"); page.keyboard.press(".")
expect(sb).to_be_focused() # the toolbar's box was not reached for
expect(sb).to_have_value(before + "l.") # both keys typed
sb.fill(before); sb.blur()
print(" PASS: l and . stand aside in a field")
toolbar(page).get_by_role("button", name="clear").click()
open_doc(page, 0)
lb_box = d.get_by_placeholder("add a label…")
expect(lb_box).not_to_be_focused()
page.keyboard.press("l")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value("") # l opened the box; it didn't type into it
print(" PASS: label shortcut focuses lightbox box")
lb_box.fill(SECOND_WORD); lb_box.press("Enter") # applied → the newest word remembered
expect(d.get_by_role("button", name=SECOND_WORD, exact=True)).to_be_visible()
lb_box.blur() # leave the field
page.keyboard.press(".")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value(SECOND_WORD) # the newest, not the one before it
print(" PASS: label repeat fills lightbox box")
lb_box.blur() # out of the box, so Escape reaches the doc
page.keyboard.press("Escape")
expect(d).to_be_hidden()
on = lambda: grid(page).locator("[data-cursor='1'] img").get_attribute("src")
was = on()
search_box(page).click() # the box takes the keys
page.keyboard.press("ArrowRight")
page.keyboard.press("ArrowDown")
assert on() == was, f"the wall cursor moved while the search box had focus: {was} → {on()}"
print(" PASS: grid cursor stands aside for a field")
sb = search_box(page); picked = checks(page).count()
sb.click(); sb.press("End")
page.keyboard.press("Control+a")
expect(checks(page)).to_have_count(picked) # the text taken, not the wall
sb.press("End"); page.keyboard.press(" ")
expect(checks(page)).to_have_count(picked) # a space typed, the cursor's tile left alone
page.keyboard.press("Backspace")
page.keyboard.press("Enter") # the box's own commit, nothing more
expect(d).to_be_hidden()
print(" PASS: ctrl+a, space and enter stay quiet in a field")
# the cursor is one thing across the three surfaces, and the wall keeps up with it
back_to_the_top(page)
open_doc(page, 0)
expect(d).to_be_visible()
page.keyboard.press("ArrowLeft") # wrap round to the last, far down the wall
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled the cursor's tile into view")
print(" PASS: wall scrolls to lightbox cursor")
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
print(" PASS: the cursor follows what you were viewing")
# and the show carries it the same way
back_to_the_top(page)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
strip = page.get_by_role("list", name="slideshow")
expect(strip).to_be_visible()
show_at_rest(page, strip) # its own opening centre has landed
page.keyboard.press("ArrowLeft") # wrap round to the last slide
wait_until(page, lambda: strip.evaluate(CENTERED) == key_thumb(KEY_N - 1),
label="the show came round to the last slide",
detail=lambda: f"it came round to {strip.evaluate(CENTERED)!r}")
show_at_rest(page, strip)
page.keyboard.press("Escape")
expect(strip).to_be_hidden()
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled to the slide we stopped on")
print(" PASS: wall scrolls to frame cursor")
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
print(" PASS: the cursor follows what you were viewing")
# and none of it is lost if the tab reloads under you
tiles(page).nth(0).click() # pick the first — the click plants the cursor there
page.keyboard.press("Shift+ArrowRight") # extend the run to the next; the cursor lands on it
expect(checks(page)).to_have_count(2)
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(tiles(page)).to_have_count(KEY_N) # the saved search brings the same wall back
expect(checks(page)).to_have_count(2) # the picked run survived
page.keyboard.press("Enter") # the cursor survived on the second → opens it
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
print(" PASS: cursor and selection survive reload")
both = tiles(page).nth(1) # picked, and the cursor's tile after the reload
outline, ring = both.evaluate("el => { const s = getComputedStyle(el);"
" return [s.outlineStyle + ' ' + s.outlineColor, s.boxShadow]; }")
assert outline.startswith("solid") and "102, 204, 255" in outline, f"the pick's outline is gone: {outline}"
assert "249, 168, 38" in ring, f"the cursor's ring is gone: {ring}"
print(" PASS: cursor and pick show at once")
finally:
for d in docs: gql(DELETE, {"cid": d["cid"]})
Driving the wall from the keyboard
- what
- the wall has a cursor — a focused tile the eye can follow, moved with the arrow keys, the way every file manager gives one.
Walking and opening
- rule
- the arrows walk the cursor across the wall — left and right by one tile, down and up by a
whole row.
- rule: the first arrow puts the cursor on the wall’s first tile.
- rule
Enteropens the cursor’s doc in the lightbox, andEscapeleaves it, as it already does.
page.keyboard.press("ArrowRight") # the first arrow focuses the first tile
page.keyboard.press("ArrowRight") # → the second
page.keyboard.press("Enter") # Enter opens the focused doc
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
expect(d).to_be_hidden()
print(" PASS: grid cursor opens focused doc")
- rule
- walking the cursor about picks nothing.
- rule
Spacepicks or unpicks the cursor’s tile — 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")
- rule
- the cursor a click leaves is the one the next arrow moves — the arrows carry on from the clicked tile rather than snapping back to the wall’s first.
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")
A run under Shift
- what
- the anchor is the tile a run reaches from.
- rule: a plain move drops the anchor where it lands, and picks nothing.
- rule
- arrowing with
Shiftheld picks the contiguous run from the anchor to the cursor.- therefore: arrowing away grows it.
- example: a folder’s
Shift-selection, built without the mouse.
- rule
- what was picked before the run stays picked underneath it.
page.keyboard.press(" ") # one picked already, before any run
expect(checks(page)).to_have_count(1)
page.keyboard.press("ArrowRight") # a plain move re-anchors where it lands
expect(checks(page)).to_have_count(1) # and selects nothing of its own
page.keyboard.press("Shift+ArrowRight") # reach on…
page.keyboard.press("Shift+ArrowRight") # …and on again
expect(checks(page)).to_have_count(4) # the run from the anchor, over the one kept
print(" PASS: grid cursor shift extends selection")
- rule
- arrowing back toward the anchor shrinks the run — it follows the cursor in both directions, never leaving a stranded tail.
page.keyboard.press("Shift+ArrowLeft") # back one → the far end drops off
expect(checks(page)).to_have_count(3)
page.keyboard.press("Shift+ArrowLeft") # back onto the anchor → the run is just it
expect(checks(page)).to_have_count(2) # and the one kept underneath is still there
toolbar(page).get_by_role("button", name="clear").click()
expect(checks(page)).to_have_count(0)
print(" PASS: grid cursor shift reduces on return")
- how
- the run is not accumulated as it goes: every step recomputes it from the anchor, over a
snapshot of what was picked before the run began.
- therefore: a reversal and the earlier picks both come out right without being tracked.
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);
};
A row at a time
- what
- the wall is responsive, the number of its columns following its size.
- rule
- for the arrow keys, a row is however many columns are on show, at whatever size the wall is drawn.
tiles(page).nth(0).click() # plant the cursor at the top-left
toolbar(page).get_by_role("button", name="clear").click()
cols = grid(page).evaluate("el => getComputedStyle(el).gridTemplateColumns.split(' ').length")
page.keyboard.press("ArrowDown"); page.keyboard.press("ArrowDown") # two rows down
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(2 * cols))
page.keyboard.press("Escape")
page.keyboard.press("ArrowUp") # back up by a row
page.keyboard.press("Enter")
expect(dialog(page).get_by_role("img")).to_have_attribute("src", key_thumb(cols))
page.keyboard.press("Escape")
print(" PASS: grid cursor steps a row, both ways")
Keeping the cursor’s tile in view
- what
- the toolbar is pinned over the foot of the wall and floats above it.
- what: that pin makes two separate troubles, and a run driving the cursor down — toolbar up, cursor heading for the bottom — walks straight into both.
- rule
- wherever the cursor lands, its tile is whole and in view — more than nudged past the edge.
- rule: a run dragged to the foot of a tall wall still leaves its last tile whole, and above the toolbar.
tiles(page).nth(0).click() # start the run at the top-left
for _ in range(KEY_N): page.keyboard.press("Shift+ArrowDown") # drag it down past the fold
last = tiles(page).nth(KEY_N - 1).bounding_box()
bar = toolbar(page).bounding_box() # the run raised the toolbar
assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
toolbar(page).get_by_role("button", name="clear").click()
print(" PASS: grid cursor reveals tile above toolbar")
- what
- the first trouble is position: a tile scrolled flush to the bottom sits half-hidden
behind the toolbar.
- how: the cursor’s tile is measured against a window whose bottom is lifted by the toolbar’s own height — zero until something is picked.
- how: the wall scrolls only as far as it takes to clear whichever edge the tile overruns.
const scrollCursorIntoView = () => {
const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
const r = tile.getBoundingClientRect();
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
if(r.top < 0) scrollBy(0, r.top); // head above the fold → pull it down
else if(r.bottom > floor) scrollBy(0, r.bottom - floor); // foot under the bar → push it up
};
- what
- the second trouble is room: the page ends where the last row does.
- therefore: the bottom tiles have nowhere to scroll to, and no nudge lifts them past a toolbar the page is too short to outrun.
- how: while something picked holds the toolbar up, a margin as tall as the toolbar is reserved beneath the wall, for those rows to rise into.
- what: the toolbar’s wrapped height is not known until it has laid out.
- therefore: the margin is measured on the paint after the toolbar appears.
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' : '';
});
});
- rule
- clearing the edge cuts both ways: a wall walked to its foot is walkable back to its head, the cursor’s tile in view as it climbs.
for _ in range(KEY_N): page.keyboard.press("ArrowUp") # climb back out, plain moves
wait_until(page, lambda: tiles(page).nth(0).bounding_box()["y"] >= 0,
label="the cursor climbed above the fold and stayed there",
detail=lambda: f"tile 0 at {tiles(page).nth(0).bounding_box()}; " + str(page.evaluate(
"() => ({ scrollY, padding: document.body.style.paddingBottom,"
" focused: document.activeElement?.getAttribute('aria-label') })")))
print(" PASS: grid cursor reveals a tile climbed back to")
Who has the keys
- rule
- an open lightbox owns the keys — arrows and all, the same arrows meaning something there.
- rule: the wall never gathers a run behind a doc you are only looking at.
open_doc(page, 0)
page.keyboard.press("Shift+ArrowRight") # a wall gesture, aimed at the open doc
page.keyboard.press("Escape")
expect(checks(page)).to_have_count(0) # nothing was gathered behind it
print(" PASS: an open doc owns the keys")
- rule
- when the whole wall is wanted rather than a run of it,
Ctrl+A(⌘Aon a Mac) picks it in one — the keyboard twin of the select-all toggle.
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")
- rule
- the cursor stands aside while you type in the search box.
- rule: every wall key goes quiet the moment a field takes the keys — the arrows, and
Ctrl+Awith them, which in a text box means the text.
- rule: every wall key goes quiet the moment a field takes the keys — the arrows, and
on = lambda: grid(page).locator("[data-cursor='1'] img").get_attribute("src")
was = on()
search_box(page).click() # the box takes the keys
page.keyboard.press("ArrowRight")
page.keyboard.press("ArrowDown")
assert on() == was, f"the wall cursor moved while the search box had focus: {was} → {on()}"
print(" PASS: grid cursor stands aside for a field")
- how
- the other wall keys are pressed in the box in their turn: each lands in the text, and the wall neither picks nor opens.
sb = search_box(page); picked = checks(page).count()
sb.click(); sb.press("End")
page.keyboard.press("Control+a")
expect(checks(page)).to_have_count(picked) # the text taken, not the wall
sb.press("End"); page.keyboard.press(" ")
expect(checks(page)).to_have_count(picked) # a space typed, the cursor's tile left alone
page.keyboard.press("Backspace")
page.keyboard.press("Enter") # the box's own commit, nothing more
expect(d).to_be_hidden()
print(" PASS: ctrl+a, space and enter stay quiet in a field")
- what
- the wall has no focus of its own to hear the keys from.
- therefore: the keys are heard at the window rather than on any tile.
- therefore: standing aside is done by hand, surface by surface, rather than falling out of where the focus is.
- what
- The slideshow is the third of those surfaces, owning the keys the same way for as long as it is up.
onMount(() => {
const onKey = e => {
if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
if(e.key === 'ArrowRight') step(1);
else if(e.key === 'ArrowLeft') step(-1);
else if(e.key === 'ArrowDown') step(gridCols());
else if(e.key === 'ArrowUp') step(-gridCols());
else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
else if(e.key === 'Enter' && cursor()){
e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){
e.preventDefault(); setSelected(new Set(shownCids())); }
};
window.addEventListener('keydown', onKey);
onCleanup(() => window.removeEventListener('keydown', onKey));
});
The ring, and a reload
- rule
- a tile both under the cursor and picked shows both at once.
both = tiles(page).nth(1) # picked, and the cursor's tile after the reload
outline, ring = both.evaluate("el => { const s = getComputedStyle(el);"
" return [s.outlineStyle + ' ' + s.outlineColor, s.boxShadow]; }")
assert outline.startswith("solid") and "102, 204, 255" in outline, f"the pick's outline is gone: {outline}"
assert "249, 168, 38" in ring, f"the cursor's ring is gone: {ring}"
print(" PASS: cursor and pick show at once")
- how
- the cursor’s ring is amber, set apart from the blue outline of a picked tile.
.tile[data-cursor='1']{ box-shadow:0 0 0 3px #f9a826, 0 0 10px #f9a826aa; }
- rule
- the run picked and the cursor survive a reload — an accidental one comes back to the tile you were on.
tiles(page).nth(0).click() # pick the first — the click plants the cursor there
page.keyboard.press("Shift+ArrowRight") # extend the run to the next; the cursor lands on it
expect(checks(page)).to_have_count(2)
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
expect(tiles(page)).to_have_count(KEY_N) # the saved search brings the same wall back
expect(checks(page)).to_have_count(2) # the picked run survived
page.keyboard.press("Enter") # the cursor survived on the second → opens it
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(1))
page.keyboard.press("Escape")
print(" PASS: cursor and selection survive reload")
- how
- the cursor and the picked run are each saved in the browser as they change — the way the
search box and the state filter already are — and read back on load.
- scope: the cursor’s half follows; the picks’ half sits in Selecting tiles and batch-editing.
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); });
The cursor follows what you’re viewing
- what
- the wall’s keyboard cursor marks where you are.
- what
- in the lightbox, or in the slideshow, you move through the wall in place.
The cursor rides along
- rule
- back from the lightbox or the slideshow, the cursor is on the doc you left, ready for the arrows to carry on, and stays there until one opens: the story runs this check once after each.
page.keyboard.press("Enter") # the bare wall reopens the doc the cursor rests on
expect(d.get_by_role("img")).to_have_attribute("src", key_thumb(KEY_N - 1))
page.keyboard.press("Escape")
print(" PASS: the cursor follows what you were viewing")
The wall scrolls to it
- how
- whether a tile is somewhere you could see it is read off its own box against the window.
- how: both blocks below ask it of the tile the cursor came to rest on.
IN_VIEW = "el => { const r = el.getBoundingClientRect(); return r.top < window.innerHeight && r.bottom > 0; }"
- rule
- the wall scrolls to the doc you left: gone deep into the wall from the lightbox, leaving it lands the wall on your tile, not at the top.
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled the cursor's tile into view")
print(" PASS: wall scrolls to lightbox cursor")
- rule
- the wall scrolls to the doc you left the slideshow on: step the slideshow far along, leave it, and that slide is in view.
wait_until(page, lambda: tiles(page).nth(KEY_N - 1).evaluate(IN_VIEW),
label="the wall scrolled to the slide we stopped on")
print(" PASS: wall scrolls to frame cursor")
- how
- the doc you are looking at is the lightbox’s open one while it is up, and otherwise the slideshow’s centred slide.
- how
- every move on either surface updates one of those — the ‹/› buttons, the arrows, a swipe, a
step of the slideshow.
- therefore: one effect mirroring whichever is live onto the cursor, and scrolling that cursor’s tile into view on the wall behind, covers them all.
- what
- leaving either surface goes back a step in the browser’s history, and the browser’s own
scroll restoration then snaps the window back to where it stood when the surface opened.
- therefore: left on, that restoration silently undoes the scroll to the cursor.
- how: 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';
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 add-label box
- what
- the cursor picks a run of docs without the mouse, and two
keys carry on from there to its label.
- example: pick with
Shift+→, pressl, type the word; or press.to drop in the label you used last and just hitEnter.
- example: pick with
- rule
- the key that opens a box never lands in it.
l reaches the box
- rule
- one key reaches the add-label box: with a doc open,
l— for label — focuses its add-label box.
lb_box = d.get_by_placeholder("add a label…")
expect(lb_box).not_to_be_focused()
page.keyboard.press("l")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value("") # l opened the box; it didn't type into it
print(" PASS: label shortcut focuses lightbox box")
- rule
- on the wall, with something picked,
lfocuses the add-label box of the toolbar instead.
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).not_to_be_focused()
page.keyboard.press("l")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value("")
print(" PASS: label shortcut focuses batch box")
- how
- a small listener at the window routes
land.to whichever box is live — the open lightbox’s, else the toolbar’s.- rule: it stands aside while a field already has the keys, or while the slideshow is up.
- how: the keystroke it acts on is swallowed, so it does not land 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));
});
. reuses the last word
- how
- the story reaches this step having already put a different word on from the wall.
- rule
- another key reuses the last word: with a doc open,
.— repeat — fills its add-label box with the word applied last of all, wherever it was applied, and focuses it.
lb_box.fill(SECOND_WORD); lb_box.press("Enter") # applied → the newest word remembered
expect(d.get_by_role("button", name=SECOND_WORD, exact=True)).to_be_visible()
lb_box.blur() # leave the field
page.keyboard.press(".")
expect(lb_box).to_be_focused()
expect(lb_box).to_have_value(SECOND_WORD) # the newest, not the one before it
print(" PASS: label repeat fills lightbox box")
- rule
- the toolbar’s box lets its word go once the word is applied.
- rule
- on the wall, with something picked,
.refills the toolbar’s box the same way.
tb_box.fill(FIRST_WORD); tb_box.press("Enter") # applied to the one picked → remembered, selection clears
tiles(page).nth(1).click() # pick another
tb_box = toolbar(page).get_by_placeholder("add a label…")
expect(tb_box).to_have_value("") # the bar lets the word go once the write lands
tb_box.blur() # leave the field
page.keyboard.press(".")
expect(tb_box).to_be_focused()
expect(tb_box).to_have_value(FIRST_WORD)
print(" PASS: label repeat fills batch box")
- how
- with a run still picked,
land.typed in the search box are letters of the query.
sb = search_box(page); before = sb.input_value()
sb.click(); sb.press("End")
page.keyboard.press("l"); page.keyboard.press(".")
expect(sb).to_be_focused() # the toolbar's box was not reached for
expect(sb).to_have_value(before + "l.") # both keys typed
sb.fill(before); sb.blur()
print(" PASS: l and . stand aside in a field")
- how
- before focusing,
.drops in the word any earlier apply, here or on a picked run, left behind.
if(e.key === '.' && lastLabel()) // '.' re-drops the label applied last
(opened() ? setLbText : setLabelText)(lastLabel());
Asking for more than the server can hand over
- what
- with sixty thousand docs, the server can be asked for more than it can hand
over, and it happens two ways.
- what: the answer takes long enough that you are left waiting on it.
- example: a bare word against all of them, a date range covering a decade.
- what: or the answer arrives simply too big for a wall — tens of thousands of tiles nobody will ever scroll — and what comes back is a part of it.
- what: the answer takes long enough that you are left waiting on it.
- what
- both are the same failure to be honest if the app says nothing.
- example: a wait it does not name looks like an app that has stopped.
- example: a slice it does not name looks like all the docs.
- rule: the app says which it is doing.
- how
- the slice comes first.
- rule: the wall says how much of the match it is holding and how it chose — every match, one end of it, or a spread across the whole.
- what: that count is a promise about the tiles.
- therefore: the tiles have to be able to keep it.
- what: a capful of them is more than one screen and more than one machine’s memory, yet the wall drops none of them.
- what: which slice it took also changes what you can do with the tiles.
- what: an end is a run and a spread is not.
- what: a range gesture across a spread would rope in whatever happened to fall between two scattered tiles and call it a run.
- what
- the search box does not search as you type; it searches when you commit, and says so while it holds something not yet run.
- what
- once committed, the wall names the query it is fetching, freezes the controls,
and offers three ways out of a wait you did not mean to start.
- scope: the one it cannot offer is a way out of the very first read, where there is no earlier answer to fall back to; that one just has to let go.
- what: bailing out means going back to the last answer.
- therefore: there has to be one — a query already asked and settled, sitting behind whatever gets tried next.
- what
- none of it can be watched while it happens unless the waiting is made to last.
- how: the hold is put on from outside, not asked for by the query.
- how: one word is agreed with the network, and a query carrying it is never answered.
- how: where there is no query to carry a word, as on the first read, or where the query is not the point, the hold is simply laid over whatever read comes next.
- how: either way the app is left standing in the middle of it for as long as we care to look.
- how: the hold is put on from outside, not asked for by the query.
SLOW = "zzslow"
def hold_a_read(page, query):
"""Commit a query the network will never answer, and wait until it has taken hold."""
search_box(page).fill(query)
page.get_by_role("button", name="run search").click()
expect(search_box(page)).to_be_disabled()
@testcase
def test_asking_for_more_than_can_be_handed_over(page):
"""Asking for too much, three ways: a match too big to show, which the wall says it has
sliced and how — and which of those slices you can still take a run in hand from; a
wall too long to hold, which lets go of what you are not looking at
and still counts what has no picture yet; and a read that drags, which the wall names,
locks the query behind, and lets go of three different ways — including on the very
first one, where there is nothing to go back to."""
make_fixtures()
pick = pick_docs()
for d in pick: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
seen, aborted = [], []
page.on("request", lambda r: "photovideosSample" in (r.post_data or "")
and seen.append((json.loads(r.post_data).get("variables") or {}).get("search", "")))
page.on("requestfailed", lambda r: "/graphql" in r.url and aborted.append(1))
drag_them_all, slow_state = [True], [None] # the two holds put on from outside
def hold(r):
body = r.request.post_data or ""
if SLOW in body or (slow_state[0] and f'"states":["{slow_state[0]}"]' in body): return
if drag_them_all[0] and "photovideosSample" in body: return
r.fallback()
page.route("**/graphql", hold)
open_app(page)
expect(page.get_by_text("searching «everything»")).to_be_visible()
print(" PASS: wall cue names empty query everything")
expect(search_box(page)).to_be_disabled() # frozen on the boot read
page.keyboard.press("Escape")
expect(search_box(page)).to_be_enabled() # unlocked, not re-locked
print(" PASS: escape during boot read unlocks")
drag_them_all[0] = False # reads answer again from here
chip(page, "all").click() # every state
wait_until(page, lambda: tiles(page).count() > 10, timeout=15000) # filled, not still the old wall
wait_until(page, lambda: "/ipfs/" in (thumb_imgs(page).first.get_attribute("src") or ""))
print(" PASS: shows thumbnails")
expect(grid(page)).to_have_attribute("aria-busy", "false") # the read answered — the region is still
print(" PASS: wall goes quiet once the read answers")
assert thumb_imgs(page).first.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
"thumbnail crops instead of fitting whole"
print(" PASS: thumbnail shows whole image")
far = thumb_imgs(page).nth(200) # hundreds of rows down
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # it carries nothing
far.scroll_into_view_if_needed()
expect(far).to_have_attribute("src", re.compile(r"/ipfs/")) # brought into view → it takes one
thumb_imgs(page).first.scroll_into_view_if_needed()
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # left again → it gives it back
print(" PASS: thumbs load in view and unload when they leave")
i, j = thumb_imgs(page).evaluate_all("""els => {
const h = innerHeight, top = e => e.getBoundingClientRect().top;
return [els.findIndex(e => top(e) > h + 20 && top(e) < h + 250),
els.findIndex(e => top(e) > h + 500)]; }""")
assert i >= 0 and j >= 0, f"no tile just under the fold ({i}) or well past it ({j})"
expect(thumb_imgs(page).nth(i)).to_have_attribute("src", re.compile(r"/ipfs/")) # ready before it shows
expect(thumb_imgs(page).nth(j)).not_to_have_attribute("src", re.compile(r"/ipfs/")) # but not this far
print(" PASS: thumbs are ready a little past the fold")
expect(page.get_by_text(re.compile(r"showing a spread of 2000 from \d+"))).to_be_visible()
print(" PASS: sampling notice")
t = tiles(page)
t.nth(0).click() # pick the first (the toolbar appears)
t.nth(2).click(modifiers=["Shift"]) # shift-click a later tile
expect(checks(page)).to_have_count(2) # only the two touched — no run roped in
rng = toolbar(page).get_by_role("button", name="range")
expect(rng).to_be_disabled() # the range affordance stands down
title = rng.get_attribute("title")
assert title and ("spread" in title.lower() or "run" in title.lower()), \
f"the disabled range toggle should say why: {title!r}"
print(" PASS: spread disables multiselect")
toolbar(page).get_by_role("button", name="clear", exact=True).click()
search_for(page, "zzpick; first:2")
expect(page.get_by_text(re.compile(r"showing the first 2 of \d+$"))).to_be_visible() # $: nothing follows
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-01", "2020-01-02"], f"not the earliest two: {tile_dates(page)}"
t = tiles(page)
t.nth(0).click() # pick the first (the toolbar appears)
expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
t.nth(1).click(modifiers=["Shift"]) # a shift-click ropes in the run
expect(checks(page)).to_have_count(2)
toolbar(page).get_by_role("button", name="clear", exact=True).click()
print(" PASS: first/last keep range select")
search_for(page, "zzpick; last:2")
expect(page.get_by_text(re.compile(r"showing the last 2 of \d+$"))).to_be_visible()
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-07", "2020-01-08"], f"not the latest two: {tile_dates(page)}"
search_for(page, "zzpick; sample:4")
expect(page.get_by_text(re.compile(r"showing a spread of 4 from \d+"))).to_be_visible()
expect(page.get_by_text(re.compile(r"narrow the search or date range"))).to_be_visible()
expect(tiles(page)).to_have_count(4)
d = tile_dates(page)
assert d == sorted(d), f"a spread must still read oldest first: {d}"
days = [int(x[-2:]) for x in d]
assert max(b - a for a, b in zip(days, days[1:])) > 1, f"contiguous — that is a run, not a spread: {d}"
print(" PASS: first/last/sample pick which rows")
sb = search_box(page)
clean = sb.evaluate("el => getComputedStyle(el).borderColor")
assert sb.get_attribute("aria-description") is None # committed → nothing stale
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially(FIXTURE_LABEL, delay=0)
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # non-colour cue
assert sb.evaluate("el => getComputedStyle(el).borderColor") != clean, "edited box wasn't recoloured"
page.get_by_role("button", name="run search").click() # commit — this one is not held
expect(sb).to_be_enabled() # the read settled
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # cleared on commit
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "colour didn't revert after commit"
print(" PASS: search bar marks uncommitted edit")
expect(tiles(page)).to_have_count(len(FIXTURES)) # settled on a query worth going back to
base = len(seen)
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzslow-typed", delay=0)
expect(sb).to_have_value("zzslow-typed") # the box shows it (the reactive tick ran)…
assert len(seen) == base, f"typing re-read the wall without a commit: {seen[base:]}" # …but no read fired
print(" PASS: wall reads only on commit")
expect(page.get_by_text(re.compile(rf"^showing all {len(FIXTURES)}$"))).to_be_visible()
print(" PASS: count always shown")
sb.press("Enter") # commit — nothing highlighted
expect(page.get_by_text("searching «zzslow-typed»")).to_be_visible() # the wall read what was typed
assert seen[base:] == ["zzslow-typed"], f"commit fired the wrong reads: {seen[base:]}"
print(" PASS: enter commits search")
expect(grid(page)).to_have_attribute("aria-busy", "true") # a read is out — the region is working
print(" PASS: wall reports busy")
expect(search_box(page)).to_be_disabled() # search frozen while the read is out
expect(page.get_by_role("button", name="run search")).to_be_disabled() # …the run button
expect(chip(page, "todo")).to_be_disabled() # …and the state chips
print(" PASS: query controls freeze while reading")
page.keyboard.press("Escape")
expect(search_box(page)).to_be_enabled() # unlocked
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # reverted to the last settled query
expect(tiles(page)).to_have_count(len(FIXTURES)) # those results again → no error state
wait_until(page, lambda: bool(aborted), label="held read aborted") # connection cancelled, not leaked
print(" PASS: escape cancels frozen read")
hold_a_read(page, "zzslow-pill")
page.get_by_role("button", name=re.compile("cancel search")).click() # tap the pill
expect(search_box(page)).to_be_enabled() # unlocked
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # reverted to the last settled query
expect(tiles(page)).to_have_count(len(FIXTURES))
print(" PASS: pill tap cancels frozen read")
hold_a_read(page, "zzslow-back")
page.go_back() # Back bails out
expect(search_box(page)).to_be_enabled()
expect(search_box(page)).to_have_value(FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
asked = []
page.on("dialog", lambda d: (asked.append(1), d.dismiss()))
page.go_back() # a further Back reaches the leave guard
wait_until(page, lambda: bool(asked))
expect(search_box(page)).to_be_visible() # cancelled → still in Memories
print(" PASS: back cancels frozen read")
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzsecond-thoughts", delay=0)
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied"))
slow_state[0] = "next" # this filter is the slow one, and stays slow
chip(page, "next").click()
assert seen[-1] == FIXTURE_LABEL, f"the chip put {seen[-1]!r} on the wire, not the committed query"
expect(sb).to_be_disabled()
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # dirty and locked at once
page.keyboard.press("Escape")
expect(chip(page, "all")).to_have_attribute("aria-pressed", "true") # the filter came back with the query
expect(tiles(page)).to_have_count(len(FIXTURES)) # the wall answered, and stayed answered
expect(sb).to_be_enabled()
expect(sb).to_have_value(FIXTURE_LABEL) # the typed half-thought is gone…
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # …and so is its mark
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "the amber tint outlived the bail-out"
print(" PASS: bailing out clears the stale cue")
for d in nomedia_docs(): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
search_for(page, NOMEDIA_LABEL) # a label only this doc carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
expect(d.get_by_text("no preview")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # nothing broken to show
expect(d.locator("video")).to_have_count(0)
d.get_by_role("button", name="close").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1) # the no-thumb one shows too
expect(thumb_imgs(page)).to_have_count(len(FIXTURES)) # but it isn't an image
expect(grid(page).get_by_text("🖼")).to_have_count(1) # it wears the icon
expect(page.get_by_text(re.compile(r"showing a spread"))).to_have_count(0) # and no notice
search_for(page, "zznwimg") # an image with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zznw-img-t") # its poster, stretched
d.get_by_role("button", name="close").click()
search_for(page, "zznwvid") # a clip with a poster and no web copy
expect(tiles(page)).to_have_count(1)
open_doc(page)
expect(d.get_by_text("no preview · video/mp4")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # and the poster is not offered in its place
for d in nomedia_docs():
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
print(" PASS: placeholders for docs with no media")
lates = [{"cid": f"https://ipfs.konubinix.eu/p/zznw-late{k}", "date": f"2020-04-2{k}T12:00:00Z", "mimetype": "video/mp4",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zznw-late{k}-t", "labels": "zznwvid", "state": "todo"} for k in (1, 2)]
shown = tiles(page).count()
for late in lates: gql(DELETE, {"cid": late["cid"]}); gql(CREATE, {"p": late})
try:
page.evaluate("() => { dispatchEvent(new Event('focus'));"
" document.dispatchEvent(new Event('visibilitychange')); }")
page.wait_for_timeout(1000) # any read it set off is back well inside this
expect(tiles(page)).to_have_count(shown) # the answer it was given, and no more
finally:
for late in lates: gql(DELETE, {"cid": late["cid"]})
print(" PASS: the wall holds still while rows land")
for d in pick:
try: gql(DELETE, {"cid": d["cid"]})
except Exception: pass
Load only the thumbnails on screen
- rule
- a tile out of sight lets go of its picture and takes it back — loading and unloading.
- rule: a tile carries its picture only while near the screen, the decoded picture released the moment the tile scrolls away.
- rule: on a wall that overflows its fold, a tile waiting far below carries no picture at all until brought into view.
- how: the wall this is read from is the one the server draws over all the docs — a full cap of tiles.
far = thumb_imgs(page).nth(200) # hundreds of rows down
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # it carries nothing
far.scroll_into_view_if_needed()
expect(far).to_have_attribute("src", re.compile(r"/ipfs/")) # brought into view → it takes one
thumb_imgs(page).first.scroll_into_view_if_needed()
expect(far).not_to_have_attribute("src", re.compile(r"/ipfs/")) # left again → it gives it back
print(" PASS: thumbs load in view and unload when they leave")
- how
- the margin is read off the wall at its top: a tile just under the fold holds its picture, one well past the margin does not.
i, j = thumb_imgs(page).evaluate_all("""els => {
const h = innerHeight, top = e => e.getBoundingClientRect().top;
return [els.findIndex(e => top(e) > h + 20 && top(e) < h + 250),
els.findIndex(e => top(e) > h + 500)]; }""")
assert i >= 0 and j >= 0, f"no tile just under the fold ({i}) or well past it ({j})"
expect(thumb_imgs(page).nth(i)).to_have_attribute("src", re.compile(r"/ipfs/")) # ready before it shows
expect(thumb_imgs(page).nth(j)).not_to_have_attribute("src", re.compile(r"/ipfs/")) # but not this far
print(" PASS: thumbs are ready a little past the fold")
- how
- out of reach, the tile shows a blank of one pixel, its square’s own background filling the rest.
- how
- one watcher, shared by every tile, tells each one when it comes near the screen and when it leaves.
- rule
- a picture is ready a little before its tile scrolls in, and dropped a little after it
scrolls out.
- how: « near » reaches 300px past the edge of the screen.
// a 1×1 transparent gif — what a tile shows when its image is unloaded
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); });
};
})();
- rule
- letting a picture go costs nothing on the wire when it is wanted again: the memory goes;
the bytes never do.
- how: a picture’s
/ipfs/address names its own content and can never come to stand for anything else. - how: the gateway says so, serving every rendition
public, max-age=29030400, immutable, so scrolling back is a cache hit.
- how: a picture’s
Placeholders for docs with no media
A tile for every match
- what
- not every row has its renditions.
- what: rows arrive before their pictures do — an import lands, and the thumbnails are still being made.
- therefore: at any moment some of what matches a search has nothing to show for itself.
- what
- the line above it counts what is shown against
what matched.
- therefore: a match quietly withheld would be a doc the server says it has and the wall says it does not.
- rule
- rows that land while the wall is up do not push themselves onto it: the wall holds the
answer it was given until it is asked something else.
- how: the new arrival is met the way any is, by searching for it.
- rule
- opening a row with no web copy shows a « no preview » placeholder rather than a broken image.
for d in nomedia_docs(): gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
search_for(page, NOMEDIA_LABEL) # a label only this doc carries
expect(tiles(page)).to_have_count(1)
open_doc(page)
d = dialog(page)
expect(d.get_by_text("no preview")).to_be_visible()
expect(d.get_by_role("img")).to_have_count(0) # nothing broken to show
expect(d.locator("video")).to_have_count(0)
- rule
- a row whose picture does not exist yet is still a tile: back on the wall, among the run it
landed in, it is counted, tiled, and wearing the icon that says why it has no picture — reachable rather
than silently dropped.
- rule: it raises no notice of a spread, since nothing was sampled away.
d.get_by_role("button", name="close").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES) + 1) # the no-thumb one shows too
expect(thumb_imgs(page)).to_have_count(len(FIXTURES)) # but it isn't an image
expect(grid(page).get_by_text("🖼")).to_have_count(1) # it wears the icon
expect(page.get_by_text(re.compile(r"showing a spread"))).to_have_count(0) # and no notice
What the lightbox opens instead
- what
- which placeholder the lightbox gives turns on what the row is.
- rule
- a photo missing its web copy still has its thumbnail, and the lightbox enlarges it — the same picture, only softer — rather than refusing the photo.
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
- what
- the lightbox opens a video in order to play it, and a poster cannot be played.
- rule
- a video missing its web copy is refused — « no preview », with its type — and its poster is not offered, a photograph where a video 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
- how
- a row lands under the video just looked at, and the tab is left and come back to; the wall shows the one video it was handed.
lates = [{"cid": f"https://ipfs.konubinix.eu/p/zznw-late{k}", "date": f"2020-04-2{k}T12:00:00Z", "mimetype": "video/mp4",
"thumbnailCid": f"https://ipfs.konubinix.eu/p/zznw-late{k}-t", "labels": "zznwvid", "state": "todo"} for k in (1, 2)]
shown = tiles(page).count()
for late in lates: gql(DELETE, {"cid": late["cid"]}); gql(CREATE, {"p": late})
try:
page.evaluate("() => { dispatchEvent(new Event('focus'));"
" document.dispatchEvent(new Event('visibilitychange')); }")
page.wait_for_timeout(1000) # any read it set off is back well inside this
expect(tiles(page)).to_have_count(shown) # the answer it was given, and no more
finally:
for late in lates: gql(DELETE, {"cid": late["cid"]})
print(" PASS: the wall holds still while rows land")
The wall fills
- how
- In practice, asking anew does not empty the wall first: the tiles already up stay where they are until
the new answer lands.
- what: read too early, the wall is the old one, or a half-drawn new one.
- therefore: a wall is read once it has filled, not the instant its question is asked.
- rule
- on all the docs —
allon the state filter, rather than the defaulttodo— the wall fills with thumbnails.
wait_until(page, lambda: tiles(page).count() > 10, timeout=15000) # filled, not still the old wall
wait_until(page, lambda: "/ipfs/" in (thumb_imgs(page).first.get_attribute("src") or ""))
print(" PASS: shows thumbnails")
- how
- the app holds the search box and the query last committed from it, and reads the wall from that query.
function App(){
// the search box persists locally
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
// keyboard-driving the suggestion list of the focused box
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, close) => {
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); }
else if(e.key === 'Escape' && close && (n || sugLoading())){
e.preventDefault(); e.stopPropagation(); close(); reportSug([]); setSugLoading(false); }
};
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
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 wall reads a COMMITTED query, not the live text — see “Reading only on commit”
const [committed, setCommitted] = createSignal(search());
const commit = () => setCommitted(search());
const dirty = () => search() !== committed(); // typed but not yet run → the wall is stale
const queryStr = createMemo(() => { const p = parseQuery(committed());
return JSON.stringify({ search: p.search, since: p.since, until: p.until,
kinds: p.kinds, owners: p.owners, aday: p.aday, awin: p.awin,
month: p.month, mwin: p.mwin, events: p.events, eventsX: p.eventsX,
cap: p.cap, pick: p.pick, state: stateFilter() }); });
const [mutating, setMutating] = createSignal(0); // in-flight edit count → aria-busy
const [editStalled, setEditStalled] = createSignal(false);
const EDIT_SILENCE_MS = Number(new URLSearchParams(location.search).get('editms')) || 15000;
async function editing(run){
setMutating(m => m + 1); setEditStalled(false);
let counted = true, timer = null;
const giveUp = () => { if(!counted) return;
counted = false; setMutating(m => m - 1); setEditStalled(true); };
const tick = () => { clearTimeout(timer); timer = setTimeout(giveUp, EDIT_SILENCE_MS); };
tick();
try { await run(tick); setEditStalled(false); }
catch(e){ setEditStalled(true); throw e; }
finally { clearTimeout(timer); if(counted){ counted = false; setMutating(m => m - 1); } }
}
// FREEZE: advance to the committed query only when idle — see “Reading only on commit”
const target = createMemo(() => ({ key: queryStr(), label: committed().trim() }));
const [running, setRunning] = createSignal(target());
let readCtl = null; // the in-flight read's AbortController
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 “Bailing out of a read”
const [lastSettled, setLastSettled] = createSignal(null);
createEffect(() => { if(!photos.loading && !photos.error) setLastSettled(running()); });
const cancelRead = () => {
if(!photos.loading) return;
const ctl = readCtl, s = lastSettled();
if(s && s.key !== running().key){ setSearch(s.label); setCommitted(s.label);
setStateFilter(JSON.parse(s.key).state); setRunning(s); }
else { setSearch(''); setCommitted(''); setRunning(target()); } // boot / no prior → everything
ctl?.abort();
};
onMount(() => { // Esc bails out of a frozen read (Back bails too, via the exit guard)
const onEsc = e => {
if(e.key === 'Escape' && photos.loading && !opened() && !frame()){
e.preventDefault(); cancelRead(); } };
window.addEventListener('keydown', onEsc);
onCleanup(() => window.removeEventListener('keydown', onEsc));
});
onMount(() => { // suppress the OS context menu on our surfaces; text fields keep it (paste/select)
const noMenu = e => { if(!e.target.closest('input, textarea, [contenteditable]')) e.preventDefault(); };
window.addEventListener('contextmenu', noMenu);
onCleanup(() => window.removeEventListener('contextmenu', noMenu));
});
const sortKey = createMemo(() => parseQuery(search()).sort);
const total = () => photos()?.total ?? 0; // how many actually match
const items = createMemo(() => {
const a = (photos.error ? undefined : photos())?.items ?? []; // shown docs, in wall order
return sortKey() === 'random' ? [...a].sort((x, y) => (x.myrandom ?? 0) - (y.myrandom ?? 0)) : a; });
const eventWindow = createMemo(() => { const k = JSON.parse(running().key);
return JSON.stringify({ since: k.since, until: k.until }); });
const [wallEvents] = createResource(eventWindow, w => fetchWindowEvents(JSON.parse(w)));
const docEvents = photo => { const evs = wallEvents(); if(!evs || !photo.owner) return [];
const t = new Date(photo.date).getTime();
return evs.filter(e => e.owner === photo.owner
&& new Date(e.starttime).getTime() <= t && t <= new Date(e.endtime).getTime()); };
const EVENT_HUES = ['#8ecae6', '#ffb703', '#90be6d', '#ff8fab', '#bdb2ff', '#ffd6a5', '#a0c4ff', '#caffbf'];
const eventKey = e => e.summary + '|' + e.starttime + '|' + e.owner;
const eventColour = createMemo(() => { const m = new Map(); let i = 0;
for(const p of items()) for(const e of docEvents(p))
if(!m.has(eventKey(e))) m.set(eventKey(e), EVENT_HUES[i++ % EVENT_HUES.length]);
return m; });
const STATES = ['todo', 'next', 'done', 'delete'];
const SEL_KEY = 'memories.selected';
const [selected, setSelected] = createSignal(new Set(JSON.parse(localStorage.getItem(SEL_KEY) || '[]')));
createEffect(() => localStorage.setItem(SEL_KEY, JSON.stringify([...selected()])));
const [labelText, setLabelText] = createSignal('');
const [labelFocus, setLabelFocus] = createSignal(false);
const [anchor, setAnchor] = createSignal(null);
const [rangeMode, setRangeMode] = createSignal(false);
const [allMatching, setAllMatching] = createSignal(false);
// a new filter, a new reach
createEffect(on([committed, stateFilter], () => setAllMatching(false), { defer: true }));
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };
const shownCids = () => items().map(p => p.cid);
const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));
const extendTo = cid => {
const list = items().map(p => p.cid);
const a = list.indexOf(anchor()), b = list.indexOf(cid);
if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
const [lo, hi] = a < b ? [a, b] : [b, a];
setSelected(s => { const n = new Set(s);
for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
};
const extendRun = cid => {
if(!canRange()) return;
const list = items();
const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
if(a < 0 || ni < 0) return;
if(!extending){ extending = true; rangeBase = new Set(selected()); }
const [lo, hi] = a < ni ? [a, ni] : [ni, a];
setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
};
const onTileClick = (e, cid) => {
setCursor(cid);
if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
extendTo(cid); setRangeMode(false); return;
}
toggle(cid); setAnchor(cid);
};
const armRange = cid => {
if(!canRange()) return;
setSelected(s => { const n = new Set(s); n.add(cid); return n; });
setAnchor(cid); setRangeMode(true); extending = false;
};
const tileCidAt = (x, y) => {
const tile = document.elementFromPoint(x, y)?.closest('.tile');
if(!tile || !gridEl) return null;
const i = [...gridEl.children].indexOf(tile);
return i < 0 ? null : items()[i]?.cid;
};
let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false,
lpPos = null, lpRaf = 0;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
const EDGE = 48, SCROLL_STEP = 14;
const dragTick = () => {
lpRaf = 0;
if(!lpFired || !lpPos) return;
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
if(!dy) return; // finger left the margin → stop the loop
scrollBy(0, dy);
dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1))); // hit-test clear of the bar
lpRaf = requestAnimationFrame(dragTick);
};
const onTileDown = (e, photo) => {
lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
lpTimer = setTimeout(() => { lpFired = true; lpCancel();
try { lpEl.setPointerCapture(lpId); } catch(_){}
armRange(photo.cid); }, 500);
};
const onTileMove = e => {
if(lpFired){
lpPos = { x: e.clientX, y: e.clientY };
dragExtendAt(e.clientX, e.clientY);
if(!lpRaf) dragTick();
return;
}
if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
};
const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; }
dragStop(); lpCancel(); };
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
onMount(() => gridEl?.addEventListener('touchmove',
e => { if(lpFired) e.preventDefault(); }, { passive: false }));
async function patchSelected(patchFor){
return editing(async tick => {
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const patch = patchFor(byCid.get(cid));
if(patch){ await gql(UPDATE_PHOTO, { cid, patch }); tick(); }
}
clearSel();
await refetch();
});
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK_ARG = { SetState: ['State', 'toState'], SetDate: ['Datetime', 'toWhen'],
AddLabel: ['String', 'label'], RemoveLabel: ['String', 'label'] };
const BULK = k => { const [ty, arg] = BULK_ARG[k];
return `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${ty}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${arg}:$v}){ result } }`; };
async function applyBulk(kind, v, over){
return editing(async tick => {
await gql(BULK(kind), { ...filterVars(), ...over, v }, PV_CTX);
tick(); clearSel(); await refetch();
});
}
const addLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
setLastLabel(words[words.length - 1]);
if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
else await patchSelected(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
setLabelText('');
};
const removeLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
: patchSelected(() => ({ state: st }));
const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const p = byCid.get(cid); if(!p) continue;
const path = res === 'web' ? p.webCid : p.cid;
if(!path) continue;
const isVid = (p.mimetype || '').startsWith('video');
const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
const raw = p.filename || p.cid.split('/').pop() || 'download';
const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
const a = document.createElement('a');
a.href = IPFS + path;
a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
document.body.appendChild(a); a.click(); a.remove();
}
}
const [moveText, setMoveText] = createSignal('');
const [moveFocus, setMoveFocus] = createSignal(false);
const [moveTarget, setMoveTarget] = createSignal(null); // the armed occasion, or null → nothing to apply
const [moveEvents] = createResource(moveFocus,
f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
const eventDist = (e, mean) => { if(mean == null) return 0;
const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
return mean < s ? s - mean : mean > en ? mean - en : 0; };
const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
const moveCandidates = () => {
const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
return (moveEvents() || []).filter(e => owners.has(e.owner))
.filter(e => !q || (e.summary || '').toLowerCase().includes(q))
.sort((a, b) => eventDist(a, mean) - eventDist(b, mean)
|| new Date(a.starttime) - new Date(b.starttime))
.slice(0, 8); };
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
if(allMatching()) await applyBulk('SetDate', ev.starttime, { owners: [ev.owner] });
else await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
setMoveText(''); setMoveTarget(null); };
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
const when = new Date(v).toISOString();
if(allMatching()) await applyBulk('SetDate', when);
else await patchSelected(() => ({ date: when }));
setDatingSel(false); };
const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
async function applyLabels(labels){
const cid = opened().cid;
setOpened({ ...opened(), labels });
await gql(UPDATE_PHOTO, { cid, patch: { labels } });
await refetch();
}
const lbAdd = input => {
const words = splitWords(input); if(!words.length) return;
setLastLabel(words[words.length - 1]);
const cur = labelsOf(opened());
for(const w of words) if(!cur.includes(w)) cur.push(w);
setLbText('');
return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
async function lbPatch(patch){
const list = items(), cur = opened(); if(!cur) return;
const i = list.findIndex(p => p.cid === cur.cid);
const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
await refetch();
requestAnimationFrame(() => {
if(opened()?.cid !== cur.cid) return; // shut, or moved on, while the verdict was out
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 swallowSpc = e => {
if(e.key === ' ' && opened() && e.target === lbVideo) e.preventDefault();
};
for(const t of ['keyup', 'keypress']) window.addEventListener(t, swallowSpc, true);
onCleanup(() => { for(const t of ['keyup', 'keypress']) window.removeEventListener(t, swallowSpc, true); });
});
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);
// an address the browser hasn't seen zoomed → it lands at 1:1
u.searchParams.set('z', String(Date.now()));
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; };
// base layer: thumbnail kept in the window, blank past it
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD)
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK;
// overlay: full-res leads the way you're going
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid)
? IPFS + p.webCid : '';
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
// each layer re-arms its mark on its own source change
createEffect(() => { thumbSrc(); setThumbOn(false); });
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;
// where a lone finger went down, while it could still be a tap
let tapFrom = null;
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
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);
// a whole day is a UTC day, wherever it is read
const zone = dayBased(s, en) ? { timeZone: 'UTC' } : {};
const sd = s.toLocaleDateString('fr-FR', zone), ed = en.toLocaleDateString('fr-FR', zone);
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';
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
// bail out of a frozen read; stay
if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; }
// retract the list; keep the root beneath
if(document.querySelector('.suggest')){
setSearchFocus(false); history.pushState({ app: true }, ''); return; }
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(); },
() => setSearchFocus(false))}
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(),
() => setLabelFocus(false)); }}
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(),
() => setMoveFocus(false))}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- stamp the selection with one instant -->
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</button>
<${Show} when=${() => datingSel()}>
<div class="date-pop">
<input type="datetime-local" aria-label="date for the selection"
value=${() => selDate()} onInput=${e => setSelDate(e.target.value)}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); stampSelDate(); } }} />
<button class="datestamp" aria-label="apply date" disabled=${() => !selDate()}
onClick=${stampSelDate}>→ date</button>
</div>
<//>
</div>
<span class="tb-sep" aria-hidden="true"></span>
<!-- state -->
${STATES.map(st => html`
<button class="st" data-st=${st} onClick=${() => setStateFor(st)}>${st}</button>`)}
<span class="tb-sep" aria-hidden="true"></span>
<!-- download the selection, one file per doc, at a chosen resolution -->
<span class="dl-grp" aria-hidden="true">↓</span>
<button class="dl" onClick=${() => downloadSelection('orig')}>orig</button>
<button class="dl" onClick=${() => downloadSelection('web')}>web</button>
<span class="tb-sep" aria-hidden="true"></span>
<button class="clear" onClick=${clearSel}>clear</button>
</div>
<//>
<${Show} when=${() => !photos.loading && !photos.error && items().length === 0 && !authNeeded()}>
<p class="empty">No photos.</p>
<//>
<${Show} when=${() => items().length > 0}>
<p class="countline">${() => {
if(!photos()?.sampled) return `showing all ${items().length}`;
const end = photos()?.pick;
return end ? `showing the ${end} ${items().length} of ${total()}`
: `showing a spread of ${items().length} from ${total()}`
+ ` — narrow the search or date range to see them all`;
}}</p>
<//>
<div class="sizer">
<button aria-label="smaller thumbnails" onClick=${() => bumpSize(-1)}>−</button>
<button aria-label="bigger thumbnails" onClick=${() => bumpSize(1)}>+</button>
</div>
<${Show} when=${() => photos.loading || mutating()}>
<${Show} when=${() => photos.loading && !mutating()}
fallback=${html`<div class="wall-busy" aria-hidden="true">updating…</div>`}>
<button class="wall-busy busy-cancel" onClick=${cancelRead}
aria-label=${() => 'cancel search: ' + (running().label || 'everything')}>${
() => `searching «${running().label || 'everything'}»`}</button>
<//>
<//>
<${Show} when=${() => editStalled()}>
<div class="wall-busy wall-stalled" role="status">the edit did not go through — try again</div>
<//>
<div class="grid" role="list" aria-label="photos"
aria-busy=${() => photos.loading || mutating() ? 'true' : 'false'}
style=${() => '--tile:' + thumbSize() + 'px'}
ref=${el => { gridEl = el; watchCell(el); }} onWheel=${onGridWheel}
onPointerDown=${onGridDown} onPointerMove=${onGridMove}
onPointerUp=${onGridUp} onPointerCancel=${onGridUp}>
<${For} each=${() => items()}>${photo => {
const [near, setNear] = createSignal(false);
const [thumbOn, setThumbOn] = createSignal(false);
const thumbSettled = () => setThumbOn(true); // painted, or failed and never coming
createEffect(() => { near(); setThumbOn(false); }); // re-shown → its source is refetched → waiting again
createEffect(() => { // one of the thumbnails the wall waits for…
if(!(near() && photo.thumbnailCid && !thumbOn())) return;
setThumbsInFlight(n => n + 1);
onCleanup(() => setThumbsInFlight(n => n - 1)); // …until it settles or leaves
});
const [sharp, setSharp] = createSignal(false);
createEffect(() => { if(!near()) setSharp(false); // gone → both layers go
else if(thumbOn() && refining()) setSharp(true); });
const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
const [webOn, setWebOn] = createSignal(false);
createEffect(() => { webSrc(); setWebOn(false); }); // a new source → fade the overlay in again
return html`
<div class="tile" role="listitem" data-selected=${() => isSel(photo.cid) ? '1' : '0'}
data-cursor=${() => cursor() === photo.cid ? '1' : '0'}
onPointerDown=${e => onTileDown(e, photo)} onPointerMove=${onTileMove}
onPointerUp=${onTileUp} onPointerCancel=${onTileUp}
onClick=${e => onTilePress(e, photo.cid)}
onDblClick=${() => openPhoto(photo)}>
<${Show} when=${() => photo.thumbnailCid}
fallback=${html`<div class="thumb noimg" title=${photo.filename || photo.date.slice(0, 10)}>
<span class="ph">${isVideo(photo) ? '🎬' : '🖼'}</span></div>`}>
<img class="thumb" draggable="false" alt=${photo.date.slice(0, 10)} title=${photo.date.slice(0, 10)}
ref=${el => watchVisible(el, setNear)}
src=${() => near() ? IPFS + photo.thumbnailCid : BLANK}
onLoad=${thumbSettled} onError=${thumbSettled} />
<${Show} when=${webSrc}>
<img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
src=${webSrc} onLoad=${() => setWebOn(true)} />
<//>
<//>
<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
<span class="play" title="video">▶</span>
<//>
<${Show} when=${() => isSel(photo.cid)}><span class="check">✓</span><//>
<span class="badge">${photo.state || ''}</span>
<div class="foot">
<${Show} when=${() => !!photo.labels}>
<span class="labels" title=${photo.labels}>${photo.labels}</span>
<//>
<${Show} when=${() => docEvents(photo).length}>
<div class="events">
<${For} each=${() => docEvents(photo)}>${e => html`
<span class="ev-pill"
style=${() => 'background:' + eventColour().get(eventKey(e))}>${() => e.summary}</span>`}<//>
</div>
<//>
</div>
</div>`;
}}
<//>
</div>
<${Show} when=${() => marquee()}>
<div class="marquee" style=${() => { const r = marqueeRect(marquee());
return `left:${r.l}px;top:${r.t}px;width:${r.r - r.l}px;height:${r.b - r.t}px`; }}></div>
<//>
<${Show} when=${() => opened()}>
<div class="lb" onClick=${e => {
if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]'))
dismissPhoto(); }}>
<div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
<button class="lb-close" aria-label="close" onClick=${dismissPhoto}>✕</button>
<button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid)
? '✓ selected' : 'select'}</button>
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
<button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}>‹</button>
<button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}>›</button>
<${Show} when=${() => hasMedia(opened())}
fallback=${html`<div class="lb-media noimg">
<span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
<span class="mt">no preview · ${() => opened()?.filename
|| opened()?.mimetype || ''}</span></div>`}>
<${Show} when=${() => isVideo(opened())}
fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
<video class="lb-media" controls autoplay ref=${el => lbVideo = el}
src=${() => IPFS + opened()?.webCid}></video>
<//>
<//>
<div class="lb-meta">
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date
? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); }
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); }
}}
onBlur=${e => commitDate(e.target.value)} />
<//>
<a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
aria-label="original" title="original — full resolution, new tab">⤢</a>
</div>
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event"
onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span
class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
<div class="lb-states">
<${For} each=${() => STATES}>${st => html`
<button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
onClick=${() => lbSetState(st)}>${st}</button>`}
<//>
</div>
<div class="lb-labels">
<${For} each=${() => labelsOf(opened())}>${w => html`
<span class="lb-chip"><button class="lb-chip-word"
onClick=${() => { setSearch(w); commit(); dismissPhoto(); }}>${w}</button><button class="x"
aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
<//>
<${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
<button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
<//>
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
onKeyDown=${e => {
if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText()),
() => setLbFocus(false)); }} />
<${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(),
() => setFrameLabelFocus(false)); }} />
<${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 app, piece by piece
- how
- the search box is kept in the browser as it changes and read back on load, so a reload — or the slideshow’s relaunch — reopens the same query, and so the same slideshow.
// the search box persists locally
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
- how
- the box is a combobox over the list of offers, with the run button at its edge; it locks while a read is out and says when it holds something not yet run.
<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(); },
() => setSearchFocus(false))}
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>
- how
- every other part of the app — occasions, batch edits, the lightbox, the slideshow, the cursor — plugs in here, each told under its own heading.
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);
// a new filter, a new reach
createEffect(on([committed, stateFilter], () => setAllMatching(false), { defer: true }));
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };
const shownCids = () => items().map(p => p.cid);
const allSelected = () => { const a = shownCids(); return a.length > 0 && a.every(isSel); };
const toggleAll = () => allSelected() ? clearSel() : setSelected(new Set(shownCids()));
const extendTo = cid => {
const list = items().map(p => p.cid);
const a = list.indexOf(anchor()), b = list.indexOf(cid);
if(a < 0 || b < 0){ toggle(cid); setAnchor(cid); return; }
const [lo, hi] = a < b ? [a, b] : [b, a];
setSelected(s => { const n = new Set(s);
for(let i = lo; i <= hi; i++) n.add(list[i]); return n; });
};
const extendRun = cid => {
if(!canRange()) return;
const list = items();
const a = list.findIndex(p => p.cid === anchor()), ni = list.findIndex(p => p.cid === cid);
if(a < 0 || ni < 0) return;
if(!extending){ extending = true; rangeBase = new Set(selected()); }
const [lo, hi] = a < ni ? [a, ni] : [ni, a];
setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
};
const onTileClick = (e, cid) => {
setCursor(cid);
if((e.shiftKey || rangeMode()) && anchor() !== null && canRange()){
extendTo(cid); setRangeMode(false); return;
}
toggle(cid); setAnchor(cid);
};
const armRange = cid => {
if(!canRange()) return;
setSelected(s => { const n = new Set(s); n.add(cid); return n; });
setAnchor(cid); setRangeMode(true); extending = false;
};
const tileCidAt = (x, y) => {
const tile = document.elementFromPoint(x, y)?.closest('.tile');
if(!tile || !gridEl) return null;
const i = [...gridEl.children].indexOf(tile);
return i < 0 ? null : items()[i]?.cid;
};
let lpTimer = null, lpFired = false, lpAt = null, lpEl = null, lpId = null, lpMoved = false,
lpPos = null, lpRaf = 0;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const dragExtendAt = (x, y) => { const cid = tileCidAt(x, y); if(cid){ extendRun(cid); lpMoved = true; } };
const EDGE = 48, SCROLL_STEP = 14;
const dragTick = () => {
lpRaf = 0;
if(!lpFired || !lpPos) return;
const bar = document.querySelector('.toolbar');
const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
const dy = lpPos.y < EDGE ? -SCROLL_STEP : lpPos.y > floor - EDGE ? SCROLL_STEP : 0;
if(!dy) return; // finger left the margin → stop the loop
scrollBy(0, dy);
dragExtendAt(lpPos.x, Math.max(EDGE, Math.min(lpPos.y, floor - 1))); // hit-test clear of the bar
lpRaf = requestAnimationFrame(dragTick);
};
const onTileDown = (e, photo) => {
lpFired = false; lpMoved = false; lpAt = { x: e.clientX, y: e.clientY };
lpEl = e.currentTarget; lpId = e.pointerId; lpCancel();
lpTimer = setTimeout(() => { lpFired = true; lpCancel();
try { lpEl.setPointerCapture(lpId); } catch(_){}
armRange(photo.cid); }, 500);
};
const onTileMove = e => {
if(lpFired){
lpPos = { x: e.clientX, y: e.clientY };
dragExtendAt(e.clientX, e.clientY);
if(!lpRaf) dragTick();
return;
}
if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel();
};
const dragStop = () => { if(lpRaf){ cancelAnimationFrame(lpRaf); lpRaf = 0; } lpPos = null; };
const onTileUp = () => { if(lpFired && lpMoved){ setRangeMode(false); extending = false; }
dragStop(); lpCancel(); };
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };
onMount(() => gridEl?.addEventListener('touchmove',
e => { if(lpFired) e.preventDefault(); }, { passive: false }));
async function patchSelected(patchFor){
return editing(async tick => {
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const patch = patchFor(byCid.get(cid));
if(patch){ await gql(UPDATE_PHOTO, { cid, patch }); tick(); }
}
clearSel();
await refetch();
});
}
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);
const filterVars = () => ({ ...photoVars(parseQuery(search())),
states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK_ARG = { SetState: ['State', 'toState'], SetDate: ['Datetime', 'toWhen'],
AddLabel: ['String', 'label'], RemoveLabel: ['String', 'label'] };
const BULK = k => { const [ty, arg] = BULK_ARG[k];
return `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${ty}!){
photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${arg}:$v}){ result } }`; };
async function applyBulk(kind, v, over){
return editing(async tick => {
await gql(BULK(kind), { ...filterVars(), ...over, v }, PV_CTX);
tick(); clearSel(); await refetch();
});
}
const addLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
setLastLabel(words[words.length - 1]);
if(allMatching()){ for(const w of words) await applyBulk('AddLabel', w); }
else await patchSelected(p => { const cur = splitLabels(p);
for(const w of words) if(!cur.includes(w)) cur.push(w); return { labels: cur.join('; ') }; });
setLabelText('');
};
const removeLabel = async () => {
const words = splitWords(labelText()); if(!words.length) return;
if(allMatching()){ for(const w of words) await applyBulk('RemoveLabel', w); }
else await patchSelected(p => ({ labels: splitLabels(p).filter(x => !words.includes(x)).join('; ') }));
setLabelText('');
};
const setStateFor = st => allMatching() ? applyBulk('SetState', st)
: patchSelected(() => ({ state: st }));
const MIME_EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/heic': 'heic', 'image/heif': 'heif',
'image/tiff': 'tiff', 'video/quicktime': 'mov', 'video/x-matroska': 'mkv',
'video/x-msvideo': 'avi' };
const extOf = mt => MIME_EXT[mt] || (mt && mt.split('/')[1]) || '';
function downloadSelection(res){
const byCid = new Map(items().map(p => [p.cid, p]));
for(const cid of selected()){
const p = byCid.get(cid); if(!p) continue;
const path = res === 'web' ? p.webCid : p.cid;
if(!path) continue;
const isVid = (p.mimetype || '').startsWith('video');
const ext = res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
const raw = p.filename || p.cid.split('/').pop() || 'download';
const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
const a = document.createElement('a');
a.href = IPFS + path;
a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
document.body.appendChild(a); a.click(); a.remove();
}
}
const [moveText, setMoveText] = createSignal('');
const [moveFocus, setMoveFocus] = createSignal(false);
const [moveTarget, setMoveTarget] = createSignal(null); // the armed occasion, or null → nothing to apply
const [moveEvents] = createResource(moveFocus,
f => f ? fetchWindowEvents({ since: '1900-01-01T00:00:00Z', until: '2100-01-01T00:00:00Z' }) : []);
const selectedDocs = () => { const byCid = new Map(items().map(p => [p.cid, p]));
return [...selected()].map(c => byCid.get(c)).filter(Boolean); };
const selMean = () => { const ts = selectedDocs().map(p => new Date(p.date).getTime()).filter(n => !isNaN(n));
return ts.length ? ts.reduce((a, b) => a + b, 0) / ts.length : null; };
const eventDist = (e, mean) => { if(mean == null) return 0;
const s = new Date(e.starttime).getTime(), en = new Date(e.endtime).getTime();
return mean < s ? s - mean : mean > en ? mean - en : 0; };
const selOwners = () => new Set(selectedDocs().map(p => p.owner).filter(Boolean));
const moveCandidates = () => {
const q = moveText().trim().toLowerCase(), mean = selMean(), owners = selOwners();
return (moveEvents() || []).filter(e => owners.has(e.owner))
.filter(e => !q || (e.summary || '').toLowerCase().includes(q))
.sort((a, b) => eventDist(a, mean) - eventDist(b, mean)
|| new Date(a.starttime) - new Date(b.starttime))
.slice(0, 8); };
const pickMove = e => { setMoveTarget(e); setMoveText(e.summary); setMoveFocus(false); };
const moveToEvent = async () => { const ev = moveTarget(); if(!ev) return;
if(allMatching()) await applyBulk('SetDate', ev.starttime, { owners: [ev.owner] });
else await patchSelected(p => p.owner === ev.owner ? { date: ev.starttime } : null);
setMoveText(''); setMoveTarget(null); };
createEffect(() => { if(moveFocus()) reportSug(moveCandidates()); });
createEffect(() => { selected(); setMoveText(''); setMoveTarget(null); });
const [datingSel, setDatingSel] = createSignal(false); // is the picker open?
const [selDate, setSelDate] = createSignal(''); // its datetime-local value
const openSelDate = () => { const m = selMean();
setSelDate(m != null ? toLocalInput(new Date(m).toISOString()) : '');
setDatingSel(true); };
const stampSelDate = async () => { const v = selDate(); if(!v) return;
const when = new Date(v).toISOString();
if(allMatching()) await applyBulk('SetDate', when);
else await patchSelected(() => ({ date: when }));
setDatingSel(false); };
const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const [editingDate, setEditingDate] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
const hasMedia = p => isVideo(p) ? !!p?.webCid : !!(p?.webCid || p?.thumbnailCid);
const labelsOf = p => splitWords(p?.labels);
const openPhoto = p => { history.pushState({ lb: p.cid }, ''); setOpened(p); };
const closePhoto = () => { setOpened(null); setLbText(''); };
const dismissPhoto = () => (history.state && history.state.lb) ? history.back() : closePhoto();
async function applyLabels(labels){
const cid = opened().cid;
setOpened({ ...opened(), labels });
await gql(UPDATE_PHOTO, { cid, patch: { labels } });
await refetch();
}
const lbAdd = input => {
const words = splitWords(input); if(!words.length) return;
setLastLabel(words[words.length - 1]);
const cur = labelsOf(opened());
for(const w of words) if(!cur.includes(w)) cur.push(w);
setLbText('');
return applyLabels(cur.join('; '));
};
const lbRemove = w => applyLabels(labelsOf(opened()).filter(x => x !== w).join('; '));
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };
async function lbPatch(patch){
const list = items(), cur = opened(); if(!cur) return;
const i = list.findIndex(p => p.cid === cur.cid);
const nextCid = list.length > 1 ? list[(i + 1) % list.length].cid : null;
await gql(UPDATE_PHOTO, { cid: cur.cid, patch });
await refetch();
requestAnimationFrame(() => {
if(opened()?.cid !== cur.cid) return; // shut, or moved on, while the verdict was out
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 swallowSpc = e => {
if(e.key === ' ' && opened() && e.target === lbVideo) e.preventDefault();
};
for(const t of ['keyup', 'keypress']) window.addEventListener(t, swallowSpc, true);
onCleanup(() => { for(const t of ['keyup', 'keypress']) window.removeEventListener(t, swallowSpc, true); });
});
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);
// an address the browser hasn't seen zoomed → it lands at 1:1
u.searchParams.set('z', String(Date.now()));
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; };
// base layer: thumbnail kept in the window, blank past it
const thumbBand = (p, k) => inReach(k, KEEP_BEHIND, KEEP_AHEAD)
? IPFS + (p?.thumbnailCid || p?.webCid || '') : BLANK;
// overlay: full-res leads the way you're going
const webBand = (p, k) => (inReach(k, WEB_BEHIND, WEB_AHEAD) && p?.webCid)
? IPFS + p.webCid : '';
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
// each layer re-arms its mark on its own source change
createEffect(() => { thumbSrc(); setThumbOn(false); });
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;
// where a lone finger went down, while it could still be a tap
let tapFrom = null;
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
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);
// a whole day is a UTC day, wherever it is read
const zone = dayBased(s, en) ? { timeZone: 'UTC' } : {};
const sd = s.toLocaleDateString('fr-FR', zone), ed = en.toLocaleDateString('fr-FR', zone);
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';
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
// bail out of a frozen read; stay
if(photos.loading){ cancelRead(); history.pushState({ app: true }, ''); return; }
// retract the list; keep the root beneath
if(document.querySelector('.suggest')){
setSearchFocus(false); history.pushState({ app: true }, ''); return; }
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));
});
- how
- what shapes the wall sits above it, and what opens one doc lies over it.
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(); },
() => setSearchFocus(false))}
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(),
() => setLabelFocus(false)); }}
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(),
() => setMoveFocus(false))}
onBlur=${() => setMoveFocus(false)} />
<${Show} when=${() => moveFocus() && moveCandidates().length > 0}>
<ul class="suggest" role="listbox" aria-label="events">
<${For} each=${() => moveCandidates()}>${(e, i) => html`
<li class=${() => 'sug' + (i() === sugActive() ? ' active' : '')} role="option"
aria-selected=${() => i() === sugActive() ? 'true' : 'false'}
onMouseDown=${ev => { ev.preventDefault(); pickMove(e); }}>
${() => e.summary}<span class="ev-when">${() => eventWhen(e)}</span></li>`}
<//>
</ul>
<//>
</div>
<button class="movebtn" aria-label="move to event" disabled=${() => !moveTarget()}
onClick=${moveToEvent}>→ event</button>
<span class="tb-sep" aria-hidden="true"></span>
<!-- stamp the selection with one instant -->
<div class="batch-date">
<button class="datebtn" aria-label="set date" title="set the selection's date"
aria-expanded=${() => datingSel() ? 'true' : 'false'}
onClick=${() => datingSel() ? setDatingSel(false) : openSelDate()}>🕓</button>
<${Show} when=${() => datingSel()}>
<div class="date-pop">
<input type="datetime-local" aria-label="date for the selection"
value=${() => selDate()} onInput=${e => setSelDate(e.target.value)}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); stampSelDate(); } }} />
<button class="datestamp" aria-label="apply date" disabled=${() => !selDate()}
onClick=${stampSelDate}>→ date</button>
</div>
<//>
</div>
<span class="tb-sep" aria-hidden="true"></span>
<!-- state -->
${STATES.map(st => html`
<button class="st" data-st=${st} onClick=${() => setStateFor(st)}>${st}</button>`)}
<span class="tb-sep" aria-hidden="true"></span>
<!-- download the selection, one file per doc, at a chosen resolution -->
<span class="dl-grp" aria-hidden="true">↓</span>
<button class="dl" onClick=${() => downloadSelection('orig')}>orig</button>
<button class="dl" onClick=${() => downloadSelection('web')}>web</button>
<span class="tb-sep" aria-hidden="true"></span>
<button class="clear" onClick=${clearSel}>clear</button>
</div>
<//>
<${Show} when=${() => !photos.loading && !photos.error && items().length === 0 && !authNeeded()}>
<p class="empty">No photos.</p>
<//>
<${Show} when=${() => items().length > 0}>
<p class="countline">${() => {
if(!photos()?.sampled) return `showing all ${items().length}`;
const end = photos()?.pick;
return end ? `showing the ${end} ${items().length} of ${total()}`
: `showing a spread of ${items().length} from ${total()}`
+ ` — narrow the search or date range to see them all`;
}}</p>
<//>
<div class="sizer">
<button aria-label="smaller thumbnails" onClick=${() => bumpSize(-1)}>−</button>
<button aria-label="bigger thumbnails" onClick=${() => bumpSize(1)}>+</button>
</div>
<${Show} when=${() => photos.loading || mutating()}>
<${Show} when=${() => photos.loading && !mutating()}
fallback=${html`<div class="wall-busy" aria-hidden="true">updating…</div>`}>
<button class="wall-busy busy-cancel" onClick=${cancelRead}
aria-label=${() => 'cancel search: ' + (running().label || 'everything')}>${
() => `searching «${running().label || 'everything'}»`}</button>
<//>
<//>
<${Show} when=${() => editStalled()}>
<div class="wall-busy wall-stalled" role="status">the edit did not go through — try again</div>
<//>
<div class="grid" role="list" aria-label="photos"
aria-busy=${() => photos.loading || mutating() ? 'true' : 'false'}
style=${() => '--tile:' + thumbSize() + 'px'}
ref=${el => { gridEl = el; watchCell(el); }} onWheel=${onGridWheel}
onPointerDown=${onGridDown} onPointerMove=${onGridMove}
onPointerUp=${onGridUp} onPointerCancel=${onGridUp}>
<${For} each=${() => items()}>${photo => {
const [near, setNear] = createSignal(false);
const [thumbOn, setThumbOn] = createSignal(false);
const thumbSettled = () => setThumbOn(true); // painted, or failed and never coming
createEffect(() => { near(); setThumbOn(false); }); // re-shown → its source is refetched → waiting again
createEffect(() => { // one of the thumbnails the wall waits for…
if(!(near() && photo.thumbnailCid && !thumbOn())) return;
setThumbsInFlight(n => n + 1);
onCleanup(() => setThumbsInFlight(n => n - 1)); // …until it settles or leaves
});
const [sharp, setSharp] = createSignal(false);
createEffect(() => { if(!near()) setSharp(false); // gone → both layers go
else if(thumbOn() && refining()) setSharp(true); });
const webSrc = () => sharp() && !isVideo(photo) && photo.webCid ? IPFS + photo.webCid : '';
const [webOn, setWebOn] = createSignal(false);
createEffect(() => { webSrc(); setWebOn(false); }); // a new source → fade the overlay in again
return html`
<div class="tile" role="listitem" data-selected=${() => isSel(photo.cid) ? '1' : '0'}
data-cursor=${() => cursor() === photo.cid ? '1' : '0'}
onPointerDown=${e => onTileDown(e, photo)} onPointerMove=${onTileMove}
onPointerUp=${onTileUp} onPointerCancel=${onTileUp}
onClick=${e => onTilePress(e, photo.cid)}
onDblClick=${() => openPhoto(photo)}>
<${Show} when=${() => photo.thumbnailCid}
fallback=${html`<div class="thumb noimg" title=${photo.filename || photo.date.slice(0, 10)}>
<span class="ph">${isVideo(photo) ? '🎬' : '🖼'}</span></div>`}>
<img class="thumb" draggable="false" alt=${photo.date.slice(0, 10)} title=${photo.date.slice(0, 10)}
ref=${el => watchVisible(el, setNear)}
src=${() => near() ? IPFS + photo.thumbnailCid : BLANK}
onLoad=${thumbSettled} onError=${thumbSettled} />
<${Show} when=${webSrc}>
<img class="thumb web" classList=${() => ({ shown: webOn() })} alt="" draggable="false"
src=${webSrc} onLoad=${() => setWebOn(true)} />
<//>
<//>
<${Show} when=${() => isVideo(photo) && photo.thumbnailCid}>
<span class="play" title="video">▶</span>
<//>
<${Show} when=${() => isSel(photo.cid)}><span class="check">✓</span><//>
<span class="badge">${photo.state || ''}</span>
<div class="foot">
<${Show} when=${() => !!photo.labels}>
<span class="labels" title=${photo.labels}>${photo.labels}</span>
<//>
<${Show} when=${() => docEvents(photo).length}>
<div class="events">
<${For} each=${() => docEvents(photo)}>${e => html`
<span class="ev-pill"
style=${() => 'background:' + eventColour().get(eventKey(e))}>${() => e.summary}</span>`}<//>
</div>
<//>
</div>
</div>`;
}}
<//>
</div>
<${Show} when=${() => marquee()}>
<div class="marquee" style=${() => { const r = marqueeRect(marquee());
return `left:${r.l}px;top:${r.t}px;width:${r.r - r.l}px;height:${r.b - r.t}px`; }}></div>
<//>
<${Show} when=${() => opened()}>
<div class="lb" onClick=${e => {
if(!e.target.closest('button, a, input, textarea, select, video, [role=option], [role=listbox]'))
dismissPhoto(); }}>
<div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo" onWheel=${onWheel}>
<button class="lb-close" aria-label="close" onClick=${dismissPhoto}>✕</button>
<button class="lb-select" aria-pressed=${() => isSel(opened()?.cid) ? 'true' : 'false'}
onClick=${() => toggle(opened().cid)}>${() => isSel(opened()?.cid)
? '✓ selected' : 'select'}</button>
<button class="lb-frame" aria-label="frame from here" onClick=${frameFromHere}>▶ frame</button>
<button class="lb-nav lb-prev" aria-label="previous photo" onClick=${() => step(-1)}>‹</button>
<button class="lb-nav lb-next" aria-label="next photo" onClick=${() => step(1)}>›</button>
<${Show} when=${() => hasMedia(opened())}
fallback=${html`<div class="lb-media noimg">
<span class="ph">${() => isVideo(opened()) ? '🎬' : '🖼'}</span>
<span class="mt">no preview · ${() => opened()?.filename
|| opened()?.mimetype || ''}</span></div>`}>
<${Show} when=${() => isVideo(opened())}
fallback=${html`<img class="lb-media" src=${() => mediaSrc(opened())} />`}>
<video class="lb-media" controls autoplay ref=${el => lbVideo = el}
src=${() => IPFS + opened()?.webCid}></video>
<//>
<//>
<div class="lb-meta">
<${Show} when=${() => editingDate()}
fallback=${html`<button class="lb-date" aria-label="edit date"
onClick=${() => setEditingDate(true)}>${() => opened()?.date
? new Date(opened().date).toLocaleString("fr-FR") : ''}</button>`}>
<input class="lb-date-edit" type="datetime-local" aria-label="date"
ref=${el => { el.value = toLocalInput(opened()?.date); requestAnimationFrame(() => el.focus()); }}
onKeyDown=${e => { if(e.key === 'Enter'){ e.preventDefault(); e.target.blur(); }
else if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); cancelEdit = true; e.target.blur(); }
}}
onBlur=${e => commitDate(e.target.value)} />
<//>
<a class="lb-orig" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
aria-label="original" title="original — full resolution, new tab">⤢</a>
</div>
<div class="lb-events">
<${For} each=${() => lbEvents() || []}>${e => html`
<button class="lb-event"
onClick=${() => { searchEvent(e.summary); dismissPhoto(); }}>${() => e.summary}<span
class="ev-when">${() => eventWhen(e)}</span></button>`}
<//>
</div>
<div class="lb-states">
<${For} each=${() => STATES}>${st => html`
<button class="lb-st" data-st=${st} aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
onClick=${() => lbSetState(st)}>${st}</button>`}
<//>
</div>
<div class="lb-labels">
<${For} each=${() => labelsOf(opened())}>${w => html`
<span class="lb-chip"><button class="lb-chip-word"
onClick=${() => { setSearch(w); commit(); dismissPhoto(); }}>${w}</button><button class="x"
aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
<//>
<${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
<button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
<//>
<div class="complete">
<input class="batch-label" role="combobox" placeholder="add a label…" aria-label="add a label"
aria-expanded=${() => lbFocus() && (sugLoading() || sugItems().length > 0) ? 'true' : 'false'}
value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
onFocus=${() => setLbFocus(true)} onBlur=${() => setLbFocus(false)}
onKeyDown=${e => {
if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText()),
() => setLbFocus(false)); }} />
<${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(),
() => setFrameLabelFocus(false)); }} />
<${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>
<//>
`;
A tile shows its doc whole
- what
- a tile is how you recognize its doc at a glance.
- rule
- the thumbnail never crops away what identifies the doc.
- example: a face at the picture’s edge, a landscape’s horizon.
- rule
- the tile shows the image whole: fitted entire, never filling the square with a cropped centre.
assert thumb_imgs(page).first.evaluate("el => getComputedStyle(el).objectFit") == "contain", \
"thumbnail crops instead of fitting whole"
print(" PASS: thumbnail shows whole image")
- how
- the look keeps every tile square and fits its image inside, the tile background showing through on whichever axis the image leaves spare.
.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, paired with aria-description — see “Reading only on commit” */
.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; see “Bailing out of a read” */
.search:disabled, .chip:disabled{ opacity:.5; cursor:default; }
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(var(--tile, 96px), 1fr)); gap:4px; }
/* dim + pill while the wall is settling (photos.loading || mutating()) */
.grid[aria-busy="true"]{ opacity:.55; transition:opacity .12s; }
.wall-busy{ position:fixed; top:12px; left:50%; transform:translateX(-50%); z-index:30;
background:#33395a; color:#dfe6ff; font-size:13px; padding:5px 12px; border-radius:999px;
box-shadow:0 4px 14px #0006; pointer-events:none; }
/* while searching, the pill is a cancel button — re-enable pointer events, reset button chrome */
.busy-cancel{ pointer-events:auto; cursor:pointer; border:0; font:inherit; }
/* an edit given up on */
.wall-stalled{ background:#5a3340; color:#ffdfe6; }
.sizer{ display:flex; gap:6px; justify-content:flex-end; margin:0 0 6px; }
.sizer button{ width:28px; height:28px; padding:0; font-size:16px; line-height:1; cursor:pointer;
border:1px solid #3a3f5a; border-radius:6px; background:#262a40; color:var(--fg); }
.sizer button:hover{ background:#33395a; }
.tile{ position:relative; aspect-ratio:1; cursor:pointer; border-radius:4px; overflow:hidden;
user-select:none; touch-action:manipulation; -webkit-touch-callout:none; }
/* manipulation: kill the double-tap zoom + tap delay; no callout */
.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, inert to the pointer */
.marquee{ position:fixed; z-index:50; border:1px solid #6cf; background:rgba(108,204,255,.18);
pointer-events:none; }
A read made on commit, and dropped
Reading only on commit
- rule
- the box searches when you commit, not as you type:
typing updates the box and its offers, never the wall.
- how: you commit by tapping the run-search button beside the box, or from the keyboard.
- rule
- a state-filter button re-reads at once, on the last committed query.
- how
- the running query drives the wall, and advances to the newest committed query only
when the wall is idle.
- therefore: the
searching «…»pill names a single, stable query.
- therefore: the
// FREEZE: advance to the committed query only when idle — see “Reading only on commit”
const target = createMemo(() => ({ key: queryStr(), label: committed().trim() }));
const [running, setRunning] = createSignal(target());
let readCtl = null; // the in-flight read's AbortController
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()); });
- rule
- typing alone sends no request.
base = len(seen)
sb = search_box(page)
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzslow-typed", delay=0)
expect(sb).to_have_value("zzslow-typed") # the box shows it (the reactive tick ran)…
assert len(seen) == base, f"typing re-read the wall without a commit: {seen[base:]}" # …but no read fired
print(" PASS: wall reads only on commit")
- rule
- from the keyboard, Enter commits when no offer is highlighted, and the wall reads
what was typed.
- scope: Enter with an offer highlighted applies it instead; that path is the completion test’s.
sb.press("Enter") # commit — nothing highlighted
expect(page.get_by_text("searching «zzslow-typed»")).to_be_visible() # the wall read what was typed
assert seen[base:] == ["zzslow-typed"], f"commit fired the wrong reads: {seen[base:]}"
print(" PASS: enter commits search")
// the wall reads a COMMITTED query, not the live text — see “Reading only on commit”
const [committed, setCommitted] = createSignal(search());
const commit = () => setCommitted(search());
const dirty = () => search() !== committed(); // typed but not yet run → the wall is stale
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() }); });
- rule
- the box says so while it holds something not yet run:
while the typed text differs from the committed one it is dirty, tinted amber, to read as stale,
not yet applied.
- rule: a screen reader gets the same state, as a description on the box.
- rule: committing, or backing out via Esc/Back, clears both at once.
sb = search_box(page)
clean = sb.evaluate("el => getComputedStyle(el).borderColor")
assert sb.get_attribute("aria-description") is None # committed → nothing stale
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially(FIXTURE_LABEL, delay=0)
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # non-colour cue
assert sb.evaluate("el => getComputedStyle(el).borderColor") != clean, "edited box wasn't recoloured"
page.get_by_role("button", name="run search").click() # commit — this one is not held
expect(sb).to_be_enabled() # the read settled
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # cleared on commit
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "colour didn't revert after commit"
print(" PASS: search bar marks uncommitted edit")
- how
- held open at load, the empty-search read shows its cue.
- rule: the empty query is a query too: with the box cleared the pill names the whole
library —
«everything»— rather than a blank.
- rule: the empty query is a query too: with the box cleared the pill names the whole
library —
expect(page.get_by_text("searching «everything»")).to_be_visible()
print(" PASS: wall cue names empty query everything")
Bailing out of a read
- rule
- while a read is out the controls freeze — locked,
so what is on screen cannot drift from what is being fetched.
- what: the search box, the run button and the state filter report themselves disabled — dimmed, read as held, not broken.
- what: they free the instant the read lands.
- therefore: a slow read is felt as a locked box.
expect(search_box(page)).to_be_disabled() # search frozen while the read is out
expect(page.get_by_role("button", name="run search")).to_be_disabled() # …the run button
expect(chip(page, "todo")).to_be_disabled() # …and the state chips
print(" PASS: query controls freeze while reading")
- rule
- a lock needs a key: three ways out of a read that
drags — tapping the
searching «…»pill, Escape, or the back button.- example: a slow
event:filter over all the docs. - rule: any of them aborts the read in flight and
goes back to the last answer — the last query the wall
actually showed, or, on the first read with nothing settled yet, everything.
- what: the controls free at once, and the box reverts to that last good query.
- rule: the abandoned read’s connection is aborted, not left running against the server.
- scope: an edit’s
updating…is not cancellable — its re-read is re-reading a committed change, not a query worth abandoning. - rule: Escape bails out though the box is disabled.
- how: In practice, a key listener on the window hears it, since the disabled box can’t hear its own keydown.
- example: a slow
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")
// ESCAPE HATCH: flush to lastSettled + abort the in-flight read — see “Bailing out of a read”
const [lastSettled, setLastSettled] = createSignal(null);
createEffect(() => { if(!photos.loading && !photos.error) setLastSettled(running()); });
const cancelRead = () => {
if(!photos.loading) return;
const ctl = readCtl, s = lastSettled();
if(s && s.key !== running().key){ setSearch(s.label); setCommitted(s.label);
setStateFilter(JSON.parse(s.key).state); setRunning(s); }
else { setSearch(''); setCommitted(''); setRunning(target()); } // boot / no prior → everything
ctl?.abort();
};
onMount(() => { // Esc bails out of a frozen read (Back bails too, via the exit guard)
const onEsc = e => {
if(e.key === 'Escape' && photos.loading && !opened() && !frame()){
e.preventDefault(); cancelRead(); } };
window.addEventListener('keydown', onEsc);
onCleanup(() => window.removeEventListener('keydown', onEsc));
});
- rule
- the back button reaches the same hatch, one rung before the leave prompt, and one
Back further still asks before leaving, so the history root stays intact.
- how: Back goes through the exit guard that retracts the offers.
hold_a_read(page, "zzslow-back")
page.go_back() # Back bails out
expect(search_box(page)).to_be_enabled()
expect(search_box(page)).to_have_value(FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
asked = []
page.on("dialog", lambda d: (asked.append(1), d.dismiss()))
page.go_back() # a further Back reaches the leave guard
wait_until(page, lambda: bool(asked))
expect(search_box(page)).to_be_visible() # cancelled → still in Memories
print(" PASS: back cancels frozen read")
- rule
- bailing out puts the box back, and that means the marks on it too.
- what: a state-filter button, re-reading the last committed query, can start a slow read while the box still holds something typed and unrun.
- rule: the half-thought sitting in the box goes with the rest.
- rule: so does the state filter, since the filter is part of the query.
sb.click(); sb.press("ControlOrMeta+a"); sb.press_sequentially("zzsecond-thoughts", delay=0)
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied"))
slow_state[0] = "next" # this filter is the slow one, and stays slow
chip(page, "next").click()
assert seen[-1] == FIXTURE_LABEL, f"the chip put {seen[-1]!r} on the wire, not the committed query"
expect(sb).to_be_disabled()
expect(sb).to_have_attribute("aria-description", re.compile("not yet applied")) # dirty and locked at once
page.keyboard.press("Escape")
expect(chip(page, "all")).to_have_attribute("aria-pressed", "true") # the filter came back with the query
expect(tiles(page)).to_have_count(len(FIXTURES)) # the wall answered, and stayed answered
expect(sb).to_be_enabled()
expect(sb).to_have_value(FIXTURE_LABEL) # the typed half-thought is gone…
expect(sb).not_to_have_attribute("aria-description", re.compile(".+")) # …and so is its mark
assert sb.evaluate("el => getComputedStyle(el).borderColor") == clean, "the amber tint outlived the bail-out"
print(" PASS: bailing out clears the stale cue")
- rule
- bailing out of the very first read just lets go: with no settled query to fall back to, it lands on an unlocked wall rather than re-locking on the same slow read.
expect(search_box(page)).to_be_disabled() # frozen on the boot read
page.keyboard.press("Escape")
expect(search_box(page)).to_be_enabled() # unlocked, not re-locked
print(" PASS: escape during boot read unlocks")
- rule
- the pill itself is the cancel button: tapping it reaches the same hatch.
- how: it is found by its accessible name,
cancel search: …to a screen reader, rather than by a keystroke.
- how: it is found by its accessible name,
hold_a_read(page, "zzslow-pill")
page.get_by_role("button", name=re.compile("cancel search")).click() # tap the pill
expect(search_box(page)).to_be_enabled() # unlocked
expect(search_box(page)).to_have_value(FIXTURE_LABEL) # reverted to the last settled query
expect(tiles(page)).to_have_count(len(FIXTURES))
print(" PASS: pill tap cancels frozen read")
Drawing our own spread
- what
- the wall’s spread is ours to draw.
- what
- the frise’s search quantises its keep-threshold
so that panning its timeline keeps the same docs on screen.
- what: Memories never pans, and that quantisation only ever under-fills the cap: ask for the cap and you get a few short of it.
- therefore: Memories draws its own spread, over the filter the frise factored out.
- how
- it keeps the cap docs whose content address hashes smallest — an even, stable spread — re-ordered by date.
- rule
- exactly the cap, no quantisation — a difference you can read straight off the line that names the slice: ask for all the docs and the number there is the cap itself.
Which end, or a spread
- what
- 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.
- example: you want the start of a holiday, or the last few docs of a run, not a taste of the whole.
- rule
- the box says which:
first:20keeps the twenty earliest,last:20the twenty latest,sample:20twenty taken from across the whole match, as before.- rule: each is also how many, so a count stands in for the wall’s own cap when you name one.
- how
- eight docs, one a day through the first eight days of a January, make the
difference plain.
- rule: asked for two, the wall hands back either end of those eight.
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-01", "2020-01-02"], f"not the earliest two: {tile_dates(page)}"
expect(tiles(page)).to_have_count(2)
assert tile_dates(page) == ["2020-01-07", "2020-01-08"], f"not the latest two: {tile_dates(page)}"
- rule
- asked instead for a spread of four, it hands back neither end but days from across
the eight — with gaps, which is what tells a spread from any run of four.
- rule: however the rows were chosen the wall reads oldest first, so
last:2is the last two in date order, not handed back reversed.
- rule: however the rows were chosen the wall reads oldest first, so
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}"
- how
- it is a thin function over the frise’s filter, whose limit makes the count exact,
with none of the threshold’s variance.
- how: that same limit is where the choice of rows lives: a count token only changes what it
counts along — the date upward, the date downward, or the hash that scatters.
- how: the ordering outside it stays put, which keeps the wall oldest-first whichever end the rows came from.
- how: that same limit is where the choice of rows lives: a count token only changes what it
counts along — the date upward, the date downward, or the hash that scatters.
- how
- it carries the same filter arguments, so when the filter gains one the spread
inherits it.
- example: the
-event:exclusion: an excluded-event search spreads like any other.
- example: the
- how
- applying it takes two steps together: the
docsDB, then a PostGraphile restart so the newpickargument is reflected.- what: the app sends that argument, 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 |
A spread is not a run
- rule
- a spread is not a run: on a spread, named by the
showing a spreadnotice, a shift-click picks only the tiles touched.- what: the
↔ rangetoggle is disabled, its title saying why. - scope: single taps still pick tiles one by one.
- what: the
t = tiles(page)
t.nth(0).click() # pick the first (the toolbar appears)
t.nth(2).click(modifiers=["Shift"]) # shift-click a later tile
expect(checks(page)).to_have_count(2) # only the two touched — no run roped in
rng = toolbar(page).get_by_role("button", name="range")
expect(rng).to_be_disabled() # the range affordance stands down
title = rng.get_attribute("title")
assert title and ("spread" in title.lower() or "run" in title.lower()), \
f"the disabled range toggle should say why: {title!r}"
print(" PASS: spread disables multiselect")
- what
- not every short wall is a spread: the earliest two are two in a row, far fewer than match but a run all the same.
- rule
- the range gesture is let through on an end: the spread stands the range down, not the shortfall.
t = tiles(page)
t.nth(0).click() # pick the first (the toolbar appears)
expect(toolbar(page).get_by_role("button", name="range")).to_be_enabled()
t.nth(1).click(modifiers=["Shift"]) # a shift-click ropes in the run
expect(checks(page)).to_have_count(2)
toolbar(page).get_by_role("button", name="clear", exact=True).click()
print(" PASS: first/last keep range select")
- how
- whether a range is allowed is read off the wall, which knows when it is a spread, and a range armed when it becomes one is disarmed.
const canRange = () => !photos()?.sampled || !!photos()?.pick;
createEffect(() => { if(!canRange()) setRangeMode(false); });
Setting Memories up on a phone that never had it
- what
- in this story somebody opens Memories on a phone for the first time: the first five minutes,
before there is a single doc to look at.
- rule: it names the build it is running.
- what: it keeps an offline copy of itself.
- rule: it comes up without the network, from that copy.
- rule: a new build drops the last build’s copy.
- what: none of it is a feature anybody asks for, and all of it is what the first five minutes are made of.
- what
- the two ways a new device is turned away, having no grant — no leave yet to read the
docs — and finding the server down, are what a phone that has never connected meets
first.
- what: each is a failure being said out loud instead of swallowed.
@testcase
def test_setting_up_on_a_new_phone(page):
"""The first five minutes on a device that never had Memories: the server shown to
report a refusal at all, then the app coming up titled, naming its build, installing
itself, launching from its own cache and dropping the last build's — speaking its
colours and answering a press, keeping clear of the phone's own bars — and then the
two ways a new device is turned away, with no grant and with the server down."""
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpgerr", "date": "2020-01-01T00:00:00Z", "mimetype": "image/jpeg",
"labels": "zzpgerr", "state": "todo"}
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
dup = gql(CREATE, {"p": doc}) # same cid again → a Postgres unique violation
msg = ((dup.get("errors") or [{}])[0]).get("message", "")
assert "duplicate key" in msg.lower(), f"fixture masked the PG error instead of surfacing it: {msg!r}"
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: fixture surfaces real pg error")
open_app(page)
expect(heading(page)).to_have_text("Memories")
print(" PASS: boots into titled shell")
tag = page.get_by_title("Build", exact=True)
assert tag.count() == 1, "no build tag"
h = (tag.text_content() or "").strip()
assert re.fullmatch(r"[0-9a-f]{7}", h), f"build tag isn't a 7-hex hash: {h!r}"
print(" PASS: build tag shows hash")
href = page.locator("link[rel='manifest']").get_attribute("href")
assert href, "no manifest linked"
man = page.evaluate("h => fetch(h).then(r => r.json())", href) # the one the page declares
assert man["display"] == "fullscreen", f"display is {man.get('display')!r}"
assert man["icons"], "manifest has no icons"
ready = page.evaluate("""() => Promise.race([
navigator.serviceWorker.ready.then(r => !!r.active),
new Promise(res => setTimeout(() => res(false), 6000))])""")
assert ready, "service worker did not become ready"
print(" PASS: pwa installable")
page.evaluate("() => navigator.serviceWorker.ready")
# seed a cache from a different build, then unregister
page.evaluate("""async () => {
await (await caches.open('memories-stale')).put('/x', new Response('x'));
for (const r of await navigator.serviceWorker.getRegistrations()) await r.unregister();
}""")
open_app(page) # re-register → fresh install/activate purges other builds
page.evaluate("() => navigator.serviceWorker.ready")
wait_until(page, lambda: "memories-stale" not in page.evaluate("() => caches.keys()"),
label="stale-version cache purged on activate")
print(" PASS: sw drops stale version cache")
ver = "memories-" + (page.get_by_title("Build", exact=True).text_content() or "").strip() # the SW's cache
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")
for path in ("https://ipfs.konubinix.eu/p/zzswprobe", "/graphql?zzswprobe"):
page.evaluate(f"() => caches.open({ver!r}).then(c => c.put({path!r}, new Response('CACHED')))")
got = page.evaluate(f"() => fetch({path!r}).then(r => r.text()).catch(() => '')")
assert got != "CACHED", f"the worker answered {path} from its cache"
print(" PASS: the worker never caches the archive or the pictures")
open_fixtures(page) # a wall to walk, from here on
hue = lambda loc: loc.evaluate("el => getComputedStyle(el).color")
pill_hues = lambda scope: [hue(scope.get_by_role("button", name=st, exact=True))
for st in ("todo", "next", "done", "delete")]
chip_hues = pill_hues(filters(page))
assert len(set(chip_hues)) == 4, f"chip state pills want distinct colours, got {chip_hues}"
rim = lambda scope: [scope.get_by_role("button", name=st, exact=True)
.evaluate("el => getComputedStyle(el).borderTopColor")
for st in ("todo", "next", "done", "delete")]
chip_rims = rim(filters(page)) # laid beside the others further on
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}"
tb_rims = rim(toolbar(page))
select_all(page).click() # clear
open_doc(page, 0)
d = dialog(page)
expect(d).to_be_visible()
lb_hues = pill_hues(d)
assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"
print(" PASS: state pills colour coded")
lb_rims = rim(d)
assert tb_rims == chip_rims, f"the toolbar's states are not the filter's: {tb_rims} vs {chip_rims}"
assert lb_rims == chip_rims, f"the lightbox's states are not the filter's: {lb_rims} vs {chip_rims}"
print(" PASS: each state wears one hue on every surface")
def held_vs_rest(btn):
bb = btn.bounding_box()
page.mouse.move(bb["x"] + bb["width"] / 2, bb["y"] + bb["height"] / 2)
rest = btn.evaluate("el => getComputedStyle(el).transform")
page.mouse.down()
held = btn.evaluate("el => getComputedStyle(el).transform") # :active while the pointer is held
page.mouse.up()
return rest, held
rest, held = held_vs_rest(d.get_by_role("button", name="done", exact=True)) # a state button
assert held != rest, f"the state button gives no press feedback: rest={rest} held={held}"
expect(grid(page).get_by_text("done")).to_have_count(2) # …and the press was a press: the verdict landed
rest, held = held_vs_rest(d.get_by_role("button", name="next photo")) # the › nav
assert held != rest, f"the nav gives no press feedback: rest={rest} held={held}"
print(" PASS: buttons acknowledge a press")
page.keyboard.press("Escape")
expect(d).to_have_count(0) # the doc is shut before the phone's bars come on
page.set_viewport_size({"width": 360, "height": 640})
TOP, BOT = 28, 48 # a status bar, and a three-button navigation bar
page.context.new_cdp_session(page).send(
"Emulation.setSafeAreaInsetsOverride",
{"insets": {"top": TOP, "left": 0, "right": 0, "bottom": BOT}})
floor = page.viewport_size["height"] - BOT
def all_clear(what, surface):
caught = surface.evaluate(
"""(root, [top, floor]) => [...root.querySelectorAll('button, input, a')]
.map(e => [e, e.getBoundingClientRect()])
.filter(([_, r]) => r.width && r.height && (r.top < top || r.bottom > floor))
.map(([e, r]) => `${e.getAttribute('aria-label') || e.placeholder
|| e.textContent.trim().slice(0, 18)}`
+ ` @${r.top.toFixed(0)}..${r.bottom.toFixed(0)}`)""",
[TOP, floor])
assert not caught, f"{what} leaves controls in a bar's strip (usable {TOP}..{floor}): {caught}"
title = heading(page).bounding_box()
assert title["y"] >= TOP, f"the title sits under the status bar: {title}"
tiles(page).nth(0).click() # one tap selects → the wall's toolbar
all_clear("the selection toolbar", toolbar(page))
tiles(page).nth(0).click() # drop the selection again
open_doc(page)
all_clear("the open doc", dialog(page))
page.keyboard.press("Escape")
expect(dialog(page)).to_have_count(0)
page.get_by_role("button", name=re.compile("frame", re.I)).click()
page.get_by_role("list", name="slideshow").click() # a tap brings the bar up
all_clear("the frame bar", page.get_by_role("toolbar", name="frame actions"))
print(" PASS: controls clear the system bars")
# …and the two ways a device that has never connected is turned away
page.route("**/graphql", lambda route: route.fulfill(
status=401, content_type="text/plain", body="Unauthorized"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("authoriz", re.I))
print(" PASS: shows auth required when unauthorized")
banner, title = page.get_by_role("alert").bounding_box(), heading(page).bounding_box()
assert banner["y"] + banner["height"] <= title["y"], f"the banner is not above the title: {banner} / {title}"
print(" PASS: the banner sits on top")
try:
urllib.request.urlopen(urllib.request.Request(
"http://home/graphql", data=b'{"query":"{ __typename }"}',
headers={"Content-Type": "application/json"}), timeout=10)
raise AssertionError("the gate let a query with no cookie through")
except urllib.error.HTTPError as e:
assert e.code == 401, f"the gate answered {e.code}, not 401"
print(" PASS: the gate refuses a device with no grant")
page.route("**/graphql", lambda route: route.fulfill(
status=500, content_type="text/plain", body="Server Error"))
open_app(page)
expect(page.get_by_role("alert")).to_contain_text(re.compile("load|reach|server", re.I))
expect(page.get_by_text("No photos.", exact=True)).to_have_count(0)
print(" PASS: load failure — banner up, empty-state down")
It boots
- what
- 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.
- rule
- the app boots into its titled shell, the first thing on screen.
expect(heading(page)).to_have_text("Memories")
print(" PASS: boots into titled shell")
render(App, document.getElementById('app'));
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
- how
- the build it is running shows in a tiny monospaced tag
in the corner, its short hash.
- how: the check reads seven hex characters in the tag.
.build-tag{ position:fixed; top:calc(env(safe-area-inset-top) + 4px); right:6px; z-index:50;
pointer-events:none; font:10px/1 monospace; color:#5b6080; }
tag = page.get_by_title("Build", exact=True)
assert tag.count() == 1, "no build tag"
h = (tag.text_content() or "").strip()
assert re.fullmatch(r"[0-9a-f]{7}", h), f"build tag isn't a 7-hex hash: {h!r}"
print(" PASS: build tag shows hash")
One data layer across the apps
- how
- Memories’ reactivity is Solid’s own, but its transport is the shared urql client —
the same one the frise uses.
- therefore: there is one client and one auth gate across the apps.
- rule
- after every edit the wall shows the true current result, never one with a removed label still
lingering.
- what: Solid’s resources are not wired to the client cache’s reactivity the way the frise’s hooks
are.
- therefore: at the end of every edit Memories re-reads the wall itself.
- what: a re-read fired through the client the instant an edit commits can be handed a query that went out just before the edit.
- how: so the wall reads through the shared
queryFresh, which posts straight to the server, bypassing the client’s document cache and its in-flight dedup.
- what: Solid’s resources are not wired to the client cache’s reactivity the way the frise’s hooks
are.
- rule
- label completion shows a label added moments ago at once.
- how: it reads past the cache (
network-only), but still through the client. - what: so it carries the same in-flight-dedup risk the wall sheds.
- how: it reads past the cache (
- how
- a signal mirrors the shared gate’s state into the banner that says the device is not authorized.
- how
- so the app takes Solid’s own pieces from Solid, and the client and its gate from the shared module.
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';
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;
}
The whole screen, in three zones
- rule
- a doc fills the screen.
- how: the app sets its viewport to
coverthe display rather than stop where the phone’s status bar and navigation bar begin.
- how: the app sets its viewport to
- what
- those bars are still painted over the page, though, and they still take the touches that
land on them.
- therefore: the screen the app is handed is really three zones, and only the middle one is its own to put a control in.
- how
- so
env(safe-area-inset-*), the phone’s own report of what the two bars’ 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
- how
- the refusal is put on from outside:
/graphql— the address the server answers on — is routed to a 401, and the app is opened on it. - rule
- a device with no grant is told it is not authorized.
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")
- how
- the banner is read against the title: it sits above it.
banner, title = page.get_by_role("alert").bounding_box(), heading(page).bounding_box()
assert banner["y"] + banner["height"] <= title["y"], f"the banner is not above the title: {banner} / {title}"
print(" PASS: the banner sits on top")
- rule
- the real server refuses a device with no grant: a query to
http://home/graphqlwithout the cookie gets a 401.- scope: this one check asks the live gate on the LAN rather than one the test stages — the user’s choice.
try:
urllib.request.urlopen(urllib.request.Request(
"http://home/graphql", data=b'{"query":"{ __typename }"}',
headers={"Content-Type": "application/json"}), timeout=10)
raise AssertionError("the gate let a query with no cookie through")
except urllib.error.HTTPError as e:
assert e.code == 401, f"the gate answered {e.code}, not 401"
print(" PASS: the gate refuses a device with no grant")
- how
- the app’s one path to the server (It boots) already flips the not-authorized flag on a 401; the banner just shows on that flag.
- rule
- the banner sits at the top of the app, the first thing seen.
- what
- the app can’t mint a grant.
- therefore: it does not try to authenticate; 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
- what
- a 401 is the courteous failure — the gate knows the device only needs a fresh link, and says so.
- what
- every other failure hands back the wall empty for a reason the user cannot see.
- example: the server down, a 500 on a bad query, a 404 where the route should answer.
- what: an empty wall reads as “nothing matches here” — a lie when the truth is that the read never landed.
- rule
- a failed load is said out loud, 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))
- rule
- after a failed load, the wall does not claim that nothing matches.
expect(page.get_by_text("No photos.", exact=True)).to_have_count(0)
print(" PASS: load failure — banner up, empty-state down")
- rule
- the banner rides on the wall’s own load 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; }
- how
- in practice, the wall reads its docs straight from the load, and that read
re-throws the load error — with no error boundary above it the throw would freeze the render before
the banner paints.
- therefore: the wall skips its own read once the load has errored: the error is left to the banner alone, and the empty-state and the tiles fall to nothing.
Room left by the phone’s bars
- what
- two concerns run through every surface at once: the room the phone’s own bars leave, and colour.
- how
- a phone showing both bars is taken, and the app walked.
- how: a desktop Chromium reports no insets at all, so the phone has to be asked for:
Emulation.setSafeAreaInsetsOverridemakesenv()answer with a status bar and a navigation bar. - what: the zone between them is where a thumb can reach.
- how: a desktop Chromium reports no insets at all, so the phone has to be asked for:
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
- rule
- on the wall, the title — what sits nearest the top edge — clears the status bar.
title = heading(page).bounding_box()
assert title["y"] >= TOP, f"the title sits under the status bar: {title}"
- rule
- at the other edge, every control of the toolbar clears the navigation bar — not only the one
you thought of, but the download buttons and the
clearat its far end, which sit lowest.- what: the toolbar is pinned to the very foot, and it is a wide, wrapping bar.
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
- rule
- the lightbox’s controls clear both bars.
- what: the stakes are highest there, since nothing in the lightbox scrolls past a bar.
open_doc(page)
all_clear("the open doc", dialog(page))
page.keyboard.press("Escape")
expect(dialog(page)).to_have_count(0)
- rule
- and so does the slideshow’s control bar, which 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
- rule
- each state wears one colour everywhere it shows.
- how: one source defines the four state hues, and every surface reads it.
- rule
- on the wall, the state filter’s buttons wear those same hues.
- scope: the neutral
allbutton keeps its plain look.
- scope: the neutral
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}"
rim = lambda scope: [scope.get_by_role("button", name=st, exact=True)
.evaluate("el => getComputedStyle(el).borderTopColor")
for st in ("todo", "next", "done", "delete")]
chip_rims = rim(filters(page)) # laid beside the others further on
- rule
- the toolbar’s state buttons carry the same 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}"
tb_rims = rim(toolbar(page))
select_all(page).click() # clear
- rule
- and so do the lightbox’s own state buttons.
open_doc(page, 0)
d = dialog(page)
expect(d).to_be_visible()
lb_hues = pill_hues(d)
assert len(set(lb_hues)) == 4, f"lightbox state pills want distinct colours, got {lb_hues}"
print(" PASS: state pills colour coded")
- how
- the rims are read, state by state, on the three surfaces side by side.
- what: the rim, since a pressed button inverts its text against a fill of its hue.
lb_rims = rim(d)
assert tb_rims == chip_rims, f"the toolbar's states are not the filter's: {tb_rims} vs {chip_rims}"
assert lb_rims == chip_rims, f"the lightbox's states are not the filter's: {lb_rims} vs {chip_rims}"
print(" PASS: each state wears one hue on every surface")
A press that shows
- what
- colour tells you what a button is; a press tells you it took.
- rule
- a tap looks like it landed: every state button, and the lightbox’s
‹/›, depresses the instant it is pressed, a quick scale felt before the result lands.- how: it rides on the browser’s own pressed state,
:active, so no button is wired for it by hand.
- how: it rides on the browser’s own pressed state,
def held_vs_rest(btn):
bb = btn.bounding_box()
page.mouse.move(bb["x"] + bb["width"] / 2, bb["y"] + bb["height"] / 2)
rest = btn.evaluate("el => getComputedStyle(el).transform")
page.mouse.down()
held = btn.evaluate("el => getComputedStyle(el).transform") # :active while the pointer is held
page.mouse.up()
return rest, held
rest, held = held_vs_rest(d.get_by_role("button", name="done", exact=True)) # a state button
assert held != rest, f"the state button gives no press feedback: rest={rest} held={held}"
expect(grid(page).get_by_text("done")).to_have_count(2) # …and the press was a press: the verdict landed
rest, held = held_vs_rest(d.get_by_role("button", name="next photo")) # the › nav
assert held != rest, f"the nav gives no press feedback: rest={rest} held={held}"
print(" PASS: buttons acknowledge a press")
Installable, fullscreen (PWA)
- rule
- on a phone the wall takes the whole screen.
- how: a web-app manifest — the file a phone reads to install a web page as an app — declaring
display:fullscreen, an SVG icon and the app’s darktheme_color, plus a service worker, make it installable to the home screen and launchable chrome-free. - what: the service worker is the worker, the background script that keeps the app’s offline copy.
- how: a web-app manifest — the file a phone reads to install a web page as an app — declaring
- rule
- the worker caches only the app’s own shell; the live data (
/graphql) and the docs' media (/ipfs/) stay on the network. - how
- a real install cannot be driven from a test, so what is read is the scaffolding an install
needs.
- rule: the manifest is linked and declares
fullscreen, and the worker reachesready.
- rule: the manifest is linked and declares
href = page.locator("link[rel='manifest']").get_attribute("href")
assert href, "no manifest linked"
man = page.evaluate("h => fetch(h).then(r => r.json())", href) # the one the page declares
assert man["display"] == "fullscreen", f"display is {man.get('display')!r}"
assert man["icons"], "manifest has no icons"
ready = page.evaluate("""() => Promise.race([
navigator.serviceWorker.ready.then(r => !!r.active),
new Promise(res => setTimeout(() => res(false), 6000))])""")
assert ready, "service worker did not become ready"
print(" PASS: pwa installable")
{
"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>
- what
- the worker’s cache is keyed to the build.
- rule
- after a deploy, the new build lands with an ordinary open — no hard-reload.
- how: the next open finds the stale cache gone and re-fetches the fresh files.
- how: that re-fetch reaches past the browser’s own HTTP cache, the very thing a hard-reload exists to bypass.
- how: on
activate, the worker drops the last build’s copy: the caches left by any other build.
- how: the next open finds the stale cache gone and re-fetches the fresh files.
page.evaluate("() => navigator.serviceWorker.ready")
# seed a cache from a different build, then unregister
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")
- how
- within a build the worker serves the shell cache-first, so it comes up without the network.
ver = "memories-" + (page.get_by_title("Build", exact=True).text_content() or "").strip() # the SW's cache
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")
- rule
- the server’s answers and the pictures are never the worker’s to keep.
- how: a stale copy of each is planted in the build’s cache, and a fetch must go past it.
for path in ("https://ipfs.konubinix.eu/p/zzswprobe", "/graphql?zzswprobe"):
page.evaluate(f"() => caches.open({ver!r}).then(c => c.put({path!r}, new Response('CACHED')))")
got = page.evaluate(f"() => fetch({path!r}).then(r => r.text()).catch(() => '')")
assert got != "CACHED", f"the worker answered {path} from its cache"
print(" PASS: the worker never caches the archive or the pictures")
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;
})());
});
The stack
- how
- Memories is built on the stack the frise proved — PostGraphile (GraphQL) and the free-text
labelsfield. - how
- it renders with Solid and its fine-grained reactivity.
- how
- there is no compile step and no bundler — an import map pulls the framework from esm.sh, and the
htmltagged template stands in for JSX. - what
- Postgres is the source of truth for each doc’s labels and its state, which the app reads and edits.
Test harness
- what
- the same Playwright scaffolding as the frise — browsers from the Nix shell, a fresh page
with clean state per test, and timestamped
TRYING=/=PASSlines. - rule
- a test waits at most 8 s for anything it expects, and past that reads it as failed.
import os, re, sys, time
import atexit, datetime, json, shutil, socket, subprocess, urllib.request, uuid
nil
from playwright.sync_api import sync_playwright, expect
expect.set_options(timeout=8000)
BASE_URL = os.environ.get("MEMORIES_URL", "http://localhost:9682/debug/memories/")
VIEWPORT = {"width": 1000, "height": 700}
PG_IMAGE = "konubinix/postgraphile:latest"
PROD_DB = os.environ.get("MEMORIES_PROD_DB", "postgres://postgres@192.168.2.14:5432/docs")
PG_NAME_PREFIX = "memories"
What the tests stand on
What the tests start from
- what
- the suite carries one real video, 8.5 s of 16×16 WebM, for the video tests to play.
import base64 as _b64, urllib.request
# a tiny real VP8/WebM clip (8.5s, 16×16), generated once with ffmpeg
CLIP_WEBM = _b64.b64decode(
"GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAToEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZU"
"rmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggTS7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsCrXsYMPQkBNgIxM"
"YXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAwJoAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIDXVOuv8PasGcgQAitZyDdW5k"
"iIEAhoVWX1ZQOIOBASPjg4QO5rKA4JCwgRC6gRCagQJVsIRVuYEBElTDZ/tzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYyLjMu"
"MTAwc3PWY8CLY8WIDXVOuv8PasFnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjExLjEwMCBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAw"
"OjAwOjA4LjUwMDAwMDAwMAAfQ7Z1QfTngQCjo4EAAIAQAgCdASoQABAAAEcIhYWIhYSIAgIADA1gAP7/q1CAo5WBAPoAsQEAARAQ"
"ABgAGFgv9AAIAACjlYEB9ACxAQABEBAAGAAYWC/0AAgAAKOVgQLuALEBAAEQEAAYABhYL/QACAAAo5WBA+gAsQEAARAQABgAGFgv"
"9AAIAACjlYEE4gCxAQABEBAAGAAYWC/0AAgAAKOVgQXcALEBAAEQEAAYABhYL/QACAAAo5WBBtYAsQEAARAQFGAAYWC/0AAgAACj"
"lYEH0ACxAQABEBAAGAAYWC/0AAgAAKOVgQjKALEBAAEQEAAYABhYL/QACAAAo5WBCcQAsQEAARAQABgAGFgv9AAIAACjlYEKvgCx"
"AQABEBAAGAAYWC/0AAgAAKOVgQu4ALEBAAEQEAAYABhYL/QACAAAo5WBDLIAsQEAARAQABgAGFgv9AAIAACjlYENrACxAQABEBAA"
"GAAYWC/0AAgAAKOVgQ6mALEBAAEQEAAYABhYL/QACAAAo5WBD6AAsQEAARAQABgAGFgv9AAIAACjlYEQmgCxAQABEBAAGAAYWC/0"
"AAgAAKOVgRGUALEBAAEQEBRgAGFgv9AAIAAAo5WBEo4AsQEAARAQABgAGFgv9AAIAACjlYETiACxAQABEBAAGAAYWC/0AAgAAB9D"
"tnVBL+eCFIKjlYEAAACxAQABEBAAGAAYWC/0AAgAAKOVgQD6ALEBAAEQEAAYABhYL/QACAAAo5WBAfQAsQEAARAQABgAGFgv9AAI"
"AACjlYEC7gCxAQABEBAAGAAYWC/0AAgAAKOVgQPoALEBAAEQEAAYABhYL/QACAAAo5WBBOIAsQEAARAQABgAGFgv9AAIAACjlYEF"
"3ACxAQABEBAAGAAYWC/0AAgAAKOVgQbWALEBAAEQEAAYABhYL/QACAAAo5WBB9AAsQEAARAQFGAAYWC/0AAgAACjlYEIygCxAQAB"
"EBAAGAAYWC/0AAgAAKOVgQnEALEBAAEQEAAYABhYL/QACAAAo5WBCr4AsQEAARAQABgAGFgv9AAIAACjlYELuACxAQABEBAAGAAY"
"WC/0AAgAABxTu2uRu4+zgQC3iveBAfGCAaPwgQM=")
- how
- the test case, the failure dump and the watch on GraphQL errors come from the shared blocks.
nil
nil
nil
Reaching the app as its user does
- how
open_appboots the app on a sync room of this run’s own.- rule: a boot is retried once, and only when Chromium says the network changed under it.
- what: the runner waits out the changes its own containers make.
- what: the host also renews its temporary IPv6 address every ten minutes or so, at no moment a run
can wait for, and Chromium then fails the boot’s fetches with
ERR_NETWORK_CHANGED. - therefore: a boot that failed for any other reason stays a failure.
- scope: the one retry is the user’s choice.
- rule: a boot is retried once, and only when Chromium says the network changed under it.
def open_app(page, qs=""):
# any launch that has not chosen a sync server is pointed at the throwaway one, in a room of this run's own
if "yws=" not in qs:
qs += ("&" if qs else "?") + f"yws={require_sync()}&room=scratch-{_RUN_ID}"
moved = []
on_fail = lambda r: moved.append(r.url) if "ERR_NETWORK_CHANGED" in (r.failure or "") else None
page.on("requestfailed", on_fail)
try:
page.goto(BASE_URL + qs, wait_until="commit")
try:
heading(page).wait_for(timeout=8000) # the titled shell has painted
except Exception:
if not moved: raise
print(f" network changed under the boot ({moved[0]}): booting once more", flush=True)
page.reload(wait_until="commit")
heading(page).wait_for(timeout=8000)
finally:
page.remove_listener("requestfailed", on_fail)
- rule
- a test finds the app as the user sees it — a role, an accessible name, a placeholder, visible text — never a test-only hook.
def heading(page): return page.get_by_role("heading")
def search_box(page): return page.get_by_label("search labels")
def grid(page): return page.get_by_role("list", name="photos")
def tiles(page): return grid(page).get_by_role("listitem") # one per shown doc (thumb or not)
def thumb_imgs(page): return grid(page).get_by_role("img") # only the tiles that have a thumbnail
def cell_px(page): return tiles(page).first.bounding_box()["width"] # the width a tile is DRAWN at
def tile_dates(page): return thumb_imgs(page).evaluate_all( # each tile's date, in wall order
"els => els.map(e => e.getAttribute('alt'))") # (the name the tile carries)
def tile_srcs(page, i): # every layer tile i is showing (thumbnail base, full-res overlay)
return tiles(page).nth(i).locator("img").evaluate_all("els => els.map(e => e.getAttribute('src'))")
- how
- what the page fetched is read off its own resource timeline.
- how
- a tile is read and worked as a hand would.
- how: a picked tile wears a tick, so the ticks count the picks.
- how: a tile is opened with a double-click.
- how: a tile is armed with a press held past the app’s 500 ms timer.
- how: the held press is then dragged onto another tile.
# what the page has actually FETCHED, read off its own resource timeline; called before open_app
def watch_fetches(page):
page.add_init_script("performance.setResourceTimingBufferSize(4000)")
return lambda: page.evaluate('() => performance.getEntriesByType("resource").map(e => e.name)')
def checks(page): return grid(page).get_by_text("✓") # one per selected tile
def open_doc(page, i=0): # a double-click on a tile opens the lightbox
tiles(page).nth(i).dblclick()
LONG_PRESS_MS = 600 # past the app's 500ms press timer
def press_and_hold(page, tile): # the pointer put down on a tile and held
b = tile.bounding_box()
page.mouse.move(b["x"] + b["width"] / 2, b["y"] + b["height"] / 2)
page.mouse.down(); page.wait_for_timeout(LONG_PRESS_MS)
def drag_onto(page, tile): # …and moved until it is over another
b = tile.bounding_box()
page.mouse.move(b["x"] + b["width"] / 2, b["y"] + b["height"] / 2, steps=5)
- how
- pinches and one-finger drags go through Chromium’s own touch pipeline, as real fingers.
def pinch_in(page, cx, cy): # two fingers spreading → the browser's zoom
cdp = page.context.new_cdp_session(page)
cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
"touchPoints": [{"x": cx - 30, "y": cy}, {"x": cx + 30, "y": cy}]})
for d in (60, 120, 200, 260):
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
"touchPoints": [{"x": cx - d, "y": cy}, {"x": cx + d, "y": cy}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
def drag_touch(page, x0, y0, x1, y1, steps=6): # one finger travelling
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}]})
for i in range(1, steps + 1):
cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [
{"x": x0 + (x1 - x0) * i / steps, "y": y0 + (y1 - y0) * i / steps}]})
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
- how
- the state filters, the toolbar, the lightbox and the label offers are each found by their role and name.
- how
- a held completion query keeps the offer list loading.
- how: on such a list the add-label boxes are each held to reporting themselves open, by one check they share.
def filters(page): return page.get_by_role("group", name="filter by state")
def chip(page, name): return filters(page).get_by_role("button", name=name, exact=True)
def select_all(page): return filters(page).get_by_role("button", name=re.compile("select all|clear", re.I))
def toolbar(page): return page.get_by_role("toolbar", name="selection actions")
def dialog(page): return page.get_by_role("dialog")
def options(page): return page.get_by_role("option") # label suggestions
def hold_completions(page): # leave the label-completion query pending → the list stays loading
page.route("**/graphql", lambda route:
route.fallback() if "labelCompletions" not in (route.request.post_data or "") else None)
def expanded_while_loading(page, box): # the popover is up (loading) → aria-expanded must say so
box.click(); box.press_sequentially("au", delay=20)
expect(page.get_by_text("completing")).to_be_visible() # the loading list is on screen
expect(box).to_have_attribute("aria-expanded", "true")
Waiting on the server
- rule
- a test reads the wall only once the read its own search set off has landed.
- what: a wall read any earlier is the one before, and passes or fails by luck.
- rule
- a wait that gives up names what it was waiting for.
# every wall read the page has finished, as (issued_at, search_term); search_for waits on it
SETTLED_READS = []
def search_for(page, text):
# type the query, then COMMIT it
search_box(page).fill(text)
clicked = time.time()
page.get_by_role("button", name="run search").click()
search_box(page).blur()
# deterministic: popover closed, no clock
expect(search_box(page)).to_have_attribute("aria-expanded", "false")
# a read issued after the click has settled, and the grid is no longer busy
wait_until(page, lambda: any(issued > clicked for issued, _ in SETTLED_READS),
label=f"the wall re-read for {text!r}",
detail=lambda: f"reads settled: {[s for _, s in SETTLED_READS[-4:]]}")
expect(grid(page)).to_have_attribute("aria-busy", "false")
def wait_until(page, pred, timeout=8000, label="", detail=None):
# label names the condition in the failure; detail() is read only at timeout
end = time.time() + timeout / 1000
while time.time() < end:
if pred(): return
page.wait_for_timeout(100)
raise AssertionError("condition not met within timeout"
+ (f": {label}" if label else "")
+ (f" — {detail()}" if detail else ""))
- how
gqlposts straight to the data layer, as the data layer note defines it.
nil
A sync room of the run’s own
- how
- a throwaway sync server runs the prod image on a free port, once per run.
import base64
nil
nil
SYNC_IMAGE = "konubinix/ywebsocket:0.1.0"
_RUN_ID = uuid.uuid4().hex[:8]
_sync = {"at": None}
def require_sync():
if _sync["at"]: return _sync["at"]
container, port = f"memories-sync-{uuid.uuid4().hex[:8]}", _free_port()
subprocess.check_call(
["docker", "run", "-d", "--rm", "--name", container,
"-p", f"127.0.0.1:{port}:{port}", "-e", "HOST=0.0.0.0", "-e", f"PORT={port}",
SYNC_IMAGE, "y-websocket-server"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
atexit.register(lambda: subprocess.run(["docker", "stop", "-t", "0", container],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
hs = _wait_ws_handshake(port)
if hs is not True: raise RuntimeError(f"fixture ywebsocket not ready on {port}: {hs}")
_sync["at"] = f"ws://localhost:{port}"
return _sync["at"]
- how
- a second client joins the same room, as the companion app does.
- therefore: a test reads what the slideshow says it shows, or speaks over it.
# a second client on the same room, as the companion app is
_ROOM_WATCHERS = {}
def watch_room(context, url, room):
page = context.new_page()
page.goto(BASE_URL, wait_until="commit")
page.evaluate("""async ([url, room]) => {
const Y = await import('https://esm.sh/yjs@13.6.20');
const { WebsocketProvider } = await import('https://esm.sh/y-websocket@2.0.4?external=yjs');
const shared = new Y.Doc();
window.__watch = new WebsocketProvider(url, room, shared);
window.__showing = shared.getMap('showing');
}""", [url, room])
_ROOM_WATCHERS[id(context)] = page
return lambda: page.evaluate("() => (window.__showing && window.__showing.get('doc')) || null")
# a rival frame, in one line: the watcher's own client writing the entry
def speak_over(context, cid):
_ROOM_WATCHERS[id(context)].evaluate(
"cid => window.__showing.set('doc', { cid, webCid: cid, mimetype: 'image/jpeg' })", cid)
The harness’s own writes
- rule
- the harness says a failure out loud before the app can be asked to.
- how: the server is made to refuse something — the same doc created twice — and the refusal is read back in full, first thing in the new-phone story.
doc = {"cid": "https://ipfs.konubinix.eu/p/zzpgerr", "date": "2020-01-01T00:00:00Z", "mimetype": "image/jpeg",
"labels": "zzpgerr", "state": "todo"}
gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
try:
dup = gql(CREATE, {"p": doc}) # same cid again → a Postgres unique violation
msg = ((dup.get("errors") or [{}])[0]).get("message", "")
assert "duplicate key" in msg.lower(), f"fixture masked the PG error instead of surfacing it: {msg!r}"
finally:
gql(DELETE, {"cid": doc["cid"]})
print(" PASS: fixture surfaces real pg error")
- how
- every write of the harness is logged: the call, the row, how long, what came back.
GQL_LOG = [] # (clock, op, cid, ms, outcome), drained per test
_gql_direct = gql
def _gql_op(query, variables):
v = variables or {}
op = next((k for k in ("createPhotovideo", "deletePhotovideo", "updatePhotovideo",
"photovideosSample") if k in query),
(query.strip().split("{", 1)[0].strip() or "query")[:40])
return op, v.get("cid") or (v.get("p") or {}).get("cid") or ""
def gql(query, variables=None):
(op, cid), t0 = _gql_op(query, variables), time.time()
def log(outcome):
GQL_LOG.append((time.time(), op, cid, (time.time() - t0) * 1000, outcome))
try:
res = _gql_direct(query, variables)
except Exception as e:
log(f"RAISED {e}"); raise
errs = [e.get("message") for e in (res or {}).get("errors") or []]
# a pre-clean delete that matched nothing is logged as ok (absent)
if errs and op == "deletePhotovideo" and all("No values were deleted" in m for m in errs):
log("ok (absent)")
else:
log(f"ERRORS={errs}" if errs else "ok")
return res
- how
- in a failure’s dump, more than three calls in a row of one kind, each answered ok within 500 ms, fold
into one line.
- rule: any other call prints as it happened.
def harness_writes(log, t0):
same = lambda e: (e[1], e[4])
quick = lambda e: e[4].startswith("ok") and e[3] < 500
out, i = [], 0
while i < len(log):
j = i
while j < len(log) and quick(log[j]) and same(log[j]) == same(log[i]): j += 1
head, span = log[i], [e[3] for e in log[i:j]]
stamp = f"{(head[0] - t0) * 1000:+7.0f}ms"
if j - i > 3:
out.append(f"{stamp} {head[1]} ×{j - i} {head[4]} ({min(span):.0f}–{max(span):.0f}ms)")
i = j
else:
out.append(f"{stamp} {head[1]}({head[2]}) {head[3]:5.0f}ms {head[4]}")
i += 1
return out
The fixtures
- what
- the fixture rows carry the label
zzbatchfixand a fake cid, and are made and dropped each run.
CREATE = "mutation($p:PhotovideoInput!){ createPhotovideo(input:{photovideo:$p}){ clientMutationId } }"
DELETE = "mutation($cid:String!){ deletePhotovideo(input:{cid:$cid}){ clientMutationId } }"
UPDATE = ("mutation($cid:String!,$patch:PhotovideoPatch!){"
" updatePhotovideo(input:{cid:$cid,patch:$patch}){ clientMutationId } }")
FIXTURE_LABEL = "zzbatchfix"
FIXTURE_STATES = ["todo", "next", "done"]
FIXTURE_RANDS = [0.7, 0.2, 0.5] # frame order (by myrandom asc) is 1,2,0
FIXTURES = [
{"cid": f"https://ipfs.konubinix.eu/p/zzbatchfix-{i}", "date": f"2020-0{i+1}-15T12:00:00Z",
"mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzbatchfix-thumb-{i}",
"labels": FIXTURE_LABEL, "state": s, "myrandom": FIXTURE_RANDS[i]}
for i, s in enumerate(FIXTURE_STATES)
]
VIDEO_LABEL = "zzvidonly"
VIDEO_FIXTURE = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-vid", "date": "2019-01-15T12:00:00Z", # earliest → first
"mimetype": "video/webm", "thumbnailCid": "https://ipfs.konubinix.eu/p/zzbatchfix-vid-thumb",
"webCid": "https://ipfs.konubinix.eu/p/zzbatchfix-vid-web",
"labels": FIXTURE_LABEL + "; " + VIDEO_LABEL, "state": "todo"}
# a match that carries the label but has no thumbnail AND no web_cid
NOMEDIA_LABEL = "zznomedia"
NOTHUMB_FIXTURE = {"cid": "https://ipfs.konubinix.eu/p/zzbatchfix-nothumb", "date": "2020-04-15T12:00:00Z",
"mimetype": "image/jpeg", "state": "todo",
"labels": FIXTURE_LABEL + "; " + NOMEDIA_LABEL}
- how
open_fixturesstarts a test from exactly the three fixtures, on every state, the wall narrowed to them.
def drop_fixtures():
for f in FIXTURES + [VIDEO_FIXTURE, NOTHUMB_FIXTURE]:
try: gql(DELETE, {"cid": f["cid"]})
except Exception: pass
def make_fixtures():
drop_fixtures() # clean slate, independent of test order
for f in FIXTURES:
gql(CREATE, {"p": f})
def open_fixtures(page, qs=""):
"""Fresh fixtures, app open on *all* states (default is todo), the wall narrowed
to just the fixtures — so a test starts from exactly len(FIXTURES) docs."""
make_fixtures()
open_app(page, qs)
chip(page, "all").click()
search_for(page, FIXTURE_LABEL)
expect(tiles(page)).to_have_count(len(FIXTURES))
- how
- a real file can be served from the app’s own origin.
- how
- any other
/ipfs/fetch is answered at once with a 404, so no test waits on the real gateway.
# a real byte at a path the app's own origin already serves (next to this script is /debug/memories/)
def serve_bytes(name):
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), name), "wb") as f:
f.write(b"IPFS")
return name # the same-origin cid the app will fetch
def unserve_bytes(*names):
for n in names:
try: os.remove(os.path.join(os.path.dirname(os.path.abspath(__file__)), n))
except OSError: pass
# /ipfs/ mocked: a fast 404, never the real gateway
def _mock_ipfs(route):
try: route.fulfill(status=404, content_type="text/plain", body="mock ipfs")
except Exception:
try: route.abort()
except Exception: pass
Running them
The runner
- how
- the runner picks the tests named on the command line, or all of them.
- rule
- it starts every container before the browser, then waits for the host’s addresses to settle.
- what: a container’s new link gives the host a new IPv6 address about a second after it starts.
- what: Chromium takes that for a network change and drops the app’s imports in flight.
- how: the host lists its addresses in
/proc/net/if_inet6: none still tentative (flag0x40), and the list unchanged for a second and a half, is settled.
- what: a container’s new link gives the host a new IPv6 address about a second after it starts.
def wait_network_settled(timeout=15, still=1.5):
def addrs():
with open("/proc/net/if_inet6") as f: return f.read()
end, last, since = time.time() + timeout, None, time.time()
while time.time() < end:
now_ = addrs()
if now_ != last: last, since = now_, time.time()
tentative = any(int(l.split()[4], 16) & 0x40 for l in now_.splitlines())
if not tentative and time.time() - since >= still: return
time.sleep(0.1)
def main():
argv = sys.argv[1:]
headed = "--headed" in argv
stop_on_fail = "-x" in argv or "--exitfirst" in argv
patterns = [a for a in argv if a not in ("--headed", "-x", "--exitfirst")]
selected = [t for t in TESTS if not patterns or any(p in t.__name__ for p in patterns)]
if not selected:
print(f"no test matches {patterns}"); sys.exit(2)
passed = failed = 0
now = lambda: time.strftime("%H:%M:%S")
try:
with sync_playwright() as pw:
require_postgraphile(); require_sync() # every container up before the browser
wait_network_settled() # and the host's addresses done moving
# KeyboardFocusableScrollers, the modern-Chrome default (≥130): a scrollable region takes
# keyboard focus
browser = pw.chromium.launch(headless=not headed,
args=["--enable-blink-features=KeyboardFocusableScrollers"])
for t in selected:
t0 = time.time()
print(f"[{now()}] TRYING {t.__name__}", flush=True)
ctx = browser.new_context(viewport=VIEWPORT) # fresh storage per test
ctx.route("**/graphql", _forward_graphql) # /graphql → the throwaway server, never the real one
ctx.route("**/ipfs/**", _mock_ipfs) # /ipfs/ → deterministic 404, never the flaky gateway
# unhandled promise rejections forwarded to the console
ctx.add_init_script(
"addEventListener('unhandledrejection', e =>"
" console.error('unhandledrejection: ' + (e.reason?.message || e.reason)))")
page = ctx.new_page()
page.set_default_timeout(8000)
events = []
# every event carries a monotonic #seq + relative ms
_seq = [0]; _t0 = time.time()
def ev(msg):
_seq[0] += 1
events.append(f"#{_seq[0]:03d} +{(time.time()-_t0)*1000:6.0f}ms {msg}")
# mocked-/ipfs 404 console errors are tallied, not traced; every other console error still traces
_ipfs404 = [0]
def _on_console(m):
if m.type not in ("error", "warning"): return
if ("Failed to load resource" in m.text and "404" in m.text
and "/ipfs/" in (m.location or {}).get("url", "")):
_ipfs404[0] += 1; return
ev(f"console.{m.type}: {m.text}")
page.on("console", _on_console)
page.on("pageerror", lambda e: ev(f"pageerror: {e}"))
page.on("requestfailed", lambda r: ev(f"requestfailed: {r.method} {r.url}"))
watch_graphql_errors(page, events)
# GraphQL wire trace — every /graphql op when its request is ISSUED (REQ) and when its response RESOLVES (RESP)
def _op(pd):
try:
b = json.loads(pd); q = b.get("query", ""); v = b.get("variables") or {}
if "photovideosSample" in q: return ("read", f"search={v.get('search','')!r}")
if "mutation" in q:
op = next((k for k in ("updatePhotovideo", "photovideosAddLabel",
"photovideosRemoveLabel", "photovideosSetState") if k in q), "mutation")
return ("write", f"{op} {({kk: v[kk] for kk in ('cid','label','v','patch') if kk in v})}")
except Exception: pass
return None
# each RESP also carries how long its own read was in flight
_req_t0 = {}
def _trace_req(req):
try:
if "/graphql" in req.url and (o := _op(req.post_data or "")):
_req_t0[req] = time.time()
ev(f"REQ {o[0]:5s} {o[1]}")
except Exception: pass
def _trace_resp(resp):
try:
if "/graphql" not in resp.url or not (o := _op(resp.request.post_data or "")): return
started = _req_t0.pop(resp.request, None)
took = f" [{(time.time() - started) * 1000:.0f}ms]" if started else ""
body = resp.json()
if body.get("errors"):
ev(f"RESP {o[0]:5s} {o[1]}{took} ERRORS={[e.get('message') for e in body['errors']]}")
elif o[0] == "read":
nodes = ((body.get("data") or {}).get("photovideosSample") or {}).get("nodes") or []
labs = [n.get("labels") for n in nodes[:6] if isinstance(n, dict)]
tail = "" if len(nodes) <= 6 else f" …(+{len(nodes)-6})"
SETTLED_READS.append((started or 0, o[1])) # search_for waits on this
ev(f"RESP {o[0]:5s} {o[1]}{took} → {len(nodes)} nodes labels={labs}{tail}")
else:
ev(f"RESP {o[0]:5s} {o[1]}{took}")
except Exception: pass
page.on("request", _trace_req)
page.on("response", _trace_resp)
# which tests install a /graphql route of their own
_held_by_route = [False]
_route = page.route
def _route_noting(pattern, handler, **kw):
if "graphql" in str(pattern): _held_by_route[0] = True
return _route(pattern, handler, **kw)
page.route = _route_noting
GQL_LOG.clear() # this test's own writes, not the last one's
SETTLED_READS.clear() # and this test's own reads
try:
t(page); passed += 1
print(f"[{now()}] PASS {t.__name__} ({time.time()-t0:.1f}s)", flush=True)
except Exception as e:
print(f"[{now()}] FAIL {t.__name__} ({time.time()-t0:.1f}s): {e}", flush=True); failed += 1
if _ipfs404[0]: events.append(f" (+{_ipfs404[0]} mocked-/ipfs 404 console errors suppressed)")
# the harness's writes, on the same clock as the timeline above
if GQL_LOG:
events.append(" --- harness writes (same clock) ---")
for line in harness_writes(GQL_LOG, _t0): events.append(" " + line)
dump_failure(page, t.__name__, "memories", events)
if stop_on_fail: break
finally:
# pending writes drained before the page is torn down; reads left alone; the wait capped
_drain0, _stuck = time.time(), []
while time.time() - _drain0 < 1.5:
_stuck = [o for rq in list(_req_t0)
if (o := _op(rq.post_data or "")) and o[0] == "write"]
if not _stuck: break
page.wait_for_timeout(25)
if _stuck and not _held_by_route[0]:
print(f" LEAKED WRITE past {t.__name__}: {_stuck}"
f" — if a later test fails unexplained, start here", flush=True)
page.close(); ctx.close()
browser.close()
finally:
drop_fixtures() # never leave test rows in the server's copy
print(f"\n{passed} passed, {failed} failed out of {len(selected)}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
- how
-xstops at the first failure,--headedshows the browser, any other word picks the tests whose name holds it.
argv = sys.argv[1:]
headed = "--headed" in argv
stop_on_fail = "-x" in argv or "--exitfirst" in argv
patterns = [a for a in argv if a not in ("--headed", "-x", "--exitfirst")]
selected = [t for t in TESTS if not patterns or any(p in t.__name__ for p in patterns)]
if not selected:
print(f"no test matches {patterns}"); sys.exit(2)
passed = failed = 0
now = lambda: time.strftime("%H:%M:%S")
- how
- each test gets a fresh context:
/graphqlgoes to the suite’s own throwaway server,/ipfs/to the mock.
t0 = time.time()
print(f"[{now()}] TRYING {t.__name__}", flush=True)
ctx = browser.new_context(viewport=VIEWPORT) # fresh storage per test
ctx.route("**/graphql", _forward_graphql) # /graphql → the throwaway server, never the real one
ctx.route("**/ipfs/**", _mock_ipfs) # /ipfs/ → deterministic 404, never the flaky gateway
# unhandled promise rejections forwarded to the console
ctx.add_init_script(
"addEventListener('unhandledrejection', e =>"
" console.error('unhandledrejection: ' + (e.reason?.message || e.reason)))")
page = ctx.new_page()
page.set_default_timeout(8000)
What the runner records of a test
- how
- console errors, page errors and failed requests go to the test’s timeline, numbered and timed.
- rule: the mocked
/ipfs/404s are only counted; a 404 anywhere else is traced like any error.
- rule: the mocked
events = []
# every event carries a monotonic #seq + relative ms
_seq = [0]; _t0 = time.time()
def ev(msg):
_seq[0] += 1
events.append(f"#{_seq[0]:03d} +{(time.time()-_t0)*1000:6.0f}ms {msg}")
# mocked-/ipfs 404 console errors are tallied, not traced; every other console error still traces
_ipfs404 = [0]
def _on_console(m):
if m.type not in ("error", "warning"): return
if ("Failed to load resource" in m.text and "404" in m.text
and "/ipfs/" in (m.location or {}).get("url", "")):
_ipfs404[0] += 1; return
ev(f"console.{m.type}: {m.text}")
page.on("console", _on_console)
page.on("pageerror", lambda e: ev(f"pageerror: {e}"))
page.on("requestfailed", lambda r: ev(f"requestfailed: {r.method} {r.url}"))
watch_graphql_errors(page, events)
- how
- a GraphQL call is told a read or a write by its query.
# GraphQL wire trace — every /graphql op when its request is ISSUED (REQ) and when its response RESOLVES (RESP)
def _op(pd):
try:
b = json.loads(pd); q = b.get("query", ""); v = b.get("variables") or {}
if "photovideosSample" in q: return ("read", f"search={v.get('search','')!r}")
if "mutation" in q:
op = next((k for k in ("updatePhotovideo", "photovideosAddLabel",
"photovideosRemoveLabel", "photovideosSetState") if k in q), "mutation")
return ("write", f"{op} {({kk: v[kk] for kk in ('cid','label','v','patch') if kk in v})}")
except Exception: pass
return None
- how
- each call goes to the timeline twice: when it is issued, and when it answers, with how long it took.
# each RESP also carries how long its own read was in flight
_req_t0 = {}
def _trace_req(req):
try:
if "/graphql" in req.url and (o := _op(req.post_data or "")):
_req_t0[req] = time.time()
ev(f"REQ {o[0]:5s} {o[1]}")
except Exception: pass
def _trace_resp(resp):
try:
if "/graphql" not in resp.url or not (o := _op(resp.request.post_data or "")): return
started = _req_t0.pop(resp.request, None)
took = f" [{(time.time() - started) * 1000:.0f}ms]" if started else ""
body = resp.json()
if body.get("errors"):
ev(f"RESP {o[0]:5s} {o[1]}{took} ERRORS={[e.get('message') for e in body['errors']]}")
elif o[0] == "read":
nodes = ((body.get("data") or {}).get("photovideosSample") or {}).get("nodes") or []
labs = [n.get("labels") for n in nodes[:6] if isinstance(n, dict)]
tail = "" if len(nodes) <= 6 else f" …(+{len(nodes)-6})"
SETTLED_READS.append((started or 0, o[1])) # search_for waits on this
ev(f"RESP {o[0]:5s} {o[1]}{took} → {len(nodes)} nodes labels={labs}{tail}")
else:
ev(f"RESP {o[0]:5s} {o[1]}{took}")
except Exception: pass
page.on("request", _trace_req)
page.on("response", _trace_resp)
- how
- the runner notes which tests hold
/graphqlwith a route of their own, a write such a test leaves pending being its own doing rather than a leak.
# which tests install a /graphql route of their own
_held_by_route = [False]
_route = page.route
def _route_noting(pattern, handler, **kw):
if "graphql" in str(pattern): _held_by_route[0] = True
return _route(pattern, handler, **kw)
page.route = _route_noting
- how
- a failure dumps the timeline, with the harness’s own writes on the same clock.
if _ipfs404[0]: events.append(f" (+{_ipfs404[0]} mocked-/ipfs 404 console errors suppressed)")
# the harness's writes, on the same clock as the timeline above
if GQL_LOG:
events.append(" --- harness writes (same clock) ---")
for line in harness_writes(GQL_LOG, _t0): events.append(" " + line)
dump_failure(page, t.__name__, "memories", events)
if stop_on_fail: break
- rule
- writes still pending are drained before the page goes, the wait capped at 1.5 s.
- how: one still pending is printed as a leaked write, unless the test held
/graphqlitself.
- how: one still pending is printed as a leaked write, unless the test held
# pending writes drained before the page is torn down; reads left alone; the wait capped
_drain0, _stuck = time.time(), []
while time.time() - _drain0 < 1.5:
_stuck = [o for rq in list(_req_t0)
if (o := _op(rq.post_data or "")) and o[0] == "write"]
if not _stuck: break
page.wait_for_timeout(25)
if _stuck and not _held_by_route[0]:
print(f" LEAKED WRITE past {t.__name__}: {_stuck}"
f" — if a later test fails unexplained, start here", flush=True)
page.close(); ctx.close()
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