glass-terminal.js

  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">&#8596;</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">&#8594;</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