Add mobile layout for commands section

- New carousel + demo + info layout for commands on mobile (<900px)
- Add touch support to framework viz (tap instead of hover)
- Fix split-content width for demo comparisons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-01-06 18:41:34 -08:00
co-authored by Claude Opus 4.5
parent 61d26a7370
commit 8ef37b011c
3 changed files with 303 additions and 39 deletions
+138 -15
View File
@@ -630,6 +630,14 @@ code {
z-index: 1;
}
.split-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.split-after {
/* Angled clip-path matching divider's skewX(-10deg): top-right, bottom-left */
clip-path: polygon(78% 0%, 100% 0%, 100% 100%, 62% 100%);
@@ -1202,6 +1210,131 @@ code {
}
}
/* ============================================
MOBILE COMMANDS LAYOUT
============================================ */
.mobile-commands-layout {
display: none;
}
@media (max-width: 900px) {
.mobile-commands-layout {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.commands-container {
display: none;
}
}
.mobile-carousel-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: var(--spacing-xs) 0;
}
.mobile-carousel-wrapper::-webkit-scrollbar {
display: none;
}
.mobile-carousel {
display: flex;
gap: var(--spacing-xs);
padding-right: var(--spacing-md);
}
.mobile-cmd-pill {
flex-shrink: 0;
padding: var(--spacing-sm) var(--spacing-md);
min-height: 44px;
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-ash);
background: var(--color-cream);
border: 1px solid var(--color-mist);
border-radius: 100px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.mobile-cmd-pill:hover {
color: var(--color-charcoal);
border-color: var(--color-charcoal);
}
.mobile-cmd-pill.active {
color: var(--color-paper);
background: var(--color-ink);
border-color: var(--color-ink);
}
.mobile-demo-area {
background: var(--color-cream);
border: 1px solid var(--color-mist);
border-radius: 8px;
padding: var(--spacing-sm);
}
.mobile-demo-area .demo-split-comparison {
width: 100%;
}
.mobile-demo-area .split-container {
width: 100%;
max-width: 100%;
height: 320px;
}
.mobile-demo-area .demo-caption {
font-size: 0.75rem;
margin-top: var(--spacing-sm);
}
.mobile-info-area {
padding-top: var(--spacing-sm);
}
.mobile-cmd-info {
display: none;
padding: var(--spacing-sm) 0;
}
.mobile-cmd-info.active {
display: block;
}
.mobile-cmd-name {
font-family: var(--font-mono);
font-size: 1.125rem;
font-weight: 600;
color: var(--color-ink);
margin: 0 0 var(--spacing-xs) 0;
}
.mobile-cmd-desc {
font-size: 0.875rem;
color: var(--color-charcoal);
line-height: 1.5;
margin: 0;
}
.mobile-cmd-rel {
margin-top: var(--spacing-xs);
font-size: 0.75rem;
color: var(--color-ash);
}
.mobile-cmd-rel code {
font-family: var(--font-mono);
color: var(--color-ink);
}
/* ============================================
DOWNLOADS SECTION
============================================ */
@@ -1684,20 +1817,18 @@ code {
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-mist);
padding-bottom: var(--spacing-sm);
}
.pattern-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
font-family: var(--font-body);
font-size: 0.875rem;
color: var(--color-ash);
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
position: relative;
transition: color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out);
}
.pattern-tab:hover {
@@ -1707,16 +1838,7 @@ code {
.pattern-tab.active {
color: var(--color-ink);
font-weight: 500;
}
.pattern-tab.active::after {
content: '';
position: absolute;
bottom: calc(-1 * var(--spacing-sm) - 1px);
left: 0;
right: 0;
height: 2px;
background: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.pattern-tab:focus-visible {
@@ -1829,9 +1951,10 @@ code {
}
.btn-small {
padding: var(--spacing-xs) var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.8rem;
gap: var(--spacing-xs);
min-height: 44px; /* Touch target minimum */
}
.btn-small svg {
+13 -2
View File
@@ -137,6 +137,10 @@ export class PeriodicTable {
}
showDefaultInfo() {
// Use "Tap" on touch devices, "Hover over" on desktop
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const action = isTouchDevice ? 'Tap' : 'Hover over';
this.infoPanel.innerHTML = `
<div style="
font-family: var(--font-body);
@@ -144,7 +148,7 @@ export class PeriodicTable {
color: var(--color-ash);
font-style: italic;
">
Hover over a command to see details
${action} a command to see details
</div>
`;
}
@@ -342,7 +346,7 @@ export class PeriodicTable {
el.style.boxShadow = 'none';
};
// Mouse events
// Mouse events (desktop)
el.addEventListener('mouseenter', activate);
el.addEventListener('mouseleave', deactivate);
@@ -350,8 +354,15 @@ export class PeriodicTable {
el.addEventListener('focus', activate);
el.addEventListener('blur', deactivate);
// Touch events - activate on tap, stay active until another tap
el.addEventListener('touchstart', (e) => {
e.preventDefault(); // Prevent double-firing with click
activate();
}, { passive: false });
// Click/Enter to scroll to command
el.addEventListener('click', () => {
activate(); // Also activate on click for touch devices
const target = document.getElementById(`cmd-${cmd}`);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+152 -22
View File
@@ -2,6 +2,16 @@ import { renderCommandDemo } from "../demo-renderer.js";
import { initSplitCompare } from "../effects/split-compare.js";
import { commandProcessSteps, commandCategories, commandRelationships } from "../data.js";
// Track current split instance and command for cleanup
let currentSplitInstance = null;
let currentCommandId = null;
const MOBILE_BREAKPOINT = 900;
function isMobile() {
return window.innerWidth <= MOBILE_BREAKPOINT;
}
export function initGlassTerminal() {
// Initial setup if needed
}
@@ -10,7 +20,34 @@ export function renderTerminalLayout(commands) {
const container = document.querySelector('.commands-gallery');
if (!container) return;
// Group commands by category
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 (unchanged)
// ============================================
function renderDesktopLayout(container, commands) {
const categoryOrder = ['diagnostic', 'quality', 'intensity', 'adaptation', 'enhancement', 'system'];
const categoryLabels = {
'diagnostic': 'Diagnose',
@@ -28,7 +65,6 @@ export function renderTerminalLayout(commands) {
grouped[cat].push(cmd);
});
// Build manual with category headers
let manualHTML = '';
categoryOrder.forEach(cat => {
if (grouped[cat] && grouped[cat].length > 0) {
@@ -51,7 +87,6 @@ export function renderTerminalLayout(commands) {
<span class="terminal-title">zsh — 80x24</span>
</div>
<div class="terminal-body" id="terminal-content">
<!-- Dynamic Content -->
<div class="terminal-line">
<span class="terminal-prompt">➜</span>
<span>Waiting for input...</span>
@@ -62,9 +97,8 @@ export function renderTerminalLayout(commands) {
</div>
`;
setupScrollSpy(commands);
setupDesktopScrollSpy(commands);
// Select first command initially
if (commands.length > 0) {
updateTerminal(commands[0], document.getElementById('terminal-content'), commands);
const firstEntry = document.querySelector('.manual-entry');
@@ -95,19 +129,16 @@ function renderManualEntry(cmd) {
`;
}
function setupScrollSpy(commands) {
function setupDesktopScrollSpy(commands) {
const entries = document.querySelectorAll('.manual-entry');
const terminalContent = document.getElementById('terminal-content');
// Intersection Observer for Scroll Spy - trigger around vertical center
const observer = new IntersectionObserver((observerEntries) => {
observerEntries.forEach(entry => {
if (entry.isIntersecting) {
// Update active state
document.querySelectorAll('.manual-entry').forEach(e => e.classList.remove('active'));
entry.target.classList.add('active');
// Update terminal
const cmdId = entry.target.dataset.id;
const cmd = commands.find(c => c.id === cmdId);
if (cmd) {
@@ -117,15 +148,13 @@ function setupScrollSpy(commands) {
});
}, {
threshold: 0.1,
rootMargin: "-35% 0px -55% 0px" // Trigger zone biased toward upper-middle of viewport
rootMargin: "-35% 0px -55% 0px"
});
entries.forEach(e => observer.observe(e));
// Click interaction - immediate selection + smooth scroll
entries.forEach(e => {
e.addEventListener('click', () => {
// Immediately update state
document.querySelectorAll('.manual-entry').forEach(el => el.classList.remove('active'));
e.classList.add('active');
@@ -135,42 +164,32 @@ function setupScrollSpy(commands) {
updateTerminal(cmd, terminalContent, commands);
}
// Then scroll into view
e.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
}
// Track current split instance and command for cleanup
let currentSplitInstance = null;
let currentCommandId = null;
function updateTerminal(cmd, container, allCommands) {
if (!cmd || !container) return;
// Skip if already showing this command
if (currentCommandId === cmd.id) return;
currentCommandId = cmd.id;
// Cleanup previous split instance
if (currentSplitInstance) {
currentSplitInstance.destroy();
currentSplitInstance = null;
}
// Get process steps for this command (or use generic fallback)
const steps = commandProcessSteps[cmd.id] || ['Analyze', 'Transform', 'Verify'];
const stepsOutput = steps.map((step, i) =>
`<span class="terminal-step">${i + 1}. ${step}...</span>`
).join('<br>');
// Content update - no extra indentation to avoid whitespace
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>`;
// Initialize split-compare effect for the demo
const splitComparison = container.querySelector('.demo-split-comparison');
if (splitComparison) {
currentSplitInstance = initSplitCompare(splitComparison, {
@@ -182,4 +201,115 @@ function updateTerminal(cmd, container, allCommands) {
}
}
// ============================================
// MOBILE LAYOUT - Carousel + Sticky Demo
// ============================================
function renderMobileLayout(container, commands) {
// Build carousel pills
const carouselHTML = commands.map((cmd, i) => `
<button class="mobile-cmd-pill${i === 0 ? ' active' : ''}" data-id="${cmd.id}">
/${cmd.id}
</button>
`).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 = '';
if (relationship) {
if (relationship.pairs) {
relationshipHTML = `<div class="mobile-cmd-rel">↔ pairs with <code>/${relationship.pairs}</code></div>`;
} else if (relationship.leadsTo && relationship.leadsTo.length > 0) {
relationshipHTML = `<div class="mobile-cmd-rel">→ leads to ${relationship.leadsTo.map(c => `<code>/${c}</code>`).join(', ')}</div>`;
}
}
return `
<div class="mobile-cmd-info${i === 0 ? ' active' : ''}" data-id="${cmd.id}">
<h3 class="mobile-cmd-name">/${cmd.id}</h3>
<p class="mobile-cmd-desc">${cmd.description}</p>
${relationshipHTML}
</div>
`;
}).join('');
container.innerHTML = `
<div class="mobile-commands-layout">
<div class="mobile-carousel-wrapper">
<div class="mobile-carousel">
${carouselHTML}
</div>
</div>
<div class="mobile-demo-area" id="mobile-demo-content">
${renderCommandDemo(commands[0]?.id || 'audit')}
</div>
<div class="mobile-info-area">
${infoCardsHTML}
</div>
</div>
`;
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,
skewOffset: 6,
minPosition: 10,
maxPosition: 90
});
}
// 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,
skewOffset: 6,
minPosition: 10,
maxPosition: 90
});
}
});
});
}