app.js

  1import {
  2	initGlassTerminal,
  3	renderTerminalLayout,
  4} from "./components/glass-terminal.js";
  5import { initLensEffect } from "./components/lens.js";
  6import { initFrameworkViz } from "./components/framework-viz.js";
  7import { initScrollReveal } from "./utils/reveal.js";
  8import { initAnchorScroll, initHashTracking } from "./utils/scroll.js";
  9import { initSectionNav } from "./components/section-nav.js";
 10import { initFoundationGrid } from "./components/foundation-grid.js";
 11import { initLiveDemo } from "./components/live-demo.js";
 12
 13// ============================================
 14// STATE
 15// ============================================
 16
 17let allCommands = [];
 18
 19// ============================================
 20// CONTENT LOADING
 21// ============================================
 22
 23function escapeHtml(value) {
 24	if (typeof value !== "string") return "";
 25	return value
 26		.replaceAll("&", "&")
 27		.replaceAll("<", "&lt;")
 28		.replaceAll(">", "&gt;")
 29		.replaceAll('"', "&quot;")
 30		.replaceAll("'", "&#39;");
 31}
 32
 33async function loadContent() {
 34	try {
 35		const [commandsRes, patternsRes] = await Promise.all([
 36			fetch("/_data/api/commands.json"),
 37			fetch("/_data/api/patterns.json"),
 38		]);
 39
 40		// Check for HTTP errors
 41		if (!commandsRes.ok) {
 42			throw new Error(`Commands API failed: ${commandsRes.status}`);
 43		}
 44		if (!patternsRes.ok) {
 45			throw new Error(`Patterns API failed: ${patternsRes.status}`);
 46		}
 47
 48		allCommands = await commandsRes.json();
 49		const patternsData = await patternsRes.json();
 50
 51		// Render commands (Glass Terminal)
 52		renderTerminalLayout(allCommands);
 53
 54		// Initialize gallery card stack
 55		initGalleryStack();
 56
 57		// Render patterns with tabbed navigation
 58		renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns);
 59	} catch (error) {
 60		console.error("Failed to load content:", error);
 61		showLoadError(error);
 62	}
 63}
 64
 65function showLoadError(error) {
 66	// Show error in commands section
 67	const commandsGallery = document.querySelector('.commands-gallery');
 68	if (commandsGallery) {
 69		commandsGallery.innerHTML = `
 70			<div class="load-error" role="alert">
 71				<div class="load-error-icon" aria-hidden="true">⚠</div>
 72				<h3 class="load-error-title">Failed to load commands</h3>
 73				<p class="load-error-text">There was a problem loading the content. Please check your connection and try again.</p>
 74				<button class="btn btn-secondary load-error-retry" onclick="location.reload()">
 75					Retry
 76				</button>
 77			</div>
 78		`;
 79	}
 80
 81	// Show error in patterns section
 82	const patternsContainer = document.getElementById("patterns-categories");
 83	if (patternsContainer) {
 84		patternsContainer.innerHTML = `
 85			<div class="load-error" role="alert">
 86				<div class="load-error-icon" aria-hidden="true">⚠</div>
 87				<h3 class="load-error-title">Failed to load patterns</h3>
 88				<p class="load-error-text">There was a problem loading the content. Please check your connection and try again.</p>
 89				<button class="btn btn-secondary load-error-retry" onclick="location.reload()">
 90					Retry
 91				</button>
 92			</div>
 93		`;
 94	}
 95}
 96
 97function initGalleryStack() {
 98	const container = document.querySelector('.gallery-stack-container');
 99	const stack = document.getElementById('gallery-stack');
100	if (!stack || !container) return;
101
102	const cards = stack.querySelectorAll('.gallery-stack-card');
103	const counter = container.querySelector('.gallery-stack-counter');
104	const total = cards.length;
105	let current = 0;
106	let lastScroll = 0;
107
108	function update() {
109		cards.forEach((card, i) => {
110			const offset = (i - current + total) % total;
111			card.dataset.offset = offset;
112		});
113	}
114
115	function next() { current = (current + 1) % total; update(); }
116	function prev() { current = (current - 1 + total) % total; update(); }
117
118	container.querySelector('.gallery-stack-prev').addEventListener('click', prev);
119	container.querySelector('.gallery-stack-next').addEventListener('click', next);
120
121	stack.addEventListener('wheel', (e) => {
122		e.preventDefault();
123		const now = Date.now();
124		if (now - lastScroll < 350) return;
125		lastScroll = now;
126		if (e.deltaY > 0) next(); else prev();
127	}, { passive: false });
128
129	update();
130}
131
132function renderPatternsWithTabs(patterns, antipatterns) {
133	const container = document.getElementById("patterns-categories");
134	if (!container || !patterns || !antipatterns) return;
135
136	const antipatternMap = {};
137	antipatterns.forEach(cat => { antipatternMap[cat.name] = cat.items; });
138
139	const tabsHTML = patterns.map((cat, i) =>
140		`<button class="patterns-tab${i === 0 ? ' is-active' : ''}" data-index="${i}">${escapeHtml(cat.name)}</button>`
141	).join('');
142
143	const panelsHTML = patterns.map((cat, i) => {
144		const antiItems = antipatternMap[cat.name] || [];
145		return `
146		<div class="patterns-content${i === 0 ? ' is-active' : ''}" data-index="${i}">
147			<div class="patterns-col patterns-col--dont">
148				<ul>${antiItems.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>
149			</div>
150			<div class="patterns-col patterns-col--do">
151				<ul>${cat.items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>
152			</div>
153		</div>`;
154	}).join('');
155
156	container.innerHTML = `<div class="patterns-tabs-wrap"><div class="patterns-tabs" data-scroll="start">${tabsHTML}</div></div>${panelsHTML}`;
157
158	const tabsEl = container.querySelector('.patterns-tabs');
159	const tabsWrap = container.querySelector('.patterns-tabs-wrap');
160
161	container.addEventListener('click', (e) => {
162		const tab = e.target.closest('.patterns-tab');
163		if (!tab) return;
164		const index = tab.dataset.index;
165		container.querySelectorAll('.patterns-tab').forEach(t => t.classList.remove('is-active'));
166		container.querySelectorAll('.patterns-content').forEach(p => p.classList.remove('is-active'));
167		tab.classList.add('is-active');
168		container.querySelector(`.patterns-content[data-index="${index}"]`).classList.add('is-active');
169		// Center the clicked tab inside the tabs strip (not the page). Using
170		// scrollBy on the container keeps the page scroll untouched.
171		if (tabsEl) {
172			const tabRect = tab.getBoundingClientRect();
173			const stripRect = tabsEl.getBoundingClientRect();
174			const offset = (tabRect.left + tabRect.width / 2) - (stripRect.left + stripRect.width / 2);
175			tabsEl.scrollBy({ left: offset, behavior: 'smooth' });
176		}
177	});
178
179	// Track scroll position so the edge-fade mask only appears on sides where
180	// there's actually more content. At the start, no left fade; at the end,
181	// no right fade; if no overflow, no fade at all.
182	const updateScrollState = () => {
183		if (!tabsEl) return;
184		const { scrollLeft, scrollWidth, clientWidth } = tabsEl;
185		const max = scrollWidth - clientWidth;
186		let state;
187		if (max <= 1) state = 'none';
188		else if (scrollLeft <= 1) state = 'start';
189		else if (scrollLeft >= max - 1) state = 'end';
190		else state = 'middle';
191		tabsEl.dataset.scroll = state;
192		if (tabsWrap) tabsWrap.dataset.scroll = state;
193	};
194	tabsEl?.addEventListener('scroll', updateScrollState, { passive: true });
195	window.addEventListener('resize', updateScrollState);
196	updateScrollState();
197}
198
199// ============================================
200// EVENT HANDLERS
201// ============================================
202
203// Handle bundle download clicks via event delegation.
204// Each download button carries the full bundle name in data-bundle
205// (currently just "universal") so the handler is just a redirect.
206document.addEventListener("click", (e) => {
207	const bundleBtn = e.target.closest("[data-bundle]");
208	if (bundleBtn) {
209		const bundleName = bundleBtn.dataset.bundle;
210		window.location.href = `/api/download/bundle/${bundleName}`;
211	}
212
213	// Handle copy button clicks
214	const copyBtn = e.target.closest("[data-copy]");
215	if (copyBtn) {
216		const textToCopy = copyBtn.dataset.copy;
217		const onCopied = () => {
218			copyBtn.classList.add('copied');
219			setTimeout(() => copyBtn.classList.remove('copied'), 1500);
220		};
221		if (navigator.clipboard?.writeText) {
222			navigator.clipboard.writeText(textToCopy).then(onCopied).catch(() => {});
223		} else {
224			// Fallback for non-HTTPS or older browsers
225			const ta = Object.assign(document.createElement('textarea'), { value: textToCopy, style: 'position:fixed;left:-9999px' });
226			document.body.appendChild(ta);
227			ta.select();
228			try { document.execCommand('copy'); onCopied(); } catch {}
229			ta.remove();
230		}
231	}
232});
233
234
235// ============================================
236// STARTUP
237// ============================================
238
239function init() {
240	initAnchorScroll();
241	initHashTracking();
242	initLensEffect();
243	initScrollReveal();
244	initGlassTerminal();
245	initFrameworkViz();
246	initFoundationGrid();
247	initSectionNav();
248	initWhyTabs();
249	initLanguageTabs();
250	initLiveDemo();
251	loadContent();
252
253	document.body.classList.add("loaded");
254}
255
256function initLanguageTabs() {
257	const toggle = document.querySelector('.language-view-toggle');
258	if (!toggle) return;
259	const tabs = Array.from(toggle.querySelectorAll('.language-view-tab'));
260	const panels = Array.from(document.querySelectorAll('.language-view[data-view-panel]'));
261	if (!tabs.length || !panels.length) return;
262
263	tabs.forEach((tab) => {
264		tab.addEventListener('click', () => {
265			const view = tab.dataset.view;
266			tabs.forEach((t) => {
267				const on = t === tab;
268				t.classList.toggle('is-active', on);
269				t.setAttribute('aria-selected', on ? 'true' : 'false');
270			});
271			panels.forEach((p) => {
272				const on = p.dataset.viewPanel === view;
273				p.classList.toggle('is-active', on);
274				if (on) p.removeAttribute('hidden');
275				else p.setAttribute('hidden', '');
276			});
277		});
278	});
279}
280
281function initWhyTabs() {
282	const container = document.querySelector('.why-layout');
283	if (!container) return;
284	const tabs = Array.from(container.querySelectorAll('.why-tab'));
285	const panels = Array.from(container.querySelectorAll('.why-panel'));
286	if (!tabs.length || !panels.length) return;
287
288	const CYCLE_MS = 7000;
289	const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
290	let current = Math.max(tabs.findIndex((tab) => tab.classList.contains('is-active')), 0);
291	let timer = null;
292	let autoRotate = !reducedMotion;
293	let visible = false;
294
295	const tabStrip = container.querySelector('.why-tabs');
296	const getPanelForTab = (tab) => {
297		const panelId = tab?.getAttribute('aria-controls');
298		return panelId ? container.querySelector(`#${CSS.escape(panelId)}`) : null;
299	};
300
301	const centerActiveInStrip = (active) => {
302		// On mobile the tab list is a horizontal scroll strip. Keep the
303		// active pill visible without touching the page scroll. Using
304		// scrollTo with behavior:auto + direct scrollLeft assignment,
305		// because smooth-scroll on this container is disabled by the
306		// parent's mask-image compositing and silently no-ops.
307		if (!tabStrip || tabStrip.scrollWidth <= tabStrip.clientWidth + 1) return;
308		const tabRect = active.getBoundingClientRect();
309		const stripRect = tabStrip.getBoundingClientRect();
310		const offset = (tabRect.left + tabRect.width / 2) - (stripRect.left + stripRect.width / 2);
311		if (Math.abs(offset) < 2) return;
312		tabStrip.scrollLeft += offset;
313	};
314
315	const activate = (index, fromAuto = false) => {
316		const targetTab = tabs[index];
317		const targetPanel = getPanelForTab(targetTab);
318		if (!targetTab || !targetPanel) return;
319		current = index;
320		tabs.forEach((tab, i) => {
321			const on = i === index;
322			tab.classList.toggle('is-active', on);
323			tab.setAttribute('aria-selected', on ? 'true' : 'false');
324			// Reset cycling class, re-add on the new active tab so the
325			// progress indicator restarts cleanly.
326			tab.classList.remove('is-cycling');
327		});
328		panels.forEach((panel) => {
329			const on = panel === targetPanel;
330			panel.classList.toggle('is-active', on);
331			if (on) panel.removeAttribute('hidden');
332			else panel.setAttribute('hidden', '');
333		});
334		if (autoRotate && visible) {
335			// Force reflow so the animation restart is picked up.
336			void targetTab.offsetWidth;
337			targetTab.classList.add('is-cycling');
338		}
339		centerActiveInStrip(targetTab);
340	};
341
342	const scheduleNext = () => {
343		clearTimeout(timer);
344		if (!autoRotate || !visible) return;
345		timer = setTimeout(() => {
346			const next = (current + 1) % tabs.length;
347			activate(next, true);
348			scheduleNext();
349		}, CYCLE_MS);
350	};
351
352	const stopAuto = () => {
353		autoRotate = false;
354		clearTimeout(timer);
355		tabs.forEach((t) => t.classList.remove('is-cycling'));
356	};
357
358	tabs.forEach((tab, index) => {
359		tab.addEventListener('click', () => {
360			stopAuto();
361			activate(index);
362		});
363		tab.addEventListener('keydown', (e) => {
364			if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
365			e.preventDefault();
366			stopAuto();
367			const dir = e.key === 'ArrowDown' ? 1 : -1;
368			const next = (index + dir + tabs.length) % tabs.length;
369			tabs[next].focus();
370			activate(next);
371		});
372	});
373
374	container.addEventListener('mouseenter', () => {
375		// Pause auto-rotation on hover. Resume only if still allowed and
376		// user hasn't interacted (stopAuto flips autoRotate off).
377		clearTimeout(timer);
378		tabs.forEach((t) => t.classList.remove('is-cycling'));
379	});
380	container.addEventListener('mouseleave', () => {
381		if (autoRotate && visible) {
382			// Re-apply cycling class to current tab and resume the timer.
383			const active = tabs[current];
384			void active.offsetWidth;
385			active.classList.add('is-cycling');
386			scheduleNext();
387		}
388	});
389
390	// Observe visibility so we only rotate while the user can see it.
391	const io = new IntersectionObserver((entries) => {
392		entries.forEach((e) => {
393			visible = e.isIntersecting;
394			if (visible) {
395				if (autoRotate) {
396					const active = tabs[current];
397					void active.offsetWidth;
398					active.classList.add('is-cycling');
399					scheduleNext();
400				}
401			} else {
402				clearTimeout(timer);
403				tabs.forEach((t) => t.classList.remove('is-cycling'));
404			}
405		});
406	}, { threshold: 0.35 });
407	io.observe(container);
408}
409
410if (document.readyState === "loading") {
411	document.addEventListener("DOMContentLoaded", init);
412} else {
413	init();
414}