glass-terminal.js

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