import { renderCommandDemo, initCommandDemo } from "../demo-renderer.js"; import { initSplitCompare } from "../effects/split-compare.js"; import { commandProcessSteps, commandCategories, commandRelationships, alphaCommands } from "../data.js"; // Track current split instance and command for cleanup let currentSplitInstance = null; let currentCommandId = null; let sourceCache = {}; // Cache fetched source content const MOBILE_BREAKPOINT = 900; // Setup / management commands that aren't "steering" verbs. They're kept off // the command palette (fisheye + mobile carousel) but still appear in the // periodic table (rendered separately by framework-viz.js). const PALETTE_EXCLUDED = new Set(['impeccable', 'init', 'extract', 'document', 'live']); function isMobile() { return window.innerWidth <= MOBILE_BREAKPOINT; } export function initGlassTerminal() { // Initial setup if needed } export function renderTerminalLayout(commands) { const container = document.querySelector('.commands-gallery'); if (!container) return; if (isMobile()) { renderMobileLayout(container, commands); } else { renderDesktopLayout(container, commands); } // Re-render on resize crossing breakpoint let wasMobile = isMobile(); window.addEventListener('resize', () => { const nowMobile = isMobile(); if (nowMobile !== wasMobile) { wasMobile = nowMobile; currentSplitInstance = null; currentCommandId = null; if (nowMobile) { renderMobileLayout(container, commands); } else { renderDesktopLayout(container, commands); } } }); } // ============================================ // DESKTOP LAYOUT - Magazine Spread // ============================================ let magazineState = { currentIndex: 0, commands: [], isTransitioning: false, keyboardBound: false, intersectionObserver: null }; const categoryOrder = ['diagnostic', 'quality', 'intensity', 'adaptation', 'enhancement', 'system']; const categoryLabels = { 'create': 'Create', 'evaluate': 'Evaluate', 'refine': 'Refine', 'simplify': 'Simplify', 'harden': 'Harden', 'system': 'System' }; function renderDesktopLayout(container, commands) { magazineState.commands = commands; let startIndex = -1; // Filter out deprecated shims. craft, teach, extract used to be filtered // too (when they were rendered as 'impeccable craft' etc.) but are now // first-class sub-commands that should appear in the gallery. const deprecated = new Set(['teach-impeccable', 'frontend-design', 'arrange', 'normalize', 'onboard', 'impeccable craft', 'impeccable teach', 'impeccable extract']); const filteredCommands = commands.filter(c => !deprecated.has(c.id) && !PALETTE_EXCLUDED.has(c.id)); const categoryOrder = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system']; const categoryLabelsShort = { 'create': 'Create', 'evaluate': 'Evaluate', 'refine': 'Refine', 'simplify': 'Simplify', 'harden': 'Harden', 'system': 'System' }; // Preferred order within each category (unlisted commands append at end) const categoryCommandOrder = { 'create': ['impeccable', 'craft', 'shape'], 'evaluate': ['critique', 'audit'], 'refine': ['typeset', 'layout', 'colorize', 'animate', 'delight', 'bolder', 'quieter', 'overdrive'], 'simplify': ['distill', 'clarify', 'adapt'], 'harden': ['polish', 'optimize', 'harden'], 'system': ['init', 'extract'] }; const grouped = {}; filteredCommands.forEach(cmd => { const cat = commandCategories[cmd.id] || 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(cmd); }); // Sort each group by preferred order Object.entries(grouped).forEach(([cat, cmds]) => { const order = categoryCommandOrder[cat] || []; cmds.sort((a, b) => { const ai = order.indexOf(a.id); const bi = order.indexOf(b.id); return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); }); }); const orderedCommands = []; const headerIndices = []; categoryOrder.forEach(cat => { if (!grouped[cat]) return; headerIndices.push({ index: orderedCommands.length, label: categoryLabelsShort[cat] || cat }); orderedCommands.push(...grouped[cat]); }); // Use ordered list for everything filteredCommands.length = 0; filteredCommands.push(...orderedCommands); magazineState.commands = filteredCommands; // Determine starting index: URL hash takes priority, otherwise default to "clarify" const hash = window.location.hash; if (hash && hash.startsWith('#cmd-')) { const idx = filteredCommands.findIndex(c => c.id === hash.slice(5)); if (idx >= 0) startIndex = idx; } if (startIndex < 0) { startIndex = Math.max(0, filteredCommands.findIndex(c => c.id === 'clarify')); } magazineState.currentIndex = startIndex; // Build spreads HTML (after ordering so indices match fisheye) const spreadsHTML = filteredCommands.map((cmd, i) => renderSpread(cmd, i, i === startIndex)).join(''); const fisheyeHTML = filteredCommands.map((cmd, i) => { const cat = commandCategories[cmd.id] || 'other'; const isAlpha = alphaCommands.includes(cmd.id); // The command palette lists command names, not raw invocations. const label = cmd.id; return ``; }).join(''); container.innerHTML = `
${fisheyeHTML}
${spreadsHTML}
`; // Init demo for active spread initSpreadDemo(startIndex); // Set up interactions setupFisheyeList(filteredCommands, headerIndices); setupMagazineKeyboard(filteredCommands); setupMagazineIntersection(container); } function renderSpread(cmd, index, isActive) { const cat = commandCategories[cmd.id] || 'other'; const isAlpha = alphaCommands.includes(cmd.id); const relationship = commandRelationships[cmd.id]; // Build relationship flow let flowHTML = ''; if (relationship) { if (relationship.pairs) { flowHTML = `
pairs with /${relationship.pairs}
`; } else if (relationship.leadsTo && relationship.leadsTo.length > 0) { flowHTML = `
leads to ${relationship.leadsTo.map(c => `/${c}`).join(' ')}
`; } else if (relationship.combinesWith && relationship.combinesWith.length > 0) { flowHTML = `
+ combines with ${relationship.combinesWith.map(c => `/${c}`).join(' ')}
`; } if (!flowHTML && relationship.flow) { flowHTML = `
${relationship.flow}
`; } } // The root skill is rendered as impeccable; sub-commands are rendered as // /impeccable on a smaller line above the command name, so the command name // stays the visual anchor at full display size. const isRoot = cmd.id === 'impeccable'; const nameHTML = isRoot ? 'impeccable' : `/impeccable${cmd.id}`; return `
${categoryLabels[cat] || cat}

${nameHTML}${isAlpha ? 'ALPHA' : ''}

${cmd.tagline || cmd.description}

${flowHTML}
`; } function initSpreadDemo(index) { const cmd = magazineState.commands[index]; if (!cmd) return; const spread = document.querySelector(`.magazine-spread[data-index="${index}"]`); if (!spread) return; const demoArea = spread.querySelector('.spread-demo-area'); if (!demoArea) return; // Cleanup previous split instance if (currentSplitInstance) { currentSplitInstance.destroy(); currentSplitInstance = null; } currentCommandId = cmd.id; // Only render HTML once; re-init split compare every time if (demoArea.dataset.loaded !== 'true') { demoArea.innerHTML = renderCommandDemo(cmd.id); demoArea.dataset.loaded = 'true'; } const splitComparison = demoArea.querySelector('.demo-split-comparison'); if (splitComparison) { currentSplitInstance = initSplitCompare(splitComparison, { defaultPosition: 50, skewAngle: 0 }); } initCommandDemo(cmd.id, demoArea); } function goToSpread(newIndex, commands) { if (newIndex < 0 || newIndex >= commands.length) return; if (newIndex === magazineState.currentIndex) return; const oldIndex = magazineState.currentIndex; magazineState.currentIndex = newIndex; const spreads = document.querySelectorAll('.magazine-spread'); // Destroy the old split instance before switching if (currentSplitInstance) { currentSplitInstance.destroy(); currentSplitInstance = null; } // Mark old as exiting spreads[oldIndex]?.classList.remove('active'); spreads[oldIndex]?.classList.add('exiting'); // Mark new as active spreads[newIndex]?.classList.add('active'); spreads[newIndex]?.classList.remove('exiting'); // No fisheye sync here -- fisheye drives goToSpread, not the other way around // Update URL hash const cmd = commands[newIndex]; if (cmd) { history.replaceState(null, '', `#cmd-${cmd.id}`); } // Init demo for new spread (lazy) initSpreadDemo(newIndex); // Clean exiting class after transition setTimeout(() => { spreads[oldIndex]?.classList.remove('exiting'); }, 500); } function setupFisheyeList(commands, headerIndices = []) { const list = document.getElementById('fisheye-list'); const scroll = list?.querySelector('.fisheye-scroll'); const items = list ? [...list.querySelectorAll('.fisheye-item')] : []; if (!list || !scroll || !items.length) return; // Fixed item height (matches CSS). All math is index-based. // -- Fisheye with absolute positioning -- // Each item is placed absolutely. Their Y positions are computed by // accumulating scaled heights, so small items cluster together // and the center item gets full space. Scroll position maps linearly // to a fractional "center index" which drives everything. const BASE_H = 36; // height of the center (scale=1) item const MIN_SCALE = 0.52; // off-center items stay legibly sized, not microscopic const RADIUS = 5; const count = items.length; const listH = list.clientHeight; const centerY = listH / 2; let currentActive = -1; // Total scroll range: one "step" per item const STEP = 30; // px of scroll per item advance const totalScroll = (count - 1) * STEP; // Set scroll container height to accommodate the range + centering padding const spacer = document.createElement('div'); spacer.style.height = `${totalScroll + listH}px`; scroll.appendChild(spacer); // Initial scroll to center first item scroll.scrollTop = 0; // Map scrollTop to fractional center index const getCenterIndex = () => scroll.scrollTop / STEP; // Compute eased scale for a given distance from center const getScale = (dist) => { const ratio = Math.max(0, 1 - dist / RADIUS); const eased = ratio * ratio * (3 - 2 * ratio); // smoothstep return MIN_SCALE + eased * (1 - MIN_SCALE); }; // Layout: position all items based on current center const layout = (center) => { // First, compute the Y position for each item by accumulating // scaled heights, centered around the center item const heights = items.map((_, i) => { const dist = Math.abs(i - center); return BASE_H * getScale(dist); }); // Find the Y offset so the fractional center position lands at centerY. // Interpolate between the integer positions for smooth scrolling. const floorIdx = Math.max(0, Math.min(count - 1, Math.floor(center))); const frac = center - floorIdx; let yAtFloor = 0; for (let i = 0; i < floorIdx; i++) yAtFloor += heights[i]; yAtFloor += heights[floorIdx] / 2; // If between two items, blend toward the next let yAtCeil = yAtFloor; if (floorIdx < count - 1) { yAtCeil = yAtFloor + heights[floorIdx] / 2 + heights[floorIdx + 1] / 2; } const yAtCenter = yAtFloor + (yAtCeil - yAtFloor) * frac; const offset = centerY - yAtCenter + scroll.scrollTop; // Position each item let y = offset; items.forEach((item, i) => { const h = heights[i]; const scale = getScale(Math.abs(i - center)); // Floor at 0.62 so off-center command names stay readable (WCAG): the // full vocabulary is the point of this view. Focus still reads clearly // via scale + the gold/weight active state, not by crushing legibility. const opacity = 0.62 + (scale - MIN_SCALE) / (1 - MIN_SCALE) * 0.38; item.style.top = `${y}px`; item.style.transform = `scale(${scale})`; item.style.opacity = opacity; y += h; }); }; const activate = (idx) => { idx = Math.max(0, Math.min(count - 1, Math.round(idx))); if (idx === currentActive) return; currentActive = idx; items.forEach((it, i) => it.classList.toggle('is-active', i === idx)); goToSpread(idx, commands); }; const scrollToIndex = (idx, behavior = 'smooth') => { idx = Math.max(0, Math.min(count - 1, idx)); scroll.scrollTo({ top: idx * STEP, behavior }); }; // Scroll handler let raf = null; scroll.addEventListener('scroll', () => { if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { const center = getCenterIndex(); layout(center); activate(Math.round(center)); }); }, { passive: true }); // Click to jump items.forEach((item, i) => { item.addEventListener('click', () => scrollToIndex(i)); }); // Expose for keyboard/external nav list._scrollToCommand = (idx) => scrollToIndex(idx); // Init const startIdx = magazineState.currentIndex; currentActive = -1; scroll.scrollTop = startIdx * STEP; layout(startIdx); activate(startIdx); } function setupMagazineKeyboard(commands) { if (magazineState.keyboardBound) return; magazineState.keyboardBound = true; document.addEventListener('keydown', (e) => { // Only respond when magazine is visible (desktop) if (isMobile()) return; const magazineEl = document.querySelector('.magazine-container'); if (!magazineEl) return; // Check if magazine is somewhat in the viewport const rect = magazineEl.getBoundingClientRect(); const inView = rect.top < window.innerHeight && rect.bottom > 0; if (!inView) return; const fisheyeList = document.getElementById('fisheye-list'); if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); fisheyeList?._scrollToCommand?.(magazineState.currentIndex + 1); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); fisheyeList?._scrollToCommand?.(magazineState.currentIndex - 1); } }); } function setupMagazineIntersection(container) { // When the magazine section enters the viewport, ensure the active demo is rendered if (magazineState.intersectionObserver) { magazineState.intersectionObserver.disconnect(); } magazineState.intersectionObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { initSpreadDemo(magazineState.currentIndex); } }); }, { threshold: 0.1 }); const magazineEl = container.querySelector('.magazine-container'); if (magazineEl) { magazineState.intersectionObserver.observe(magazineEl); } } function truncateDescription(text, maxLen = 120) { if (text.length <= maxLen) return text; // Cut at last sentence boundary within limit, or last word boundary const truncated = text.slice(0, maxLen); const lastPeriod = truncated.lastIndexOf('.'); if (lastPeriod > maxLen * 0.5) return truncated.slice(0, lastPeriod + 1); const lastSpace = truncated.lastIndexOf(' '); return truncated.slice(0, lastSpace) + '...'; } // ============================================ // MOBILE LAYOUT - Carousel + Sticky Demo // ============================================ function renderMobileLayout(container, commands) { // Keep setup/management commands off the palette (they stay in the periodic // table); match the desktop fisheye filter. commands = commands.filter(c => !PALETTE_EXCLUDED.has(c.id)); // Build carousel pills // Carousel pills show bare command names for sub-commands, and /impeccable // for the root entry. const carouselHTML = commands.map((cmd, i) => ` `).join(''); // Build command info cards (one per command, only active one shown) const infoCardsHTML = commands.map((cmd, i) => { const relationship = commandRelationships[cmd.id]; let relationshipHTML = ''; // Relationships show bare command names (e.g., "pairs with quieter") // because the invocation is /impeccable , not /. if (relationship) { if (relationship.pairs) { relationshipHTML = `
↔ pairs with ${relationship.pairs}
`; } else if (relationship.leadsTo && relationship.leadsTo.length > 0) { relationshipHTML = `
→ leads to ${relationship.leadsTo.map(c => `${c}`).join(', ')}
`; } } const cardName = cmd.id === 'impeccable' ? '/impeccable' : `/impeccable ${cmd.id}`; return `

${cardName}

${cmd.tagline || cmd.description}

${relationshipHTML}
`; }).join(''); container.innerHTML = `
${renderCommandDemo(commands[0]?.id || 'audit')}
${infoCardsHTML}
`; setupMobileInteractions(commands); } function setupMobileInteractions(commands) { const pills = document.querySelectorAll('.mobile-cmd-pill'); const demoArea = document.getElementById('mobile-demo-content'); const infoCards = document.querySelectorAll('.mobile-cmd-info'); // Initialize first demo's split compare const initialSplit = demoArea.querySelector('.demo-split-comparison'); if (initialSplit) { currentSplitInstance = initSplitCompare(initialSplit, { defaultPosition: 50, skewAngle: 0, minPosition: 10, maxPosition: 90 }); } if (commands[0]) initCommandDemo(commands[0].id, demoArea); // Pill click/tap handler pills.forEach(pill => { pill.addEventListener('click', () => { const cmdId = pill.dataset.id; const cmd = commands.find(c => c.id === cmdId); if (!cmd || currentCommandId === cmdId) return; currentCommandId = cmdId; // Update active pill pills.forEach(p => p.classList.remove('active')); pill.classList.add('active'); // Scroll pill into view horizontally pill.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); // Update info card infoCards.forEach(card => { card.classList.toggle('active', card.dataset.id === cmdId); }); // Cleanup previous split if (currentSplitInstance) { currentSplitInstance.destroy(); currentSplitInstance = null; } // Update demo demoArea.innerHTML = renderCommandDemo(cmdId); // Init new split compare const splitComparison = demoArea.querySelector('.demo-split-comparison'); if (splitComparison) { currentSplitInstance = initSplitCompare(splitComparison, { defaultPosition: 50, skewAngle: 0 }); } initCommandDemo(cmdId, demoArea); }); }); } // ============================================ // STACKED WINDOWS - Tab Switching // ============================================ function setupStackTabs() { const tabs = document.querySelectorAll('.terminal-stack-tab'); const demoWindow = document.querySelector('.terminal-window--demo'); const sourceWindow = document.querySelector('.terminal-window--source'); tabs.forEach(tab => { tab.addEventListener('click', () => { const view = tab.dataset.view; // Update tab states tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); // Switch windows if (view === 'source') { demoWindow.classList.add('is-back'); sourceWindow.classList.add('is-front'); } else { demoWindow.classList.remove('is-back'); sourceWindow.classList.remove('is-front'); } }); }); } async function fetchCommandSource(cmdId) { // Check cache first if (sourceCache[cmdId]) { return sourceCache[cmdId]; } try { const response = await fetch(`/api/command-source/${cmdId}`); if (!response.ok) throw new Error('Failed to fetch source'); const data = await response.json(); sourceCache[cmdId] = data.content; return data.content; } catch (error) { console.error('Error fetching command source:', error); return null; } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } async function updateSourceContent(cmdId) { const titleEl = document.getElementById('source-title'); const contentEl = document.getElementById('source-content'); if (!titleEl || !contentEl) return; titleEl.textContent = `${cmdId}.md`; contentEl.innerHTML = 'Loading...'; const source = await fetchCommandSource(cmdId); if (source) { contentEl.textContent = source; } else { contentEl.innerHTML = 'Source not available'; } }