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