271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
// storebound.dev: copy buttons, AI tool tabs, the hero demo (real CLI output) and the How it works stepper. No network, no storage.
|
|
(() => {
|
|
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();
|
|
}
|
|
})();
|