Redesign Commands section: magazine spread + fisheye nav

Replace the old scrollspy + glass terminal layout with:
- Editorial magazine spread: one command at a time with big display
  type name, category label, description, relationships, and
  before/after demo
- Fisheye command list: vertical scrollable list with transform-based
  scale effect (0.4x to 1.0x), smooth center detection, synced to
  the active spread. Index-based math avoids layout oscillation.
- Removed deprecated commands (teach-impeccable, frontend-design)
  from the browsable list
- Removed transition lock that blocked rapid scrolling updates
- Mobile layout preserved (existing carousel)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-31 08:20:48 -07:00
co-authored by Claude Opus 4.6
parent 17f389db45
commit 487ac0cf3a
3 changed files with 902 additions and 163 deletions
+280
View File
@@ -100,6 +100,280 @@
.commands-gallery {
display: block;
}
.magazine-spread[data-category="diagnostic"] {
--spread-accent: oklch(55% 0.20 25);
}
.magazine-spread[data-category="quality"] {
--spread-accent: oklch(55% 0.20 160);
}
.magazine-spread[data-category="intensity"] {
--spread-accent: oklch(55% 0.22 350);
}
.magazine-spread[data-category="adaptation"] {
--spread-accent: oklch(55% 0.18 260);
}
.magazine-spread[data-category="enhancement"] {
--spread-accent: oklch(55% 0.20 300);
}
.magazine-spread[data-category="system"] {
--spread-accent: oklch(45% 0.05 0);
}
.magazine-container {
position: relative;
width: 100%;
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--spacing-lg);
align-items: start;
overflow: hidden;
}
.fisheye-list {
position: relative;
height: 520px;
overflow: hidden;
mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
}
.fisheye-scroll {
height: 100%;
overflow-y: auto;
scrollbar-width: none;
}
.fisheye-scroll::-webkit-scrollbar {
display: none;
}
.fisheye-item {
display: block;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--color-ink);
background: none;
border: none;
padding: 4px 0;
height: 39px;
cursor: pointer;
white-space: nowrap;
text-align: left;
width: 100%;
line-height: 1.3;
transform-origin: left center;
will-change: transform, opacity;
}
.fisheye-slash {
color: var(--color-mist);
font-weight: 300;
}
.fisheye-item.is-active {
font-weight: 600;
}
.fisheye-item.is-active .fisheye-slash {
color: var(--color-accent);
}
.magazine-viewport {
position: relative;
height: 520px;
overflow: hidden;
}
.magazine-spread {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 38% 1fr;
grid-template-rows: 1fr auto;
gap: 0 var(--spacing-xl);
padding: var(--spacing-lg) var(--spacing-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transform: translateY(16px);
will-change: opacity, transform;
}
.magazine-spread.active {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
z-index: 2;
}
.magazine-spread.exiting {
opacity: 0;
transform: translateY(-16px);
z-index: 1;
}
.spread-identity {
grid-column: 1;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--spacing-md);
padding-right: var(--spacing-lg);
border-right: 1px solid var(--color-mist);
}
.spread-category-label {
font-family: var(--font-body);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--spread-accent);
}
.spread-command-name {
font-family: var(--font-display);
font-size: clamp(3rem, 5.5vw, 5.5rem);
font-weight: 600;
line-height: 0.95;
color: var(--color-ink);
margin: 0;
letter-spacing: -0.02em;
word-break: break-word;
hyphens: auto;
}
.spread-command-name .spread-slash {
color: var(--spread-accent);
font-weight: 300;
}
.spread-command-name .beta-badge {
font-family: var(--font-body);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--spread-accent);
border: 1px solid var(--spread-accent);
border-radius: 3px;
padding: 2px 6px;
vertical-align: super;
margin-left: 8px;
}
.spread-description {
font-family: var(--font-body);
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-charcoal);
max-width: 360px;
}
.spread-flow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: var(--spacing-xs);
}
.spread-flow-label {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--color-ash);
}
.spread-flow-icon {
color: var(--spread-accent);
font-weight: 600;
font-size: 0.8125rem;
}
.spread-flow-cmd {
font-family: var(--font-mono);
font-size: 0.6875rem;
background: var(--color-mist);
padding: 2px 7px;
border-radius: 3px;
color: var(--color-ink);
}
.spread-demo-area {
grid-column: 2;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.spread-demo-area .demo-split-comparison {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
}
.spread-demo-area .demo-split-comparison .split-container {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
cursor: ew-resize;
user-select: none;
background: var(--color-cream);
}
.spread-demo-area .demo-split-comparison .split-before, .spread-demo-area .demo-split-comparison .split-after {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.spread-demo-area .demo-split-comparison .split-before {
z-index: 1;
background: var(--color-cream);
}
.spread-demo-area .demo-split-comparison .split-after {
z-index: 2;
background: var(--color-paper);
clip-path: polygon(58% 0%, 100% 0%, 100% 100%, 42% 100%);
}
.spread-demo-area .demo-split-comparison .split-content {
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.spread-demo-area .demo-split-comparison .split-divider {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: var(--spread-accent, var(--color-accent));
transform: translateX(-50%) skewX(-10deg);
pointer-events: none;
z-index: 3;
box-shadow: 0 0 12px rgba(0,0,0,0.1);
}
.spread-demo-area .demo-split-comparison .split-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) skewX(10deg);
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-paper);
background: var(--spread-accent, var(--color-accent));
padding: 4px 10px;
border-radius: 3px;
white-space: nowrap;
}
.spread-demo-area .demo-split-comparison .demo-caption {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-ash);
text-align: center;
padding: var(--spacing-sm) var(--spacing-md);
border-top: 1px solid var(--color-mist);
background: var(--color-paper);
}
.spread-demo-area .demo-container {
flex: 1;
display: flex;
flex-direction: column;
}
.spread-demo-area .demo-container .demo-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.commands-container {
display: grid;
grid-template-columns: 1fr 1.2fr;
@@ -314,6 +588,12 @@
font-style: italic;
}
@media (max-width: 900px) {
.magazine-container {
display: none;
}
.fisheye-list {
display: none;
}
.glass-terminal-wrapper {
display: none;
}
+321 -2
View File
@@ -8,9 +8,327 @@
.commands-gallery {
/* Reset grid */
display: block;
display: block;
}
/* ============================================
MAGAZINE SPREAD - Desktop Layout
============================================ */
/* Category accent colors */
.magazine-spread[data-category="diagnostic"] { --spread-accent: oklch(55% 0.20 25); }
.magazine-spread[data-category="quality"] { --spread-accent: oklch(55% 0.20 160); }
.magazine-spread[data-category="intensity"] { --spread-accent: oklch(55% 0.22 350); }
.magazine-spread[data-category="adaptation"] { --spread-accent: oklch(55% 0.18 260); }
.magazine-spread[data-category="enhancement"] { --spread-accent: oklch(55% 0.20 300); }
.magazine-spread[data-category="system"] { --spread-accent: oklch(45% 0.05 0); }
.magazine-container {
position: relative;
width: 100%;
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--spacing-lg);
align-items: start;
overflow: hidden;
}
/* Fisheye command list */
.fisheye-list {
position: relative;
height: 520px;
overflow: hidden;
mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
}
.fisheye-scroll {
height: 100%;
overflow-y: auto;
scrollbar-width: none;
/* Padding set dynamically by JS based on container height */
}
.fisheye-scroll::-webkit-scrollbar { display: none; }
.fisheye-item {
display: block;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 400;
color: var(--color-ink);
background: none;
border: none;
/* Fixed height: 1.5rem * 1.3 line-height = 31.2px + 8px padding = ~39px */
padding: 4px 0;
height: 39px;
cursor: pointer;
white-space: nowrap;
text-align: left;
width: 100%;
line-height: 1.3;
transform-origin: left center;
will-change: transform, opacity;
}
.fisheye-slash {
color: var(--color-mist);
font-weight: 300;
}
.fisheye-item.is-active {
font-weight: 600;
}
.fisheye-item.is-active .fisheye-slash {
color: var(--color-accent);
}
/* The viewport that clips to one spread at a time */
.magazine-viewport {
position: relative;
height: 520px;
overflow: hidden;
}
/* Individual spread */
.magazine-spread {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 38% 1fr;
grid-template-rows: 1fr auto;
gap: 0 var(--spacing-xl);
padding: var(--spacing-lg) var(--spacing-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transform: translateY(16px);
will-change: opacity, transform;
}
.magazine-spread.active {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
z-index: 2;
}
.magazine-spread.exiting {
opacity: 0;
transform: translateY(-16px);
z-index: 1;
}
/* Left column: Big name + meta */
.spread-identity {
grid-column: 1;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--spacing-md);
padding-right: var(--spacing-lg);
border-right: 1px solid var(--color-mist);
}
.spread-category-label {
font-family: var(--font-body);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--spread-accent);
}
.spread-command-name {
font-family: var(--font-display);
font-size: clamp(3rem, 5.5vw, 5.5rem);
font-weight: 600;
line-height: 0.95;
color: var(--color-ink);
margin: 0;
letter-spacing: -0.02em;
word-break: break-word;
hyphens: auto;
}
.spread-command-name .spread-slash {
color: var(--spread-accent);
font-weight: 300;
}
.spread-command-name .beta-badge {
font-family: var(--font-body);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--spread-accent);
border: 1px solid var(--spread-accent);
border-radius: 3px;
padding: 2px 6px;
vertical-align: super;
margin-left: 8px;
}
.spread-description {
font-family: var(--font-body);
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-charcoal);
max-width: 360px;
}
/* Flow / relationship line */
.spread-flow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: var(--spacing-xs);
}
.spread-flow-label {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--color-ash);
}
.spread-flow-icon {
color: var(--spread-accent);
font-weight: 600;
font-size: 0.8125rem;
}
.spread-flow-cmd {
font-family: var(--font-mono);
font-size: 0.6875rem;
background: var(--color-mist);
padding: 2px 7px;
border-radius: 3px;
color: var(--color-ink);
}
/* Right column: Demo area */
.spread-demo-area {
grid-column: 2;
grid-row: 1 / 3;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* Demo split comparison inside the spread */
.spread-demo-area .demo-split-comparison {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
}
.spread-demo-area .demo-split-comparison .split-container {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
cursor: ew-resize;
user-select: none;
background: var(--color-cream);
}
.spread-demo-area .demo-split-comparison .split-before,
.spread-demo-area .demo-split-comparison .split-after {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.spread-demo-area .demo-split-comparison .split-before {
z-index: 1;
background: var(--color-cream);
}
.spread-demo-area .demo-split-comparison .split-after {
z-index: 2;
background: var(--color-paper);
clip-path: polygon(58% 0%, 100% 0%, 100% 100%, 42% 100%);
}
.spread-demo-area .demo-split-comparison .split-content {
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.spread-demo-area .demo-split-comparison .split-divider {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: var(--spread-accent, var(--color-accent));
transform: translateX(-50%) skewX(-10deg);
pointer-events: none;
z-index: 3;
box-shadow: 0 0 12px rgba(0,0,0,0.1);
}
.spread-demo-area .demo-split-comparison .split-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) skewX(10deg);
font-size: 0.5625rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-paper);
background: var(--spread-accent, var(--color-accent));
padding: 4px 10px;
border-radius: 3px;
white-space: nowrap;
}
.spread-demo-area .demo-split-comparison .demo-caption {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-ash);
text-align: center;
padding: var(--spacing-sm) var(--spacing-md);
border-top: 1px solid var(--color-mist);
background: var(--color-paper);
}
/* Fallback demo container inside spread */
.spread-demo-area .demo-container {
flex: 1;
display: flex;
flex-direction: column;
}
.spread-demo-area .demo-container .demo-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* (nav moved to fisheye list) */
/* ============================================
OLD DESKTOP STYLES (kept for mobile reuse)
============================================ */
.commands-container {
display: grid;
grid-template-columns: 1fr 1.2fr;
@@ -264,9 +582,10 @@
}
@media (max-width: 900px) {
.magazine-container { display: none; }
.fisheye-list { display: none; }
.glass-terminal-wrapper {
display: none; /* Hide on mobile for now, or stack */
/* Alternatively: Position fixed bottom sheet */
}
}
+301 -161
View File
@@ -45,20 +45,41 @@ export function renderTerminalLayout(commands) {
}
// ============================================
// DESKTOP LAYOUT (unchanged)
// DESKTOP LAYOUT - Magazine Spread
// ============================================
function renderDesktopLayout(container, commands) {
const categoryOrder = ['diagnostic', 'quality', 'intensity', 'adaptation', 'enhancement', 'system'];
const categoryLabels = {
'diagnostic': 'Diagnose',
'quality': 'Quality',
'intensity': 'Intensity',
'adaptation': 'Adaptation',
'enhancement': 'Enhancement',
'system': 'System'
};
let magazineState = {
currentIndex: 0,
commands: [],
isTransitioning: false,
keyboardBound: false,
intersectionObserver: null
};
const categoryOrder = ['diagnostic', 'quality', 'intensity', 'adaptation', 'enhancement', 'system'];
const categoryLabels = {
'diagnostic': 'Diagnose',
'quality': 'Quality',
'intensity': 'Intensity',
'adaptation': 'Adaptation',
'enhancement': 'Enhancement',
'system': 'System'
};
function renderDesktopLayout(container, commands) {
magazineState.commands = commands;
// Determine starting index from URL hash
const hash = window.location.hash;
let startIndex = 0;
if (hash && hash.startsWith('#cmd-')) {
const cmdId = hash.slice(5);
const idx = commands.findIndex(c => c.id === cmdId);
if (idx >= 0) startIndex = idx;
}
magazineState.currentIndex = startIndex;
// Build ordered list with category separators for nav
const grouped = {};
commands.forEach(cmd => {
const cat = commandCategories[cmd.id] || 'other';
@@ -66,64 +87,284 @@ function renderDesktopLayout(container, commands) {
grouped[cat].push(cmd);
});
let manualHTML = '';
categoryOrder.forEach(cat => {
if (grouped[cat] && grouped[cat].length > 0) {
manualHTML += `<div class="command-category-header">${categoryLabels[cat] || cat}</div>`;
manualHTML += grouped[cat].map(cmd => renderManualEntry(cmd)).join('');
}
});
// Filter out deprecated shims
const deprecated = new Set(['teach-impeccable', 'frontend-design']);
const filteredCommands = commands.filter(c => !deprecated.has(c.id));
magazineState.commands = filteredCommands;
// Build spreads HTML
const spreadsHTML = filteredCommands.map((cmd, i) => renderSpread(cmd, i, i === startIndex)).join('');
// Build fisheye list
const fisheyeHTML = filteredCommands.map((cmd, i) => {
const cat = commandCategories[cmd.id] || 'other';
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}</button>`;
}).join('');
container.innerHTML = `
<div class="commands-container">
<div class="command-manual">
${manualHTML}
<div class="magazine-container">
<div class="fisheye-list" id="fisheye-list">
<div class="fisheye-scroll">${fisheyeHTML}</div>
</div>
<div class="glass-terminal-wrapper">
<div class="terminal-stack">
<div class="terminal-stack-tabs">
<button class="terminal-stack-tab active" data-view="demo">Demo</button>
<button class="terminal-stack-tab" data-view="source">Source</button>
</div>
<div class="terminal-window terminal-window--source">
<div class="source-window">
<div class="source-header">
<span class="source-title" id="source-title">command.md</span>
</div>
<div class="source-body" id="source-content">
<span class="source-loading">Select a command to view source...</span>
</div>
</div>
</div>
<div class="terminal-window terminal-window--demo">
<div class="glass-terminal">
<div class="terminal-header">
<span class="terminal-dot red"></span>
<span class="terminal-dot yellow"></span>
<span class="terminal-dot green"></span>
<span class="terminal-title">zsh — 80x24</span>
</div>
<div class="terminal-body" id="terminal-content">
<div class="terminal-line">
<span class="terminal-prompt">➜</span>
<span>Waiting for input...</span>
</div>
</div>
</div>
</div>
</div>
<div class="magazine-viewport">
${spreadsHTML}
</div>
</div>
`;
setupStackTabs();
// Init demo for active spread
initSpreadDemo(startIndex);
setupDesktopScrollSpy(commands);
// Set up interactions
setupFisheyeList(filteredCommands);
setupMagazineKeyboard(filteredCommands);
setupMagazineIntersection(container);
}
if (commands.length > 0) {
updateTerminal(commands[0], document.getElementById('terminal-content'), commands);
const firstEntry = document.querySelector('.manual-entry');
if (firstEntry) firstEntry.classList.add('active');
function renderSpread(cmd, index, isActive) {
const cat = commandCategories[cmd.id] || 'other';
const isBeta = betaCommands.includes(cmd.id);
const relationship = commandRelationships[cmd.id];
// Build relationship flow
let flowHTML = '';
if (relationship) {
if (relationship.pairs) {
flowHTML = `
<div class="spread-flow">
<span class="spread-flow-icon">&#8596;</span>
<span class="spread-flow-label">pairs with</span>
<span class="spread-flow-cmd">/${relationship.pairs}</span>
</div>`;
} else if (relationship.leadsTo && relationship.leadsTo.length > 0) {
flowHTML = `
<div class="spread-flow">
<span class="spread-flow-icon">&#8594;</span>
<span class="spread-flow-label">leads to</span>
${relationship.leadsTo.map(c => `<span class="spread-flow-cmd">/${c}</span>`).join(' ')}
</div>`;
} else if (relationship.combinesWith && relationship.combinesWith.length > 0) {
flowHTML = `
<div class="spread-flow">
<span class="spread-flow-icon">+</span>
<span class="spread-flow-label">combines with</span>
${relationship.combinesWith.map(c => `<span class="spread-flow-cmd">/${c}</span>`).join(' ')}
</div>`;
}
if (!flowHTML && relationship.flow) {
flowHTML = `
<div class="spread-flow">
<span class="spread-flow-label">${relationship.flow}</span>
</div>`;
}
}
return `
<div class="magazine-spread${isActive ? ' active' : ''}" data-index="${index}" data-category="${cat}" data-id="${cmd.id}" id="cmd-${cmd.id}">
<div class="spread-identity">
<span class="spread-category-label">${categoryLabels[cat] || cat}</span>
<h3 class="spread-command-name"><span class="spread-slash">/</span>${cmd.id}${isBeta ? '<span class="beta-badge">BETA</span>' : ''}</h3>
<p class="spread-description">${cmd.description}</p>
${flowHTML}
</div>
<div class="spread-demo-area" data-demo-index="${index}">
<!-- Demo rendered lazily -->
</div>
</div>
`;
}
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
});
}
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) {
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.
const ITEM_H = 39; // px per item (font 1.5rem * 1.3 line-height + 4px*2 padding)
const RADIUS = 4; // items on each side affected by fisheye
const count = items.length;
let currentActive = -1;
// Convert scroll position to fractional center index
const scrollToFractional = () => {
// Scroll area: padding-top puts index 0 at center when scrollTop=0
return scroll.scrollTop / ITEM_H;
};
// Apply fisheye visual based on fractional center
const applyFisheye = (center) => {
items.forEach((item, i) => {
const dist = Math.abs(i - center);
const ratio = Math.max(0, 1 - dist / RADIUS);
// Strong ease-out for dramatic center pop
const eased = ratio * ratio * (3 - 2 * ratio); // smoothstep
const scale = 0.35 + eased * 0.65;
const opacity = 0.06 + eased * 0.94;
item.style.transform = `scale(${scale})`;
item.style.opacity = opacity;
});
};
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 * ITEM_H, behavior });
};
// Scroll: update fisheye + activate nearest
let raf = null;
scroll.addEventListener('scroll', () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const center = scrollToFractional();
applyFisheye(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;
// Set padding so index 0 sits at center and last index sits at center
const padH = list.clientHeight / 2 - ITEM_H / 2;
scroll.style.paddingTop = `${padH}px`;
scroll.style.paddingBottom = `${padH}px`;
scroll.scrollTop = startIdx * ITEM_H;
applyFisheye(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);
}
}
@@ -137,107 +378,6 @@ function truncateDescription(text, maxLen = 120) {
return truncated.slice(0, lastSpace) + '...';
}
function renderManualEntry(cmd) {
const relationship = commandRelationships[cmd.id];
let relationshipHTML = '';
if (relationship) {
if (relationship.pairs) {
relationshipHTML = `<div class="manual-cmd-rel"><span class="rel-icon">↔</span> pairs with <code>/${relationship.pairs}</code></div>`;
} else if (relationship.leadsTo && relationship.leadsTo.length > 0) {
relationshipHTML = `<div class="manual-cmd-rel"><span class="rel-icon">→</span> leads to ${relationship.leadsTo.map(c => `<code>/${c}</code>`).join(', ')}</div>`;
} else if (relationship.combinesWith && relationship.combinesWith.length > 0) {
relationshipHTML = `<div class="manual-cmd-rel"><span class="rel-icon">+</span> combines with ${relationship.combinesWith.map(c => `<code>/${c}</code>`).join(', ')}</div>`;
}
}
const isBeta = betaCommands.includes(cmd.id);
const shortDesc = truncateDescription(cmd.description);
return `
<div class="manual-entry" data-id="${cmd.id}" id="cmd-${cmd.id}">
<h3 class="manual-cmd-name">/${cmd.id}${isBeta ? ' <span class="beta-badge">BETA</span>' : ''}</h3>
<p class="manual-cmd-desc">${shortDesc}</p>
${relationshipHTML}
</div>
`;
}
function setupDesktopScrollSpy(commands) {
const entries = document.querySelectorAll('.manual-entry');
const terminalContent = document.getElementById('terminal-content');
const observer = new IntersectionObserver((observerEntries) => {
observerEntries.forEach(entry => {
if (entry.isIntersecting) {
document.querySelectorAll('.manual-entry').forEach(e => e.classList.remove('active'));
entry.target.classList.add('active');
const cmdId = entry.target.dataset.id;
const cmd = commands.find(c => c.id === cmdId);
if (cmd) {
updateTerminal(cmd, terminalContent, commands);
history.replaceState(null, '', `#cmd-${cmdId}`);
}
}
});
}, {
threshold: 0.1,
rootMargin: "-35% 0px -55% 0px"
});
entries.forEach(e => observer.observe(e));
entries.forEach(e => {
e.addEventListener('click', () => {
document.querySelectorAll('.manual-entry').forEach(el => el.classList.remove('active'));
e.classList.add('active');
const cmdId = e.dataset.id;
const cmd = commands.find(c => c.id === cmdId);
if (cmd) {
updateTerminal(cmd, terminalContent, commands);
history.replaceState(null, '', `#cmd-${cmdId}`);
}
e.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
}
function updateTerminal(cmd, container, allCommands) {
if (!cmd || !container) return;
if (currentCommandId === cmd.id) return;
currentCommandId = cmd.id;
// Also update source content
updateSourceContent(cmd.id);
if (currentSplitInstance) {
currentSplitInstance.destroy();
currentSplitInstance = null;
}
const steps = commandProcessSteps[cmd.id] || ['Analyze', 'Transform', 'Verify'];
const stepsOutput = steps.map((step, i) =>
`<span class="terminal-step">${i + 1}. ${step}...</span>`
).join('<br>');
container.innerHTML = `<div class="terminal-line"><span class="terminal-prompt">➜</span><span class="terminal-cmd">/${cmd.id}</span></div>
<div class="terminal-output">${stepsOutput}<br><span class="terminal-done">✓ Complete</span></div>
<div class="terminal-preview command-demo-area">${renderCommandDemo(cmd.id)}</div>
<div class="terminal-line terminal-cursor-line"><span class="terminal-prompt">➜</span><span class="terminal-cursor"></span></div>`;
const splitComparison = container.querySelector('.demo-split-comparison');
if (splitComparison) {
currentSplitInstance = initSplitCompare(splitComparison, {
defaultPosition: 50
});
}
initCommandDemo(cmd.id, container);
}
// ============================================
// MOBILE LAYOUT - Carousel + Sticky Demo
// ============================================