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("<", "<")
28 .replaceAll(">", ">")
29 .replaceAll('"', """)
30 .replaceAll("'", "'");
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}