README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
356 lines
18 KiB
JavaScript
356 lines
18 KiB
JavaScript
// 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.
|
|
(() => {
|
|
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);
|
|
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'));
|
|
if (unsigned.length) {
|
|
const n = el('div', { class: 'dl-notice', role: 'note' });
|
|
n.appendChild(el('b', null, 'Unsigned preview. '));
|
|
n.appendChild(document.createTextNode('This early build is not yet signed by Apple or Microsoft, so your system asks once before opening it. Check the SHA-256 under "Checksums and signatures" first.'));
|
|
const ol = el('ol');
|
|
if (unsigned.includes('macos-universal-dmg')) ol.appendChild(el('li', null, 'macOS: drag storebound to Applications and open it; when macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).'));
|
|
if (unsigned.includes('windows-x86_64-nsis')) ol.appendChild(el('li', null, 'Windows: when SmartScreen shows "Windows protected your PC", choose More info > Run anyway.'));
|
|
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.');
|
|
});
|
|
}
|
|
})();
|