// storebound.dev: copy buttons, tabs (AI tools, desktop screens), the teaser player, the demo (real CLI output), the // How it works stepper and the desktop downloads (reads desktop/latest.json, same origin). No storage, no third parties. // Stats: self-hosted Umami at /s/ (cookieless, Do Not Track respected); ev() is a no-op when the script is blocked or off. (() => { const ev = (name, data) => { try { window.umami?.track(name, data); } catch {} }; document.addEventListener('click', e => { const a = e.target.closest?.('a[href]'); if (!a) return; if (a.dataset.dl) { ev('desktop-download', { os: a.dataset.dl.split('-')[0], file: a.dataset.dl }); return; } let u; try { u = new URL(a.href, location.href); } catch { return; } if (u.host === location.host) return; const docs = u.host === 'git.crt.fyi' && /\/(docs\/|README|CHANGELOG|SECURITY)/.test(u.pathname); const kind = docs ? 'docs' : u.host === 'git.crt.fyi' ? 'git' : u.host.endsWith('npmjs.com') ? 'npm' : 'other'; ev('outbound', { kind, host: u.host, path: u.pathname.slice(0, 120) }); }, true); const vid = document.querySelector('#teaser video'); if (vid) { const seen = new Set(); const once = (k, d) => { if (!seen.has(k)) { seen.add(k); ev(k, d); } }; vid.addEventListener('play', () => once('teaser-play')); vid.addEventListener('timeupdate', () => { if (vid.duration && vid.currentTime / vid.duration >= 0.5) once('teaser-50'); }); vid.addEventListener('ended', () => once('teaser-complete')); } window.__sbEv = ev; })(); (() => { const copyText = async text => { try { await navigator.clipboard.writeText(text); return true; } catch {} const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch {} ta.remove(); return ok; }; for (const btn of document.querySelectorAll('.copy')) { btn.addEventListener('click', async () => { const target = btn.dataset.copyTarget && document.getElementById(btn.dataset.copyTarget); // Drop "# comment" tails so the copied commands run as is. const text = btn.dataset.copy ?? (target ? target.innerText.split('\n').map(l => l.replace(/\s+#.*$/, '')).join('\n').trim() : ''); const ok = await copyText(text); window.__sbEv?.('copy', { what: (btn.dataset.copy ?? btn.dataset.copyTarget ?? '').slice(0, 80) }); btn.textContent = ok ? 'Copied' : 'Press Ctrl+C'; btn.classList.toggle('done', ok); setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('done'); }, 1600); }); } // Teaser: click to play (nothing loads before that); native controls take over afterwards. for (const root of document.querySelectorAll('[data-player]')) { const video = root.querySelector('video'); const btn = root.querySelector('.play'); video.controls = false; btn.hidden = false; btn.addEventListener('click', () => { btn.hidden = true; video.controls = true; video.play().catch(() => {}); video.focus(); }); } for (const root of document.querySelectorAll('[data-tabs]')) { const tabs = [...root.querySelectorAll('[role="tab"]')]; const select = (tab, focus) => { for (const t of tabs) { const on = t === tab; t.setAttribute('aria-selected', String(on)); t.tabIndex = on ? 0 : -1; document.getElementById(t.getAttribute('aria-controls')).hidden = !on; } if (focus) { tab.focus(); tab.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } }; tabs.forEach((t, i) => { t.addEventListener('click', () => select(t, false)); t.addEventListener('keydown', e => { const k = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key]; if (k === undefined) return; e.preventDefault(); select(tabs[(k + tabs.length) % tabs.length], true); }); }); select(tabs.find(t => t.getAttribute('aria-selected') === 'true') ?? tabs[0], false); } const reduce = matchMedia('(prefers-reduced-motion: reduce)'); // Hero demo: four scenes of real CLI output, each under a plain-language headline. Auto-cycles while the hero is on // screen and the page is visible, pauses on hover; a picked tab stops the cycle. Every scene holds its final frame // for HOLD_MS. Reduced motion: no animation, the tabs show each final frame. // Steps: [data-s] elements appear in order (data-o overrides document order); data-s="type" types a command, // a number is the pause after it in ms. data-flip="id ..." marks checklist rows fixed, data-pill sets the pill. const demo = document.querySelector('[data-demo]'); if (demo) { const tabs = [...demo.querySelectorAll('[role="tab"]')]; const panels = tabs.map(t => document.getElementById(t.getAttribute('aria-controls'))); const cwd = demo.querySelector('[data-cwd]'); const HOLD_MS = 3400, START_MS = 250, CHAR_MS = 30, AFTER_TYPE_MS = 220; const motion = !reduce.matches; let cur = 0, auto = motion, visible = false, hovered = false, run = 0; const shown = () => visible && !document.hidden; const live = () => shown() && !hovered; const sync = () => demo.classList.toggle('is-held', !live()); const STOP = {}; // a wait that only counts down while the demo is on screen (and, while cycling, not hovered); throws when a newer run started const wait = (ms, token) => new Promise((done, fail) => { let left = ms; const tick = () => { const step = Math.min(left, 40); setTimeout(() => { if (token !== run) return fail(STOP); if (auto ? live() : shown()) left -= step; if (left <= 0) done(); else tick(); }, step); }; if (token !== run) fail(STOP); else if (ms <= 0) done(); else tick(); }); const steps = p => { const els = [...p.querySelectorAll('[data-s]')]; return els.map((el, i) => ({ el, i, o: el.dataset.o ? Number(el.dataset.o) : i })).sort((a, b) => a.o - b.o || a.i - b.i).map(x => x.el); }; const stepMs = el => el.dataset.s === 'type' ? el.querySelector('span').textContent.length * CHAR_MS + AFTER_TYPE_MS : el.dataset.s === '' ? 22 : Number(el.dataset.s); const sceneMs = panels.map(p => START_MS + steps(p).reduce((n, el) => n + stepMs(el), 0) + HOLD_MS); const countUp = el => { const to = Number(el.dataset.count), t0 = performance.now(), dur = 700; const f = now => { const k = Math.min(1, (now - t0) / dur); el.textContent = String(Math.round(to * (1 - (1 - k) ** 3))); if (k < 1) requestAnimationFrame(f); }; requestAnimationFrame(f); }; const select = (i, focus) => { cur = i; tabs.forEach((t, k) => { const on = k === i; t.setAttribute('aria-selected', String(on)); t.tabIndex = on ? 0 : -1; panels[k].hidden = !on; }); cwd.textContent = panels[i].dataset.cwd; demo.style.setProperty('--sc-ms', sceneMs[i] + 'ms'); if (focus) tabs[i].focus(); }; // plays one scene from empty to its final frame const play = async (i, token) => { const p = panels[i]; const items = steps(p); for (const el of items) el.classList.remove('on', 'typing'); for (const el of p.querySelectorAll('.fixed')) el.classList.remove('fixed'); for (const c of p.querySelectorAll('[data-count]')) c.textContent = '0'; delete p.dataset.pill; p.classList.add('is-anim'); demo.classList.add('is-ready'); await wait(START_MS, token); for (const el of items) { if (el.dataset.s === 'type') { const n = el.querySelector('span').textContent.length; el.style.setProperty('--n', '0'); el.classList.add('typing', 'on'); for (let k = 1; k <= n; k++) { el.style.setProperty('--n', String(k)); await wait(CHAR_MS, token); } await wait(AFTER_TYPE_MS, token); el.classList.remove('typing'); } else { el.classList.add('on'); if (el.dataset.pill) p.dataset.pill = el.dataset.pill; for (const id of (el.dataset.flip ?? '').split(' ').filter(Boolean)) document.getElementById(id)?.classList.add('fixed'); const c = el.matches('[data-count]') ? el : el.querySelector('[data-count]'); if (c) countUp(c); await wait(stepMs(el), token); } } }; const restartBar = () => { demo.classList.remove('is-auto'); void demo.offsetWidth; if (auto) demo.classList.add('is-auto'); }; const cycle = async () => { const token = ++run; try { for (;;) { select(cur, false); restartBar(); await play(cur, token); await wait(HOLD_MS, token); cur = (cur + 1) % tabs.length; } } catch (e) { if (e !== STOP) throw e; } }; const pick = (i, focus) => { auto = false; demo.classList.remove('is-auto'); const token = ++run; select(i, focus); if (motion) play(i, token).catch(e => { if (e !== STOP) throw e; }); }; tabs.forEach((t, i) => { t.addEventListener('click', () => pick(i, false)); t.addEventListener('keydown', e => { const k = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[e.key]; if (k === undefined) return; e.preventDefault(); pick((k + tabs.length) % tabs.length, true); }); }); if (motion) { demo.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { hovered = true; sync(); } }); demo.addEventListener('pointerleave', () => { hovered = false; sync(); }); document.addEventListener('visibilitychange', sync); new IntersectionObserver(es => { visible = es[es.length - 1].isIntersecting; sync(); }, { threshold: 0.25 }).observe(demo); sync(); cycle(); } } // How it works: on wide screens a vertical tab list with one shared preview, auto-advancing while in view // until the visitor picks a step. On narrow screens a plain timeline with every step's preview inline. const steps = document.querySelector('[data-steps]'); if (steps) { const items = [...steps.children]; const heads = items.map(li => li.querySelector('.st-head')); const panels = items.map(li => li.querySelector('.st-panel')); const wide = matchMedia('(min-width: 1024px)'); const STEP_MS = 6500; steps.style.setProperty('--step-ms', STEP_MS + 'ms'); let cur = 0, auto = !reduce.matches, timer = 0, inView = false, held = false; const stopTimer = () => { clearTimeout(timer); timer = 0; }; const schedule = () => { stopTimer(); const run = wide.matches && auto && inView && !held && !document.hidden; steps.classList.toggle('is-auto', auto && wide.matches); steps.classList.toggle('is-paused', !run); if (run) timer = setTimeout(() => show((cur + 1) % heads.length, false, true), STEP_MS); }; const restartBar = () => { steps.classList.remove('is-auto'); void steps.offsetWidth; }; const show = (i, focus, animate) => { cur = i; heads.forEach((h, k) => { const on = k === i; h.setAttribute('aria-selected', String(on)); h.tabIndex = on ? 0 : -1; panels[k].hidden = !on; if (on && animate) { panels[k].classList.remove('enter'); void panels[k].offsetWidth; panels[k].classList.add('enter'); } }); if (focus) heads[i].focus(); restartBar(); schedule(); }; const resume = () => { if (wide.matches && auto) show(cur, false, false); }; const pick = (i, focus) => { auto = false; show(i, focus, true); }; heads.forEach((h, i) => { h.addEventListener('click', () => { if (wide.matches) pick(i, false); }); h.addEventListener('keydown', e => { if (!wide.matches) return; const k = { ArrowDown: i + 1, ArrowRight: i + 1, ArrowUp: i - 1, ArrowLeft: i - 1, Home: 0, End: heads.length - 1 }[e.key]; if (k === undefined) return; e.preventDefault(); pick((k + heads.length) % heads.length, true); }); }); for (const b of steps.querySelectorAll('[data-next]')) b.addEventListener('click', () => pick(Number(b.dataset.next), true)); steps.addEventListener('pointerenter', () => { held = true; schedule(); }); steps.addEventListener('pointerleave', () => { held = false; resume(); }); steps.addEventListener('focusin', () => { held = true; schedule(); }); steps.addEventListener('focusout', e => { if (!steps.contains(e.relatedTarget)) { held = false; resume(); } }); document.addEventListener('visibilitychange', schedule); new IntersectionObserver(es => { inView = es[es.length - 1].isIntersecting; resume(); }, { threshold: 0.35 }).observe(steps); const layout = () => { if (wide.matches) { steps.setAttribute('role', 'tablist'); steps.setAttribute('aria-orientation', 'vertical'); steps.setAttribute('aria-label', 'Steps'); items.forEach((li, k) => { li.setAttribute('role', 'presentation'); heads[k].setAttribute('role', 'tab'); heads[k].setAttribute('aria-controls', panels[k].id); panels[k].setAttribute('role', 'tabpanel'); panels[k].setAttribute('aria-labelledby', heads[k].id); panels[k].tabIndex = 0; }); for (const f of steps.querySelectorAll('.st-foot')) f.hidden = false; show(cur, false, false); } else { for (const f of steps.querySelectorAll('.st-foot')) f.hidden = true; stopTimer(); for (const a of ['role', 'aria-orientation', 'aria-label']) steps.removeAttribute(a); items.forEach((li, k) => { li.removeAttribute('role'); for (const a of ['role', 'aria-controls', 'aria-selected', 'tabindex']) heads[k].removeAttribute(a); for (const a of ['role', 'aria-labelledby', 'tabindex']) panels[k].removeAttribute(a); panels[k].hidden = false; }); steps.classList.remove('is-auto', 'is-paused'); } }; wide.addEventListener('change', layout); layout(); } // Desktop downloads: desktop/latest.json (same origin, published with the binaries by tools/desktop-release.ts) fills // the per-system buttons and the checksum table. Everything from it is set as text, never as HTML; only // storebound.dev/desktop/dl/ links are used. Before the first release the buttons stay off and say so. const dl = document.getElementById('download'); if (dl) { const LABEL = { 'macos-universal-dmg': ['macOS 11 or later', 'Apple silicon and Intel, .dmg'], 'windows-x86_64-nsis': ['Windows 10 / 11', 'x64 installer, installs for your user only'], 'linux-x86_64-appimage': ['Linux', 'x64 AppImage (Ubuntu 22.04+, Debian 12+)'], 'linux-x86_64-deb': ['Linux (Debian, Ubuntu)', 'x64 .deb package'], }; const ORDER = Object.keys(LABEL); const el = (tag, attrs, text) => { const e = document.createElement(tag); for (const k in attrs || {}) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; }; const mb = n => (n / 1e6).toFixed(1) + ' MB'; const ours = u => { try { const x = new URL(u, location.href); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.startsWith('/desktop/dl/'); } catch { return false; } }; const system = () => { const p = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '') + ' ' + navigator.userAgent; if (/android|iphone|ipad|ipod/i.test(p)) return 'mobile'; if (/mac/i.test(p)) return 'macos-universal-dmg'; if (/win/i.test(p)) return 'windows-x86_64-nsis'; if (/linux|x11/i.test(p)) return 'linux-x86_64-appimage'; return null; }; const status = document.getElementById('dl-status'); const notice = document.getElementById('dl-notice'); const buttons = [...dl.querySelectorAll('[data-dl]')]; const off = text => { status.textContent = text; for (const b of buttons) b.querySelector('small').textContent += ' · coming soon'; }; fetch('desktop/latest.json', { cache: 'no-cache' }).then(r => { if (r.status === 404) throw new Error('none'); if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(m => { const d = m.downloads || {}; const keys = ORDER.filter(k => d[k] && ours(d[k].url)); if (!keys.length) throw new Error('none'); const here = system(); status.textContent = 'Version ' + m.version + (m.pub_date ? ' · ' + m.pub_date.slice(0, 10) : '') + (here === 'mobile' ? ' · the desktop app runs on macOS, Windows and Linux' : ''); for (const b of buttons) { const k = b.dataset.dl, x = d[k]; if (!x || !keys.includes(k)) { b.querySelector('small').textContent += ' · not in this release'; continue; } b.href = x.url; b.classList.remove('is-off'); b.removeAttribute('aria-disabled'); b.querySelector('small').textContent += ' · ' + mb(x.size) + (k === here ? ' · your system' : ''); if (k === here) { b.classList.add('is-here', 'btn-primary'); b.classList.remove('btn-ghost'); } } const unsigned = keys.filter(k => !d[k].codeSigned && (k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis')); { // signing per OS, from latest.json's codeSigned (macOS is Developer ID signed and notarized) const n = el('div', { class: 'dl-notice', role: 'note' }); n.appendChild(el('b', null, 'Signing. ')); n.appendChild(document.createTextNode(unsigned.length ? 'Check the SHA-256 under "Checksums and signatures" before opening an unsigned build.' : 'The SHA-256 of every file is under "Checksums and signatures".')); const ol = el('ol'); const has = k => keys.includes(k); if (has('macos-universal-dmg')) ol.appendChild(el('li', null, unsigned.includes('macos-universal-dmg') ? 'macOS: this build is not signed; when macOS refuses to open it, open System Settings > Privacy & Security and choose Open Anyway (once).' : 'macOS: signed with an Apple Developer ID and notarized by Apple; it opens without a warning.')); if (has('windows-x86_64-nsis')) ol.appendChild(el('li', null, unsigned.includes('windows-x86_64-nsis') ? 'Windows: not signed yet, so SmartScreen may warn ("Windows protected your PC"): choose More info › Run anyway.' : 'Windows: signed; it installs without a SmartScreen warning.')); if (keys.some(k => k.startsWith('linux'))) ol.appendChild(el('li', null, 'Linux: AppImage and .deb are not signed (Linux has no OS signing check); compare the SHA-256.')); n.appendChild(ol); notice.appendChild(n); } const rows = document.getElementById('dl-rows'); for (const k of keys) { const x = d[k]; const tr = el('tr'); const sys = el('td'); sys.appendChild(el('b', null, LABEL[k][0])); sys.appendChild(el('div', { class: 'small' }, LABEL[k][1] + ((k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis') && !x.codeSigned ? ' · unsigned preview' : ''))); const file = el('td'); file.appendChild(el('a', { class: 'fname', href: x.url }, x.file)); const sub = el('div', { class: 'small' }, mb(x.size) + ' · '); sub.appendChild(el('a', { href: x.url + '.sig' }, 'signature')); file.appendChild(sub); const sha = el('td'); sha.appendChild(el('code', { class: 'sha' }, x.sha256)); tr.append(sys, file, sha); rows.appendChild(tr); } document.getElementById('dl-table').hidden = false; }).catch(e => { off(e && e.message === 'none' ? 'The first desktop release is not published yet. Until then the storebound CLI does everything the app shows.' : 'Could not read the release list (' + (e && e.message) + '). desktop/latest.json has every file and its SHA-256.'); }); } })();