Konubinix' opinionated web of thoughts

Slides in Vue3

Fleeting

Why this note

Its real job is to run all day as a photo frame on the meuble — a self-advancing show of the archive, glanced at, occasionally jogged by hand. The Alpine slider (/braindump/posts/wip_ipfsdocs_slider_alpinejs/) swiped through one photo at a time, reading PostgREST and the key-value tag model. This is a rewrite on the frise stack — PostGraphile + free-text labels — with a fourth render technology to round out the comparison: Alpine (slider), Preact (frise), Solid (thumbs), and here Vue 3. No build: an import map pulls Vue’s full esm-browser build (runtime template compiler included) from esm.sh, so templates are plain strings — Composition API, ref=/=computed, no .vue files, no bundler.

Same discipline as the frise: feature = chapter (prose, Playwright test, code, CSS), test-first, short blocks, rewrite over patch.

It boots

Prove the no-build Vue stack: the import map resolves Vue’s esm-browser build (the one that ships the template compiler, so string templates work without a build), createApp(...).mount runs, and data-app-ready is raised for the tests.

@testcase
def test_boots(page):
    """The Vue app loads from the import map and mounts the stage."""
    open_app(page)
    page.wait_for_selector("[data-stage]")
    print("  PASS: boots")

import { createApp, ref, computed, onMounted, watchEffect } from 'vue';

Like the Solid thumbs, the data layer is plain fetch GraphQL (no cache lib) — Vue’s reactivity (ref=/=computed) holds the state.

const GRAPHQL = '/graphql', IPFS = '';
async function gql(query, variables){
    const r = await fetch(GRAPHQL, { method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, variables }) });
    const { data, errors } = await r.json();
    if(errors) throw new Error(errors[0].message);
    return data;
}

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

One photo at a time

The slider’s essence: a full-bleed photo with ‹=/=› to move through the set, a position readout, and the photo’s labels underneath. photovideosSearch (the sampled set, same as the frise) fills an array; a ref index points at the current one, a computed derives it. prev=/=next clamp at the ends.

The test opens the app, sees a photo and a 1 / N position, presses next, and checks the position advanced.

@testcase
def test_swipe_advances(page):
    """next advances to the following photo (auto-advance off via a long dwell)."""
    open_app(page, "?ms=900000")
    page.wait_for_selector(".slide")          # img or video — a shuffled first slide may be a video
    assert page.locator("[data-pos]").inner_text().startswith("1 /"), "should open on the first photo"
    first = page.locator(".slide").get_attribute("data-photo-date")
    page.click("[data-next]")
    page.wait_for_function(
        "(d) => { const e = document.querySelector('.slide'); return e && e.getAttribute('data-photo-date') !== d; }",
        arg=first)
    assert page.locator("[data-pos]").inner_text().startswith("2 /"), "next didn't advance the position"
    print("  PASS: swipe advances")

const MIN_DATE = '2007-01-01';
const SEARCH = `query($search:String!,$since:Datetime!,$until:Datetime!){
  photovideosSearch(search:$search, since:$since, until:$until, first:400){
    nodes{ cid date thumbnailCid webCid mimetype labels }
  }
}`;
const todayISO = () => new Date(Date.now()).toISOString().slice(0, 10);
const shuffle = a => {   // Fisher–Yates, in place; a frame shouldn't run chronologically
    for(let k = a.length - 1; k > 0; k--){
        const j = Math.floor(Math.random() * (k + 1));
        [a[k], a[j]] = [a[j], a[k]];
    }
    return a;
};
// best effort: keep the meuble's screen awake while the frame runs
async function keepAwake(){
    try { if('wakeLock' in navigator) await navigator.wakeLock.request('screen'); } catch(_) {}
}

// the slide dwell time, ms (overridable as ?ms= so it can run slow on the meuble
// or fast in a test)
const ADVANCE_MS = Number(new URLSearchParams(location.search).get('ms')) || 8000;
const App = {
    setup(){
        const photos = ref([]);
        const i = ref(0);
        const playing = ref(true);
        const current = computed(() => photos.value[i.value] || null);
        const isVideo = computed(() => (current.value?.mimetype || '').startsWith('video'));
        async function load(){
            const d = await gql(SEARCH, { search: '', since: MIN_DATE, until: todayISO() });
            photos.value = shuffle((d?.photovideosSearch?.nodes ?? []).filter(n => n.thumbnailCid));
            i.value = 0;
        }
        const go = step => { const n = photos.value.length; if(n) i.value = (i.value + step + n) % n; };
        // the whole point: a photo frame that advances on its own and loops forever.
        // re-arms when playing toggles or the photos arrive; clears the old timer first.
        watchEffect(onCleanup => {
            if(!playing.value || !photos.value.length) return;
            const t = setInterval(() => go(1), ADVANCE_MS);
            onCleanup(() => clearInterval(t));
        });
        onMounted(() => { load(); keepAwake(); });
        return { photos, i, current, isVideo, playing, next: () => go(1), prev: () => go(-1), IPFS };
    },
    template: `
      <div v-if="current" class="stage" data-stage>
        <button class="edge prev" data-prev @click="prev">‹</button>
        <video v-if="isVideo" class="slide media" controls playsinline
               :data-photo-date="current.date" :poster="IPFS + current.thumbnailCid"
               :src="IPFS + (current.webCid || current.thumbnailCid)"></video>
        <img v-else class="slide media" :data-photo-date="current.date"
             :src="IPFS + (current.webCid || current.thumbnailCid)" />
        <button class="edge next" data-next @click="next">›</button>
        <div class="bar">
          <button class="play" data-playpause @click="playing = !playing">{{ playing ? '⏸' : '▶' }}</button>
          <span data-pos>{{ i + 1 }} / {{ photos.length }}</span>
          <span class="labels" data-labels>{{ current.labels || '—' }}</span>
        </div>
      </div>
    `,
};

createApp(App).mount('#app');
document.body.setAttribute('data-app-ready', '1');

.stage{ position:relative; display:flex; align-items:center; justify-content:center;
        height:calc(100vh - 80px); }
.media{ max-width:96vw; max-height:calc(100vh - 110px); object-fit:contain; border-radius:6px; }
.edge{ position:absolute; top:0; bottom:0; width:18%; border:0; cursor:pointer;
       background:transparent; color:#fff; font-size:48px; opacity:.4; }
.edge:hover{ opacity:.9; background:#0003; }
.edge.prev{ left:0; } .edge.next{ right:0; }
.edge:disabled{ opacity:.1; cursor:default; }
.bar{ position:fixed; bottom:0; left:0; right:0; display:flex; gap:16px; align-items:center;
      padding:8px 12px; background:#2b6cb0; font-size:14px; }
.play{ background:none; border:0; color:#fff; font-size:16px; cursor:pointer; padding:0; }
.labels{ color:#dfe7ff; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

Runs itself — a photo frame

The real job: sit on the meuble and cycle through the archive all day, untouched. So it advances on its own every ADVANCE_MS (8s by default, ?ms= to override) and loops at the end — a watchEffect owns the timer, re-arming when the photos arrive and tearing it down when paused. A ⏸/▶ toggle stops and resumes it; ‹=/=› still jog by hand. (?ms= lets the test run the clock fast.)

Two tests: it advances with no interaction, and pausing freezes it.

@testcase
def test_auto_advances(page):
    """Left alone, the frame moves to the next photo on its own."""
    open_app(page, "?ms=500")
    page.wait_for_selector(".slide")
    first = page.locator(".slide").get_attribute("data-photo-date")
    page.wait_for_function(   # no clicks — the timer does it
        "(d) => { const e = document.querySelector('.slide'); return e && e.getAttribute('data-photo-date') !== d; }",
        arg=first, timeout=4000)
    print("  PASS: auto advances")

@testcase
def test_pause_freezes(page):
    """Pausing stops the auto-advance until resumed."""
    open_app(page, "?ms=500")
    page.wait_for_selector(".slide")
    page.click("[data-playpause]")
    page.wait_for_timeout(300)                       # let Vue tear the timer down
    frozen = page.locator(".slide").get_attribute("data-photo-date")
    page.wait_for_timeout(1500)                      # 3× the dwell — must not move
    assert page.locator(".slide").get_attribute("data-photo-date") == frozen, \
        "paused frame still advanced"
    print("  PASS: pause freezes")

Shuffled, not chronological

A frame that marched through the archive in date order would be dull and predictable — you’d always know what comes next. So the loaded set is shuffled (Fisher–Yates) before it plays, mixing eras together. (keepAwake rides along here too — the meuble’s screen mustn’t sleep — best-effort via the Wake Lock API, so it’s not unit-tested.)

The test steps through a dozen slides and checks their dates are not in chronological order — with a shuffled set that’s essentially certain, with a sorted one impossible.

@testcase
def test_shuffled_order(page):
    """The frame plays in shuffled order, not chronologically."""
    open_app(page, "?ms=900000")        # auto-advance off; step by hand
    page.wait_for_selector(".slide")
    seen = []
    for _ in range(13):
        d = page.locator(".slide").get_attribute("data-photo-date")
        seen.append(d)
        page.click("[data-next]")
        page.wait_for_function(
            "(d) => { const e = document.querySelector('.slide'); return e && e.getAttribute('data-photo-date') !== d; }",
            arg=d, timeout=3000)
    assert seen != sorted(seen), f"order is chronological, not shuffled: {seen[:4]}…"
    print("  PASS: shuffled order")