Konubinix' opinionated web of thoughts

A Photos/Videos Organiser With Preact

Fleeting

Why this note

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

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

It boots

First, prove the no-build Solid stack loads at all: the import map resolves Solid from esm.sh, the html template renders, and a data-app-ready flag is raised for the tests to wait on. Solid’s web and html submodules must share one core instance (?external=solid-js in the map dedupes them, else reactivity is dead).

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

import { render } from 'solid-js/web';
import html from 'solid-js/html';
import { createSignal, createResource, createMemo, createEffect, For, Index, Show, onMount, onCleanup } from 'solid-js';
import { client, getAuthNeeded, subscribeAuth } from '../shared/gql.js';

The data layer is shared: memories’ reactivity is Solid’s createResource, but its transport is the shared urql client — the same one the frise uses — so there is one client and one auth gate across the apps. The triage wall reads through the cache, tagging its query with the Photovideo type so a revisited filter comes back without a round-trip. Because createResource isn’t wired to the cache’s reactivity — unlike the frise’s hooks, which re-run on their own — memories re-reads the wall itself after each edit; the edit has invalidated the views it touched, so that re-read comes back fresh instead of stale. Label completion stays network-only — a vocab word added moments ago has to appear at once. A Solid signal mirrors the shared gate’s store into the banner.

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

render(App, document.getElementById('app'));
document.body.setAttribute('data-app-ready', '1');
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});

:root{ --bg:#1b1d2e; --fg:#e8e8f0; }
body{ background:var(--bg); color:var(--fg); font-family:system-ui,sans-serif; margin:0; padding:12px; }
h1{ font-size:18px; margin:0 0 12px; }

When the device isn’t authorized

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

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

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

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

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

A grid of thumbnails

The point of the app: a wall of thumbnails from the archive. A createResource keyed on the search term fetches photovideosSample (memories’ own even ~2000 spread over the frise’s shared filter — see Drawing our own spread); For renders a tile per photo, lazy-loaded. Empty results and the in-flight state are handled with Show.

The test opens the app on the whole archive (the all chip, since the default todo view may be empty once everything’s triaged) and checks a wall of thumbnails appears.

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

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

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

The window spans the whole dated archive (floored at 2007, like the frise); the server samples it down. Each tile is a plain lazy img pointed at its /ipfs/ thumbnail, with the date as its alt=/=title so tests can address it the way a user reads it.

Memories doesn’t spell out the server filter itself — the parse-to-variables mapping and the GraphQL declarations and arguments that carry it are the same in every app, so they live in the shared data module beside photoVars and the PhotoCore field set. Memories splices those in, adding only its own: a states filter, a sampling cap (the shared SAMPLE_CAP), and the state=/=myrandom fields.

const SEARCH = `query(${PHOTO_FILTER_DECL}, $states:[State!], $cap:Int!){
  photovideosSample(${PHOTO_FILTER_ARGS}, states:$states, cap:$cap, first:8000){
    nodes{ ...PhotoCore state myrandom }
  }
  photovideosCount(${PHOTO_FILTER_ARGS}, states:$states)
}
${PHOTO_CORE}`;
// The server samples ~SAMPLE_CAP across the range, and only *drops* rows when the
// match count exceeds that cap. So "was it sampled?" is "did it return fewer rows
// than match?" — the honest trigger for the notice. Every node is shown (those
// without a thumbnail get a placeholder tile), so shown == returned. `first` sits
// well above the cap so GraphQL never truncates before the sampler does.
const fetchPhotos = async (key) => {
    const d = await gql(SEARCH, { ...photoVars(key), states: key.state === 'all' ? null : [key.state], cap: SAMPLE_CAP }, PV_CTX);
    const nodes = d?.photovideosSample?.nodes ?? [];
    const total = d?.photovideosCount ?? nodes.length;
    return { items: nodes, total, sampled: nodes.length < total };
};

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

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

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

@testcase
def test_filter_by_owner(page):
    """An owner: token narrows the wall to one person's photos."""
    drop_fixtures()
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzown-k", "date": "2020-01-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzown-k-t", "labels": "zzown", "state": "todo", "owner": "konubinix"},
            {"cid": "https://ipfs.konubinix.eu/p/zzown-a", "date": "2020-02-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzown-a-t", "labels": "zzown", "state": "todo", "owner": "aylapomme"}]
    for d in docs: gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzown")
        expect(tiles(page)).to_have_count(2)                  # both owners shown
        search_for(page, "zzown; owner:konubinix")
        expect(tiles(page)).to_have_count(1)                  # narrowed to one
        expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzown-k-t")
        print("  PASS: filter by owner")
    finally:
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

A month: token keeps a month across every year — so it threads through the same photovideos_match filter the wall and its count share. Two Junes in different years and a March prove it picks the month, not a date range.

@testcase
def test_filter_by_month(page):
    """A month: token keeps that month across every year the window spans."""
    docs = [{"cid": "https://ipfs.konubinix.eu/p/zzmon-j19", "date": "2019-06-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmon-j19-t", "labels": "zzmon", "state": "todo", "owner": "konubinix"},
            {"cid": "https://ipfs.konubinix.eu/p/zzmon-j21", "date": "2021-06-10T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmon-j21-t", "labels": "zzmon", "state": "todo", "owner": "konubinix"},
            {"cid": "https://ipfs.konubinix.eu/p/zzmon-m20", "date": "2020-03-20T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": "https://ipfs.konubinix.eu/p/zzmon-m20-t", "labels": "zzmon", "state": "todo", "owner": "konubinix"}]
    for d in docs: gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzmon")
        expect(tiles(page)).to_have_count(3)
        search_for(page, "zzmon; month:6")
        expect(tiles(page)).to_have_count(2)                  # both Junes, across years
        search_for(page, "zzmon; month:march")
        expect(tiles(page)).to_have_count(1)
        print("  PASS: filter by month")
    finally:
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass

A year: token pins one calendar year — sugar for a since:=/=until: pair on that year, threaded through the same filter. Three docs in three different years prove it keeps just the one.

@testcase
def test_filter_by_year(page):
    """A year: token narrows the wall to one calendar year."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzyr-{y}", "date": f"{y}-06-15T12:00:00Z", "mimetype": "image/jpeg",
             "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzyr-{y}-t", "labels": "zzyr", "state": "todo"} for y in (2019, 2020, 2021)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page); chip(page, "all").click()
        search_for(page, "zzyr")
        expect(tiles(page)).to_have_count(3)
        search_for(page, "zzyr; year:2020")
        expect(tiles(page)).to_have_count(1)
        expect(thumb_imgs(page).first).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzyr-2020-t")
    finally:
        for d in docs:
            try: gql(DELETE, {"cid": d["cid"]})
            except Exception: pass
    print("  PASS: filter by year")

function App(){
    // the search box persists locally, so a reload (or the frame's reboot) restores
    // the same query — and thus the same frame show.
    const SEARCH_KEY = 'memories.search';
    const [search, setSearch] = createSignal(localStorage.getItem(SEARCH_KEY) || '');
    createEffect(() => localStorage.setItem(SEARCH_KEY, search()));
    const [searchFocus, setSearchFocus] = createSignal(false);
    // hiding the suggestions on blur is deferred (so a click on one lands first); refocusing
    // must cancel that pending hide, or the list blinks away ~150ms after you're back in.
    let searchBlurT;
    const [caret, setCaret] = createSignal(0);        // cursor position, so completion follows it
    // keyboard-driving the suggestion list (only one box is focused at a time, so a single
    // highlight is enough): the focused box's Suggest reports its items here and reads the
    // active index back. ↓ enters at the top and ↑ at the bottom — both cycle through the
    // -1 "none" slot so either key reaches the list and either escapes it. Enter applies the
    // highlighted word (or, with nothing highlighted, runs the box's own commit).
    const [sugItems, setSugItems] = createSignal([]);
    const [sugActive, setSugActive] = createSignal(-1);
    const reportSug = list => { setSugItems(list); setSugActive(-1); };   // a new list clears the highlight
    const sugNav = (e, commit) => {
        const n = sugItems().length;
        if(e.key === 'ArrowDown' && n){ e.preventDefault(); setSugActive(i => i >= n - 1 ? -1 : i + 1); }
        else if(e.key === 'ArrowUp' && n){ e.preventDefault(); setSugActive(i => i < 0 ? n - 1 : i - 1); }
        else if(e.key === 'Enter'){ e.preventDefault();
            commit(sugActive() >= 0 ? sugItems()[sugActive()] : null); setSugActive(-1); }
    };
    // apply a search suggestion to the caret's segment. A finished label gets a "; "
    // so the next one starts without typing the separator; a DSL token (since:, type:…)
    // keeps drilling. Either way the box stays open so you can keep going.
    const pickSearch = w => { const [s, e] = segSpan(search(), caret());
        const lead = (search().slice(s, e).match(/^\s*/) || [''])[0];
        const tail = w.includes(':') ? '' : '; ';
        const next = search().slice(0, s) + lead + w + tail + search().slice(e);
        setSearch(next); setCaret(s + lead.length + w.length + tail.length); setSearchFocus(true); };
    // the chip choice persists; the first-ever visit defaults to the triage view (todo)
    const STATE_KEY = 'memories.state';
    const [stateFilter, setStateFilter] = createSignal(localStorage.getItem(STATE_KEY) || 'todo');
    createEffect(() => localStorage.setItem(STATE_KEY, stateFilter()));
    // the last label applied (lightbox or batch), offered for one-tap reuse on the next
    // doc so labelling a run of photos doesn't mean retyping the same word each time.
    const [lastLabel, setLastLabel] = createSignal('');
    // thumbnail size is user-adjustable and persisted; past a threshold the tiles source
    // from web_cid so an enlarged image isn't a pixelated little thumbnail.
    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)));
    // Ctrl+wheel zooms the wall — the browser's own zoom gesture, turned on the tiles
    // (preventDefault so the page itself doesn't zoom). One step per burst via a short
    // cooldown, so a trackpad pinch doesn't rocket through the range; scroll up = bigger.
    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);
    };
    // the fetch key excludes sort (a client-side reorder shouldn't re-query); compared
    // as a string so a sort-only change doesn't refetch the same sample.
    const queryStr = createMemo(() => { const p = parseQuery(search());
        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, state: stateFilter() }); });
    const [photos, { refetch }] = createResource(() => JSON.parse(queryStr()), fetchPhotos);
    const sortKey = createMemo(() => parseQuery(search()).sort);
    const total = () => photos()?.total ?? 0;          // how many actually match
    const items = createMemo(() => { const a = photos()?.items ?? [];   // shown docs, in wall order
        return sortKey() === 'random' ? [...a].sort((x, y) => (x.myrandom ?? 0) - (y.myrandom ?? 0)) : a; });
    const STATES = ['todo', 'next', 'done', 'delete'];
    // the picked set persists, so an accidental refresh mid-session doesn't drop the run
    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);   // touch: extend on next tap
    const [allMatching, setAllMatching] = createSignal(false);   // act on the whole filter, not the sample
    const isSel = cid => selected().has(cid);
    const selCount = () => selected().size;
    const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
    const toggle = cid => { setAllMatching(false); setSelected(s => {
        const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
    }); };

    // select-all toggles the whole shown wall on/off (it lives outside the selection
    // toolbar, which is hidden when nothing is selected).
    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()));

    // select the whole contiguous run from the anchor to cid, in the wall's date order.
    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; });
    };
    // plain click toggles one tile and moves the anchor. A range is extended either by
    // shift-click (desktop) or by arming the toolbar's range toggle then tapping the end
    // tile (touch — no modifier key). Either way it clears range mode afterwards.
    const onTileClick = (e, cid) => {
        setCursor(cid);   // a click plants the keyboard cursor, so the arrows carry on from here
        if((e.shiftKey || rangeMode()) && anchor() !== null){
            extendTo(cid); setRangeMode(false); return;
        }
        toggle(cid); setAnchor(cid);
    };

    // double-tap a tile to start a range — the fast touch gesture (the toolbar ↔ range toggle
    // is the explicit route). The tapped tile becomes the anchor and arms range mode, so the
    // next tap selects the run to it.
    const armRange = cid => {
        setSelected(s => { const n = new Set(s); n.add(cid); return n; });
        setAnchor(cid); setRangeMode(true);
    };

    // press-and-hold a tile to open it in the lightbox — the deliberate gesture asks for one
    // doc, full size. A move (a scroll) or an early release cancels the press; the press's own
    // click is swallowed so it doesn't fall through to a select once the modal is up.
    let lpTimer = null, lpFired = false, lpAt = null;
    const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
    const onTileDown = (e, photo) => {
        lpFired = false; lpAt = { x: e.clientX, y: e.clientY }; lpCancel();
        lpTimer = setTimeout(() => { lpFired = true; lpCancel(); openPhoto(photo); }, 500);
    };
    const onTileMove = e => { if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel(); };
    const onTileUp = () => lpCancel();
    const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };

    async function patchSelected(patchFor){
        const byCid = new Map(items().map(p => [p.cid, p]));
        for(const cid of selected())
            await gql(UPDATE_PHOTO, { cid, patch: patchFor(byCid.get(cid)) });
        clearSel();
        await refetch();
    }
    // labels are a ;-delimited list, so the same split parses both a doc's stored labels and
    // what's typed into an add-label box — typing "a; b" adds two labels in one go.
    const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
    const splitLabels = p => splitWords(p.labels);

    // "all matching" applies to *every* doc the filter matches (server-side, beyond the
    // ~2000 sample), via the bulk functions, using the same query the wall is showing.
    const filterVars = () => ({ ...photoVars(parseQuery(search())),
                                states: stateFilter() === 'all' ? null : [stateFilter()] });
    const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
      photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
    async function applyBulk(kind, v){ await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); 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 }));

    // save the picked docs as files — one per doc, at a chosen resolution: the original
    // (=cid=), the downscaled =web_cid=, or the =thumbnail=. It acts on the explicit
    // selection (the shown, ticked docs), not "all matching" — there's no client-side list
    // of the whole filter to fetch. The browser asks once before saving several files.
    // the original's extension comes from the db mimetype (a cid carries none, and a missing
    // /ipfs/ object answers text/plain); a few common ones get a friendly extension, the rest
    // fall back to the mimetype subtype.
    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 === 'thumb' ? (p.thumbnailCid || p.cid)
                       : res === 'web' ? (p.webCid || p.cid) : p.cid;
            // the renditions are fixed formats (like the legacy slider): a thumbnail is a
            // JPEG, a video's web copy is an MP4 (an image's is a JPEG); only the original
            // keeps its true type from the db mimetype. Tag the resolution so one doc's
            // orig/web/thumb never collide in the download folder.
            const isVid = (p.mimetype || '').startsWith('video');
            const ext = res === 'thumb' ? 'jpg' : 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 [opened, setOpened] = createSignal(null);
    const [lbText, setLbText] = createSignal('');
    const [lbFocus, setLbFocus] = createSignal(false);
    const isVideo = p => (p?.mimetype || '').startsWith('video');
    const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
    // a video needs its web_cid to play; an image can fall back to its thumbnail
    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 });          // optimistic — keep the modal correct
        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('; '));
    // the box's S-RET counterpart to lbAdd: strip every typed label from the open doc
    const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
        setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };

    // change the open doc's state. Like the frame's frameEdit, the edit may push it out of
    // the current filter; if so re-anchor on the previous doc (else keep it; close if none).
    async function lbSetState(st){
        const list = items(), cur = opened(); if(!cur) return;
        const i = list.findIndex(p => p.cid === cur.cid);
        const prevCid = i > 0 ? list[i - 1].cid : null;
        await gql(UPDATE_PHOTO, { cid: cur.cid, patch: { state: st } });
        await refetch();
        requestAnimationFrame(() => {
            const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
            const stay = l2.find(p => p.cid === cur.cid);
            setOpened(stay || (prevCid && l2.find(p => p.cid === prevCid)) || l2[0]);
        });
    }

    // step to the previous/next doc in the wall (date order), wrapping at the ends.
    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('');
    };
    // while a video is playing, an arrow seeks it ±5s; only at the end/start (or for a
    // paused video / a photo) does it give up and step to the next/previous doc.
    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);
    };
    // horizontal swipe on the media navigates (mobile); arrows do the same on desktop.
    let swipeX = null;
    const onSwipeStart = e => { swipeX = e.clientX; };
    const onSwipeEnd = e => { if(swipeX == null) return;
        const dx = e.clientX - swipeX; swipeX = null;
        if(Math.abs(dx) > 40) step(dx < 0 ? 1 : -1); };

    // S-scroll: Shift + wheel steps prev/next (plain scroll stays free for the panel). Shift
    // can map the wheel to the horizontal axis, so read whichever delta is set. One step per
    // scroll burst via a short cooldown, so a flick doesn't skip several docs.
    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;
            // ‹ › step the wall, but not while a text field has focus — there the arrows
            // move the cursor (and ↑/↓/Enter drive the label box's suggestions).
            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(); }   // replay from the end
                else if(v.paused) v.play(); else v.pause();                              // else toggle play/pause
            }
        };
        window.addEventListener('keydown', onKey);
        onCleanup(() => window.removeEventListener('keydown', onKey));
    });
    const ZOOM_MAX = 4;
    const [zoom, setZoom] = createSignal(1);              // scale of the centred slide, 1‒ZOOM_MAX
    const [panX, setPanX] = createSignal(0), [panY, setPanY] = createSignal(0);   // its translation, px
    const zoomed = () => zoom() > 1;
    const [zoomBeat, setZoomBeat] = createSignal(0);      // bumped per gesture, to re-arm the idle reset
    const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
    // keep the doc on its pane: with the transform anchored top-left, tx spans [W(1−s), 0]
    // and ty spans [H(1−s), 0] — so a magnified slide can be dragged to either edge, no further.
    const clampPan = (s, tx, ty) => { const w = stripEl?.clientWidth || 1, h = stripEl?.clientHeight || 1;
        return [clamp(tx, w * (1 - s), 0), clamp(ty, h * (1 - s), 0)]; };
    // zoom toward a focal screen point: the point under (fx,fy) keeps its place as the scale
    // changes (t' = f − (f − t)·s'/s), which is what makes a pinch feel anchored to the fingers.
    const zoomToward = (ns, fx, fy) => { const s = zoom(); ns = clamp(ns, 1, ZOOM_MAX); const r = ns / s;
        let tx = ns === 1 ? 0 : fx - (fx - panX()) * r, ty = ns === 1 ? 0 : fy - (fy - panY()) * r;
        [tx, ty] = clampPan(ns, tx, ty);
        setZoom(ns); setPanX(tx); setPanY(ty); setZoomBeat(b => b + 1); };
    const panBy = (dx, dy) => { const [tx, ty] = clampPan(zoom(), panX() + dx, panY() + dy);
        setPanX(tx); setPanY(ty); setZoomBeat(b => b + 1); };
    const onZoomWheel = e => { if(!e.ctrlKey) return; e.preventDefault();
        zoomToward(zoom() * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY); };
    const zptr = new Map();                               // live pointers, by id
    let lastMid = null, lastGap = 0, lastPan = null;      // previous-frame pinch/drag anchors
    let tapFrom = null;                                   // where a lone finger went down, while it could still be a tap
    const TAP_SLOP = 10;                                  // travel past this (px) makes it a swipe, not a tap
    const pts = () => [...zptr.values()];
    const span = () => { const [a, b] = pts();
        return { d: Math.hypot(a.x - b.x, a.y - b.y), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
    const onZoomDown = e => { zptr.set(e.pointerId, { x: e.clientX, y: e.clientY });
        if(zptr.size === 2){ const s = span(); lastGap = s.d; lastMid = { x: s.x, y: s.y }; tapFrom = null; }  // a pinch, not a tap
        else if(zptr.size === 1){ lastPan = { x: e.clientX, y: e.clientY }; tapFrom = { x: e.clientX, y: e.clientY }; } };
    const onZoomMove = e => { if(!zptr.has(e.pointerId)) return;
        zptr.set(e.pointerId, { x: e.clientX, y: e.clientY });
        if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null;  // travelled → a swipe
        if(zptr.size >= 2){ const s = span();
            if(lastMid){ panBy(s.x - lastMid.x, s.y - lastMid.y); zoomToward(zoom() * s.d / lastGap, s.x, s.y); }
            lastGap = s.d; lastMid = { x: s.x, y: s.y }; }
        else if(zptr.size === 1 && zoomed() && lastPan){
            panBy(e.clientX - lastPan.x, e.clientY - lastPan.y); lastPan = { x: e.clientX, y: e.clientY }; } };
    const onZoomUp = e => { zptr.delete(e.pointerId);
        if(zptr.size === 1){ const p = pts()[0]; lastPan = { x: p.x, y: p.y }; lastMid = null; }   // pinch → drag
        else if(zptr.size === 0){ lastMid = null; lastPan = null;
            if(e.type === 'pointerup' && tapFrom) onFrameTap(e);   // a lone, still finger lifted → the side-tap step
            tapFrom = null; } };
    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, for present-aware completion
    const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);      // its strip index, for the load bands
    const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
    const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
    // the frame plays the wall *in the order shown* (date, or sort:random — same as the
    // grid). An infinite carousel: clone the last slide before the first and the first
    // after the last. Real slide 0 sits at strip index 1; a scroll that settles on a clone
    // (index 0 or n+1) silently jumps to its real twin, so swiping past either edge loops.
    const frameSlides = () => { const o = items();
        return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
    let stripEl, wakeLock = null;
    // index and position by the slides' own geometry — never the rounded clientWidth (see prose)
    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) setFrameCenterIdx(slideAt());          // the load bands ride the live scroll, not just the settle
        clearTimeout(snapT); snapT = setTimeout(() => {   // ~150ms after the last scroll
        if(!stripEl) 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];                            // remember where we are, to resume on reboot
        setFrameCenterIdx(i);
        if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
    }, 150); };
    const FRAME_ON_KEY = 'memories.frame.on';              // remembers we're in frame mode
    async function enterFrame(){
        if(!items().length) return;
        setFrame(true); setPlaying(true); setFrameUI(false);
        localStorage.setItem(FRAME_ON_KEY, '1');           // so a relaunch (PWA shortcut) re-enters
        history.pushState({ frame: true }, '');           // so the back button leaves the frame
        try { await document.documentElement.requestFullscreen?.(); } catch(e) {}
        try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
    }
    function closeFrame(){                                 // the teardown itself
        setFrame(false);
        localStorage.setItem(FRAME_ON_KEY, '0');           // leaving turns off auto-enter
        try { wakeLock?.release(); } catch(e) {} wakeLock = null;
        try { if(document.fullscreenElement) document.exitFullscreen?.(); } catch(e) {}
    }
    // Esc / the exit button unwind the history entry (→ popstate → closeFrame), so back
    // and explicit-exit leave history balanced.
    const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();

    // edit the centered doc from within the frame. The edit may push it out of the current
    // filter; if so, re-anchor on the *previous* doc (so the next advance shows the one
    // that filled the gap rather than skipping it); if it stayed, keep it centered.
    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 => 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('; ') })); };
    // auto-start the show as soon as the (persisted) wall loads — when we were last in the
    // frame (the frame launches from a PWA shortcut, no query string), or when forced with
    // ?frame=1. Once only, so exiting doesn't immediately re-enter.
    const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1'
        || new URLSearchParams(location.search).get('frame') === '1';
    let autoEntered = false;
    createEffect(() => {
        if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
    });
    // open on the slide we left off (resume across reboot), else the first real slide.
    createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
        const saved = localStorage.getItem(FRAME_CID_KEY);
        const r = saved ? items().findIndex(p => p.cid === saved) : -1;
        stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1);   // +1 for the leading clone
        setFrameCenterIdx(r >= 0 ? r + 1 : 1);                             // seed the centre before any scroll
        setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
    }); });
    // a video scrolled out of view must stop — pause any slide video that's mostly off-screen
    createEffect(() => {
        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() || !playing() || zoomed()) return;   // a pinch freezes the slide (next section)
        const id = setInterval(() => {
            if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
            frameGo(1);
        }, intervalMs());
        onCleanup(() => clearInterval(id));
    });
    onMount(() => {
        const onKey = e => {
            if(!frame()) return;
            const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);   // leave the arrows to the label box
            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 = () => {                                // the back button unwinds frame, then lightbox
            if(frame()) closeFrame();
            const st = history.state || {};
            if(opened() && !st.lb) closePhoto();             // popped past the lightbox → grid
            if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }   // back into the lightbox under the frame
        };
        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 WEB_NEAR = 2, KEEP_FAR = 10;
    const frameSrc = (p, k) => { const d = Math.abs(k - frameCenterIdx());
        if(d > KEEP_FAR) return BLANK;                            // far → unloaded
        if(d <= WEB_NEAR) return IPFS + (p?.webCid || p?.thumbnailCid || '');   // near → full-res
        return IPFS + (p?.thumbnailCid || p?.webCid || ''); };    // middle → thumbnail
    const onFrameTap = e => {
        if(zoomed()) return;
        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);
    };
    createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
        const on = (t, h, o) => el.addEventListener(t, h, o), off = (t, h) => el.removeEventListener(t, h);
        on('wheel', onZoomWheel, { passive: false }); on('pointerdown', onZoomDown);
        on('pointermove', onZoomMove); on('pointerup', onZoomUp); on('pointercancel', onZoomUp);
        onCleanup(() => { off('wheel', onZoomWheel); off('pointerdown', onZoomDown);
            off('pointermove', onZoomMove); off('pointerup', onZoomUp); off('pointercancel', onZoomUp); }); });
    createEffect(() => { const s = zoom(), tx = panX(), ty = panY(); if(!frame() || !stripEl) return;
        const i = slideAt();
        [...stripEl.children].forEach((sl, k) => { const m = sl.querySelector('.slide-media');
            if(!m) return;
            m.style.transformOrigin = '0 0';
            m.style.transform = (k === i && s > 1) ? `translate(${tx}px,${ty}px) scale(${s})` : ''; }); });
    const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
    const resetZoom = () => { setZoom(1); setPanX(0); setPanY(0); };
    createEffect(() => { if(!zoomed()) return; zoomBeat();
        const id = setTimeout(resetZoom, ZOOM_IDLE_MS); onCleanup(() => clearTimeout(id)); });
    const frameFromHere = () => { const cur = opened(); if(!cur) return;
        localStorage.setItem(FRAME_CID_KEY, cur.cid);    // the slide the frame will open on
        closePhoto();                                    // hide the modal but leave its history entry, so Back returns to it
        enterFrame(); };
    const lbDelete = force => { if(force || confirm('Mark this for deletion?')) lbSetState('delete'); };
    // the cursor persists too, so a refresh lands back on the focused tile, not the first one
    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;
    // a row is however many columns the responsive grid is showing right now
    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){
            if(!extending){ extending = true; rangeBase = new Set(selected()); }   // snapshot before the run
            const a = list.findIndex(p => p.cid === anchor()), [lo, hi] = a < ni ? [a, ni] : [ni, a];
            setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
        } 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));
    });
    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) return;
        marqueeFrom = { x: e.clientX, y: e.clientY };
        setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY });
        gridEl.setPointerCapture?.(e.pointerId);
    };
    const onGridMove = e => {
        if(!marqueeFrom) return;
        setMarquee({ x0: marqueeFrom.x, y0: marqueeFrom.y, x1: e.clientX, y1: e.clientY });
        marqueeSelect();
    };
    const onGridUp = () => { if(marqueeFrom){ marqueeSelect(); marqueeFrom = null; setMarquee(null); } };
    onMount(() => {
        const onKey = e => {
            if((e.key !== 'l' && e.key !== '.') || frame()) return;    // the frame has its own bar
            if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;    // a field already owns the key
            const box = opened() ? document.querySelector('.lb .batch-label')
                      : selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
            if(!box) return;
            e.preventDefault();
            if(e.key === '.' && lastLabel())                           // '.' re-drops the label applied last
                (opened() ? setLbText : setLabelText)(lastLabel());
            box.focus();
        };
        window.addEventListener('keydown', onKey);
        onCleanup(() => window.removeEventListener('keydown', onKey));
    });
    onMount(() => {
        history.pushState({ app: true }, '');          // the root entry the back button stops on
        const onExit = () => {
            const st = history.state || {};
            if(!frame() && !opened() && !st.lb && !st.app){      // popped below the root with nothing open
                if(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>
      <//>
      <h1>Memories</h1>
      <div class="complete">
        <input class="search" type="search" aria-label="search labels"
               placeholder="labels; since:2010; until:2015; type:video; sort:random"
               value=${() => search()}
               onInput=${e => { setSearch(e.target.value); setCaret(e.target.selectionStart);
                                clearTimeout(searchBlurT); setSearchFocus(true); }}
               onKeyUp=${e => setCaret(e.target.selectionStart)}
               onClick=${e => setCaret(e.target.selectionStart)}
               onFocus=${() => { clearTimeout(searchBlurT); setSearchFocus(true); }}
               onKeyDown=${e => sugNav(e, w => { if(w) pickSearch(w); })}
               onBlur=${() => { searchBlurT = setTimeout(() => setSearchFocus(false), 150); }} />
        <${Show} when=${() => searchFocus() && !opened()}>
          <${Suggest} text=${search} caret=${caret} dsl=${true}
                      active=${sugActive} onItems=${reportSug} onPick=${pickSearch} />
        <//>
      </div>
      <div class="chips" role="group" aria-label="filter by state">
        ${['all', ...STATES].map(st => html`
          <button class="chip" 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'}
                  onClick=${() => setRangeMode(m => !m)}>↔ range</button>
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- label edit -->
          <div class="complete">
            <input class="batch-label" placeholder="add a label…" aria-label="label for the selection"
                   value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
                   onFocus=${() => setLabelFocus(true)}
                   onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
                     sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
                   onBlur=${() => setTimeout(() => setLabelFocus(false), 150)} />
            <${Show} when=${() => labelFocus()}>
              <${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug}
                          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>
          <!-- state -->
          ${STATES.map(st => html`
            <button class="st" onClick=${() => setStateFor(st)}>${st}</button>`)}
          <span class="tb-sep" aria-hidden="true"></span>
          <!-- download the selection, one file per doc, at a chosen resolution -->
          <span class="dl-grp" aria-hidden="true">&#x2193;</span>
          <button class="dl" onClick=${() => downloadSelection('orig')}>orig</button>
          <button class="dl" onClick=${() => downloadSelection('web')}>web</button>
          <button class="dl" onClick=${() => downloadSelection('thumb')}>thumb</button>
          <span class="tb-sep" aria-hidden="true"></span>
          <button class="clear" onClick=${clearSel}>clear</button>
        </div>
      <//>
      <${Show} when=${() => !photos.loading && items().length === 0 && !authNeeded()}>
        <p class="empty">No photos.</p>
      <//>
      <${Show} when=${() => items().length > 0}>
        <p class="countline">${() => photos()?.sampled
          ? `showing a spread of ${items().length} from ${total()} — narrow the search or date range to see them all`
          : `showing all ${items().length}`}</p>
      <//>
      <div class="sizer">
        <button aria-label="smaller thumbnails" onClick=${() => bumpSize(-1)}>−</button>
        <button aria-label="bigger thumbnails" onClick=${() => bumpSize(1)}>+</button>
      </div>
      <div class="grid" role="list" aria-label="photos" style=${() => '--tile:' + thumbSize() + 'px'}
           ref=${el => gridEl = el} onWheel=${onGridWheel}
           onPointerDown=${onGridDown} onPointerMove=${onGridMove}
           onPointerUp=${onGridUp} onPointerCancel=${onGridUp}>
        <${For} each=${() => items()}>${photo => {
          // each tile loads its image only while near the viewport (watchVisible
          // observes it via ref), and drops back to BLANK when it scrolls away — so
          // the wall can grow huge without keeping every image decoded.
          const [near, setNear] = createSignal(false);
          const tileSrc = () => IPFS + (thumbSize() > 160 && photo.webCid && !isVideo(photo) ? photo.webCid : photo.thumbnailCid);
          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}
               onContextMenu=${e => e.preventDefault()}
               onClick=${e => onTilePress(e, photo.cid)}
               onDblClick=${() => armRange(photo.cid)}>
            <${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" ref=${el => watchVisible(el, setNear)} alt=${photo.date.slice(0, 10)} title=${photo.date.slice(0, 10)}
                   src=${() => near() ? tileSrc() : BLANK} />
            <//>
            <${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>
            <${Show} when=${() => !!photo.labels}>
              <span class="labels" title=${photo.labels}>${photo.labels}</span>
            <//>
          </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=${dismissPhoto}>
          <div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo"
               onClick=${e => e.stopPropagation()}
               onPointerDown=${onSwipeStart} onPointerUp=${onSwipeEnd} 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">
              <span class="lb-date">${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</span>
              <a class="lb-orig" target="_blank" href=${() => IPFS + (opened()?.cid || '')} target="_blank" rel="noopener"
                 aria-label="original" title="original — full resolution, new tab">⤢</a>
            </div>
            <div class="lb-states">
              <${For} each=${() => STATES}>${st => html`
                <button class="lb-st" aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
                        onClick=${() => lbSetState(st)}>${st}</button>`}
              <//>
            </div>
            <div class="lb-labels">
              <${For} each=${() => labelsOf(opened())}>${w => html`
                <span class="lb-chip"><button class="lb-chip-word"
                      onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
                      aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
              <//>
              <${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
                <button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}>+ ${() => lastLabel()}</button>
              <//>
              <div class="complete">
                <input class="batch-label" placeholder="add a label…" aria-label="add a label"
                       value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
                       onFocus=${() => setLbFocus(true)} onBlur=${() => setTimeout(() => setLbFocus(false), 150)}
                       onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
                         sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
                <${Show} when=${() => lbFocus()}>
                  <${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
                              onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
                <//>
              </div>
            </div>
          </div>
        </div>
      <//>
      <${Show} when=${() => frame()}>
        <div class=${() => 'frame' + (zoomed() ? ' zoomed' : '')}>
          <div class="strip" role="list" aria-label="slideshow"
               ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
            <${Index} each=${() => frameSlides()}>${(slide, k) => {
              const src = createMemo(() => frameSrc(slide(), k));   // band-chosen source, re-picked as the centre moves
              const [loaded, setLoaded] = createSignal(false);      // until this slide's CURRENT source paints
              createEffect(() => { src(); setLoaded(false); });     // a fresh source — doc swapped in, or band re-picked — re-shows the mark
              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=${() => !loaded()}>
                               <span class="ph load-ph" aria-label="loading">🖼</span><//>
                             <img class="slide-media" loading="lazy"
                                  src=${src} onLoad=${() => setLoaded(true)} /></div>`}>
                    <video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
                  <//>
                <//>
              </div>`; }}
            <//>
          </div>
          <${Show} when=${() => frameUI()}>
            <div class="frame-bar" role="toolbar" aria-label="frame actions">
              <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>
              ${STATES.map(st => html`
                <button class="st" onClick=${() => frameSetState(st)}>${st}</button>`)}
              <div class="complete">
                <input class="frame-label" placeholder="add a label…" aria-label="add a label in the frame"
                       value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
                       onFocus=${() => setFrameLabelFocus(true)}
                       onBlur=${() => setTimeout(() => setFrameLabelFocus(false), 150)}
                       onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
                         sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
                <${Show} when=${() => frameLabelFocus()}>
                  <${Suggest} text=${frameLabel} present=${() => labelsOf(items().find(p => p.cid === frameCenterCid()))}
                              active=${sugActive} onItems=${reportSug}
                              onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
                <//>
              </div>
              <button aria-label="exit frame" onClick=${exitFrame}> exit</button>
            </div>
          <//>
        </div>
      <//>
    `;
}

.search{ width:100%; box-sizing:border-box; margin-bottom:12px; padding:8px 12px; font-size:15px;
         background:#262a40; color:var(--fg); border:1px solid #3a3f5a; border-radius:6px; }
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(var(--tile, 96px), 1fr)); gap:4px; }
.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 (double-tap arms a range); no
   callout: a press-and-hold opens the doc, so don't let the OS claim it for save-image */
.thumb{ width:100%; height:100%; object-fit:contain; background:#262a40; display:block; }
.thumb.noimg{ display:flex; align-items:center; justify-content:center; }
.thumb.noimg .ph{ font-size:28px; opacity:.55; }
.tile[data-selected='1']{ outline:3px solid #6cf; outline-offset:-3px; }
.check{ position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%;
        background:#6cf; color:#08111e; font-size:13px; line-height:20px; text-align:center; font-weight:700; }
.badge{ position:absolute; top:3px; right:3px; font-size:10px; padding:1px 5px; border-radius:8px;
        background:#000a; color:#cdd; text-transform:uppercase; letter-spacing:.04em; }
.labels{ position:absolute; left:0; right:0; bottom:0; padding:6px 5px 3px; font-size:10px;
         line-height:1.2; color:#eef; background:linear-gradient(transparent, #000d);
         white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:none; }
.empty{ color:#8a8ea5; }
.countline{ margin:0 0 10px; font-size:12px; color:#c8b27a; }
/* the rubber-band box — fixed to the viewport (its coords are client-space), inert to the pointer */
.marquee{ position:fixed; z-index:50; border:1px solid #6cf; background:rgba(108,204,255,.18); pointer-events:none; }

Load only the thumbnails on screen

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

The test widens the wall to the whole archive, reads a tile far below the fold (no /ipfs/ source yet), scrolls it into view (it loads), then scrolls back to the top (it unloads again) — the observable contract of an on-demand image.

@testcase
def test_thumbs_load_in_view_and_unload(page):
    """A thumbnail holds its /ipfs/ <img src> only while near the viewport — an
    off-screen tile drops the image — so the wall scales to thousands of docs
    without keeping every photo decoded in memory."""
    open_app(page); chip(page, "all").click()   # the whole archive — a wall tall enough to scroll
    expect(tiles(page).first).to_be_visible()
    # wait for the wall to *settle* before reading .last: the resource resolves the
    # whole wall in one pass (0 → N), so count>30 means the final tiles are in place.
    wait_until(page, lambda: thumb_imgs(page).count() > 30)
    last = thumb_imgs(page).last
    expect(last).not_to_have_attribute("src", re.compile(r"/ipfs/"))   # below the fold → unloaded
    last.scroll_into_view_if_needed()
    expect(last).to_have_attribute("src", re.compile(r"/ipfs/"))       # scrolled in → loaded
    tiles(page).first.scroll_into_view_if_needed()
    expect(last).not_to_have_attribute("src", re.compile(r"/ipfs/"))   # left again → unloaded
    print("  PASS: thumbs load in view and unload when they leave")

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

// a 1×1 transparent gif — what a tile shows when its image is unloaded (the .thumb
// background fills the square). Swapping back to this releases the decoded image.
const BLANK = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
// one IntersectionObserver for the whole wall; each tile's <img> registers its
// setter and is told when it enters/leaves the viewport (+300px margin).
const watchVisible = (() => {
    const cbs = new WeakMap();
    const io = new IntersectionObserver(
        entries => entries.forEach(e => cbs.get(e.target)?.(e.isIntersecting)),
        { rootMargin: '300px' });
    return (el, set) => {
        if(!el) return;
        cbs.set(el, set);
        io.observe(el);
        onCleanup(() => { io.unobserve(el); cbs.delete(el); });
    };
})();

Saying when the wall is sampled

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

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

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

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

Placeholders for docs with no media. A match without a thumbnail still gets a tile — a placeholder with an icon — so it’s reachable rather than silently dropped (and it raises no sampling notice). Opening it, when there’s no web_cid either, shows a “no preview” placeholder instead of a broken image. A video falls back the same way; an image can use its thumbnail as the lightbox source.

@testcase
def test_no_thumbnail_placeholder(page):
    """A doc with no thumbnail appears as a placeholder tile (not dropped, no notice)."""
    make_fixtures()
    gql(CREATE, {"p": NOTHUMB_FIXTURE})
    try:
        open_app(page); chip(page, "all").click()
        search_box(page).fill(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(page.get_by_text(re.compile(r"showing a spread"))).to_have_count(0)  # and no notice
    finally:
        gql(DELETE, {"cid": NOTHUMB_FIXTURE["cid"]})
    print("  PASS: no-thumbnail placeholder")

@testcase
def test_lightbox_no_media(page):
    """Opening a doc with no web_cid shows a 'no preview' placeholder, not broken media."""
    make_fixtures()
    gql(CREATE, {"p": NOTHUMB_FIXTURE})
    try:
        open_app(page); chip(page, "all").click()
        search_box(page).fill(NOMEDIA_LABEL)                     # a label only this doc carries
        expect(tiles(page)).to_have_count(1)                    # let the wall narrow first
        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)
    finally:
        gql(DELETE, {"cid": NOTHUMB_FIXTURE["cid"]})
    print("  PASS: lightbox no media")

Drawing our own spread

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

An over-cap query (the whole archive) now shows exactly the cap, not a few short of it.

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

It’s a thin function over the filter: take the matching rows, keep the cap with the smallest hashtext(cid) — a uniform, stable subset — and hand them back in date order. The LIMIT makes the count exact, with none of the threshold’s variance; the frise can’t use a LIMIT because it would reshuffle as the window pans, but memories holds still. Apply it to the docs DB and restart PostGraphile so photovideosSample appears.

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[]);
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)
  RETURNS SETOF photovideo LANGUAGE sql STABLE AS $f$
  SELECT * FROM (
    SELECT * FROM photovideos_match(search, since, until, states, kinds, aday, awin, owners, amonth, mwin)
    ORDER BY hashtext(cid) LIMIT cap
  ) q ORDER BY q.date
$f$;
COMMIT;
BEGIN
DROP FUNCTION
DROP FUNCTION
CREATE FUNCTION
COMMIT

Seeing a doc’s labels

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

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

Telling a video from a photo

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

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

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

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

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

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

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

Search by label

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

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

@testcase
def test_search_narrows(page):
    """Typing a label narrows the grid to matching photos."""
    open_app(page)
    chip(page, "all").click()                          # default is todo; search across all
    page.wait_for_load_state("networkidle")            # let the all-states wall fully settle
    expect(tiles(page).first).to_be_visible()
    before = tiles(page).count()                       # the whole-archive wall, at the sample cap
    search_box(page).fill("Aurélie")                   # an exact label under the cap (search keeps accents)
    wait_until(page, lambda: 0 < tiles(page).count() < before)
    print("  PASS: search narrows")

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

Completing labels

Free-text labels rot into near-duplicates (cosmo, cosmos, cosmo =) unless you can see what already exists while typing. The vocabulary and the =labelCompletions function this reads aren’t ours — they live in the frise’s labels schema (the label_vocab table and the label_completions SQL function); this app only consumes them, through the shared LABEL_COMPLETIONS query the frise asks too. The match runs over the whole label — typing smo surfaces cosmo — and folds case and accents, yet offers the label as written, so aurel surfaces Aurélie (not aurelie) and picking it keeps the accent.

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

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

Suggest is a leaf component: a createResource keyed on the live text getter, a Show that hides it when there’s nothing, and an onMouseDown (fires before the input’s blur) that hands the word back to whoever mounted it. A box that edits a known doc can also hand it the labels already on that doc, which it drops from the offers — so completion never proposes a label the doc already wears. Below two characters it returns nothing — completion on one letter is just noise.

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

The list is keyboard-driven too, so a hand never leaves the keys: ↑=/=↓ move a highlight and Enter applies the highlighted word; Enter with nothing highlighted runs the box’s own action instead — in an add-label box it adds the typed labels and Shift+Enter removes them (the keyboard twins of the / buttons), while in the search box it just leaves the live query be. Each box reports its current list to the App and reads the highlight index back (only one box is focused at a time, so a single shared highlight suffices). In the lightbox and the frame this is also why ←=/=→ step the wall only when no field has focus — inside the label box the arrows belong to the text.

Applying a label suggestion completes its segment and appends a ;, so labels chain without your typing the separator — in the search box and the add-label boxes alike (a since: / type: token keeps drilling instead, no ;). The box keeps focus; the new blank segment has nothing to offer, so the list is momentarily empty. The next keystroke has to bring it back — otherwise completing one term would silently kill completion for the rest (you’d have to click out and in). Typing therefore re-asserts the box’s focus flag, and the list returns for the segment now under the cursor.

The test types a fragment of a known label, waits for the dropdown, and clicks the first suggestion; the box should then hold exactly that word.

@testcase
def test_label_completion(page):
    """Typing part of a label offers vocabulary matches; picking one fills the box."""
    open_app(page)
    search_box(page).click()
    search_box(page).press_sequentially("cos", delay=20)         # typed, as a user would
    expect(options(page).first).to_be_visible()
    word = options(page).first.inner_text().strip()
    assert "cos" in word.lower(), f"suggestion {word!r} doesn't contain the typed text"
    options(page).first.click()
    expect(search_box(page)).to_have_value(word + "; ")   # the picked label, then a ';' for the next
    print("  PASS: label completion")

@testcase
def test_owner_token_completes(page):
    """The search box offers the owner: token and its values, like type:/sort: do."""
    open_app(page)
    box = search_box(page)
    box.click()
    box.press_sequentially("owner", delay=20)               # 2+ chars → the key is offered
    expect(options(page).filter(has_text="owner:").first).to_be_visible()
    box.press_sequentially(":k", delay=20)                  # owner:k → its values
    expect(options(page).filter(has_text="konubinix").first).to_be_visible()
    print("  PASS: owner token completes")

@testcase
def test_search_suggestion_keyboard(page):
    """↓ highlights a suggestion in the search box and Enter applies it — no mouse."""
    open_app(page)
    search_box(page).click(); search_box(page).press_sequentially("cos", delay=20)
    expect(options(page).first).to_be_visible()
    word = options(page).first.inner_text().strip()
    search_box(page).press("ArrowDown")          # highlight the first suggestion
    search_box(page).press("Enter")              # apply the highlighted one
    expect(search_box(page)).to_have_value(word + "; ")
    print("  PASS: search suggestion keyboard")

@testcase
def test_suggestion_up_enters_from_none(page):
    """↑ also reaches the list: from nothing selected it jumps to the *last* suggestion
    (the shared sugNav drives the search box and every label box alike)."""
    VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
    VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
    for w in ("zzupa", "zzupb"): gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
    try:
        open_app(page)
        sb = search_box(page)
        sb.click(); sb.press_sequentially("zzup", delay=20)
        expect(options(page).first).to_be_visible()
        items = [t.strip() for t in options(page).all_inner_texts()]
        assert items == ["zzupa", "zzupb"], f"unexpected suggestions: {items}"
        sb.press("ArrowUp")                      # from nothing selected — must enter at the bottom
        sel = page.get_by_role("option", selected=True)
        assert sel.count() == 1 and sel.inner_text().strip() == "zzupb", \
            "ArrowUp from none should select the last suggestion"
        print("  PASS: suggestion up enters from none")
    finally:
        for w in ("zzupa", "zzupb"): gql(VOCAB_DEL, {"w": w})

@testcase
def test_completion_reopens_after_pick(page):
    """Applying a suggestion then typing more re-opens the dropdown — without a refocus."""
    VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
    VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
    for w in ("zzbalade", "zzalois"): gql(VOCAB_DEL, {"w": w}); gql(VOCAB_ADD, {"w": w})
    try:
        open_app(page)
        sb = search_box(page)
        sb.click(); sb.press_sequentially("zzbal", delay=20)         # a seeded label fragment
        first = options(page).first
        expect(first).to_be_visible()
        word = first.inner_text().strip()            # 'zzbalade'
        sb.press("ArrowDown"); sb.press("Enter")     # apply it — a ';' is appended, list empties
        expect(sb).to_have_value(word + "; ")
        sb.press_sequentially("zzalo", delay=20)     # keep typing the next segment
        expect(options(page).filter(has_text="zzalois").first).to_be_visible()   # must reappear
    finally:
        for w in ("zzbalade", "zzalois"): gql(VOCAB_DEL, {"w": w})
    print("  PASS: completion reopens after pick")

@testcase
def test_completion_matches_inside_label(page):
    """The match reaches inside a word: a mid-word fragment surfaces a label it sits in."""
    open_app(page)
    search_box(page).click()
    search_box(page).press_sequentially("smo", delay=20)   # 'smo' sits inside 'cosmo', not at its start
    expect(options(page).filter(has_text="cosmo").first).to_be_visible()
    print("  PASS: completion matches inside label")

@testcase
def test_completion_preserves_case_and_accent(page):
    """Completion matches accent/case-insensitively but offers the label exactly as written."""
    # the verbatim word is a seeded vocabulary fixture, not live data — what this app
    # consumes is the vocab; its upkeep from label edits is the frise's schema, tested there
    VOCAB_ADD = "mutation($w:String!){ createLabelVocab(input:{labelVocab:{word:$w, n:1}}){ clientMutationId } }"
    VOCAB_DEL = "mutation($w:String!){ deleteLabelVocab(input:{word:$w}){ clientMutationId } }"
    gql(VOCAB_DEL, {"w": "Zzélodie"})                      # a crashed earlier run may have left it
    gql(VOCAB_ADD, {"w": "Zzélodie"})
    try:
        open_app(page)
        sb = search_box(page)
        sb.click(); sb.press_sequentially("zzelod", delay=20)   # no capital, no accent
        expect(options(page).first).to_be_visible()
        texts = [t.strip() for t in options(page).all_inner_texts()]
        assert "Zzélodie" in texts, f"expected the label as written, got {texts}"
    finally:
        gql(VOCAB_DEL, {"w": "Zzélodie"})
    print("  PASS: completion preserves case and accent")

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

The year: token completes like the others — typing it offers the key, then its years.

@testcase
def test_year_token_completes(page):
    """The search box offers the year: token and its year values, like month:/day: do."""
    open_app(page)
    box = search_box(page)
    box.click(); box.press_sequentially("year", delay=20)       # 2+ chars → the key is offered
    expect(options(page).filter(has_text="year:").first).to_be_visible()
    box.press_sequentially(":202", delay=20)                    # year:202 → its year values
    expect(options(page).filter(has_text=re.compile(r"year:202\d")).first).to_be_visible()
    print("  PASS: year token completes")

const fetchCompletions = async word => {
    if((word || '').length < 2) return [];
    const d = await gql(LABEL_COMPLETIONS, { prefix: word, first: 8 });
    return d?.labelCompletions?.nodes ?? [];
};
// complete the *current* word: in the search box (dsl), a since:/until: token gets
// date suggestions, a 2+-char prefix of a key offers the key; otherwise it's a label.
async function suggestFor(text, caret, dsl, present){
    const word = segAt(text, caret);
    if(dsl){ const d = dslSuggestions(word); if(d.length) return d; }
    if(word.includes(':')) return [];
    const out = await fetchCompletions(word);
    // a box editing a known doc hands us the labels already on it — never re-offer those
    const has = (present || []).map(s => s.toLowerCase());
    return has.length ? out.filter(w => !has.includes(w.toLowerCase())) : out;
}
function Suggest(props){
    // props.text/props.caret are reactive; the caret defaults to end-of-text, so an
    // add-label box completes its last ;-segment while the search box follows the cursor.
    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));
    // hand the live list up so the box's keydown can ↑/↓/Enter through it
    createEffect(() => props.onItems?.(items() || []));
    const active = () => props.active == null ? -1 : props.active;   // a 0-arg accessor prop arrives unwrapped
    return html`
      <${Show} when=${() => items.loading || (items() || []).length > 0}>
        <ul class="suggest" role="listbox" aria-label="suggestions" 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>
      <//>`;
}

.complete{ position:relative; }
/* a long list still scrolls, but with a thin themed bar — the chunky default one clashes
   with the dark panel (scrollbar-color for Firefox/modern Chromium, ::-webkit for the rest). */
.suggest{ position:absolute; z-index:10; left:0; right:0; top:100%; margin:2px 0 0; padding:4px;
          list-style:none; background:#1b1d2e; border:1px solid #3a3f5a; border-radius:6px;
          max-height:240px; overflow:auto; box-shadow:0 6px 20px #0008;
          scrollbar-width:thin; scrollbar-color:#4a4f6e transparent; }
.suggest::-webkit-scrollbar{ width:8px }
.suggest::-webkit-scrollbar-thumb{ background:#4a4f6e; border-radius:8px }
.suggest::-webkit-scrollbar-track{ background:transparent }
.sug{ padding:9px 10px; border-radius:4px; cursor:pointer; font-size:14px; }
.sug:hover, .sug.active{ background:#33395a; }
/* the query has a real round-trip; a pulsing row says "working" so an empty wait
   doesn't read as broken. */
.sug-loading{ padding:9px 10px; font-size:14px; color:#8a8ea5; cursor:default;
              animation:sug-pulse 1s ease-in-out infinite; }
@keyframes sug-pulse{ 0%,100%{ opacity:.45 } 50%{ opacity:.9 } }

A query language in the search box

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Opening a doc — the lightbox

The wall is for scanning; sometimes you need one doc up close — to actually watch a video, or to read and fix its labels. A press-and-hold on a tile opens a modal over the wall: a single tap stays the triage select and a double-tap arms a range, so asking for one doc full-size gets its own deliberate gesture and the three don’t collide. Holding a thumbnail is also what a touchscreen reads as a request for its own callout (the save image context menu), so the tile suppresses that — the hold is ours. The modal shows the full web_cid media (a <video controls> when the mimetype is video, else the image), the doc’s labels as removable chips, and an add-label box with the same completion as elsewhere. Edits go straight to updatePhotovideo and are reflected optimistically, so the modal and the wall caption stay in step. Backdrop click, the , or Escape close.

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

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

And the device back button leaves the lightbox: opening a doc pushes a history entry, so Back pops it — landing back on the grid — while Esc and unwind that same entry, so explicit-close and Back stay balanced (just as they do for the frame).

@testcase
def test_back_button_closes_lightbox(page):
    """The device Back button steps out of the lightbox to the grid, like ✕ does."""
    open_fixtures(page)
    open_doc(page, 0)
    expect(dialog(page)).to_be_visible()
    page.go_back()
    expect(dialog(page)).to_be_hidden()
    expect(grid(page)).to_be_visible()
    print("  PASS: back button closes lightbox")

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

@testcase
def test_lightbox_links_to_original(page):
    """The lightbox links to the original doc at its /ipfs/ cid (full-res, new tab)."""
    open_fixtures(page)
    open_doc(page)
    link = dialog(page).get_by_role("link", name="original")
    expect(link).to_be_visible()
    expect(link).to_have_attribute("href", FIXTURES[0]["cid"])    # the original's /ipfs/ path
    expect(link).to_have_attribute("target", "_blank")
    print("  PASS: lightbox links to original")

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

@testcase
def test_lightbox_media_fills_screen(page):
    """The lightbox media box takes the viewport, and the doc is shown whole — never cropped."""
    open_fixtures(page)
    open_doc(page)
    img = dialog(page).get_by_role("img")
    box = img.bounding_box()
    assert box["width"] >= VIEWPORT["width"] * 0.9, f"media width {box['width']} < 90% of viewport"
    assert box["height"] >= VIEWPORT["height"] * 0.65, f"media height {box['height']} < 65% of viewport"
    assert img.evaluate("el => getComputedStyle(el).objectFit") == "contain", "media crops instead of fitting whole"
    print("  PASS: lightbox media fills screen")

Editing labels from inside the modal — type into the box (several at once, ;-separated) and Enter adds them, Shift+Enter removes them; a chip’s × drops a single one. Edits persist and are visible by searching the new word afterwards.

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

@testcase
def test_lightbox_edits_labels(page):
    """Adding and removing labels in the modal persists to the doc."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    expect(d).to_be_visible()
    box = d.get_by_placeholder("add a label…")
    box.click(); box.press_sequentially("lbadded", delay=20); box.press("Enter")
    expect(d.get_by_text("lbadded")).to_be_visible()             # new chip appears
    d.get_by_role("button", name="remove " + FIXTURE_LABEL).click()
    expect(d.get_by_text(FIXTURE_LABEL)).to_have_count(0)        # original chip gone
    d.get_by_role("button", name="close").click()
    search_box(page).fill("lbadded")                             # persisted? search finds it
    expect(tiles(page)).to_have_count(1)
    print("  PASS: lightbox edits labels")

@testcase
def test_lightbox_adds_several_labels(page):
    """The add-label box takes several ;-separated labels at once, skipping any already set."""
    open_fixtures(page)
    open_doc(page)                                       # a fixture carrying 'zzbatchfix'
    d = dialog(page)
    box = d.get_by_placeholder("add a label…")
    box.click(); box.press_sequentially(FIXTURE_LABEL + "; zzmulti-a; zzmulti-b", delay=20)   # one existing + two new, typed
    box.press("Enter")
    expect(d.get_by_role("button", name="zzmulti-a", exact=True)).to_be_visible()
    expect(d.get_by_role("button", name="zzmulti-b", exact=True)).to_be_visible()
    expect(d.get_by_role("button", name=FIXTURE_LABEL, exact=True)).to_have_count(1)  # not duplicated
    print("  PASS: lightbox adds several labels")

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

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

@testcase
def test_lightbox_enter_adds_shift_enter_removes(page):
    """In the lightbox box, Enter adds a label and Shift+Enter removes it."""
    open_fixtures(page)
    open_doc(page)
    d = dialog(page)
    box = d.get_by_placeholder("add a label…")
    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")

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

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

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

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

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

Browsing without closing. Once a doc is open you move through the wall in place — the / buttons, the keyboard arrows, or a horizontal swipe on the media — stepping to the previous/next doc in the current (filtered, dated) order, wrapping at the ends. The lightbox also shows whether the current doc is selected and lets you toggle it, so you can build a selection while reviewing one by one; the change shows on the wall behind.

@testcase
def test_lightbox_prev_next(page):
    """Arrows, the nav buttons, and a swipe step through the wall in the lightbox."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    open_doc(page)
    img = dialog(page).get_by_role("img")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    page.keyboard.press("ArrowRight")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    page.keyboard.press("ArrowLeft")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    dialog(page).get_by_role("button", name="next photo").click()
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    # a leftward swipe on the media advances to the next doc
    box = img.bounding_box()
    page.mouse.move(box["x"] + box["width"] - 20, box["y"] + box["height"] / 2)
    page.mouse.down()
    page.mouse.move(box["x"] + 20, box["y"] + box["height"] / 2, steps=8)
    page.mouse.up()
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2")
    print("  PASS: lightbox prev/next")

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

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

@testcase
def test_lightbox_reuse_last_label(page):
    """The last-applied label is offered for one-tap reuse on the next photo."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    d.get_by_label("add a label").fill("zzreuse")
    d.get_by_label("add a label").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")

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

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

The drag above is a mouse drag, and mouse input ignores touch-action — so it passes even when a real finger-swipe does nothing. This next test drives Chromium’s actual touch pipeline (CDP Input.dispatchTouchEvent): with the default touch-action, the browser claims the horizontal drag for panning and fires pointercancel instead of pointerup, so onSwipeEnd never runs. The fix is touch-action on the swipe surface so the gesture stays ours.

@testcase
def test_lightbox_swipe_touch(page):
    """A real touch swipe (not a mouse drag) steps the lightbox."""
    open_fixtures(page)
    open_doc(page)
    img = dialog(page).get_by_role("img")
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = img.bounding_box()
    # swipe across the photo itself, well clear of the vertically-centred ‹ › buttons
    # (which sit over the media edges) — a finger on a button isn't a swipe.
    y = box["y"] + box["height"] * 0.30
    x_from, x_to = box["x"] + box["width"] * 0.75, box["x"] + box["width"] * 0.25
    cdp = page.context.new_cdp_session(page)
    cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 1})
    cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x_from, "y": y}]})
    for frac in (0.6, 0.45, 0.3, 0.25):
        cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": box["x"] + box["width"] * frac, "y": y}]})
    cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
    expect(img).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: lightbox swipe (touch)")

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

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

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

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

const [opened, setOpened] = createSignal(null);
const [lbText, setLbText] = createSignal('');
const [lbFocus, setLbFocus] = createSignal(false);
const isVideo = p => (p?.mimetype || '').startsWith('video');
const mediaSrc = p => IPFS + (p?.webCid || p?.thumbnailCid || '');
// a video needs its web_cid to play; an image can fall back to its thumbnail
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 });          // optimistic — keep the modal correct
    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('; '));
// the box's S-RET counterpart to lbAdd: strip every typed label from the open doc
const lbDrop = input => { const words = splitWords(input); if(!words.length) return;
    setLbText(''); return applyLabels(labelsOf(opened()).filter(x => !words.includes(x)).join('; ')); };

// change the open doc's state. Like the frame's frameEdit, the edit may push it out of
// the current filter; if so re-anchor on the previous doc (else keep it; close if none).
async function lbSetState(st){
    const list = items(), cur = opened(); if(!cur) return;
    const i = list.findIndex(p => p.cid === cur.cid);
    const prevCid = i > 0 ? list[i - 1].cid : null;
    await gql(UPDATE_PHOTO, { cid: cur.cid, patch: { state: st } });
    await refetch();
    requestAnimationFrame(() => {
        const l2 = items(); if(!l2.length){ dismissPhoto(); return; }
        const stay = l2.find(p => p.cid === cur.cid);
        setOpened(stay || (prevCid && l2.find(p => p.cid === prevCid)) || l2[0]);
    });
}

// step to the previous/next doc in the wall (date order), wrapping at the ends.
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('');
};
// while a video is playing, an arrow seeks it ±5s; only at the end/start (or for a
// paused video / a photo) does it give up and step to the next/previous doc.
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);
};
// horizontal swipe on the media navigates (mobile); arrows do the same on desktop.
let swipeX = null;
const onSwipeStart = e => { swipeX = e.clientX; };
const onSwipeEnd = e => { if(swipeX == null) return;
    const dx = e.clientX - swipeX; swipeX = null;
    if(Math.abs(dx) > 40) step(dx < 0 ? 1 : -1); };

// S-scroll: Shift + wheel steps prev/next (plain scroll stays free for the panel). Shift
// can map the wheel to the horizontal axis, so read whichever delta is set. One step per
// scroll burst via a short cooldown, so a flick doesn't skip several docs.
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;
        // ‹ › step the wall, but not while a text field has focus — there the arrows
        // move the cursor (and ↑/↓/Enter drive the label box's suggestions).
        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(); }   // replay from the end
            else if(v.paused) v.play(); else v.pause();                              // else toggle play/pause
        }
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

<${Show} when=${() => opened()}>
  <div class="lb" onClick=${dismissPhoto}>
    <div class="lb-inner" role="dialog" aria-modal="true" aria-label="photo"
         onClick=${e => e.stopPropagation()}
         onPointerDown=${onSwipeStart} onPointerUp=${onSwipeEnd} 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">
        <span class="lb-date">${() => opened()?.date ? new Date(opened().date).toLocaleString("fr-FR") : ''}</span>
        <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-states">
        <${For} each=${() => STATES}>${st => html`
          <button class="lb-st" aria-pressed=${() => opened()?.state === st ? 'true' : 'false'}
                  onClick=${() => lbSetState(st)}>${st}</button>`}
        <//>
      </div>
      <div class="lb-labels">
        <${For} each=${() => labelsOf(opened())}>${w => html`
          <span class="lb-chip"><button class="lb-chip-word"
                onClick=${() => { setSearch(w); dismissPhoto(); }}>${w}</button><button class="x"
                aria-label=${'remove ' + w} onClick=${() => lbRemove(w)}>×</button></span>`}
        <//>
        <${Show} when=${() => lastLabel() && !labelsOf(opened()).includes(lastLabel())}>
          <button class="lb-reuse" onClick=${() => lbAdd(lastLabel())}> ${() => lastLabel()}</button>
        <//>
        <div class="complete">
          <input class="batch-label" placeholder="add a label…" aria-label="add a label"
                 value=${() => lbText()} onInput=${e => { setLbText(e.target.value); setLbFocus(true); }}
                 onFocus=${() => setLbFocus(true)} onBlur=${() => setTimeout(() => setLbFocus(false), 150)}
                 onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); lbDrop(lbText()); return; }
                   sugNav(e, w => w ? setLbText(replaceSeg(lbText(), w) + '; ') : lbAdd(lbText())); }} />
          <${Show} when=${() => lbFocus()}>
            <${Suggest} text=${lbText} present=${() => labelsOf(opened())} active=${sugActive} onItems=${reportSug}
                        onPick=${w => setLbText(replaceSeg(lbText(), w) + '; ')} />
          <//>
        </div>
      </div>
    </div>
  </div>
<//>

.lb{ position:fixed; inset:0; z-index:100; background:#000d; display:flex; align-items:center;
     justify-content:center; padding:16px; }
.lb-inner{ position:relative; width:96vw; height:96vh; display:flex;
           flex-direction:column; gap:10px; }
/* pan-y: let the browser keep vertical scroll/pinch, but hand horizontal drags to
   our swipe handler instead of consuming them as a pan (which fires pointercancel
   and silently kills the swipe on touch — invisible to a mouse-drag test). */
.lb-media{ flex:1; min-height:0; width:100%; object-fit:contain; border-radius:6px; background:#000; touch-action:pan-y; }
.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-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[aria-pressed='true']{ background:#6cf; color:#08111e; font-weight:700; }
.lb-labels{ display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
.lb-chip{ display:inline-flex; align-items:center; gap:4px; padding:4px 6px 4px 10px; font-size:13px;
          background:#262a40; border:1px solid #3a3f5a; border-radius:999px; }
.lb-chip-word{ border:none; background:none; color:inherit; font:inherit; cursor:pointer; padding:0; }
.lb-chip-word:hover{ text-decoration:underline; }
.lb-chip .x{ border:none; background:none; color:#9aa; font-size:16px; line-height:1; cursor:pointer; padding:0 2px; }
.lb-chip .x:hover{ color:#f88; }
/* one-tap reuse of the last-applied label — dashed accent so it reads as an offer, not a set tag */
.lb-reuse{ border:1px dashed #6cf; background:#16243b; color:#6cf; padding:4px 10px;
           border-radius:999px; font-size:13px; cursor:pointer; }
.lb-reuse:hover{ background:#1d2f4d; }

Change state from the lightbox

Triage shouldn’t need closing the photo: the lightbox carries the state buttons too, and tapping one saves it at once. The catch is the same one the frame faces — the edited doc may fall out of the current filter (flip a todo to done while viewing todos) and vanish from the wall. So, mirroring the frame’s frameEdit, the lightbox re-anchors on the previous doc when the open one leaves the filter, and closes if nothing is left.

The test makes two todo docs, opens the second, flips it to done — out of the todo view — and checks the lightbox stayed open, re-anchored on the first.

@testcase
def test_lightbox_set_state_reanchors(page):
    """Setting state in the lightbox saves it; when the doc leaves the filter it
    re-anchors on the previous one (order-agnostic: uses DOM positions)."""
    make_fixtures()
    gql(UPDATE, {"cid": FIXTURES[1]["cid"], "patch": {"state": "todo"}})   # two todos now
    open_app(page)
    search_box(page).fill(FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(2)              # default filter is todo
    prev_src = thumb_imgs(page).nth(0).get_attribute("src")
    open_doc(page, 1)                                 # open the second tile
    d = dialog(page)
    img = d.get_by_role("img")
    assert img.get_attribute("src") != prev_src, "expected to open the second doc"
    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", prev_src)    # …re-anchored on the previous doc
    print("  PASS: lightbox set state re-anchors")

Marking for deletion with a key

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

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

@testcase
def test_lightbox_delete_confirms(page):
    """Delete in the lightbox asks first; accepting marks the doc for deletion."""
    open_fixtures(page)                              # filter 'all' → every state stays shown
    open_doc(page, 0)                                # the first fixture is 'todo'
    d = dialog(page)
    del_btn = d.get_by_role("button", name="delete", exact=True)
    expect(del_btn).to_have_attribute("aria-pressed", "false")
    asked = []
    page.on("dialog", lambda dlg: (asked.append(dlg.message), dlg.accept()))
    page.keyboard.press("Delete")
    expect(del_btn).to_have_attribute("aria-pressed", "true")   # moved to delete
    assert asked, "Delete should have asked to confirm"
    print("  PASS: lightbox delete confirms")

Shift+Delete is the same move without the prompt — for a confident run down a stack.

@testcase
def test_lightbox_shift_delete_skips_confirm(page):
    """Shift+Delete marks the doc for deletion without asking."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    del_btn = d.get_by_role("button", name="delete", exact=True)
    asked = []
    page.on("dialog", lambda dlg: (asked.append(1), dlg.accept()))
    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")

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

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

Customizable thumbnail size

Wall density is a taste call, so the tile size is adjustable and persisted — the − / + controls, or Ctrl+scroll over the wall, the browser’s own zoom gesture turned on the thumbnails: scroll up to grow them, down to shrink, and the page zoom itself is suppressed so only the tiles resize. And past a size threshold a small thumbnail enlarges into a pixelated mess — so beyond it an image tile sources from its higher-res web_cid instead. Videos stay on their poster thumbnail (their web_cid is the clip, not an image).

@testcase
def test_thumbnail_size_adjustable(page):
    """The − / + controls grow the wall tiles (and the size persists)."""
    open_fixtures(page)
    t = tiles(page)
    w0 = t.nth(0).bounding_box()["width"]
    page.get_by_role("button", name="bigger thumbnails").click()
    page.get_by_role("button", name="bigger thumbnails").click()
    wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w0 + 8)
    print("  PASS: thumbnail size adjustable")

@testcase
def test_ctrl_scroll_resizes(page):
    """Ctrl+wheel over the wall zooms the tile size — up to grow, like the + control."""
    open_fixtures(page)
    t = tiles(page)
    w0 = 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.wait_for_timeout(200)                         # clear the one-step cooldown
    page.mouse.wheel(0, -240)
    page.keyboard.up("Control")
    wait_until(page, lambda: t.nth(0).bounding_box()["width"] > w0 + 8)
    print("  PASS: ctrl scroll resizes")

@testcase
def test_big_thumbnail_uses_webcid(page):
    """Past the size threshold, an image tile sources from web_cid (sharper enlarged)."""
    make_fixtures()
    gql(CREATE, {"p": WEBIMG_FIXTURE})
    open_app(page)
    chip(page, "all").click()
    search_box(page).fill(WEBIMG_LABEL)
    expect(tiles(page)).to_have_count(1)                                   # wait for the search to settle
    img = grid(page).get_by_role("img")
    expect(img).to_have_attribute("src", WEBIMG_FIXTURE["thumbnailCid"])   # small → thumbnail
    page.get_by_role("button", name="bigger thumbnails").click()
    page.get_by_role("button", name="bigger thumbnails").click()
    expect(img).to_have_attribute("src", WEBIMG_FIXTURE["webCid"])         # big → web_cid
    print("  PASS: big thumbnail uses web_cid")

The frame — a fullscreen slideshow

This folds the photo-frame in: no separate app. ▶ frame turns the current wall — whatever the query language has narrowed to — into a frame show. It is not the lightbox: it’s a full-screen horizontal filmstrip, each doc a viewport-wide slide laid side by side in a native scroll container. That gives the feel the legacy slider built by hand: a fluid lateral swipe whose momentum coasts across several docs, easing onto whichever one it comes to rest nearest. Auto-advance is just a smooth scroll to the next slide every interval (default 60s, ?ms= overrides), and it continues from wherever you left it (it reads the current scroll position each tick), wrapping at the end. It plays the wall in the order shown — chronological by default, or the myrandom draw under sort:random (ordering is an app-wide concern, not a frame one). The media fills the screen; videos show without auto-playing; there’s no chrome — a small bar (pause, interval, exit) is a tap away, and Esc, exit, or the back button leaves (entering pushes a history entry; exit unwinds it via history.back so back and explicit-exit stay balanced), releasing the wake lock and fullscreen. The frame tablet launches from a PWA home-screen shortcut — which opens the manifest’s start_url with no query string — so the auto-start can’t ride a URL param. Instead being in the frame is remembered (localStorage): once you enter it, every relaunch (reboot, power-cut, shortcut tap) auto-starts the show over the persisted query as soon as the wall loads, and resumes the slide it was on; exiting turns that off. (?frame=1 still forces it, for testing.) It enters once per launch, so exiting doesn’t re-trigger it. A video the viewer started is paused once it scrolls out of view — an IntersectionObserver over the strip stops any slide video that drops below half-visible.

By default the frame is chronological, so the fixtures play thumb-0, thumb-1, thumb-2. centered reads the image of whatever slide the strip is settled on, robust to the clone offset.

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

def enter_frame(page, ms):
    make_fixtures()
    page.goto(BASE_URL + f"?ms={ms}", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    chip(page, "all").click()
    search_for(page, FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(len(FIXTURES))
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    return strip

@testcase
def test_frame_autoadvances(page):
    """▶ frame plays a fullscreen filmstrip, auto-scrolling in wall (date) order, wrapping."""
    # a brisk clock, but slower than a smooth-scroll so each advance settles between ticks
    strip = enter_frame(page, 1200)
    for src in ["thumb-0", "thumb-1", "thumb-2", "thumb-0"]:        # date order, then wraps
        wait_until(page, lambda s=src: strip.evaluate(CENTERED) == f"https://ipfs.konubinix.eu/p/zzbatchfix-{s}")
    page.keyboard.press("Escape")
    expect(strip).to_be_hidden()
    print("  PASS: frame auto-advances")

@testcase
def test_frame_wraps_both_ways(page):
    """Swiping/arrowing past either edge loops (the left-of-first case)."""
    strip = enter_frame(page, 999999)                              # no auto-advance interference
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    page.keyboard.press("ArrowLeft")                               # before the first → last
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2")
    page.keyboard.press("ArrowRight")                              # past the last → first
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    print("  PASS: frame wraps both ways")

@testcase
def test_frame_arrow_steps_off_control(page):
    """An arrow steps the show even when focus rests on the page, not a control."""
    strip = enter_frame(page, 999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    # leave the entry control for the natural just-viewing state: a tap in the CENTRE third
    # (which only reveals the bar — never navigates) drops focus to the body. With the strip a
    # keyboard-focusable scroller, an unguarded arrow would scroll it natively and cancel the step.
    box = strip.bounding_box()
    page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    page.keyboard.press("ArrowRight")
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: frame arrow steps off control")

@testcase
def test_frame_exits_on_back(page):
    """The browser back button leaves the frame (rather than navigating away)."""
    strip = enter_frame(page, 999999)
    expect(strip).to_be_visible()
    page.go_back()
    expect(strip).to_be_hidden()
    expect(heading(page)).to_be_visible()                          # still on the app, not gone
    print("  PASS: frame exits on back")

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

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

Both checks below ride that one gesture, so they share its scaffolding: twelve dated docs — enough that a fling can cross several — with the frame entered on the first, and one hard fling. A mouse drag carries no momentum, so the behaviour only shows under a real touch fling, driven (as for the lightbox) through Chromium’s touch pipeline.

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)]
ON_SLIDE = "el => Math.round(el.scrollLeft / (el.clientWidth || 1))"

def enter_swipe_frame(page):
    """Seed the twelve docs, open the frame on the first, return the strip."""
    for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    page.goto(BASE_URL + "?ms=999999", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    chip(page, "all").click()
    search_for(page, "zzswipe")
    expect(tiles(page)).to_have_count(12)
    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) == 1)            # settled on the first doc
    return strip

def hard_frame_flick(page, strip):
    """A hard, fast touch fling — the finger flies ~900px left in ten quick steps —
    returning once the strip (and any recentre ease) comes to rest."""
    box = strip.bounding_box()
    cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
    cdp = page.context.new_cdp_session(page)
    cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": cx, "y": cy}]})
    for k in range(1, 11):
        cdp.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": cx - 90 * k, "y": cy}]})
        time.sleep(0.001)
    cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
    # The show settles a beat (~150ms of quiet) after the last scroll, then eases onto the
    # centred doc. So the strip is briefly still BEFORE the snap — but that pre-snap lull is
    # capped at the 0.15s debounce. Require a quiet streak that outlasts it (7 reads ≈ 0.7s,
    # reset by any motion): only the post-snap rest can satisfy it, never the lull. Cap
    # 60×100ms = 6s, above the worst settle (fling <1s + 0.15s wait + ease).
    prev = None; stable = 0
    for _ in range(60):
        cur = strip.evaluate("el => el.scrollLeft")
        stable = stable + 1 if (prev is not None and abs(cur - prev) < 1) else 0
        if stable >= 7: break
        prev = cur; page.wait_for_timeout(100)

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

@testcase
def test_frame_swipe_does_not_overshoot(page):
    """A fast touch fling coasts a controlled few docs, never across the whole strip."""
    strip = enter_swipe_frame(page)
    try:
        hard_frame_flick(page, strip)
        # measured 8/8 each way on this env: mandatory snap over-projected to slide 6,
        # native momentum rests by slide 4 — so "within the first five" (advanced ≤ 4)
        # is red on the runaway snap, green once it's gone, with one slide of margin.
        landed = strip.evaluate(ON_SLIDE)
        assert landed <= 5, f"the swipe flung to slide {landed} — past the controlled range"
    finally:
        for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame swipe does not overshoot")

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

@testcase
def test_frame_swipe_settles_centered(page):
    """After a fling the strip eases onto the nearest doc — it never rests between two."""
    strip = enter_swipe_frame(page)
    try:
        hard_frame_flick(page, strip)
        w = strip.evaluate("el => el.clientWidth || 1"); rest = strip.evaluate("el => el.scrollLeft")
        off = min(rest % w, w - (rest % w))                           # distance to the nearest slide boundary
        # 20px is a slack settle tolerance: the recentre lands on the exact multiple, so any
        # near-zero offset is "centred"; the no-recentre build rested ~300px off (measured).
        assert off < 20, f"the strip rested {off:.0f}px off a slide boundary — straddling two docs"
    finally:
        for d in SWIPE_DOCS: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame swipe settles centered")

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

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

Showing the doc whole is no good if it shows up late: web_cid is heavy, and fetching it only once a doc reaches the centre makes you watch it sharpen from thumbnail to full-res on arrival. So full resolution travels with you — the centred doc and the two slides on either side all hold their web_cid, so the next step lands on a doc already whole, no thumbnail-to-web swap. Further out the lighter thumbnail is enough (and already cached from the wall you came in through); past ten slides from the centre a doc is dropped to a blank altogether, so a long wall never keeps thousands of decoded images alive at once.

@testcase
def test_frame_preloads_web_window(page):
    """Near docs carry full-res web_cid over the thumbnail; a middle band is thumbnail only; far docs unload."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzwin-{i}", "date": f"2020-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzwin-t-{i}",
             "webCid": f"https://ipfs.konubinix.eu/p/zzwin-web-{i}", "labels": "zzwin", "state": "todo"} for i in range(14)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.goto(BASE_URL + "?ms=999999", wait_until="commit")
        page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
        chip(page, "all").click()
        search_for(page, "zzwin")
        expect(tiles(page)).to_have_count(14)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate("el => Math.round(el.scrollLeft/(el.scrollWidth/el.children.length))") == 1)
        # centred is strip slot 1 (doc 0, after the leading clone); slot k holds doc k-1, distance |k-1|.
        # each slot's loaded sources: the thumbnail base, plus the web_cid overlay only when close in.
        srcs_at = "(el, k) => { const s = el.children[k]; return s ? Array.from(s.querySelectorAll('img')).map(im => im.getAttribute('src')) : []; }"
        srcs = lambda k: strip.evaluate(srcs_at, k)
        wait_until(page, lambda: srcs(12) != [])
        s2 = srcs(2)
        assert any("zzwin-web-1" in x for x in s2) and any("zzwin-t-1" in x for x in s2), f"dist 1 wants web over thumbnail, got {s2}"
        assert any("zzwin-web-2" in x for x in srcs(3)), f"dist 2 wants web, got {srcs(3)}"
        s4 = srcs(4)
        assert any("zzwin-t-3" in x for x in s4) and not any("web" in x for x in s4), f"dist 3 wants thumbnail only, got {s4}"
        s11 = srcs(11)
        assert any("zzwin-t-10" in x for x in s11) and not any("web" in x for x in s11), f"dist 10 wants thumbnail only, got {s11}"
        s12 = srcs(12)
        assert any(x and x.startswith("data:image/gif") for x in s12), f"dist 11 should be unloaded (blank), got {s12}"
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame preloads web window")

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

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

In code each slide reads its distance from that centre — the strip index frameCenterIdx — and picks its band:

const WEB_NEAR = 2, KEEP_FAR = 10;
const frameSrc = (p, k) => { const d = Math.abs(k - frameCenterIdx());
    if(d > KEEP_FAR) return BLANK;                            // far → unloaded
    if(d <= WEB_NEAR) return IPFS + (p?.webCid || p?.thumbnailCid || '');   // near → full-res
    return IPFS + (p?.thumbnailCid || p?.webCid || ''); };    // middle → thumbnail

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

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

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

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

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

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

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

Triaging from the frame. The control bar (tap to reveal) also carries the state buttons and a label box, acting on the centered doc — so the frame doubles as a review station. Such an edit often pushes the doc out of the current filter (mark a todo done while viewing todos); frameEdit handles that: if the doc leaves the set it re-anchors on the previous doc, so the next advance lands on whatever filled the gap instead of skipping it; if it stays, it keeps it centered.

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

@testcase
def test_frame_resumes_position(page):
    """On reboot (?frame=1) the show resumes the slide it was last on, not the first."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzresume-{i}", "date": f"2020-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzresume-t-{i}",
             "labels": "zzresume", "state": "todo"} for i in range(3)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.goto(BASE_URL + "?ms=999999", wait_until="commit")
        page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
        search_for(page, "zzresume")                           # persisted; default state = todo
        expect(tiles(page)).to_have_count(3)
        page.get_by_role("button", name=re.compile("frame", re.I)).click()
        strip = page.get_by_role("list", name="slideshow")
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzresume-t-0")
        page.keyboard.press("ArrowRight")                      # move to the 2nd slide (saved)
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzresume-t-1")
        wait_until(page, lambda: page.evaluate(            # the position is persisted before reboot
            "() => localStorage.getItem('memories.frame.cid')") == "https://ipfs.konubinix.eu/p/zzresume-1")
        # reboot: ?frame=1 with the persisted query
        page.goto(BASE_URL + "?frame=1&ms=999999", wait_until="commit")
        page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
        strip = page.get_by_role("list", name="slideshow")
        expect(strip).to_be_visible()
        wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzresume-t-1")   # resumed
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: frame resumes position")

@testcase
def test_frame_mode_persists(page):
    """Being in the frame is remembered: a plain relaunch (no ?frame) re-enters; exiting stops it."""
    make_fixtures()
    page.goto(BASE_URL + "?ms=999999", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    search_for(page, FIXTURE_LABEL)                            # persisted query
    expect(tiles(page).first).to_be_visible()
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    page.goto(BASE_URL + "?ms=999999", wait_until="commit")    # relaunch, NO ?frame (a PWA shortcut)
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()                              # remembered → auto-entered
    page.keyboard.press("Escape")                              # leaving turns it off
    expect(strip).to_be_hidden()
    page.goto(BASE_URL + "?ms=999999", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    expect(page.get_by_role("list", name="slideshow")).to_be_hidden()   # stays out
    print("  PASS: frame mode persists")

@testcase
def test_frame_autostart(page):
    """?frame=1 forces the slideshow on load (overrides the remembered flag)."""
    make_fixtures()
    page.goto(BASE_URL + "?frame=1&ms=999999", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()                              # auto-entered, no click
    page.keyboard.press("Escape")                              # and exiting doesn't re-enter
    expect(strip).to_be_hidden()
    page.wait_for_timeout(300)
    expect(strip).to_be_hidden()
    print("  PASS: frame autostart")

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

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

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

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

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

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, for present-aware completion
const [frameCenterIdx, setFrameCenterIdx] = createSignal(1);      // its strip index, for the load bands
const FRAME_MS = Number(new URLSearchParams(location.search).get('ms')) || 60000;
const [intervalMs, setIntervalMs] = createSignal(FRAME_MS);
// the frame plays the wall *in the order shown* (date, or sort:random — same as the
// grid). An infinite carousel: clone the last slide before the first and the first
// after the last. Real slide 0 sits at strip index 1; a scroll that settles on a clone
// (index 0 or n+1) silently jumps to its real twin, so swiping past either edge loops.
const frameSlides = () => { const o = items();
    return o.length ? [o[o.length - 1], ...o, o[0]] : []; };
let stripEl, wakeLock = null;
// index and position by the slides' own geometry — never the rounded clientWidth (see prose)
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) setFrameCenterIdx(slideAt());          // the load bands ride the live scroll, not just the settle
    clearTimeout(snapT); snapT = setTimeout(() => {   // ~150ms after the last scroll
    if(!stripEl) 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];                            // remember where we are, to resume on reboot
    setFrameCenterIdx(i);
    if(doc){ localStorage.setItem(FRAME_CID_KEY, doc.cid); setFrameCenterCid(doc.cid); }
}, 150); };
const FRAME_ON_KEY = 'memories.frame.on';              // remembers we're in frame mode
async function enterFrame(){
    if(!items().length) return;
    setFrame(true); setPlaying(true); setFrameUI(false);
    localStorage.setItem(FRAME_ON_KEY, '1');           // so a relaunch (PWA shortcut) re-enters
    history.pushState({ frame: true }, '');           // so the back button leaves the frame
    try { await document.documentElement.requestFullscreen?.(); } catch(e) {}
    try { wakeLock = await navigator.wakeLock?.request('screen'); } catch(e) {}
}
function closeFrame(){                                 // the teardown itself
    setFrame(false);
    localStorage.setItem(FRAME_ON_KEY, '0');           // leaving turns off auto-enter
    try { wakeLock?.release(); } catch(e) {} wakeLock = null;
    try { if(document.fullscreenElement) document.exitFullscreen?.(); } catch(e) {}
}
// Esc / the exit button unwind the history entry (→ popstate → closeFrame), so back
// and explicit-exit leave history balanced.
const exitFrame = () => (history.state && history.state.frame) ? history.back() : closeFrame();

// edit the centered doc from within the frame. The edit may push it out of the current
// filter; if so, re-anchor on the *previous* doc (so the next advance shows the one
// that filled the gap rather than skipping it); if it stayed, keep it centered.
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 => 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('; ') })); };
// auto-start the show as soon as the (persisted) wall loads — when we were last in the
// frame (the frame launches from a PWA shortcut, no query string), or when forced with
// ?frame=1. Once only, so exiting doesn't immediately re-enter.
const FRAME_AUTO = localStorage.getItem(FRAME_ON_KEY) === '1'
    || new URLSearchParams(location.search).get('frame') === '1';
let autoEntered = false;
createEffect(() => {
    if(FRAME_AUTO && !autoEntered && items().length > 0){ autoEntered = true; enterFrame(); }
});
// open on the slide we left off (resume across reboot), else the first real slide.
createEffect(() => { if(frame() && stripEl) requestAnimationFrame(() => {
    const saved = localStorage.getItem(FRAME_CID_KEY);
    const r = saved ? items().findIndex(p => p.cid === saved) : -1;
    stripEl.scrollLeft = slideLeft(r >= 0 ? r + 1 : 1);   // +1 for the leading clone
    setFrameCenterIdx(r >= 0 ? r + 1 : 1);                             // seed the centre before any scroll
    setFrameCenterCid((items()[r >= 0 ? r : 0] || {}).cid || null);
}); });
// a video scrolled out of view must stop — pause any slide video that's mostly off-screen
createEffect(() => {
    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() || !playing() || zoomed()) return;   // a pinch freezes the slide (next section)
    const id = setInterval(() => {
        if(stripEl && [...stripEl.querySelectorAll('video')].some(v => !v.paused && !v.ended)) return;
        frameGo(1);
    }, intervalMs());
    onCleanup(() => clearInterval(id));
});
onMount(() => {
    const onKey = e => {
        if(!frame()) return;
        const editing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName);   // leave the arrows to the label box
        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 = () => {                                // the back button unwinds frame, then lightbox
        if(frame()) closeFrame();
        const st = history.state || {};
        if(opened() && !st.lb) closePhoto();             // popped past the lightbox → grid
        if(!opened() && st.lb){ const p = items().find(x => x.cid === st.lb); if(p) setOpened(p); }   // back into the lightbox under the frame
    };
    window.addEventListener('keydown', onKey);
    window.addEventListener('popstate', onPop);
    document.addEventListener('visibilitychange', onVis);
    onCleanup(() => { window.removeEventListener('keydown', onKey);
                      window.removeEventListener('popstate', onPop);
                      document.removeEventListener('visibilitychange', onVis); });
});

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

Those arrows want a keyboard the cabinet tablet doesn’t have, and one-handed from across the room a dependable swipe wants two hands. So the screen itself carries the same step: its outer thirds move the show — left back, right forward — while the centre third is left to reveal the control bar, so the bar stays a tap away without claiming the edges.

A tap in an outer third steps the show — the left third back a slide, the right third forward.

strip = enter_frame(page, 999999)                              # no auto-advance to fight
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
page.mouse.click(box["x"] + box["width"] * 0.92, midY)         # right third → forward
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
page.mouse.click(box["x"] + box["width"] * 0.08, midY)         # left third → back
wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")

The centre third stays the control bar’s: a tap there reveals it and leaves the show where it is.

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(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0", "a centre tap must not navigate"

Where the tap lands is the whole of it — the outer thirds step the show, the centre toggles the bar. The catch is the trigger: a plain click can’t carry it, because a <video controls> in the slide swallows the tap before any click bubbles up. So the handler stays unwired here and is called from the strip’s pointer pair — alongside the pinch below — the moment a lone finger presses and lifts without travelling:

const onFrameTap = e => {
    if(zoomed()) return;
    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);
};

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

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

onMount(() => {
    history.pushState({ app: true }, '');          // the root entry the back button stops on
    const onExit = () => {
        const st = history.state || {};
        if(!frame() && !opened() && !st.lb && !st.app){      // popped below the root with nothing open
            if(confirm('Leave Memories?')) history.back();   // really leave
            else history.pushState({ app: true }, '');       // stay — restore the root
        }
    };
    window.addEventListener('popstate', onExit);
    onCleanup(() => window.removeEventListener('popstate', onExit));
});

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

<${Show} when=${() => frame()}>
  <div class=${() => 'frame' + (zoomed() ? ' zoomed' : '')}>
    <div class="strip" role="list" aria-label="slideshow"
         ref=${el => { stripEl = el; el.addEventListener('scroll', onFrameScroll); }}>
      <${Index} each=${() => frameSlides()}>${(slide, k) => {
        const src = createMemo(() => frameSrc(slide(), k));   // band-chosen source, re-picked as the centre moves
        const [loaded, setLoaded] = createSignal(false);      // until this slide's CURRENT source paints
        createEffect(() => { src(); setLoaded(false); });     // a fresh source — doc swapped in, or band re-picked — re-shows the mark
        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=${() => !loaded()}>
                         <span class="ph load-ph" aria-label="loading">🖼</span><//>
                       <img class="slide-media" loading="lazy"
                            src=${src} onLoad=${() => setLoaded(true)} /></div>`}>
              <video class="slide-media" controls src=${() => IPFS + slide().webCid}></video>
            <//>
          <//>
        </div>`; }}
      <//>
    </div>
    <${Show} when=${() => frameUI()}>
      <div class="frame-bar" role="toolbar" aria-label="frame actions">
        <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>
        ${STATES.map(st => html`
          <button class="st" onClick=${() => frameSetState(st)}>${st}</button>`)}
        <div class="complete">
          <input class="frame-label" placeholder="add a label…" aria-label="add a label in the frame"
                 value=${() => frameLabel()} onInput=${e => { setFrameLabel(e.target.value); setFrameLabelFocus(true); }}
                 onFocus=${() => setFrameLabelFocus(true)}
                 onBlur=${() => setTimeout(() => setFrameLabelFocus(false), 150)}
                 onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); frameDropLabel(); return; }
                   sugNav(e, w => w ? setFrameLabel(replaceSeg(frameLabel(), w) + '; ') : frameAddLabel()); }} />
          <${Show} when=${() => frameLabelFocus()}>
            <${Suggest} text=${frameLabel} present=${() => labelsOf(items().find(p => p.cid === frameCenterCid()))}
                        active=${sugActive} onItems=${reportSug}
                        onPick=${w => setFrameLabel(replaceSeg(frameLabel(), w) + '; ')} />
          <//>
        </div>
        <button aria-label="exit frame" onClick=${exitFrame}> exit</button>
      </div>
    <//>
  </div>
<//>

.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; }
.frame-bar{ position:fixed; bottom:18px; 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; }
.frame-bar .ivl{ width:48px; background:#262a40; color:var(--fg); border:1px solid #3a3f5a;
                 border-radius:4px; padding:3px 5px; }
.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 */

Pinching a slide to look closer

The frame plays from across the room, but now and then a face in a crowd or the text on a sign is worth leaning in on — so a pinch magnifies the centred slide, and a drag moves the magnified doc around. To feel like a real pinch and not a toy, two things matter: the zoom homes on the point between your fingers (the spot you’re reaching for stays under them, rather than the image springing from its centre), and once enlarged the doc can be panned to bring any corner into view — bounded so it can never be dragged off its own pane. The model is therefore a scale plus a translation, both living on the centred slide.

const ZOOM_MAX = 4;
const [zoom, setZoom] = createSignal(1);              // scale of the centred slide, 1‒ZOOM_MAX
const [panX, setPanX] = createSignal(0), [panY, setPanY] = createSignal(0);   // its translation, px
const zoomed = () => zoom() > 1;
const [zoomBeat, setZoomBeat] = createSignal(0);      // bumped per gesture, to re-arm the idle reset
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
// keep the doc on its pane: with the transform anchored top-left, tx spans [W(1−s), 0]
// and ty spans [H(1−s), 0] — so a magnified slide can be dragged to either edge, no further.
const clampPan = (s, tx, ty) => { const w = stripEl?.clientWidth || 1, h = stripEl?.clientHeight || 1;
    return [clamp(tx, w * (1 - s), 0), clamp(ty, h * (1 - s), 0)]; };
// zoom toward a focal screen point: the point under (fx,fy) keeps its place as the scale
// changes (t' = f − (f − t)·s'/s), which is what makes a pinch feel anchored to the fingers.
const zoomToward = (ns, fx, fy) => { const s = zoom(); ns = clamp(ns, 1, ZOOM_MAX); const r = ns / s;
    let tx = ns === 1 ? 0 : fx - (fx - panX()) * r, ty = ns === 1 ? 0 : fy - (fy - panY()) * r;
    [tx, ty] = clampPan(ns, tx, ty);
    setZoom(ns); setPanX(tx); setPanY(ty); setZoomBeat(b => b + 1); };
const panBy = (dx, dy) => { const [tx, ty] = clampPan(zoom(), panX() + dx, panY() + dy);
    setPanX(tx); setPanY(ty); setZoomBeat(b => b + 1); };

A pinch reaches the page two ways, and both drive that model. A trackpad or touchpad sends it as a ctrl=+wheel — the gesture the browser reserves for zoom, which we take over (=preventDefault stops the page zooming) and aim at the cursor. A touchscreen sends raw pointers: one finger drags (pans) a magnified doc, two fingers pinch — we track their gap and midpoint frame to frame, so the doc scales by the gap’s ratio and follows the midpoint as it slides, the two combining into one fluid gesture. A lone finger that presses and lifts in place — drifting no more than ten pixels before it would count as a swipe — is neither pan nor pinch but a tap, and this is where the side-step from the tap zones is fired.

const onZoomWheel = e => { if(!e.ctrlKey) return; e.preventDefault();
    zoomToward(zoom() * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY); };
const zptr = new Map();                               // live pointers, by id
let lastMid = null, lastGap = 0, lastPan = null;      // previous-frame pinch/drag anchors
let tapFrom = null;                                   // where a lone finger went down, while it could still be a tap
const TAP_SLOP = 10;                                  // travel past this (px) makes it a swipe, not a tap
const pts = () => [...zptr.values()];
const span = () => { const [a, b] = pts();
    return { d: Math.hypot(a.x - b.x, a.y - b.y), x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; };
const onZoomDown = e => { zptr.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if(zptr.size === 2){ const s = span(); lastGap = s.d; lastMid = { x: s.x, y: s.y }; tapFrom = null; }  // a pinch, not a tap
    else if(zptr.size === 1){ lastPan = { x: e.clientX, y: e.clientY }; tapFrom = { x: e.clientX, y: e.clientY }; } };
const onZoomMove = e => { if(!zptr.has(e.pointerId)) return;
    zptr.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if(tapFrom && Math.hypot(e.clientX - tapFrom.x, e.clientY - tapFrom.y) > TAP_SLOP) tapFrom = null;  // travelled → a swipe
    if(zptr.size >= 2){ const s = span();
        if(lastMid){ panBy(s.x - lastMid.x, s.y - lastMid.y); zoomToward(zoom() * s.d / lastGap, s.x, s.y); }
        lastGap = s.d; lastMid = { x: s.x, y: s.y }; }
    else if(zptr.size === 1 && zoomed() && lastPan){
        panBy(e.clientX - lastPan.x, e.clientY - lastPan.y); lastPan = { x: e.clientX, y: e.clientY }; } };
const onZoomUp = e => { zptr.delete(e.pointerId);
    if(zptr.size === 1){ const p = pts()[0]; lastPan = { x: p.x, y: p.y }; lastMid = null; }   // pinch → drag
    else if(zptr.size === 0){ lastMid = null; lastPan = null;
        if(e.type === 'pointerup' && tapFrom) onFrameTap(e);   // a lone, still finger lifted → the side-tap step
        tapFrom = null; } };

The gestures hang off the strip itself, bound while the frame is up and torn down on exit — the same lifecycle as the scrollend and off-screen-video wiring next to them.

createEffect(() => { if(!frame() || !stripEl) return; const el = stripEl;
    const on = (t, h, o) => el.addEventListener(t, h, o), off = (t, h) => el.removeEventListener(t, h);
    on('wheel', onZoomWheel, { passive: false }); on('pointerdown', onZoomDown);
    on('pointermove', onZoomMove); on('pointerup', onZoomUp); on('pointercancel', onZoomUp);
    onCleanup(() => { off('wheel', onZoomWheel); off('pointerdown', onZoomDown);
        off('pointermove', onZoomMove); off('pointerup', onZoomUp); off('pointercancel', onZoomUp); }); });

A magnified slide must hold still — chasing detail on a photo that scrolls out from under you is hopeless — so while a pinch is on, the interval timer skips its tick (it consults zoomed(), shown earlier) and the strip itself stops sliding between docs (its .zoomed class kills the scroll, so a one-finger drag pans instead of swiping to the next slide). The scale-and-translation rides only on the centred slide’s media — anchored top-left so the focal maths line up — clipped to its own box so the close-up never bleeds onto its neighbours.

createEffect(() => { const s = zoom(), tx = panX(), ty = panY(); if(!frame() || !stripEl) return;
    const i = slideAt();
    [...stripEl.children].forEach((sl, k) => { const m = sl.querySelector('.slide-media');
        if(!m) return;
        m.style.transformOrigin = '0 0';
        m.style.transform = (k === i && s > 1) ? `translate(${tx}px,${ty}px) scale(${s})` : ''; }); });

Now the behaviour. A =ctrl=+wheel is how a trackpad’s pinch reaches the page, so the tests drive that. First, focus: zooming at the left edge keeps the left in view, zooming at the right edge shifts the doc to keep the right — proof the zoom homes on the cursor, not the centre.

def enter_frame_zoom(page, ms, idle):
    make_fixtures()
    page.goto(BASE_URL + f"?ms={ms}&zoomidle={idle}", wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    chip(page, "all").click()
    search_for(page, FIXTURE_LABEL)
    expect(tiles(page)).to_have_count(len(FIXTURES))
    page.get_by_role("button", name=re.compile("frame", re.I)).click()
    strip = page.get_by_role("list", name="slideshow")
    expect(strip).to_be_visible()
    return strip

def centered_xform(strip):                            # {a: scale, e: translateX, f: translateY}
    return strip.evaluate(
        "el => { const w = el.clientWidth || 1, i = Math.round(el.scrollLeft / w);"
        " const m = el.children[i] && el.children[i].querySelector('img,video');"
        " const t = m ? getComputedStyle(m).transform : 'none';"
        " if(t === 'none') return {a:1, e:0, f:0};"
        " const M = new DOMMatrixReadOnly(t); return {a:M.a, e:M.e, f:M.f}; }")
def centered_scale(strip): return centered_xform(strip)["a"]

def ctrl_wheel_at(page, x, y, dy):
    page.mouse.move(x, y)
    page.keyboard.down("Control"); page.mouse.wheel(0, dy); page.keyboard.up("Control")
def ctrl_wheel(page, strip, dy):
    box = strip.bounding_box()
    ctrl_wheel_at(page, box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, dy)

@testcase
def test_frame_pinch_focus(page):
    """The pinch zooms toward the cursor: left-edge zoom keeps the left, right-edge the right."""
    strip = enter_frame_zoom(page, ms=999999, idle=999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
    ctrl_wheel_at(page, box["x"] + 2, midY, -300)               # zoom in at the left edge
    wait_until(page, lambda: centered_scale(strip) > 1.5)
    assert abs(centered_xform(strip)["e"]) < 100, "left-edge zoom should keep the left anchored"
    ctrl_wheel_at(page, box["x"] + 2, midY, 600)                # back to 1:1
    wait_until(page, lambda: centered_scale(strip) <= 1.001)
    ctrl_wheel_at(page, box["x"] + box["width"] - 2, midY, -300)  # zoom in at the right edge
    wait_until(page, lambda: centered_scale(strip) > 1.5)
    assert centered_xform(strip)["e"] < -box["width"] * 0.5, "right-edge zoom should shift to keep the right"
    print("  PASS: frame pinch focus")

Once enlarged, a drag pans the doc, and the show stays frozen until the pinch is undone.

@testcase
def test_frame_pinch_pan_and_freeze(page):
    """A magnified slide pans under a drag and never auto-advances; pinching back resumes."""
    strip = enter_frame_zoom(page, ms=1500, idle=999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    ctrl_wheel(page, strip, -300)                               # zoom in at the centre
    wait_until(page, lambda: centered_scale(strip) > 1.5)
    e0 = centered_xform(strip)["e"]
    box = strip.bounding_box(); cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
    page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx - 150, cy, steps=6); page.mouse.up()
    wait_until(page, lambda: centered_xform(strip)["e"] < e0 - 20)   # dragged left → content shifts left
    page.wait_for_timeout(2500)                                 # past an advance tick…
    assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0", "a zoomed slide must not advance"
    ctrl_wheel(page, strip, 600)                                # pinch back to 1:1
    wait_until(page, lambda: centered_scale(strip) <= 1.001)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")   # show resumes
    print("  PASS: frame pinch pan and freeze")

The freeze reaches the tap-zones as well: while a slide is magnified, an outer-third tap holds the close-up instead of stepping the show, so leaning in to read a detail never jumps you to another doc.

@testcase
def test_frame_zoom_suppresses_tap_zones(page):
    """A magnified slide ignores the tap-zones — an outer-third tap doesn't step the show."""
    strip = enter_frame_zoom(page, ms=999999, idle=999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    ctrl_wheel(page, strip, -300)                                  # pinch in at the centre
    wait_until(page, lambda: centered_scale(strip) > 1.5)
    box = strip.bounding_box(); midY = box["y"] + box["height"] / 2
    page.mouse.click(box["x"] + box["width"] * 0.92, midY)         # an outer-third tap, while zoomed
    page.wait_for_timeout(300)
    assert strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0", "a magnified slide must ignore the tap-zones"
    print("  PASS: frame zoom suppresses tap zones")

A touchscreen reaches the same model through raw pointers, which is what a tablet actually uses, so that channel earns its own check: two fingers planted and spread apart magnify the slide just as the wheel did.

@testcase
def test_frame_pinch_touch(page):
    """Two fingers spreading apart (a real touch pinch) magnify the centred slide."""
    strip = enter_frame_zoom(page, ms=999999, idle=999999)
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    box = strip.bounding_box()
    cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
    cdp = page.context.new_cdp_session(page)
    cdp.send("Emulation.setTouchEmulationEnabled", {"enabled": True, "maxTouchPoints": 2})
    cdp.send("Input.dispatchTouchEvent", {"type": "touchStart",
        "touchPoints": [{"x": cx - 20, "y": cy}, {"x": cx + 20, "y": cy}]})
    for d in (60, 120, 200):                                # the gap widens → zoom grows
        cdp.send("Input.dispatchTouchEvent", {"type": "touchMove",
            "touchPoints": [{"x": cx - d, "y": cy}, {"x": cx + d, "y": cy}]})
    cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
    wait_until(page, lambda: centered_scale(strip) > 1.5)
    print("  PASS: frame pinch (touch)")

Left alone, the close-up must not trap the frame forever, so after a span with no gesture it resets — scale and pan — and the show resumes: five minutes on the wall (?zoomidle= overrides the span, the way ?ms= overrides the slide tempo), shortened here for the test.

const ZOOM_IDLE_MS = Number(new URLSearchParams(location.search).get('zoomidle')) || 300000;
const resetZoom = () => { setZoom(1); setPanX(0); setPanY(0); };
createEffect(() => { if(!zoomed()) return; zoomBeat();
    const id = setTimeout(resetZoom, ZOOM_IDLE_MS); onCleanup(() => clearTimeout(id)); });

@testcase
def test_frame_zoom_resets_when_idle(page):
    """Untouched, a zoomed slide snaps back to 1:1 (and recentres) after the idle span; the show resumes."""
    strip = enter_frame_zoom(page, ms=1500, idle=700)      # a short idle window for the test
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-0")
    ctrl_wheel(page, strip, -300)
    wait_until(page, lambda: centered_scale(strip) > 1.5)
    wait_until(page, lambda: centered_scale(strip) <= 1.001)   # auto-reset, no interaction
    assert centered_xform(strip)["e"] == 0, "reset should recentre the pan too"
    wait_until(page, lambda: strip.evaluate(CENTERED) == "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")   # and resumes
    print("  PASS: frame zoom resets when idle")

The slide clips its overflow so a magnified close-up stays within its own pane, and while zoomed the strip stops scrolling so a drag pans rather than swiping between docs.

.slide{ overflow:hidden; }
.frame.zoomed .strip{ overflow:hidden; touch-action:none; }

Into the frame, from the lightbox

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

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

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

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

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

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

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

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

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

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

Filtering by state

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

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

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

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

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

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

The chips: all plus one per state, in a labelled group so the filter buttons are unambiguous from the like-named batch buttons. The active one carries aria-pressed (which both styles it and is what the test reads); clicking sets the signal.

<div class="chips" role="group" aria-label="filter by state">
  ${['all', ...STATES].map(st => html`
    <button class="chip" 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[aria-pressed='true']{ background:#6cf; color:#08111e; border-color:#6cf; font-weight:700; }
.selall{ margin-left:auto; }

Selecting tiles and batch-editing

The reason the old Alpine thumbs existed: triaging a wall of photos in bulk. Click tiles to build a selection, then apply one change to all of them at once — add a label (free-text, merged into each photo’s labels) or set a state (todo / next / done / delete, the photovideo workflow enum). Both ride the auto-generated updatePhotovideo(input:{cid,patch}) mutation, one call per selected cid; after the batch the resource =refetch=es so the wall reflects the change.

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

Selection is a Solid Set signal — toggling replaces the set so fine-grained reactivity re-renders only the touched tiles’ outline/check. A sticky toolbar appears only while something is selected. A plain click toggles one tile and remembers it as the anchor; a contiguous run from the anchor to a target tile (in the wall’s date order) is then selected three ways: shift-click the target on desktop, or — since touch has no modifier key — tap the toolbar’s ↔ range toggle and then tap the target, or double-tap a tile to arm the same range mode anchored there (the fast touch gesture; a press-and-hold is reserved for opening the doc). All routes go through the same extendTo, so they can’t drift apart.

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

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

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

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

@testcase
def test_double_tap_starts_range(page):
    """Double-tapping a tile arms range mode (it becomes the anchor); the next tap
    selects the run to it — the fast touch gesture, no trip to the toolbar."""
    open_fixtures(page)
    t = tiles(page)
    t.nth(0).dblclick()                               # arm range on the first tile
    expect(checks(page)).to_have_count(1)             # the tapped tile, now the anchor
    t.nth(2).click()                                  # a plain tap completes the range
    expect(checks(page)).to_have_count(len(FIXTURES))
    print("  PASS: double tap starts range")

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

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

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

@testcase
def test_select_toggles_tiles(page):
    """Clicking tiles toggles selection; a toolbar shows the count."""
    open_fixtures(page)
    t = tiles(page)
    t.nth(0).click(); t.nth(1).click()
    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")

@testcase
def test_select_does_not_shift_grid(page):
    """Selecting (which raises the toolbar) must not move the tiles under the cursor."""
    open_fixtures(page)
    before = tiles(page).first.bounding_box()
    tiles(page).nth(1).click()                 # select a tile → toolbar appears
    expect(toolbar(page)).to_be_visible()
    after = tiles(page).first.bounding_box()
    assert abs(before["y"] - after["y"]) < 1, f"grid shifted: {before['y']} -> {after['y']}"
    print("  PASS: select doesn't shift grid")

The same input drives both + label (merge the words into every selected doc) and - label (strip them from every selected doc) — both splitWords the box the same way (so a value left with the autocomplete’s trailing ; still parses to the bare label), both through patchSelected, one updatePhotovideo per cid; from the keyboard, RET stands in for + and S-RET for -. Adding selects the whole fixture set, types a fresh word, applies it, and proves it stuck by searching that word and getting exactly the fixtures back. The box is a controlled input the toolbar tears down whenever the selection clears and rebuilds on the next select-all, so the keyboard test touches it as a person would: it lets the selection settle, then types the word key by key into the live box — each keystroke tracking the bound signal — before pressing RET or S-RET.

@testcase
def test_batch_add_label(page):
    """A label added to the selection is written to every selected photo."""
    open_fixtures(page)
    n = tiles(page).count()
    select_all(page).click()
    tb = toolbar(page)
    tb.get_by_placeholder("add a label…").fill("addedbybatch")
    tb.get_by_role("button", name="add label").click()
    expect(checks(page)).to_have_count(0)            # selection clears once applied
    search_box(page).fill("addedbybatch")            # the fresh word now finds them all
    expect(tiles(page)).to_have_count(n)
    print("  PASS: batch add label")

@testcase
def test_batch_remove_label(page):
    """Removing a label from the selection strips it from every selected photo."""
    open_fixtures(page)                              # all fixtures carry FIXTURE_LABEL
    select_all(page).click()
    tb = toolbar(page)
    tb.get_by_placeholder("add a label…").fill(FIXTURE_LABEL)
    tb.get_by_role("button", name="remove label").click()
    # the wall is filtered by FIXTURE_LABEL; once it's stripped from all, none remain
    expect(tiles(page)).to_have_count(0)
    print("  PASS: batch remove label")

@testcase
def test_batch_remove_label_trailing_separator(page):
    """A label left in the box with the autocomplete's trailing '; ' still removes:
    the box is ;-split like + label, not merely trimmed (so '<label>;' wouldn't strip)."""
    open_fixtures(page)                              # all carry FIXTURE_LABEL
    select_all(page).click()
    tb = toolbar(page)
    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")

@testcase
def test_batch_enter_adds_shift_enter_removes(page):
    """In the batch box, Enter behaves like + label and Shift+Enter like - label."""
    open_fixtures(page)
    n = tiles(page).count()
    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_box(page).fill("zzbatchret")
    expect(tiles(page)).to_have_count(n)             # every selected photo got it
    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)             # stripped from all → none match
    print("  PASS: batch enter adds, shift-enter removes")

Setting a state selects the set, clicks a state button, and waits for every tile’s badge to read the new value after the refetch.

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

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

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

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

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

Past triage you often just want the files. The toolbar’s group saves the explicit selection — one file per doc — at a chosen resolution: orig (the doc’s own /ipfs/ cid), web (the downscaled web_cid), or thumb (the poster). We select one doc and confirm the saved file’s URL is the right cid for each resolution.

@testcase
def test_download_selection(page):
    """The ↓ group downloads each selected doc at the chosen resolution."""
    open_fixtures(page)
    tiles(page).nth(0).click()                          # select the first (earliest) fixture
    expect(checks(page)).to_have_count(1)
    tb = toolbar(page)
    with page.expect_download() as di:
        tb.get_by_role("button", name="thumb", exact=True).click()
    assert FIXTURES[0]["thumbnailCid"] in di.value.url, f"thumb url: {di.value.url}"
    with page.expect_download() as di2:
        tb.get_by_role("button", name="orig", exact=True).click()
    assert FIXTURES[0]["cid"] in di2.value.url, f"orig url: {di2.value.url}"
    print("  PASS: download selection")

One doc has three downloadable forms, so a single name would make orig and thumb overwrite each other in the download folder. Each saved file therefore carries its resolution in the name — holiday-orig.jpg, holiday-thumb.jpg — so grabbing more than one form of the same photo never collides.

@testcase
def test_download_names_by_resolution(page):
    """orig/web/thumb of one doc download under distinct, resolution-tagged names."""
    make_fixtures()
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzdl", "date": "2020-05-15T12:00:00Z", "mimetype": "image/jpeg",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzdl-thumb", "webCid": "https://ipfs.konubinix.eu/p/zzdl-web",
           "filename": "holiday.jpg", "labels": "zzdlonly", "state": "todo"}
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_box(page).fill("zzdlonly")
        expect(tiles(page)).to_have_count(1)
        tiles(page).nth(0).click()
        tb, names = toolbar(page), {}
        for res in ["orig", "thumb"]:
            with page.expect_download() as di:
                tb.get_by_role("button", name=res, exact=True).click()
            names[res] = di.value.suggested_filename
        assert names["orig"] != names["thumb"], f"same name → they collide: {names}"
        assert "orig" in names["orig"] and "thumb" in names["thumb"], f"resolution missing from name: {names}"
    finally:
        gql(DELETE, {"cid": doc["cid"]})
    print("  PASS: download names by resolution")

A cid carries no extension and a missing object answers text/plain, so a name from either is unopenable. The right extension depends on the rendition — the same split the legacy slider drew: a thumbnail is always a JPEG, a video’s web copy is an MP4 (an image’s stays a JPEG), and the original keeps its true type from the DB mimetype. So a .mov original saves …-orig.mov, its web copy …-web.mp4, and its poster …-thumb.jpg.

@testcase
def test_download_extensions_by_rendition(page):
    """thumb→jpg, a video's web→mp4, original→its real type (here .mov)."""
    make_fixtures()
    doc = {"cid": "https://ipfs.konubinix.eu/p/zzext", "date": "2020-05-15T12:00:00Z", "mimetype": "video/quicktime",
           "thumbnailCid": "https://ipfs.konubinix.eu/p/zzext-thumb", "webCid": "https://ipfs.konubinix.eu/p/zzext-web",
           "labels": "zzextonly", "state": "todo"}                  # a .mov video, no filename
    gql(DELETE, {"cid": doc["cid"]}); gql(CREATE, {"p": doc})
    try:
        open_app(page); chip(page, "all").click()
        search_box(page).fill("zzextonly")
        expect(tiles(page)).to_have_count(1)
        tiles(page).nth(0).click()
        tb, ext = toolbar(page), {}
        for res in ["orig", "web", "thumb"]:
            with page.expect_download() as di:
                tb.get_by_role("button", name=res, exact=True).click()
            ext[res] = di.value.suggested_filename.rsplit(".", 1)[-1]
        assert ext["orig"] == "mov", f"original keeps its real type: {ext}"
        assert ext["web"] == "mp4", f"a video's web copy is mp4: {ext}"
        assert ext["thumb"] == "jpg", f"a thumbnail is always jpg: {ext}"
    finally:
        gql(DELETE, {"cid": doc["cid"]})
    print("  PASS: download extensions by rendition")

The selection model and the two batch actions. patchSelected maps the current photos by cid (so the label-merge can read each row’s existing labels), fires one updatePhotovideo per selected cid, clears the selection, then re-reads the wall. Its all-matching counterpart edits the whole filter through the bulk functions, which return only a count of the rows touched — so where a per-cid edit hands back the changed row for the cache to act on, the bulk path tags the Photovideo type instead.

const STATES = ['todo', 'next', 'done', 'delete'];
// the picked set persists, so an accidental refresh mid-session doesn't drop the run
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);   // touch: extend on next tap
const [allMatching, setAllMatching] = createSignal(false);   // act on the whole filter, not the sample
const isSel = cid => selected().has(cid);
const selCount = () => selected().size;
const clearSel = () => { setSelected(new Set()); setRangeMode(false); setAllMatching(false); };
const toggle = cid => { setAllMatching(false); setSelected(s => {
    const n = new Set(s); n.has(cid) ? n.delete(cid) : n.add(cid); return n;
}); };

// select-all toggles the whole shown wall on/off (it lives outside the selection
// toolbar, which is hidden when nothing is selected).
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()));

// select the whole contiguous run from the anchor to cid, in the wall's date order.
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; });
};
// plain click toggles one tile and moves the anchor. A range is extended either by
// shift-click (desktop) or by arming the toolbar's range toggle then tapping the end
// tile (touch — no modifier key). Either way it clears range mode afterwards.
const onTileClick = (e, cid) => {
    setCursor(cid);   // a click plants the keyboard cursor, so the arrows carry on from here
    if((e.shiftKey || rangeMode()) && anchor() !== null){
        extendTo(cid); setRangeMode(false); return;
    }
    toggle(cid); setAnchor(cid);
};

// double-tap a tile to start a range — the fast touch gesture (the toolbar ↔ range toggle
// is the explicit route). The tapped tile becomes the anchor and arms range mode, so the
// next tap selects the run to it.
const armRange = cid => {
    setSelected(s => { const n = new Set(s); n.add(cid); return n; });
    setAnchor(cid); setRangeMode(true);
};

// press-and-hold a tile to open it in the lightbox — the deliberate gesture asks for one
// doc, full size. A move (a scroll) or an early release cancels the press; the press's own
// click is swallowed so it doesn't fall through to a select once the modal is up.
let lpTimer = null, lpFired = false, lpAt = null;
const lpCancel = () => { clearTimeout(lpTimer); lpTimer = null; };
const onTileDown = (e, photo) => {
    lpFired = false; lpAt = { x: e.clientX, y: e.clientY }; lpCancel();
    lpTimer = setTimeout(() => { lpFired = true; lpCancel(); openPhoto(photo); }, 500);
};
const onTileMove = e => { if(lpAt && Math.hypot(e.clientX - lpAt.x, e.clientY - lpAt.y) > 10) lpCancel(); };
const onTileUp = () => lpCancel();
const onTilePress = (e, cid) => { if(lpFired){ lpFired = false; return; } onTileClick(e, cid); };

async function patchSelected(patchFor){
    const byCid = new Map(items().map(p => [p.cid, p]));
    for(const cid of selected())
        await gql(UPDATE_PHOTO, { cid, patch: patchFor(byCid.get(cid)) });
    clearSel();
    await refetch();
}
// labels are a ;-delimited list, so the same split parses both a doc's stored labels and
// what's typed into an add-label box — typing "a; b" adds two labels in one go.
const splitWords = s => (s || '').split(';').map(x => x.trim()).filter(Boolean);
const splitLabels = p => splitWords(p.labels);

// "all matching" applies to *every* doc the filter matches (server-side, beyond the
// ~2000 sample), via the bulk functions, using the same query the wall is showing.
const filterVars = () => ({ ...photoVars(parseQuery(search())),
                            states: stateFilter() === 'all' ? null : [stateFilter()] });
const BULK = k => `mutation(${PHOTO_FILTER_DECL}, $states:[State!], $v:${k === 'SetState' ? 'State' : 'String'}!){
  photovideos${k}(input:{${PHOTO_FILTER_ARGS}, states:$states, ${k === 'SetState' ? 'toState' : 'label'}:$v}){ result } }`;
async function applyBulk(kind, v){ await gql(BULK(kind), { ...filterVars(), v }, PV_CTX); 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 }));

// save the picked docs as files — one per doc, at a chosen resolution: the original
// (=cid=), the downscaled =web_cid=, or the =thumbnail=. It acts on the explicit
// selection (the shown, ticked docs), not "all matching" — there's no client-side list
// of the whole filter to fetch. The browser asks once before saving several files.
// the original's extension comes from the db mimetype (a cid carries none, and a missing
// /ipfs/ object answers text/plain); a few common ones get a friendly extension, the rest
// fall back to the mimetype subtype.
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 === 'thumb' ? (p.thumbnailCid || p.cid)
                   : res === 'web' ? (p.webCid || p.cid) : p.cid;
        // the renditions are fixed formats (like the legacy slider): a thumbnail is a
        // JPEG, a video's web copy is an MP4 (an image's is a JPEG); only the original
        // keeps its true type from the db mimetype. Tag the resolution so one doc's
        // orig/web/thumb never collide in the download folder.
        const isVid = (p.mimetype || '').startsWith('video');
        const ext = res === 'thumb' ? 'jpg' : res === 'web' ? (isVid ? 'mp4' : 'jpg') : extOf(p.mimetype);
        const raw = p.filename || p.cid.split('/').pop() || 'download';
        const dot = raw.lastIndexOf('.'), base = dot > 0 ? raw.slice(0, dot) : raw;
        const a = document.createElement('a');
        a.href = IPFS + path;
        a.download = ext ? `${base}-${res}.${ext}` : `${base}-${res}`;
        document.body.appendChild(a); a.click(); a.remove();
    }
}

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

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

The toolbar: only mounted while a selection exists, and clustered by purpose so the row of controls reads as groups rather than a wall of buttons — scope (the count, the all matching toggle, the ↔ range toggle), label (the input with +/- — Enter in the input is +, Shift+Enter is -), state (one button per state), download ( then orig=/=web=/=thumb), then clear — 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'}
            onClick=${() => setRangeMode(m => !m)}> range</button>
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- label edit -->
    <div class="complete">
      <input class="batch-label" placeholder="add a label…" aria-label="label for the selection"
             value=${() => labelText()} onInput=${e => { setLabelText(e.target.value); setLabelFocus(true); }}
             onFocus=${() => setLabelFocus(true)}
             onKeyDown=${e => { if(e.key === 'Enter' && e.shiftKey){ e.preventDefault(); removeLabel(); return; }
               sugNav(e, w => w ? setLabelText(replaceSeg(labelText(), w) + '; ') : addLabel()); }}
             onBlur=${() => setTimeout(() => setLabelFocus(false), 150)} />
      <${Show} when=${() => labelFocus()}>
        <${Suggest} text=${labelText} active=${sugActive} onItems=${reportSug}
                    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>
    <!-- state -->
    ${STATES.map(st => html`
      <button class="st" onClick=${() => setStateFor(st)}>${st}</button>`)}
    <span class="tb-sep" aria-hidden="true"></span>
    <!-- download the selection, one file per doc, at a chosen resolution -->
    <span class="dl-grp" aria-hidden="true">&#x2193;</span>
    <button class="dl" onClick=${() => downloadSelection('orig')}>orig</button>
    <button class="dl" onClick=${() => downloadSelection('web')}>web</button>
    <button class="dl" onClick=${() => downloadSelection('thumb')}>thumb</button>
    <span class="tb-sep" aria-hidden="true"></span>
    <button class="clear" onClick=${clearSel}>clear</button>
  </div>
<//>

/* a fixed bottom action bar: appearing on first select must NOT reflow the wall and
   jump the tiles, so it overlays rather than taking flow space. */
.toolbar{ position:fixed; left:0; right:0; bottom:0; z-index:20; display:flex; flex-wrap:wrap;
          gap:6px; align-items:center; background:#11131f; border-top:1px solid #3a3f5a; padding:8px 12px; }
.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 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; }

Driving the wall from the keyboard

Triage is a two-handed rhythm — glance, judge, move on — and reaching for the mouse between every photo breaks it. The wall already answers a click and a hold, but a reviewer running down a folder of photos wants what every file manager gives: a focused tile the eye can follow, moved with the arrow keys. So the grid grows a cursor.

The arrows walk it across the wall — left and right by one tile, down and up by a whole row — and Enter opens the focused doc in the lightbox (Escape leaves it, as it already does).

@testcase
def test_grid_cursor_opens_focused_doc(page):
    """The arrows move a focus cursor across the wall; Enter opens the focused doc,
    and Escape closes it again."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    page.keyboard.press("ArrowRight")               # the first arrow focuses the first tile
    page.keyboard.press("ArrowRight")               # → the second
    page.keyboard.press("Enter")                    # Enter opens the focused doc
    d = dialog(page)
    expect(d.get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    page.keyboard.press("Escape")
    expect(d).to_be_hidden()
    print("  PASS: grid cursor opens focused doc")

And Space toggles the focused tile’s selection — the keyboard twin of a click.

@testcase
def test_grid_cursor_space_toggles_selection(page):
    """Space toggles the focused tile's selection — picked, then unpicked again."""
    open_fixtures(page)                              # 3 fixtures, nothing selected yet
    page.keyboard.press("ArrowRight")               # focus the first tile
    expect(checks(page)).to_have_count(0)
    page.keyboard.press(" ")                        # Space picks it
    expect(checks(page)).to_have_count(1)
    page.keyboard.press(" ")                        # Space again unpicks it
    expect(checks(page)).to_have_count(0)
    print("  PASS: grid cursor space toggles selection")

A click plants the cursor on the tile it touched, so the arrows carry on from there rather than snapping back to the wall’s first tile.

@testcase
def test_cursor_starts_at_last_clicked_tile(page):
    """A click plants the cursor on that tile, so the arrows carry on from it."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    tiles(page).nth(1).click()                       # pick the 2nd tile with the mouse
    page.keyboard.press("ArrowRight")               # the arrows carry on → the 3rd tile
    page.keyboard.press("Enter")                     # open the now-focused tile
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-2")
    print("  PASS: cursor starts at last clicked tile")

Holding Shift while arrowing selects: the selection becomes the contiguous run from the anchor to the cursor, so arrowing away grows it and arrowing back toward the anchor shrinks it again — a folder’s Shift-selection, built without the mouse. Whatever was selected before the Shift-run is kept underneath.

@testcase
def test_grid_cursor_shift_extends_selection(page):
    """Shift+arrow grows the selection to the run from the anchor to the cursor."""
    open_fixtures(page)                              # 3 fixtures in date order
    page.keyboard.press("ArrowRight")               # focus + anchor the first tile
    expect(checks(page)).to_have_count(0)           # a plain move selects nothing
    page.keyboard.press("Shift+ArrowRight")         # reach to the second…
    page.keyboard.press("Shift+ArrowRight")         # …and on to the third
    expect(checks(page)).to_have_count(3)           # the whole run from the anchor is selected
    print("  PASS: grid cursor shift extends selection")

And arrowing back toward the anchor shrinks the run — the selection follows the cursor in both directions, never leaving a stranded tail.

@testcase
def test_grid_cursor_shift_reduces_on_return(page):
    """Arrowing back toward the anchor with Shift shrinks the selection again."""
    open_fixtures(page)                              # 3 fixtures in date order
    page.keyboard.press("ArrowRight")               # cursor + anchor on the first tile
    page.keyboard.press("Shift+ArrowRight")         # grow to the 2nd…
    page.keyboard.press("Shift+ArrowRight")         # …and the 3rd
    expect(checks(page)).to_have_count(3)
    page.keyboard.press("Shift+ArrowLeft")          # back one → the 3rd drops off
    expect(checks(page)).to_have_count(2)
    page.keyboard.press("Shift+ArrowLeft")          # back onto the anchor → only it remains
    expect(checks(page)).to_have_count(1)
    print("  PASS: grid cursor shift reduces on return")

Down and up move by a row, not a tile, so the cursor travels the grid in two dimensions — and because the wall is responsive, a row is however many columns it is showing at that size.

@testcase
def test_grid_cursor_down_steps_a_row(page):
    """ArrowDown moves a whole row — by the wall's live column count — so the cursor
    travels the grid in two dimensions."""
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzcursor-{i}", "date": f"2021-0{i + 1}-15T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzcursor-t-{i}",
             "labels": "zzcursor", "state": "todo"} for i in range(6)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        open_app(page)
        search_for(page, "zzcursor")                # default state chip = todo; all six are todo
        expect(tiles(page)).to_have_count(6)
        cols_of = "el => getComputedStyle(el).gridTemplateColumns.split(' ').length"
        cols = grid(page).evaluate(cols_of)
        if cols >= 6:                               # all on one row — narrow for a second row
            page.set_viewport_size({"width": 380, "height": 700})
            cols = grid(page).evaluate(cols_of)
        page.keyboard.press("ArrowRight")           # focus the first tile (index 0)
        page.keyboard.press("ArrowDown")            # → one row down, i.e. index `cols`
        page.keyboard.press("Enter")
        expect(dialog(page).get_by_role("img")).to_have_attribute("src", f"https://ipfs.konubinix.eu/p/zzcursor-t-{cols}")
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: grid cursor down steps a row")

And Ctrl+A (⌘A on a Mac) grabs the whole shown wall at once — the keyboard twin of the select-all toggle — so a reviewer can scoop up everything for a bulk edit without leaving the home row.

@testcase
def test_ctrl_a_selects_whole_wall(page):
    """Ctrl+A selects every shown doc — the keyboard twin of select-all."""
    open_fixtures(page)                              # 3 fixtures, nothing selected yet
    expect(checks(page)).to_have_count(0)
    page.keyboard.press("Control+a")
    expect(checks(page)).to_have_count(len(FIXTURES))   # the whole wall is selected
    print("  PASS: ctrl+a selects whole wall")

The cursor is a signal holding the focused doc’s cid, and moving it is index arithmetic over the shown wall: ±1 steps across, ±(a row) down and up, clamped to the ends. A row is read straight off the grid’s live column count, and each move ends by bringing the focused tile fully into view, so the eye follows the cursor past the fold. A plain move drops the anchor where it lands and ends any Shift-run; the first Shift move snapshots the current selection, and every Shift move then sets the selection to that snapshot plus the run from the anchor to the cursor — recomputed each step from the fixed anchor, so reversing direction shrinks the run rather than stranding it.

// the cursor persists too, so a refresh lands back on the focused tile, not the first one
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;
// a row is however many columns the responsive grid is showing right now
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){
        if(!extending){ extending = true; rangeBase = new Set(selected()); }   // snapshot before the run
        const a = list.findIndex(p => p.cid === anchor()), [lo, hi] = a < ni ? [a, ni] : [ni, a];
        setSelected(new Set([...rangeBase, ...list.slice(lo, hi + 1).map(p => p.cid)]));
    } else { extending = false; setAnchor(list[ni].cid); }   // a plain move re-anchors and ends the run
    requestAnimationFrame(scrollCursorIntoView);
};

Bringing it fully into view is more than nudging it past the edge, because the selection toolbar is pinned over the foot of the wall and floats above it. That pin causes two separate troubles, and a Shift-run driving the cursor downward — toolbar up, cursor heading for the bottom — walks straight into both. A run dragged to the foot of a tall wall must still leave its last tile whole, and above the bar.

@testcase
def test_grid_cursor_reveals_tile_above_toolbar(page):
    """A Shift-run to the foot of a tall wall scrolls the focused tile whole into
    view — clear of the selection toolbar pinned over the bottom of the screen."""
    n = 24
    docs = [{"cid": f"https://ipfs.konubinix.eu/p/zzreveal-{i}", "date": f"2021-01-{i + 1:02d}T12:00:00Z",
             "mimetype": "image/jpeg", "thumbnailCid": f"https://ipfs.konubinix.eu/p/zzreveal-t-{i}",
             "labels": "zzreveal", "state": "todo"} for i in range(n)]
    for d in docs: gql(DELETE, {"cid": d["cid"]}); gql(CREATE, {"p": d})
    try:
        page.set_viewport_size({"width": 420, "height": 600})    # narrow + short: the wall overflows
        open_app(page)
        search_for(page, "zzreveal")
        expect(tiles(page)).to_have_count(n)
        page.keyboard.press("ArrowRight")                        # focus + anchor the first tile
        for _ in range(n): page.keyboard.press("Shift+ArrowDown") # extend a run down to the last row
        last = tiles(page).nth(n - 1).bounding_box()
        bar = toolbar(page).bounding_box()                       # the run raised the toolbar
        assert last["y"] >= 0, f"tile head scrolled above the fold: {last}"
        assert last["y"] + last["height"] <= bar["y"] + 1, f"tile foot behind the toolbar: {last} vs {bar}"
    finally:
        for d in docs: gql(DELETE, {"cid": d["cid"]})
    print("  PASS: grid cursor reveals tile above toolbar")

The first trouble is position: a tile scrolled flush to the bottom sits half-hidden behind the bar. So the reveal measures the focused tile against a viewport whose bottom is lifted by the toolbar’s own height — zero until a selection summons it — and scrolls only as far as it takes to clear whichever edge the tile overruns: up when its head is above the fold, down when its foot dips under the bar.

const scrollCursorIntoView = () => {
    const tile = gridEl?.querySelector('[data-cursor="1"]'); if(!tile) return;
    const r = tile.getBoundingClientRect();
    const bar = document.querySelector('.toolbar');
    const floor = innerHeight - (bar ? bar.getBoundingClientRect().height : 0);
    if(r.top < 0) scrollBy(0, r.top);                          // head above the fold → pull it down
    else if(r.bottom > floor) scrollBy(0, r.bottom - floor);   // foot under the bar → push it up
};

The second is room: the page ends where the last row does, so the bottom tiles have nowhere to scroll to — no nudge lifts them past a bar the document is too short to outrun. So while a selection holds the toolbar up, we reserve a strip beneath the wall as tall as the bar, giving those rows somewhere to rise into. The strip is measured a frame after the toolbar mounts, since its wrapped height isn’t known until it has laid out.

createEffect(() => {
    const shown = selCount() > 0;   // tracked synchronously; the toolbar mounts on this turn
    requestAnimationFrame(() => {
        const bar = shown && document.querySelector('.toolbar');
        document.body.style.paddingBottom = bar ? bar.getBoundingClientRect().height + 'px' : '';
    });
});

The keys live on a window listener that stands aside whenever another surface owns them — the lightbox or the frame while either is up, or any text field while it has focus — so the cursor only ever drives the bare wall. Space toggles the focused tile’s selection and Enter opens it in the lightbox; the Shift flag is what tells an arrow to reach rather than step; and Ctrl=/=⌘+A selects the whole shown wall.

onMount(() => {
    const onKey = e => {
        if(opened() || frame() || /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;
        const step = d => { e.preventDefault(); moveCursor(d, e.shiftKey); };
        if(e.key === 'ArrowRight') step(1);
        else if(e.key === 'ArrowLeft') step(-1);
        else if(e.key === 'ArrowDown') step(gridCols());
        else if(e.key === 'ArrowUp') step(-gridCols());
        else if(e.key === ' ' && cursor()){ e.preventDefault(); toggle(cursor()); setAnchor(cursor()); }
        else if(e.key === 'Enter' && cursor()){ e.preventDefault(); openPhoto(items().find(p => p.cid === cursor())); }
        else if((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')){ e.preventDefault(); setSelected(new Set(shownCids())); }
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

The focus ring is amber, set apart from selection’s blue outline so a tile that is both focused and selected shows both at once.

/* the keyboard cursor: an amber ring distinct from selection's blue inset outline */
.tile[data-cursor='1']{ box-shadow:0 0 0 3px #f9a826, 0 0 10px #f9a826aa; }

A triaging session can run long, building up a selection and a cursor position that would be tedious to rebuild — and a refresh is easy to hit by accident. So the cursor and the run you’ve picked persist: each is written to localStorage as it changes (the same way the search box and the state chip already do) and read back on load, so an accidental reload comes back to the tile you were on with the selection intact.

@testcase
def test_cursor_and_selection_survive_reload(page):
    """The picked run and the keyboard cursor outlast a reload, like the search does."""
    open_fixtures(page)                              # 3 fixtures, thumbs -0/-1/-2 by date
    tiles(page).nth(0).click()                       # pick tile 0 — the click plants the cursor there
    page.keyboard.press("Shift+ArrowRight")         # extend the run to tile 1; the cursor lands on it
    expect(checks(page)).to_have_count(2)
    page.reload(wait_until="commit")
    page.wait_for_selector("body[data-app-ready='1']", timeout=8000)
    expect(tiles(page)).to_have_count(3)            # the saved search brings the same wall back
    expect(checks(page)).to_have_count(2)           # the picked run survived
    page.keyboard.press("Enter")                     # the cursor survived on tile 1 → opens it
    expect(dialog(page).get_by_role("img")).to_have_attribute("src", "https://ipfs.konubinix.eu/p/zzbatchfix-thumb-1")
    print("  PASS: cursor and selection survive reload")

Jumping to the label box

Selecting a run of photos and then dragging the mouse all the way to the label field breaks the keyboard flow the cursor just built. So l — for label — jumps straight to the add-label box: the lightbox’s when a doc is open, or the selection toolbar’s when a batch is waiting on the wall. Hands stay on the keys — select with Shift+→, press l, type the word; or press . to drop in the label you used last and just hit Enter.

With a doc open, l focuses its add-label box.

@testcase
def test_label_shortcut_focuses_lightbox_box(page):
    """In the lightbox, pressing l jumps focus to the add-label box, ready to type."""
    open_fixtures(page)
    open_doc(page, 0)
    box = dialog(page).get_by_placeholder("add a label…")
    expect(box).not_to_be_focused()
    page.keyboard.press("l")
    expect(box).to_be_focused()
    expect(box).to_have_value("")                # l opened the box; it didn't type into it
    print("  PASS: label shortcut focuses lightbox box")

On the wall, with a selection up, l focuses the toolbar’s label box instead.

@testcase
def test_label_shortcut_focuses_batch_box(page):
    """On the wall, with a selection up, l jumps focus to the toolbar's label box."""
    open_fixtures(page)
    tiles(page).nth(0).click()                   # select one → the toolbar appears
    box = toolbar(page).get_by_placeholder("add a label…")
    page.keyboard.press("l")
    expect(box).to_be_focused()
    expect(box).to_have_value("")
    print("  PASS: label shortcut focuses batch box")

. — repeat — goes one further: it fills that same box with the label applied last and focuses it, so a run of photos can take the same tag without retyping. With a doc open, . refills the lightbox’s box.

@testcase
def test_label_repeat_fills_lightbox_box(page):
    """In the lightbox, '.' refills the add-label box with the label applied last."""
    open_fixtures(page)
    open_doc(page, 0)
    d = dialog(page)
    box = d.get_by_placeholder("add a label…")
    box.click(); box.fill("zzrep"); box.press("Enter")          # apply → remembered as last
    expect(d.get_by_role("button", name="zzrep", exact=True)).to_be_visible()
    box.blur()                                                  # leave the field so '.' is a shortcut
    page.keyboard.press(".")
    expect(box).to_be_focused()
    expect(box).to_have_value("zzrep")                          # refilled, ready to commit again
    print("  PASS: label repeat fills lightbox box")

And on the wall, with a selection up, . refills the toolbar’s box the same way.

@testcase
def test_label_repeat_fills_batch_box(page):
    """On the wall, '.' refills the toolbar's label box with the label applied last."""
    open_fixtures(page)
    tiles(page).nth(0).click()
    box = toolbar(page).get_by_placeholder("add a label…")
    box.click(); box.fill("zzrep"); box.press("Enter")          # apply to one → remembered, selection clears
    tiles(page).nth(1).click()                                  # select another → toolbar back
    page.keyboard.press(".")
    box2 = toolbar(page).get_by_placeholder("add a label…")
    expect(box2).to_be_focused()
    expect(box2).to_have_value("zzrep")
    print("  PASS: label repeat fills batch box")

A small window listener routes l and . to whichever box is live — the open lightbox’s, else the selection toolbar’s — and stands aside while a field already has focus or the frame is up. l just focuses; . first drops in lastLabel, the word any earlier apply (here or in a batch) left behind. The preventDefault keeps that keystroke from landing in the box it just opened.

onMount(() => {
    const onKey = e => {
        if((e.key !== 'l' && e.key !== '.') || frame()) return;    // the frame has its own bar
        if(/^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return;    // a field already owns the key
        const box = opened() ? document.querySelector('.lb .batch-label')
                  : selCount() > 0 ? document.querySelector('.toolbar .batch-label') : null;
        if(!box) return;
        e.preventDefault();
        if(e.key === '.' && lastLabel())                           // '.' re-drops the label applied last
            (opened() ? setLbText : setLabelText)(lastLabel());
        box.focus();
    };
    window.addEventListener('keydown', onKey);
    onCleanup(() => window.removeEventListener('keydown', onKey));
});

Installable, fullscreen (PWA)

On a phone the wall wants the whole screen. A web-app manifest (display:fullscreen, an SVG icon, the app’s dark theme_color) plus a tiny service worker make it installable to the home screen and launchable chrome-free. The worker is intentionally minimal — it claims the page and lets fetches hit the network untouched (no offline caching of a live archive); its job is just to satisfy installability.

The test can’t drive a real install, so it asserts the observable scaffolding: the manifest is linked and declares fullscreen, and the service worker reaches ready.

@testcase
def test_pwa_installable(page):
    """The app ships a fullscreen manifest and a registered service worker."""
    open_app(page)
    assert page.locator("link[rel='manifest']").get_attribute("href"), "no manifest linked"
    man = page.evaluate("() => fetch('manifest.json').then(r => r.json())")
    assert man["display"] == "fullscreen", f"display is {man.get('display')!r}"
    assert man["icons"], "manifest has no icons"
    ready = page.evaluate("""() => Promise.race([
        navigator.serviceWorker.ready.then(r => !!r.active),
        new Promise(res => setTimeout(() => res(false), 6000))])""")
    assert ready, "service worker did not become ready"
    print("  PASS: pwa installable")

{
  "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>

self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
self.addEventListener('fetch', () => {});   // pass-through; installability only

Notes linking here