1import { renderCommandDemo, initCommandDemo } from "../demo-renderer.js";
2import { initSplitCompare } from "../effects/split-compare.js";
3import { commandProcessSteps, commandCategories, commandRelationships, betaCommands } from "../data.js";
4
5// Track current split instance and command for cleanup
6let currentSplitInstance = null;
7let currentCommandId = null;
8let sourceCache = {}; // Cache fetched source content
9
10const MOBILE_BREAKPOINT = 900;
11
12function isMobile() {
13 return window.innerWidth <= MOBILE_BREAKPOINT;
14}
15
16export function initGlassTerminal() {
17 // Initial setup if needed
18}
19
20export function renderTerminalLayout(commands) {
21 const container = document.querySelector('.commands-gallery');
22 if (!container) return;
23
24 if (isMobile()) {
25 renderMobileLayout(container, commands);
26 } else {
27 renderDesktopLayout(container, commands);
28 }
29
30 // Re-render on resize crossing breakpoint
31 let wasMobile = isMobile();
32 window.addEventListener('resize', () => {
33 const nowMobile = isMobile();
34 if (nowMobile !== wasMobile) {
35 wasMobile = nowMobile;
36 currentSplitInstance = null;
37 currentCommandId = null;
38 if (nowMobile) {
39 renderMobileLayout(container, commands);
40 } else {
41 renderDesktopLayout(container, commands);
42 }
43 }
44 });
45}
46
47// ============================================
48// DESKTOP LAYOUT - Magazine Spread
49// ============================================
50
51let magazineState = {
52 currentIndex: 0,
53 commands: [],
54 isTransitioning: false,
55 keyboardBound: false,
56 intersectionObserver: null
57};
58
59const categoryOrder = ['diagnostic', 'quality', 'intensity', 'adaptation', 'enhancement', 'system'];
60const categoryLabels = {
61 'create': 'Create',
62 'evaluate': 'Evaluate',
63 'refine': 'Refine',
64 'simplify': 'Simplify',
65 'harden': 'Harden',
66 'system': 'System'
67};
68
69function renderDesktopLayout(container, commands) {
70 magazineState.commands = commands;
71
72 let startIndex = -1;
73
74 // Filter out deprecated shims and sub-commands (no standalone demos)
75 const deprecated = new Set(['teach-impeccable', 'frontend-design', 'arrange', 'normalize', 'onboard', 'extract', 'impeccable craft', 'impeccable teach', 'impeccable extract']);
76 const filteredCommands = commands.filter(c => !deprecated.has(c.id));
77
78 const categoryOrder = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
79 const categoryLabelsShort = {
80 'create': 'Create', 'evaluate': 'Evaluate', 'refine': 'Refine',
81 'simplify': 'Simplify', 'harden': 'Harden', 'system': 'System'
82 };
83 // Preferred order within each category (unlisted commands append at end)
84 const categoryCommandOrder = {
85 'create': ['impeccable', 'shape'],
86 'evaluate': ['critique', 'audit'],
87 'refine': ['typeset', 'arrange', 'colorize', 'animate', 'delight', 'bolder', 'quieter', 'onboard', 'overdrive'],
88 'simplify': ['distill', 'clarify', 'adapt'],
89 'harden': ['normalize', 'polish', 'optimize', 'harden'],
90 'system': ['extract']
91 };
92 const grouped = {};
93 filteredCommands.forEach(cmd => {
94 const cat = commandCategories[cmd.id] || 'other';
95 if (!grouped[cat]) grouped[cat] = [];
96 grouped[cat].push(cmd);
97 });
98 // Sort each group by preferred order
99 Object.entries(grouped).forEach(([cat, cmds]) => {
100 const order = categoryCommandOrder[cat] || [];
101 cmds.sort((a, b) => {
102 const ai = order.indexOf(a.id);
103 const bi = order.indexOf(b.id);
104 return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
105 });
106 });
107 const orderedCommands = [];
108 const headerIndices = [];
109 categoryOrder.forEach(cat => {
110 if (!grouped[cat]) return;
111 headerIndices.push({ index: orderedCommands.length, label: categoryLabelsShort[cat] || cat });
112 orderedCommands.push(...grouped[cat]);
113 });
114 // Use ordered list for everything
115 filteredCommands.length = 0;
116 filteredCommands.push(...orderedCommands);
117 magazineState.commands = filteredCommands;
118
119 // Determine starting index: URL hash takes priority, otherwise default to "clarify"
120 const hash = window.location.hash;
121 if (hash && hash.startsWith('#cmd-')) {
122 const idx = filteredCommands.findIndex(c => c.id === hash.slice(5));
123 if (idx >= 0) startIndex = idx;
124 }
125 if (startIndex < 0) {
126 startIndex = Math.max(0, filteredCommands.findIndex(c => c.id === 'clarify'));
127 }
128 magazineState.currentIndex = startIndex;
129
130 // Build spreads HTML (after ordering so indices match fisheye)
131 const spreadsHTML = filteredCommands.map((cmd, i) => renderSpread(cmd, i, i === startIndex)).join('');
132
133 const fisheyeHTML = filteredCommands.map((cmd, i) => {
134 const cat = commandCategories[cmd.id] || 'other';
135 const isBeta = betaCommands.includes(cmd.id);
136 return `<button class="fisheye-item${i === startIndex ? ' is-active' : ''}" data-index="${i}" data-id="${cmd.id}" data-cat="${cat}"><span class="fisheye-slash">/</span>${cmd.id}${isBeta ? '<span class="fisheye-beta">BETA</span>' : ''}</button>`;
137 }).join('');
138
139 container.innerHTML = `
140 <div class="magazine-container">
141 <div class="fisheye-list" id="fisheye-list">
142 <div class="fisheye-scroll">${fisheyeHTML}</div>
143 </div>
144 <div class="magazine-viewport">
145 ${spreadsHTML}
146 </div>
147 </div>
148 `;
149
150 // Init demo for active spread
151 initSpreadDemo(startIndex);
152
153 // Set up interactions
154 setupFisheyeList(filteredCommands, headerIndices);
155 setupMagazineKeyboard(filteredCommands);
156 setupMagazineIntersection(container);
157}
158
159function renderSpread(cmd, index, isActive) {
160 const cat = commandCategories[cmd.id] || 'other';
161 const isBeta = betaCommands.includes(cmd.id);
162 const relationship = commandRelationships[cmd.id];
163 // Build relationship flow
164 let flowHTML = '';
165 if (relationship) {
166 if (relationship.pairs) {
167 flowHTML = `
168 <div class="spread-flow">
169 <span class="spread-flow-icon">↔</span>
170 <span class="spread-flow-label">pairs with</span>
171 <span class="spread-flow-cmd">/${relationship.pairs}</span>
172 </div>`;
173 } else if (relationship.leadsTo && relationship.leadsTo.length > 0) {
174 flowHTML = `
175 <div class="spread-flow">
176 <span class="spread-flow-icon">→</span>
177 <span class="spread-flow-label">leads to</span>
178 ${relationship.leadsTo.map(c => `<span class="spread-flow-cmd">/${c}</span>`).join(' ')}
179 </div>`;
180 } else if (relationship.combinesWith && relationship.combinesWith.length > 0) {
181 flowHTML = `
182 <div class="spread-flow">
183 <span class="spread-flow-icon">+</span>
184 <span class="spread-flow-label">combines with</span>
185 ${relationship.combinesWith.map(c => `<span class="spread-flow-cmd">/${c}</span>`).join(' ')}
186 </div>`;
187 }
188 if (!flowHTML && relationship.flow) {
189 flowHTML = `
190 <div class="spread-flow">
191 <span class="spread-flow-label">${relationship.flow}</span>
192 </div>`;
193 }
194 }
195
196 return `
197 <div class="magazine-spread${isActive ? ' active' : ''}" data-index="${index}" data-category="${cat}" data-id="${cmd.id}" id="cmd-${cmd.id}">
198 <div class="spread-identity">
199 <span class="spread-category-label">${categoryLabels[cat] || cat}</span>
200 <h3 class="spread-command-name"><span class="spread-slash">/</span>${cmd.id}${isBeta ? '<span class="beta-badge">BETA</span>' : ''}</h3>
201 <p class="spread-description">${cmd.description}</p>
202 ${flowHTML}
203 </div>
204 <div class="spread-demo-area" data-demo-index="${index}">
205 <!-- Demo rendered lazily -->
206 </div>
207 </div>
208 `;
209}
210
211function initSpreadDemo(index) {
212 const cmd = magazineState.commands[index];
213 if (!cmd) return;
214
215 const spread = document.querySelector(`.magazine-spread[data-index="${index}"]`);
216 if (!spread) return;
217
218 const demoArea = spread.querySelector('.spread-demo-area');
219 if (!demoArea) return;
220
221 // Cleanup previous split instance
222 if (currentSplitInstance) {
223 currentSplitInstance.destroy();
224 currentSplitInstance = null;
225 }
226
227 currentCommandId = cmd.id;
228
229 // Only render HTML once; re-init split compare every time
230 if (demoArea.dataset.loaded !== 'true') {
231 demoArea.innerHTML = renderCommandDemo(cmd.id);
232 demoArea.dataset.loaded = 'true';
233 }
234
235 const splitComparison = demoArea.querySelector('.demo-split-comparison');
236 if (splitComparison) {
237 currentSplitInstance = initSplitCompare(splitComparison, {
238 defaultPosition: 50
239 });
240 }
241 initCommandDemo(cmd.id, demoArea);
242}
243
244function goToSpread(newIndex, commands) {
245 if (newIndex < 0 || newIndex >= commands.length) return;
246 if (newIndex === magazineState.currentIndex) return;
247
248 const oldIndex = magazineState.currentIndex;
249 magazineState.currentIndex = newIndex;
250
251 const spreads = document.querySelectorAll('.magazine-spread');
252
253 // Destroy the old split instance before switching
254 if (currentSplitInstance) {
255 currentSplitInstance.destroy();
256 currentSplitInstance = null;
257 }
258
259 // Mark old as exiting
260 spreads[oldIndex]?.classList.remove('active');
261 spreads[oldIndex]?.classList.add('exiting');
262
263 // Mark new as active
264 spreads[newIndex]?.classList.add('active');
265 spreads[newIndex]?.classList.remove('exiting');
266
267 // No fisheye sync here -- fisheye drives goToSpread, not the other way around
268
269 // Update URL hash
270 const cmd = commands[newIndex];
271 if (cmd) {
272 history.replaceState(null, '', `#cmd-${cmd.id}`);
273 }
274
275 // Init demo for new spread (lazy)
276 initSpreadDemo(newIndex);
277
278 // Clean exiting class after transition
279 setTimeout(() => {
280 spreads[oldIndex]?.classList.remove('exiting');
281 }, 500);
282}
283
284function setupFisheyeList(commands, headerIndices = []) {
285 const list = document.getElementById('fisheye-list');
286 const scroll = list?.querySelector('.fisheye-scroll');
287 const items = list ? [...list.querySelectorAll('.fisheye-item')] : [];
288 if (!list || !scroll || !items.length) return;
289
290 // Fixed item height (matches CSS). All math is index-based.
291 // -- Fisheye with absolute positioning --
292 // Each item is placed absolutely. Their Y positions are computed by
293 // accumulating scaled heights, so small items cluster together
294 // and the center item gets full space. Scroll position maps linearly
295 // to a fractional "center index" which drives everything.
296
297 const BASE_H = 36; // height of the center (scale=1) item
298 const MIN_SCALE = 0.35;
299 const RADIUS = 5;
300 const count = items.length;
301 const listH = list.clientHeight;
302 const centerY = listH / 2;
303 let currentActive = -1;
304
305 // Total scroll range: one "step" per item
306 const STEP = 30; // px of scroll per item advance
307 const totalScroll = (count - 1) * STEP;
308
309 // Set scroll container height to accommodate the range + centering padding
310 const spacer = document.createElement('div');
311 spacer.style.height = `${totalScroll + listH}px`;
312 scroll.appendChild(spacer);
313 // Initial scroll to center first item
314 scroll.scrollTop = 0;
315
316 // Map scrollTop to fractional center index
317 const getCenterIndex = () => scroll.scrollTop / STEP;
318
319 // Compute eased scale for a given distance from center
320 const getScale = (dist) => {
321 const ratio = Math.max(0, 1 - dist / RADIUS);
322 const eased = ratio * ratio * (3 - 2 * ratio); // smoothstep
323 return MIN_SCALE + eased * (1 - MIN_SCALE);
324 };
325
326 // Layout: position all items based on current center
327 const layout = (center) => {
328 // First, compute the Y position for each item by accumulating
329 // scaled heights, centered around the center item
330 const heights = items.map((_, i) => {
331 const dist = Math.abs(i - center);
332 return BASE_H * getScale(dist);
333 });
334
335 // Find the Y offset so the fractional center position lands at centerY.
336 // Interpolate between the integer positions for smooth scrolling.
337 const floorIdx = Math.max(0, Math.min(count - 1, Math.floor(center)));
338 const frac = center - floorIdx;
339
340 let yAtFloor = 0;
341 for (let i = 0; i < floorIdx; i++) yAtFloor += heights[i];
342 yAtFloor += heights[floorIdx] / 2;
343
344 // If between two items, blend toward the next
345 let yAtCeil = yAtFloor;
346 if (floorIdx < count - 1) {
347 yAtCeil = yAtFloor + heights[floorIdx] / 2 + heights[floorIdx + 1] / 2;
348 }
349 const yAtCenter = yAtFloor + (yAtCeil - yAtFloor) * frac;
350 const offset = centerY - yAtCenter + scroll.scrollTop;
351
352 // Position each item
353 let y = offset;
354 items.forEach((item, i) => {
355 const h = heights[i];
356 const scale = getScale(Math.abs(i - center));
357 const opacity = 0.25 + (scale - MIN_SCALE) / (1 - MIN_SCALE) * 0.75;
358
359 item.style.top = `${y}px`;
360 item.style.transform = `scale(${scale})`;
361 item.style.opacity = opacity;
362 y += h;
363 });
364 };
365
366 const activate = (idx) => {
367 idx = Math.max(0, Math.min(count - 1, Math.round(idx)));
368 if (idx === currentActive) return;
369 currentActive = idx;
370 items.forEach((it, i) => it.classList.toggle('is-active', i === idx));
371 goToSpread(idx, commands);
372 };
373
374 const scrollToIndex = (idx, behavior = 'smooth') => {
375 idx = Math.max(0, Math.min(count - 1, idx));
376 scroll.scrollTo({ top: idx * STEP, behavior });
377 };
378
379 // Scroll handler
380 let raf = null;
381 scroll.addEventListener('scroll', () => {
382 if (raf) cancelAnimationFrame(raf);
383 raf = requestAnimationFrame(() => {
384 const center = getCenterIndex();
385 layout(center);
386 activate(Math.round(center));
387 });
388 }, { passive: true });
389
390
391 // Click to jump
392 items.forEach((item, i) => {
393 item.addEventListener('click', () => scrollToIndex(i));
394 });
395
396 // Expose for keyboard/external nav
397 list._scrollToCommand = (idx) => scrollToIndex(idx);
398
399 // Init
400 const startIdx = magazineState.currentIndex;
401 currentActive = -1;
402 scroll.scrollTop = startIdx * STEP;
403 layout(startIdx);
404 activate(startIdx);
405}
406
407function setupMagazineKeyboard(commands) {
408 if (magazineState.keyboardBound) return;
409 magazineState.keyboardBound = true;
410
411 document.addEventListener('keydown', (e) => {
412 // Only respond when magazine is visible (desktop)
413 if (isMobile()) return;
414 const magazineEl = document.querySelector('.magazine-container');
415 if (!magazineEl) return;
416
417 // Check if magazine is somewhat in the viewport
418 const rect = magazineEl.getBoundingClientRect();
419 const inView = rect.top < window.innerHeight && rect.bottom > 0;
420 if (!inView) return;
421
422 const fisheyeList = document.getElementById('fisheye-list');
423 if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
424 e.preventDefault();
425 fisheyeList?._scrollToCommand?.(magazineState.currentIndex + 1);
426 } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
427 e.preventDefault();
428 fisheyeList?._scrollToCommand?.(magazineState.currentIndex - 1);
429 }
430 });
431}
432
433function setupMagazineIntersection(container) {
434 // When the magazine section enters the viewport, ensure the active demo is rendered
435 if (magazineState.intersectionObserver) {
436 magazineState.intersectionObserver.disconnect();
437 }
438
439 magazineState.intersectionObserver = new IntersectionObserver((entries) => {
440 entries.forEach(entry => {
441 if (entry.isIntersecting) {
442 initSpreadDemo(magazineState.currentIndex);
443 }
444 });
445 }, { threshold: 0.1 });
446
447 const magazineEl = container.querySelector('.magazine-container');
448 if (magazineEl) {
449 magazineState.intersectionObserver.observe(magazineEl);
450 }
451}
452
453function truncateDescription(text, maxLen = 120) {
454 if (text.length <= maxLen) return text;
455 // Cut at last sentence boundary within limit, or last word boundary
456 const truncated = text.slice(0, maxLen);
457 const lastPeriod = truncated.lastIndexOf('.');
458 if (lastPeriod > maxLen * 0.5) return truncated.slice(0, lastPeriod + 1);
459 const lastSpace = truncated.lastIndexOf(' ');
460 return truncated.slice(0, lastSpace) + '...';
461}
462
463// ============================================
464// MOBILE LAYOUT - Carousel + Sticky Demo
465// ============================================
466
467function renderMobileLayout(container, commands) {
468 // Build carousel pills
469 const carouselHTML = commands.map((cmd, i) => `
470 <button class="mobile-cmd-pill${i === 0 ? ' active' : ''}" data-id="${cmd.id}">
471 /${cmd.id}
472 </button>
473 `).join('');
474
475 // Build command info cards (one per command, only active one shown)
476 const infoCardsHTML = commands.map((cmd, i) => {
477 const relationship = commandRelationships[cmd.id];
478 let relationshipHTML = '';
479
480 if (relationship) {
481 if (relationship.pairs) {
482 relationshipHTML = `<div class="mobile-cmd-rel">↔ pairs with <code>/${relationship.pairs}</code></div>`;
483 } else if (relationship.leadsTo && relationship.leadsTo.length > 0) {
484 relationshipHTML = `<div class="mobile-cmd-rel">→ leads to ${relationship.leadsTo.map(c => `<code>/${c}</code>`).join(', ')}</div>`;
485 }
486 }
487
488 return `
489 <div class="mobile-cmd-info${i === 0 ? ' active' : ''}" data-id="${cmd.id}">
490 <h3 class="mobile-cmd-name">/${cmd.id}</h3>
491 <p class="mobile-cmd-desc">${cmd.description}</p>
492 ${relationshipHTML}
493 </div>
494 `;
495 }).join('');
496
497 container.innerHTML = `
498 <div class="mobile-commands-layout">
499 <div class="mobile-carousel-wrapper">
500 <div class="mobile-carousel">
501 ${carouselHTML}
502 </div>
503 </div>
504 <div class="mobile-demo-area" id="mobile-demo-content">
505 ${renderCommandDemo(commands[0]?.id || 'audit')}
506 </div>
507 <div class="mobile-info-area">
508 ${infoCardsHTML}
509 </div>
510 </div>
511 `;
512
513 setupMobileInteractions(commands);
514}
515
516function setupMobileInteractions(commands) {
517 const pills = document.querySelectorAll('.mobile-cmd-pill');
518 const demoArea = document.getElementById('mobile-demo-content');
519 const infoCards = document.querySelectorAll('.mobile-cmd-info');
520
521 // Initialize first demo's split compare
522 const initialSplit = demoArea.querySelector('.demo-split-comparison');
523 if (initialSplit) {
524 currentSplitInstance = initSplitCompare(initialSplit, {
525 defaultPosition: 50,
526 minPosition: 10,
527 maxPosition: 90
528 });
529 }
530 if (commands[0]) initCommandDemo(commands[0].id, demoArea);
531
532 // Pill click/tap handler
533 pills.forEach(pill => {
534 pill.addEventListener('click', () => {
535 const cmdId = pill.dataset.id;
536 const cmd = commands.find(c => c.id === cmdId);
537 if (!cmd || currentCommandId === cmdId) return;
538
539 currentCommandId = cmdId;
540
541 // Update active pill
542 pills.forEach(p => p.classList.remove('active'));
543 pill.classList.add('active');
544
545 // Scroll pill into view horizontally
546 pill.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
547
548 // Update info card
549 infoCards.forEach(card => {
550 card.classList.toggle('active', card.dataset.id === cmdId);
551 });
552
553 // Cleanup previous split
554 if (currentSplitInstance) {
555 currentSplitInstance.destroy();
556 currentSplitInstance = null;
557 }
558
559 // Update demo
560 demoArea.innerHTML = renderCommandDemo(cmdId);
561
562 // Init new split compare
563 const splitComparison = demoArea.querySelector('.demo-split-comparison');
564 if (splitComparison) {
565 currentSplitInstance = initSplitCompare(splitComparison, {
566 defaultPosition: 50
567 });
568 }
569 initCommandDemo(cmdId, demoArea);
570 });
571 });
572}
573
574// ============================================
575// STACKED WINDOWS - Tab Switching
576// ============================================
577
578function setupStackTabs() {
579 const tabs = document.querySelectorAll('.terminal-stack-tab');
580 const demoWindow = document.querySelector('.terminal-window--demo');
581 const sourceWindow = document.querySelector('.terminal-window--source');
582
583 tabs.forEach(tab => {
584 tab.addEventListener('click', () => {
585 const view = tab.dataset.view;
586
587 // Update tab states
588 tabs.forEach(t => t.classList.remove('active'));
589 tab.classList.add('active');
590
591 // Switch windows
592 if (view === 'source') {
593 demoWindow.classList.add('is-back');
594 sourceWindow.classList.add('is-front');
595 } else {
596 demoWindow.classList.remove('is-back');
597 sourceWindow.classList.remove('is-front');
598 }
599 });
600 });
601}
602
603async function fetchCommandSource(cmdId) {
604 // Check cache first
605 if (sourceCache[cmdId]) {
606 return sourceCache[cmdId];
607 }
608
609 try {
610 const response = await fetch(`/api/command-source/${cmdId}`);
611 if (!response.ok) throw new Error('Failed to fetch source');
612 const data = await response.json();
613 sourceCache[cmdId] = data.content;
614 return data.content;
615 } catch (error) {
616 console.error('Error fetching command source:', error);
617 return null;
618 }
619}
620
621function escapeHtml(text) {
622 const div = document.createElement('div');
623 div.textContent = text;
624 return div.innerHTML;
625}
626
627async function updateSourceContent(cmdId) {
628 const titleEl = document.getElementById('source-title');
629 const contentEl = document.getElementById('source-content');
630
631 if (!titleEl || !contentEl) return;
632
633 titleEl.textContent = `${cmdId}.md`;
634 contentEl.innerHTML = '<span class="source-loading">Loading...</span>';
635
636 const source = await fetchCommandSource(cmdId);
637 if (source) {
638 contentEl.textContent = source;
639 } else {
640 contentEl.innerHTML = '<span class="source-loading">Source not available</span>';
641 }
642}
643