// Shared by both pages (/ and /x/): scroll reveals, the header progress line, the Shell/Runtime/Adapter // steps, counters, the live theme preview, copy buttons and the demo videos. No libraries. import { t } from './i18n'; import { THEMES, type PreviewTheme } from './themes'; const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; function initScrollReveal(): void { const targets = Array.from(document.querySelectorAll('[data-reveal]')); if (prefersReducedMotion || !('IntersectionObserver' in window)) { targets.forEach((el) => el.classList.add('is-in')); return; } const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { entry.target.classList.add('is-in'); observer.unobserve(entry.target); } } }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' }, ); targets.forEach((el) => observer.observe(el)); } /** The brush line under the header grows with the scroll position. */ function initProgress(): void { const bar = document.querySelector('.top-progress'); if (!bar) return; let queued = false; const update = () => { queued = false; const max = document.documentElement.scrollHeight - window.innerHeight; bar.style.setProperty('--p', String(max > 0 ? Math.min(1, window.scrollY / max) : 0)); }; window.addEventListener('scroll', () => { if (!queued) { queued = true; requestAnimationFrame(update); } }, { passive: true }); update(); } /** Shell / Runtime / Adapter: the step in the middle of the viewport lights up its layer in the window. */ function initSystemSteps(): void { const win = document.querySelector('.sys-win'); const steps = Array.from(document.querySelectorAll('.sys-step')); if (!win || !steps.length) return; const legend = Array.from(document.querySelectorAll('.sys-legend li')); const activate = (name: string) => { win.dataset.active = name; steps.forEach((step) => step.classList.toggle('is-active', step.dataset.step === name)); legend.forEach((item) => item.classList.toggle('is-on', item.dataset.for === name)); }; activate(steps[0]?.dataset.step ?? 'shell'); if (!('IntersectionObserver' in window)) return; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) if (entry.isIntersecting) activate((entry.target as HTMLElement).dataset.step ?? 'shell'); }, { rootMargin: '-45% 0px -45% 0px' }, ); steps.forEach((step) => observer.observe(step)); } /** Numbers count up once when they scroll into view. The final value is already in the HTML. */ function initCounters(): void { if (prefersReducedMotion || !('IntersectionObserver' in window)) return; const counters = Array.from(document.querySelectorAll('[data-count]')); const run = (el: HTMLElement) => { const target = Number(el.dataset.count); if (!Number.isFinite(target)) return; const start = performance.now(); const duration = 1100; const tick = (now: number) => { const t = Math.min(1, (now - start) / duration); el.textContent = String(Math.round(target * (1 - Math.pow(1 - t, 3)))); if (t < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { run(entry.target as HTMLElement); observer.unobserve(entry.target); } } }, { threshold: 0.6 }, ); counters.forEach((el) => observer.observe(el)); } function applyTheme(preview: HTMLElement, theme: PreviewTheme): void { const c = theme.colors; const set = (name: string, value: string) => preview.style.setProperty(name, value); set('--page', c.page); set('--bg', c.bg); set('--surface', c.surface); set('--elevated', c.elevated); set('--border', c.border); set('--text', c.text); set('--muted', c.muted); set('--accent', c.accent); set('--inverse', c.inverse); set('--on-inverse', c.onInverse); set('--canvas', theme.canvas ?? c.page); set('--radius', `${theme.radius}px`); set('--btn', `${Math.min(theme.button, 999)}px`); set('--avatar', theme.avatar === 'circle' ? '50%' : theme.avatar === 'rounded' ? '28%' : '0'); set('--font', theme.font); set('--tweet', theme.tweet ?? theme.font); const name = preview.querySelector('[data-theme-name]'); if (name) name.textContent = theme.name; } /** A small X-like feed painted with each built-in theme's real palette, shape and font. */ function initThemePreview(): void { const preview = document.querySelector('[data-theme-preview]'); if (!preview) return; const chips = document.querySelector('[data-theme-chips]'); const buttons: HTMLButtonElement[] = []; let index = THEMES.findIndex((t) => t.id === 'night-ink'); let pinned = false; const select = (i: number) => { index = (i + THEMES.length) % THEMES.length; const theme = THEMES[index]; if (theme) applyTheme(preview, theme); buttons.forEach((b, n) => b.setAttribute('aria-pressed', String(n === index))); }; if (chips) { THEMES.forEach((theme, i) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'tp-chip'; const sw = document.createElement('span'); sw.className = 'sw'; sw.setAttribute('aria-hidden', 'true'); for (const color of [theme.colors.bg, theme.colors.accent, theme.colors.text]) { const i2 = document.createElement('i'); i2.style.background = color; sw.append(i2); } button.append(sw, document.createTextNode(theme.name)); button.addEventListener('click', () => { pinned = true; select(i); }); chips.append(button); buttons.push(button); }); } document.querySelectorAll('[data-pick-theme]').forEach((el) => { el.addEventListener('click', () => { const i = THEMES.findIndex((t) => t.id === el.dataset.pickTheme); if (i >= 0) { pinned = true; select(i); preview.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'center' }); } }); }); select(index); if (prefersReducedMotion || !('IntersectionObserver' in window)) return; let timer = 0; const stop = () => { window.clearInterval(timer); timer = 0; }; const start = () => { if (!timer && !pinned) timer = window.setInterval(() => (pinned ? stop() : select(index + 1)), 2200); }; preview.addEventListener('pointerenter', stop); preview.addEventListener('pointerleave', start); new IntersectionObserver((entries) => { for (const entry of entries) (entry.isIntersecting ? start : stop)(); }, { threshold: 0.3 }).observe(preview); } /** [data-copy] copies its value; [data-copy-from] copies the text of the element with that id. */ function initCopy(): void { document.querySelectorAll('[data-copy], [data-copy-from]').forEach((el) => { el.addEventListener('click', async () => { const source = el.dataset.copyFrom ? document.getElementById(el.dataset.copyFrom)?.textContent : el.dataset.copy; const label = el.querySelector('[data-copy-label]') ?? el; const badge = label === el ? el : label; let ok = false; try { await navigator.clipboard.writeText(source ?? ''); ok = true; } catch { ok = false; } // The button's own word (in the page's language) comes back after a moment. const original = label.dataset.label ?? label.textContent ?? ''; label.dataset.label = original; label.textContent = ok ? t('site.js.copied') : t('site.js.selectIt'); badge.classList.toggle('is-done', ok); window.setTimeout(() => { label.textContent = original; badge.classList.remove('is-done'); }, 1600); }); }); } /** * Demo videos: the hero plays at once (muted, looped); the fix loops below the fold load and play only when * they come into view and pause when they leave. With reduced motion nothing plays: the posters stay. */ function initDemoVideos(): void { const videos = Array.from(document.querySelectorAll('video.demo-video')); // Phones get the stacked loops (before above after) and their posters. if (window.matchMedia('(max-width: 640px)').matches) { for (const video of videos) if (video.dataset.posterSmall) video.poster = video.dataset.posterSmall; } const load = (video: HTMLVideoElement) => { for (const source of Array.from(video.querySelectorAll('source[data-src]'))) { source.src = source.dataset.src ?? ''; source.removeAttribute('data-src'); video.load(); } }; if (prefersReducedMotion) { for (const video of videos) { video.removeAttribute('autoplay'); video.pause(); } return; } const play = (video: HTMLVideoElement) => { void video.play().catch(() => {}); }; if (!('IntersectionObserver' in window)) { videos.forEach((video) => { load(video); play(video); }); return; } const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { const video = entry.target as HTMLVideoElement; if (entry.isIntersecting) { load(video); play(video); } else { video.pause(); } } }, { threshold: 0.25, rootMargin: '200px 0px 200px 0px' }, ); videos.forEach((video) => observer.observe(video)); } /** * Home hero: the app window tilts back and flattens as the page scrolls (--tilt 1 → 0), and the window, the * brush light and the floating chips follow the pointer by depth (--mx/--my). Only CSS variables change, so * nothing lays out again. The theme chip walks through a few real built-in palettes. Reduced motion: all off. */ function initHeroStage(): void { const stage = document.querySelector('[data-hero-stage]'); if (!stage || prefersReducedMotion) return; const hero = stage.closest('.hero') ?? stage; let tilt = 1; let mx = 0, my = 0, tx = 0, ty = 0; let frame = 0; const flat = window.matchMedia('(max-width: 760px)'); const readTilt = () => { if (flat.matches) { tilt = 0; return; } const top = stage.getBoundingClientRect().top; // Flat once the window's top edge reaches ~18% of the viewport. const start = window.innerHeight * 0.95; const end = window.innerHeight * 0.18; tilt = Math.max(0, Math.min(1, (top - end) / (start - end))); }; const render = () => { frame = 0; mx += (tx - mx) * 0.12; my += (ty - my) * 0.12; stage.style.setProperty('--tilt', tilt.toFixed(4)); stage.style.setProperty('--mx', mx.toFixed(4)); stage.style.setProperty('--my', my.toFixed(4)); if (Math.abs(tx - mx) > 0.001 || Math.abs(ty - my) > 0.001) frame = requestAnimationFrame(render); }; const queue = () => { if (!frame) frame = requestAnimationFrame(render); }; window.addEventListener('scroll', () => { readTilt(); queue(); }, { passive: true }); window.addEventListener('resize', () => { readTilt(); queue(); }, { passive: true }); if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) { hero.addEventListener('pointermove', (event) => { const r = stage.getBoundingClientRect(); tx = Math.max(-1, Math.min(1, ((event.clientX - r.left) / r.width) * 2 - 1)); ty = Math.max(-1, Math.min(1, ((event.clientY - r.top) / r.height) * 2 - 1)); queue(); }); hero.addEventListener('pointerleave', () => { tx = 0; ty = 0; queue(); }); } readTilt(); queue(); const swatch = Array.from(stage.querySelectorAll('[data-hs-swatch] i')); const label = stage.querySelector('[data-hs-theme]'); const cycle = ['night-ink', 'classic', 'dracula', 'terminal', 'tokyo-night', 'frutiger-aero'] .map((id) => THEMES.find((t) => t.id === id)) .filter((t): t is PreviewTheme => Boolean(t)); if (!label || swatch.length < 3 || cycle.length < 2) return; let at = 0; window.setInterval(() => { if (document.hidden) return; at = (at + 1) % cycle.length; const theme = cycle[at]!; [theme.colors.bg, theme.colors.accent, theme.colors.text].forEach((color, i) => { swatch[i]!.style.background = color; }); label.classList.add('is-out'); window.setTimeout(() => { label.textContent = theme.name; label.classList.remove('is-out'); }, 250); }, 2600); } function init(): void { initScrollReveal(); initProgress(); initSystemSteps(); initCounters(); initThemePreview(); initCopy(); initDemoVideos(); initHeroStage(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }