storebound/site/main.js
CRT 710c3a53d0 README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
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.
2026-10-13 03:29:59 +08:00

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.');
});
}
})();