Slides in Vue3
FleetingWhy 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")