File size: 20,507 Bytes
e2c452c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
// Sightfern: camera, photo and manual spotting, the field album (IndexedDB, this device only), badges, streak,
// quest and sighting cards. Rules: core.js. Recogniser: detect.js (on-device). (c) 2026 CyberMax. All rights reserved.
import { ALBUMS, SPECIES, LABEL, COLOURS, SLOTS, RARITY, TIMES, coatColour, sighting, coarsen, progress, slotsOf, streak, badges, quest, questDone, localDay } from './core.js';
import { loadDetector, MIN_SCORE } from './detect.js';
import { count, store, toast, share, download, pwa, themeToggle } from './cmx.js';
import * as pass from './funpass.js';

const GAME = 'sightfern', PATH = '/games/sightfern/';
const $ = (id) => document.getElementById(id);
pass.init({ game: GAME, name: 'Sightfern', perks: ['Farm and Wild & zoo albums (horses, sheep, cows, bears, elephants, zebras, giraffes)', 'Gold, Film and Holo card frames', 'Extras in every CyberMax game'] });
pwa(GAME, $('install'));
themeToggle($('theme'));

// ------------------------------------------------------------------ storage: IndexedDB on this device only
const DB = new Promise((ok, bad) => {
  const r = indexedDB.open('sightfern', 1);
  r.onupgradeneeded = () => { r.result.createObjectStore('sightings', { keyPath: 'id' }); r.result.createObjectStore('photos'); };
  r.onsuccess = () => ok(r.result); r.onerror = () => bad(r.error);
});
const tx = async (st, mode, fn) => { const db = await DB; return new Promise((ok, bad) => { const t = db.transaction(st, mode); const out = fn(t); t.oncomplete = () => ok(out && 'result' in out ? out.result : out); t.onerror = () => bad(t.error); }); };
const allSightings = () => tx('sightings', 'readonly', (t) => t.objectStore('sightings').getAll());
const putSighting = (s, blob) => tx(['sightings', 'photos'], 'readwrite', (t) => { t.objectStore('sightings').put(s); if (blob) t.objectStore('photos').put(blob, s.id); });
const getPhoto = (id) => tx('photos', 'readonly', (t) => t.objectStore('photos').get(id));
const wipeAll = () => tx(['sightings', 'photos'], 'readwrite', (t) => { t.objectStore('sightings').clear(); t.objectStore('photos').clear(); });

let S = [];
let tab = 'cats';
let frame = store('sightfern:frame') || 'field';
const FRAMES = { field: { name: 'Field journal', pro: false }, plain: { name: 'Plain', pro: false }, gold: { name: 'Gold', pro: true }, film: { name: 'Film', pro: true }, holo: { name: 'Holo', pro: true } };
const today = () => localDay(new Date());

// ------------------------------------------------------------------ header: quest, streak, totals
function header() {
  const q = quest(today());
  $('quest').textContent = q.text;
  $('quest-ok').hidden = !questDone(q, S, today());
  $('streak').textContent = `🔥 ${streak(S, today())}`;
  $('total').textContent = `📒 ${S.length}`;
  const free = Object.keys(ALBUMS).filter((a) => !ALBUMS[a].pro || pass.isPro());
  const f = free.reduce((n, a) => n + progress(S, a).found, 0), t = free.reduce((n, a) => n + progress(S, a).total, 0);
  $('found').textContent = `${f} / ${t} album slots`;
}

// ------------------------------------------------------------------ album view
const urls = new Map();
async function thumb(id) {
  if (urls.has(id)) return urls.get(id);
  const b = await getPhoto(id);
  const u = b ? URL.createObjectURL(b) : null;
  urls.set(id, u);
  return u;
}
async function album() {
  $('tabs').innerHTML = Object.entries(ALBUMS).map(([k, a]) => `<button role="tab" type="button" class="tab${k === tab ? ' on' : ''}" aria-selected="${k === tab}" data-tab="${k}">${a.icon} ${a.name}${a.pro && !pass.isPro() ? ' 🔒' : ''} <small>${progress(S, k).found}/${progress(S, k).total}</small></button>`).join('');
  const locked = ALBUMS[tab].pro && !pass.isPro();
  const cells = [];
  for (const key of slotsOf(tab)) {
    const [sp, c] = key.split(':');
    const mine = S.filter((s) => s.species === sp && s.colour === c).sort((a, b) => b.at.localeCompare(a.at));
    const r = SLOTS[sp][c];
    const sw = COLOURS[c].hex;
    if (mine.length && !locked) {
      const u = await thumb(mine[0].id);
      cells.push(`<button class="slot got r${r}" type="button" data-id="${mine[0].id}" title="${COLOURS[c].name} ${LABEL[sp]}: ${mine.length} sighting(s)">${u ? `<img src="${u}" alt="">` : `<i style="background:${sw}"></i>`}<span>${COLOURS[c].name} ${LABEL[sp]}</span><em>${RARITY[r]}${mine.length > 1 ? ` · ×${mine.length}` : ''}</em></button>`);
    } else cells.push(`<div class="slot r${r}"><i style="background:${sw}"></i><span>${COLOURS[c].name} ${LABEL[sp]}</span><em>${locked ? '🔒 Fun Pass' : RARITY[r]}</em></div>`);
  }
  $('slots').innerHTML = cells.join('');
  $('badges').innerHTML = badges(S, today()).map((b) => `<span class="bdg${b.got ? ' got' : ''}" title="${b.name}">${b.icon}<small>${b.name}</small></span>`).join('');
  $('frames').innerHTML = Object.entries(FRAMES).map(([k, f]) => `<button class="chip${k === frame ? ' on' : ''}" type="button" data-frame="${k}" aria-pressed="${k === frame}">${f.name}${f.pro && !pass.isPro() ? ' 🔒' : ''}</button>`).join('');
}
$('tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-tab]'); if (!b) return; tab = b.dataset.tab; if (ALBUMS[tab].pro && !pass.isPro()) pass.open('album'); album(); });
$('slots').addEventListener('click', async (e) => { const b = e.target.closest('[data-id]'); if (!b) return; const s = S.find((x) => x.id === b.dataset.id); if (s) openCard(s, false); });
$('frames').addEventListener('click', (e) => { const b = e.target.closest('[data-frame]'); if (!b) return; if (FRAMES[b.dataset.frame].pro && !pass.isPro()) { pass.open('frame'); return; } frame = b.dataset.frame; store('sightfern:frame', frame); album(); });
async function refresh() { S = await allSightings(); header(); await album(); }

// ------------------------------------------------------------------ the sighting card (canvas)
const RCOL = ['#6b7a70', '#2f7d4f', '#2f66c9', '#8a3fd1', '#d79a10'];
async function drawCard(s) {
  const c = $('card'), x = c.getContext('2d'), W = 1080, H = 1350;
  const bg = { field: '#f3ead6', plain: '#ffffff', gold: '#2a2214', film: '#121212', holo: '#1b1730' }[frame] || '#f3ead6';
  const ink = ['gold', 'film', 'holo'].includes(frame) ? '#f6f1e4' : '#1d2a22';
  x.fillStyle = bg; x.fillRect(0, 0, W, H);
  if (frame === 'holo') { const g = x.createLinearGradient(0, 0, W, H); ['#ff6ad5', '#c774e8', '#ad8cff', '#8795e8', '#94d0ff'].forEach((col, i) => g.addColorStop(i / 4, col)); x.globalAlpha = 0.35; x.fillStyle = g; x.fillRect(0, 0, W, H); x.globalAlpha = 1; }
  if (frame === 'gold') { x.strokeStyle = '#e2b55a'; x.lineWidth = 18; x.strokeRect(24, 24, W - 48, H - 48); }
  if (frame === 'film') { x.fillStyle = '#2b2b2b'; for (let i = 0; i < 14; i++) { x.fillRect(18, 40 + i * 92, 34, 50); x.fillRect(W - 52, 40 + i * 92, 34, 50); } }
  const blob = await getPhoto(s.id);
  const px = 90, py = 150, pw = W - 180, ph = 760;
  x.fillStyle = '#d9d1bf'; x.fillRect(px, py, pw, ph);
  if (blob) {
    const im = await createImageBitmap(blob);
    const sc = Math.max(pw / im.width, ph / im.height), dw = im.width * sc, dh = im.height * sc;
    x.save(); x.beginPath(); x.rect(px, py, pw, ph); x.clip(); x.drawImage(im, px + (pw - dw) / 2, py + (ph - dh) / 2, dw, dh); x.restore();
  } else { x.font = '220px system-ui, "Apple Color Emoji", "Segoe UI Emoji"'; x.textAlign = 'center'; x.fillText(ALBUMS[s.album].icon, W / 2, py + ph / 2 + 80); x.textAlign = 'left'; }
  x.fillStyle = ink; x.font = '900 46px system-ui, sans-serif'; x.fillText('SIGHTFERN', 90, 108);
  x.font = '600 32px system-ui, sans-serif'; x.globalAlpha = 0.7; x.textAlign = 'right'; x.fillText(`#${S.findIndex((y) => y.id === s.id) + 1 || S.length} in my journal`, W - 90, 108); x.textAlign = 'left'; x.globalAlpha = 1;
  x.font = '900 82px system-ui, sans-serif'; x.fillText(`${COLOURS[s.colour].name} ${LABEL[s.species]}`, 90, 1020);
  x.fillStyle = RCOL[s.rarity]; x.beginPath(); x.roundRect(90, 1050, 330, 74, 37); x.fill();
  x.fillStyle = '#fff'; x.font = '800 40px system-ui, sans-serif'; x.fillText(RARITY[s.rarity], 120, 1100);
  const t = TIMES.find((y) => y.id === s.time);
  x.fillStyle = ink; x.font = '600 38px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif';
  x.fillText(`${t.icon} ${t.name} · ${new Date(s.at).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}${s.manual ? ' · ✍️ self-tagged' : ''}`, 90, 1190);
  x.globalAlpha = 0.65; x.font = '600 30px system-ui, sans-serif'; x.fillText('Spot your own: cybermaxtools.com/games/sightfern', 90, 1280); x.globalAlpha = 1;
}
let shown = null;
async function openCard(s, fresh) {
  shown = s;
  $('sheet-title').textContent = fresh ? (s.rarity >= 2 ? `${RARITY[s.rarity]} find! ✨` : 'New sighting!') : `${COLOURS[s.colour].name} ${LABEL[s.species]}`;
  await drawCard(s);
  const where = s.where ? ` · noted near ${s.where.lat}, ${s.where.lon} (on this phone only)` : '';
  $('sheet-info').textContent = `${s.manual ? 'Tagged by you' : `Recognised on this device (${Math.round((s.score || 0) * 100)}% sure)`}${where}.`;
  $('sheet').showModal();
  if (fresh && !pass.isPro() && S.length === 3) pass.afterWin($('sheet-after'), 'win');
}
$('sheet-close').onclick = () => $('sheet').close();
const cardBlob = () => new Promise((ok) => $('card').toBlob(ok, 'image/png'));
$('share').onclick = async () => { const s = shown; share(GAME, `I spotted a ${COLOURS[s.colour].name.toLowerCase()} ${LABEL[s.species].toLowerCase()} (${RARITY[s.rarity]}) for my Sightfern field album 📒`, PATH, await cardBlob(), `sightfern-${s.species}.png`); };
$('dl').onclick = async () => { download(await cardBlob(), `sightfern-${shown.species}-${shown.day}.png`); count(GAME, 'share', 'png'); };

// ------------------------------------------------------------------ saving a sighting
async function where() {
  if (!store('sightfern:loc') || !navigator.geolocation) return null;
  return new Promise((ok) => navigator.geolocation.getCurrentPosition((p) => ok(coarsen(p.coords.latitude, p.coords.longitude)), () => ok(null), { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 }));
}
/** Crop around the box (15% margin), at most 720 px, as a JPEG blob. */
function cropBlob(src, box) {
  const m = 0.15, x = Math.max(0, box.x - box.w * m), y = Math.max(0, box.y - box.h * m);
  const w = Math.min(src.width - x, box.w * (1 + 2 * m)), h = Math.min(src.height - y, box.h * (1 + 2 * m));
  const s = Math.min(1, 720 / Math.max(w, h));
  const c = document.createElement('canvas'); c.width = Math.round(w * s); c.height = Math.round(h * s);
  c.getContext('2d').drawImage(src, x, y, w, h, 0, 0, c.width, c.height);
  return new Promise((ok) => c.toBlob(ok, 'image/jpeg', 0.85));
}
async function save(rec, blob) {
  const loc = await where();
  if (loc) rec.where = loc;
  const before = new Set(S.map((s) => `${s.species}:${s.colour}`));
  await putSighting(rec, blob);
  await refresh();
  count(GAME, 'win', `${rec.species}-${RARITY[rec.rarity].toLowerCase()}${rec.manual ? '-manual' : ''}`);
  if (!before.has(`${rec.species}:${rec.colour}`)) toast(`New album slot: ${COLOURS[rec.colour].name} ${LABEL[rec.species]}!`, 3000);
  const q = quest(today());
  if (questDone(q, S, today()) && !questDone(q, S.filter((s) => s.id !== rec.id), today())) toast(`Quest done: ${q.text} ✅`, 3000);
  await openCard(rec, true);
}
let det = null, perf = [];
async function detector() {
  if (det) return det;
  $('cam-note').textContent = 'Loading the on-device recogniser (once, about 14 MB)…';
  try { det = await loadDetector(); $('cam-note').textContent = 'Recogniser ready. It runs on this device; nothing is uploaded.'; }
  catch (e) { $('cam-note').textContent = 'This browser could not run the recogniser. Use “Tag it myself” to add sightings by hand.'; throw e; }
  return det;
}
/** Find the best animal in a canvas; returns { item, colour } or null. Detection runs on a 512 px copy. */
async function analyse(src) {
  const d = await detector();
  const s = Math.min(1, 512 / Math.max(src.width, src.height));
  const small = document.createElement('canvas'); small.width = Math.round(src.width * s); small.height = Math.round(src.height * s);
  small.getContext('2d').drawImage(src, 0, 0, small.width, small.height);
  const r = d.detect(small);
  perf.push(r.ms);
  const it = r.items.find((i) => i.score >= MIN_SCORE);
  if (!it) return null;
  const box = { x: it.box.x / s, y: it.box.y / s, w: it.box.w / s, h: it.box.h / s };
  const cw = Math.max(1, Math.round(box.w)), ch = Math.max(1, Math.round(box.h));
  const cc = document.createElement('canvas'); cc.width = Math.min(cw, 256); cc.height = Math.round(ch * (cc.width / cw)) || 1;
  cc.getContext('2d').drawImage(src, box.x, box.y, box.w, box.h, 0, 0, cc.width, cc.height);
  const px = cc.getContext('2d').getImageData(0, 0, cc.width, cc.height).data;
  return { item: { ...it, box }, colour: coatColour(px, cc.width, cc.height, it.species) };
}
async function spotFrom(src, how) {
  let a;
  try { a = await analyse(src); } catch { openTagger(src); return; }
  if (!a) { toast('No cat, dog or bird found in that shot. Get a bit closer, or tag it yourself.', 3800); lastShot = src; return; }
  count(GAME, 'play', how);
  const rec = sighting({ species: a.item.species, colour: a.colour, score: a.item.score });
  await save(rec, await cropBlob(src, a.item.box));
}

// ------------------------------------------------------------------ camera
let stream = null, looking = false, lastShot = null, lastSeen = null;
const video = $('video'), boxes = $('boxes');
async function startCam() {
  if (stream) { stopCam(); return; }
  try {
    stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false });
  } catch { toast('Camera not available. Use a photo or tag it yourself.', 3500); return; }
  video.srcObject = stream; await video.play();
  $('viewer').hidden = false; $('shutter').hidden = false; $('cam-btn').textContent = '⏹ Stop camera';
  count(GAME, 'play', 'camera-open');
  detector().then(() => { looking = true; look(); }).catch(() => {});
}
function stopCam() {
  looking = false;
  if (stream) stream.getTracks().forEach((t) => t.stop());
  stream = null; $('viewer').hidden = true; $('shutter').hidden = true; $('cam-btn').textContent = '📷 Start camera';
}
function frameCanvas(maxSide) {
  const s = Math.min(1, maxSide / Math.max(video.videoWidth, video.videoHeight));
  const c = document.createElement('canvas'); c.width = Math.round(video.videoWidth * s); c.height = Math.round(video.videoHeight * s);
  c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
  return c;
}
async function look() {
  if (!looking || !video.videoWidth) { if (looking) setTimeout(look, 300); return; }
  const c = frameCanvas(384);
  const r = det.detect(c);
  perf.push(r.ms); if (perf.length > 20) perf.shift();
  const it = r.items.find((i) => i.score >= MIN_SCORE);
  lastSeen = it || null;
  const vw = boxes.clientWidth, vh = boxes.clientHeight;
  boxes.width = vw * devicePixelRatio; boxes.height = vh * devicePixelRatio;
  const x = boxes.getContext('2d'); x.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); x.clearRect(0, 0, vw, vh);
  // video is shown with object-fit: cover; map the detection box onto it
  const sc = Math.max(vw / c.width, vh / c.height), ox = (vw - c.width * sc) / 2, oy = (vh - c.height * sc) / 2;
  if (it) { x.strokeStyle = '#7CFFB2'; x.lineWidth = 3; x.setLineDash([10, 6]); x.strokeRect(ox + it.box.x * sc, oy + it.box.y * sc, it.box.w * sc, it.box.h * sc); }
  $('seen').textContent = it ? `${LABEL[it.species]} in sight (${Math.round(it.score * 100)}%). Tap Spot it!` : 'Looking for a cat, dog or bird…';
  $('shutter').disabled = !it;
  const avg = perf.reduce((a, b) => a + b, 0) / perf.length;
  setTimeout(look, Math.max(250, avg * 2)); // leave the phone breathing room between looks
}
$('cam-btn').onclick = startCam;
$('shutter').onclick = async () => { if (!stream) return; const c = frameCanvas(1280); lastShot = c; await spotFrom(c, 'camera'); };
$('file').onchange = async (e) => {
  const f = e.target.files[0]; e.target.value = ''; if (!f) return;
  const im = await createImageBitmap(f);
  const s = Math.min(1, 1600 / Math.max(im.width, im.height));
  const c = document.createElement('canvas'); c.width = Math.round(im.width * s); c.height = Math.round(im.height * s);
  c.getContext('2d').drawImage(im, 0, 0, c.width, c.height);
  lastShot = c;
  $('cam-note').textContent = 'Checking your photo on this device…';
  await spotFrom(c, 'photo');
};

// ------------------------------------------------------------------ tag it yourself (manual fallback)
let tagSp = null, tagCol = null, tagSrc = null;
function openTagger(src = lastShot) {
  tagSrc = src; tagSp = null; tagCol = null;
  $('tag-species').innerHTML = Object.entries(SPECIES).map(([sp, al]) => `<button class="chip" type="button" data-sp="${sp}">${ALBUMS[al].icon} ${LABEL[sp]}${ALBUMS[al].pro && !pass.isPro() ? ' 🔒' : ''}</button>`).join('');
  $('tag-colour').innerHTML = ''; $('tag-save').disabled = true;
  $('tag-why').textContent = src ? 'Pick what is in your last photo. Self-tagged sightings get a ✍️ stamp.' : 'Pick what you saw. Self-tagged sightings get a ✍️ stamp.';
  $('tagger').showModal();
}
$('manual-btn').onclick = () => openTagger(lastShot);
$('tag-species').addEventListener('click', (e) => {
  const b = e.target.closest('[data-sp]'); if (!b) return;
  if (ALBUMS[SPECIES[b.dataset.sp]].pro && !pass.isPro()) { pass.open('album'); return; }
  tagSp = b.dataset.sp; tagCol = null; $('tag-save').disabled = true;
  $('tag-species').querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === b));
  $('tag-colour').innerHTML = Object.keys(SLOTS[tagSp]).map((c) => `<button class="chip" type="button" data-col="${c}"><i style="background:${COLOURS[c].hex}"></i>${COLOURS[c].name}</button>`).join('');
});
$('tag-colour').addEventListener('click', (e) => { const b = e.target.closest('[data-col]'); if (!b) return; tagCol = b.dataset.col; $('tag-colour').querySelectorAll('.chip').forEach((c) => c.classList.toggle('on', c === b)); $('tag-save').disabled = false; });
$('tag-cancel').onclick = () => $('tagger').close();
$('tag-save').onclick = async () => {
  $('tagger').close();
  const rec = sighting({ species: tagSp, colour: tagCol, manual: true });
  const blob = tagSrc ? await cropBlob(tagSrc, { x: 0, y: 0, w: tagSrc.width, h: tagSrc.height }) : null;
  count(GAME, 'play', 'manual');
  await save(rec, blob);
};

// ------------------------------------------------------------------ privacy settings
$('loc').checked = !!store('sightfern:loc');
$('loc').onchange = (e) => {
  if (e.target.checked && navigator.geolocation) navigator.geolocation.getCurrentPosition(() => { store('sightfern:loc', true); toast('Rough location on: rounded to about 11 km, kept on this phone only.', 3500); }, () => { e.target.checked = false; store('sightfern:loc', false); toast('Location stays off.'); }, { enableHighAccuracy: false, timeout: 10000 });
  else store('sightfern:loc', false);
};
$('wipe').onclick = async () => {
  if (!confirm('Delete every sighting and photo in Sightfern on this device? This cannot be undone.')) return;
  await wipeAll(); urls.forEach((u) => u && URL.revokeObjectURL(u)); urls.clear(); await refresh(); toast('Everything deleted.');
};
$('pass-btn').onclick = () => pass.open('button');
pass.onChange(() => { if (FRAMES[frame].pro && !pass.isPro()) frame = 'field'; refresh(); });
document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stopCam(); });
refresh();

// Test + store-screenshot hooks (tests/e2e.mjs, scripts/games/render_assets.mjs)
window.__sightfern = { spotFrom, openTagger, refresh, perf: () => perf, add: async (recs) => { for (const r of recs) await putSighting(r, null); await refresh(); } };
window.__cmxShot = async (stage = 1) => {
  const now = new Date(); const mk = (species, colour, h, d = 0) => { const at = new Date(now); at.setDate(at.getDate() - d); at.setHours(h); return sighting({ species, colour, at, score: 0.9 }); };
  await window.__sightfern.add([mk('cat', 'ginger', 9), mk('cat', 'black', 22, 1), mk('dog', 'golden', 12, 2), mk('bird', 'blue', 7), mk('cat', 'grey', 15, 3), mk('dog', 'white', 18)]);
  if (stage === 2) await openCard(S.find((s) => s.species === 'bird') || S[0], true);
};