diff --git a/public/app.js b/public/app.js index 85959db8b..d02e35f59 100644 --- a/public/app.js +++ b/public/app.js @@ -30,30 +30,72 @@ async function loadContent() { // Render commands (Glass Terminal) renderTerminalLayout(allCommands); - // Render patterns and antipatterns - renderPatterns(patternsData.patterns, "patterns-grid", "pattern"); - renderPatterns(patternsData.antipatterns, "antipatterns-grid", "antipattern"); + // Render patterns with tabbed navigation + renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns); } catch (error) { console.error("Failed to load content:", error); } } -function renderPatterns(categories, containerId, classPrefix) { - const container = document.getElementById(containerId); - if (!container || !categories) return; +function renderPatternsWithTabs(patterns, antipatterns) { + const container = document.getElementById("patterns-categories"); + if (!container || !patterns || !antipatterns) return; - container.innerHTML = categories - .map( - (category) => ` -
-

${category.name}

- -
- `, - ) + // Create a map of antipatterns by category name + const antipatternMap = {}; + antipatterns.forEach(cat => { + antipatternMap[cat.name] = cat.items; + }); + + // Build tabs + const tabsHTML = patterns + .map((category, i) => ``) .join(""); + + // Build panels + const panelsHTML = patterns + .map((category, i) => { + const antiItems = antipatternMap[category.name] || []; + return ` +
+
+
+ Don't +
    + ${antiItems.map((item) => `
  • ${item}
  • `).join("")} +
+
+
+ Do +
    + ${category.items.map((item) => `
  • ${item}
  • `).join("")} +
+
+
+
+ `; + }) + .join(""); + + container.innerHTML = ` +
${tabsHTML}
+
${panelsHTML}
+ `; + + // Tab click handling + container.querySelectorAll('.pattern-tab').forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + + // Update active tab + container.querySelectorAll('.pattern-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + // Update active panel + container.querySelectorAll('.pattern-panel').forEach(p => p.classList.remove('active')); + container.querySelector(`[data-panel="${tabName}"]`).classList.add('active'); + }); + }); } // ============================================ diff --git a/public/css/main.css b/public/css/main.css index 4d9de3942..88a55d380 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -371,14 +371,15 @@ code { gap: var(--spacing-xl); } -.lens-comparison { +/* Split Screen Comparison */ +.split-comparison { position: relative; width: 100%; max-width: 600px; margin: 0 auto; } -.lens-container { +.split-container { position: relative; width: 100%; max-width: 500px; @@ -388,10 +389,12 @@ code { overflow: hidden; background: var(--color-cream); border: 1px solid var(--color-mist); + cursor: ew-resize; + user-select: none; } -.lens-before, -.lens-after { +.split-before, +.split-after { position: absolute; inset: 0; display: flex; @@ -399,46 +402,45 @@ code { justify-content: center; } -.lens-before { +.split-before { z-index: 1; } -.lens-after { - clip-path: circle(120px at var(--lens-x, 70%) var(--lens-y, 50%)); - transition: clip-path 0.08s ease-out; +.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%); z-index: 2; background: var(--color-paper); } -.lens-circle { +.split-divider { position: absolute; - width: 240px; - height: 240px; - border: 2px solid var(--color-accent); - border-radius: 50%; + top: 0; + bottom: 0; + left: 70%; + width: 3px; + background: var(--color-accent); + transform: translateX(-50%) skewX(-10deg); pointer-events: none; z-index: 3; - left: var(--lens-x, 70%); - top: var(--lens-y, 50%); - transform: translate(-50%, -50%); - transition: left 0.08s ease-out, top 0.08s ease-out; - box-shadow: 0 0 0 4px var(--color-accent-dim), 0 20px 60px rgba(0,0,0,0.15); + box-shadow: 0 0 20px rgba(0,0,0,0.15); } -.lens-label { +.split-label { position: absolute; - top: -28px; + top: 50%; left: 50%; - transform: translateX(-50%); + transform: translate(-50%, -50%) skewX(10deg); font-size: 0.6875rem; font-weight: 600; - letter-spacing: 0.1em; + letter-spacing: 0.08em; text-transform: uppercase; - color: var(--color-accent); - background: var(--color-paper); - padding: 4px 12px; + color: var(--color-paper); + background: var(--color-accent); + padding: 6px 14px; border-radius: 4px; white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); } /* Slop Card - Generic AI output */ @@ -607,15 +609,15 @@ code { background: var(--color-accent); } -/* Lens Labels */ -.lens-labels { +/* Split Labels */ +.split-labels { display: flex; justify-content: center; - gap: var(--spacing-lg); + gap: var(--spacing-xl); margin-top: var(--spacing-md); } -.lens-label-item { +.split-label-item { display: flex; align-items: center; gap: var(--spacing-xs); @@ -623,14 +625,14 @@ code { color: var(--color-ash); } -.lens-label-dot { +.split-label-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-mist); } -.lens-label-dot--accent { +.split-label-dot--accent { background: var(--color-accent); } @@ -1322,168 +1324,133 @@ code { margin: 0 auto; } -.antidote-intro { - text-align: center; +/* Patterns - tabbed layout */ +.patterns-categories { margin-bottom: var(--spacing-xl); } -.antidote-intro p { - font-size: 1.25rem; +/* Tab bar */ +.pattern-tabs { + display: flex; + 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; + 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; +} + +.pattern-tab:hover { color: var(--color-charcoal); - max-width: 50ch; - margin: 0 auto; } -.antidote-intro strong { - color: var(--color-accent); - font-weight: 600; -} - -/* Section titles for patterns/antipatterns */ -.patterns-section-title { - font-family: var(--font-display); - font-size: 1.5rem; - font-weight: 400; +.pattern-tab.active { color: var(--color-ink); - margin: var(--spacing-lg) 0 var(--spacing-md); - text-align: center; + font-weight: 500; } -.patterns-section-title::before { - content: '✓ '; +.pattern-tab.active::after { + content: ''; + position: absolute; + bottom: calc(-1 * var(--spacing-sm) - 1px); + left: 0; + right: 0; + height: 2px; + background: var(--color-accent); +} + +/* Panels */ +.pattern-panel { + display: none; +} + +.pattern-panel.active { + display: block; +} + +.pattern-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xl); +} + +@media (max-width: 768px) { + .pattern-columns { + grid-template-columns: 1fr; + gap: var(--spacing-md); + } +} + +.pattern-column-label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: var(--spacing-sm); + color: var(--color-ash); +} + +.pattern-column--anti .pattern-column-label { + color: var(--color-accent); +} + +.pattern-column--do .pattern-column-label { color: var(--color-success, #22c55e); } -.patterns-section-title--anti::before { - content: '× '; - color: var(--color-accent); -} - -/* Patterns grid (what TO do) */ -.patterns-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--spacing-lg); - margin-bottom: var(--spacing-xl); -} - -.pattern-category { - background: var(--color-paper); - border: 1px solid var(--color-mist); - padding: var(--spacing-lg); - transition: all var(--duration-base) var(--ease-out); -} - -.pattern-category:hover { - border-color: var(--color-success, #22c55e); - box-shadow: 0 10px 40px rgba(34, 197, 94, 0.1); -} - -.pattern-category-title { - font-family: var(--font-display); - font-size: 1.25rem; - font-weight: 400; - color: var(--color-ink); - margin: 0 0 var(--spacing-md); - padding-bottom: var(--spacing-sm); - border-bottom: 1px solid var(--color-mist); -} - +/* Pattern list styles */ .pattern-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; - gap: var(--spacing-sm); + gap: var(--spacing-xs); } -.pattern-list li { - font-size: 0.875rem; - color: var(--color-charcoal); +.pattern-item { + font-size: 0.8125rem; padding-left: var(--spacing-md); position: relative; line-height: 1.5; } -.pattern-list li::before { - content: '✓'; - position: absolute; - left: 0; - color: var(--color-success, #22c55e); - font-weight: 600; - font-size: 0.875rem; +/* Anti-pattern items - muted */ +.pattern-item--anti { + color: var(--color-ash); } -/* Anti-patterns grid */ -.antipatterns-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--spacing-lg); - margin-bottom: var(--spacing-xl); -} - -.antipattern-category { - background: var(--color-paper); - border: 1px solid var(--color-mist); - padding: var(--spacing-lg); - transition: all var(--duration-base) var(--ease-out); -} - -.antipattern-category:hover { - border-color: var(--color-accent); - box-shadow: 0 10px 40px var(--color-accent-dim); -} - -.antipattern-category-title { - font-family: var(--font-display); - font-size: 1.25rem; - font-weight: 400; - color: var(--color-ink); - margin: 0 0 var(--spacing-md); - padding-bottom: var(--spacing-sm); - border-bottom: 1px solid var(--color-mist); -} - -.antipattern-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.antipattern-list li { - font-size: 0.875rem; - color: var(--color-charcoal); - padding-left: var(--spacing-md); - position: relative; - line-height: 1.5; -} - -.antipattern-list li::before { +.pattern-item--anti::before { content: '×'; position: absolute; left: 0; color: var(--color-accent); font-weight: 600; - font-size: 1rem; } -/* Antidote CTA */ -.antidote-cta { - text-align: center; - padding-top: var(--spacing-lg); - border-top: 1px solid var(--color-mist); -} - -.antidote-cta p { - font-size: 1.125rem; +/* Pattern items - prominent */ +.pattern-item--do { color: var(--color-charcoal); - max-width: 50ch; - margin: 0 auto; - font-style: italic; +} + +.pattern-item--do::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--color-success, #22c55e); + font-weight: 600; } /* ============================================ diff --git a/public/css/problem-section.css b/public/css/problem-section.css index aa18cda41..d375d8960 100644 --- a/public/css/problem-section.css +++ b/public/css/problem-section.css @@ -1,130 +1,94 @@ /** - * Problem Section - The Clarity Lens - * Interactive before/after comparison + * Problem Section - Split Screen Comparison + * Interactive before/after comparison with angled divider */ -/* Additional lens styles that need complex CSS */ - -.lens-container { - cursor: crosshair; - user-select: none; - -webkit-user-select: none; -} - -/* Subtle grid background for the lens container */ -.lens-container::before { +/* Subtle grid background for the container */ +.split-container::before { content: ''; position: absolute; inset: 0; - background-image: + background-image: linear-gradient(var(--color-mist) 1px, transparent 1px), linear-gradient(90deg, var(--color-mist) 1px, transparent 1px); background-size: 20px 20px; - opacity: 0.5; + opacity: 0.3; pointer-events: none; } -/* Lens glow effect */ -.lens-circle::before { - content: ''; +/* Hover hint */ +.split-container::after { + content: '← Drag →'; position: absolute; - inset: -20px; - border-radius: 50%; - background: radial-gradient( - circle, - var(--color-accent-dim) 0%, - transparent 70% - ); - opacity: 0.5; - pointer-events: none; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-ash); + background: var(--color-paper); + padding: 4px 12px; + border-radius: 4px; + opacity: 0.8; + transition: opacity 0.3s ease; + z-index: 10; } -/* Pulsing animation for the lens when idle */ -@keyframes lensGlow { - 0%, 100% { - box-shadow: - 0 0 0 4px var(--color-accent-dim), - 0 20px 60px rgba(0,0,0,0.15); - } - 50% { - box-shadow: - 0 0 0 8px var(--color-accent-dim), - 0 20px 80px rgba(0,0,0,0.2); - } +.split-container:hover::after { + opacity: 0; } -.lens-container:not(:hover) .lens-circle { - animation: lensGlow 3s ease-in-out infinite; -} - -/* Smooth transitions when hovering */ -.lens-container:hover .lens-circle { - transition: left 0.05s linear, top 0.05s linear; -} - -.lens-container:hover .lens-after { - transition: clip-path 0.05s linear; -} - -/* Mobile: tap to reveal */ -@media (hover: none) { - .lens-label { - display: none; - } - - .lens-circle::after { - content: 'Tap & Drag'; - position: absolute; - bottom: -36px; - left: 50%; - transform: translateX(-50%); - font-size: 0.625rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-accent); - background: var(--color-paper); - padding: 4px 12px; - border-radius: 4px; - white-space: nowrap; - } -} - -/* Hover state for impeccable card inside lens */ -.lens-after .impeccable-card { +/* Hover state for impeccable card */ +.split-after .impeccable-card { box-shadow: 0 10px 40px rgba(0,0,0,0.08); } -/* Entry animation for the lens */ -@keyframes lensEntry { +/* Entry animation for the divider */ +@keyframes splitEntry { from { opacity: 0; - transform: translate(-50%, -50%) scale(0.8); + transform: translateX(-50%) skewX(-10deg) scaleY(0.8); } to { opacity: 1; - transform: translate(-50%, -50%) scale(1); + transform: translateX(-50%) skewX(-10deg) scaleY(1); } } -.lens-circle { - animation: lensEntry 0.6s var(--ease-out) 0.3s backwards; +.split-divider { + animation: splitEntry 0.6s var(--ease-out) 0.3s backwards; } /* Labels hover states */ -.lens-label-item { +.split-label-item { transition: color var(--duration-fast) var(--ease-out); cursor: default; } -.lens-label-item:hover { +.split-label-item:hover { color: var(--color-text); } -.lens-label-item[data-point="after"]:hover .lens-label-dot--accent { +.split-label-item[data-point="after"]:hover .split-label-dot--accent { transform: scale(1.3); } -.lens-label-dot { +.split-label-dot { transition: transform var(--duration-fast) var(--ease-spring); } + +/* Mobile adjustments */ +@media (hover: none) { + .split-container::after { + content: '← Swipe →'; + } +} + +@media (max-width: 600px) { + .split-label { + font-size: 0.5625rem; + padding: 4px 10px; + } +} diff --git a/public/css/workflow.css b/public/css/workflow.css index b5b4d7705..05a1124d4 100644 --- a/public/css/workflow.css +++ b/public/css/workflow.css @@ -243,89 +243,89 @@ margin-top: var(--spacing-sm) !important; } -.terminal-preview .demo-container { +/* Demo Split Comparison in Terminal */ +.terminal-preview .demo-split-comparison { display: flex; flex-direction: column; height: 100%; } -.terminal-preview .demo-header { - flex-shrink: 0; - padding: 0; +.terminal-preview .demo-split-comparison .split-container { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + cursor: ew-resize; + user-select: none; + background: var(--color-cream); } -.terminal-preview .demo-viewport { - flex: 1; +.terminal-preview .demo-split-comparison .split-before, +.terminal-preview .demo-split-comparison .split-after { + position: absolute; + inset: 0; display: flex; - align-items: flex-start; + align-items: center; justify-content: center; padding: var(--spacing-md); - min-height: 0; - max-height: 320px; - overflow: auto; - overscroll-behavior: contain; } -.terminal-preview .demo-viewport > * { - margin-block: auto; /* Center vertically when content is shorter than container */ +.terminal-preview .demo-split-comparison .split-before { + z-index: 1; + background: var(--color-cream); } -.terminal-preview .demo-caption { +.terminal-preview .demo-split-comparison .split-after { + z-index: 2; + background: var(--color-paper); + clip-path: polygon(58% 0%, 100% 0%, 100% 100%, 42% 100%); +} + +.terminal-preview .demo-split-comparison .split-content { + width: 100%; + max-width: 280px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.terminal-preview .demo-split-comparison .split-divider { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 2px; + background: 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); +} + +.terminal-preview .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(--color-accent); + padding: 4px 10px; + border-radius: 3px; + white-space: nowrap; +} + +.terminal-preview .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); -} - -/* Demo toggle in terminal */ -.terminal-preview .demo-toggle { - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); -} - -.terminal-preview .demo-toggle-switch { - width: 36px; - height: 20px; - background: var(--color-mist); - border-radius: 10px; - position: relative; - cursor: pointer; - transition: background 0.2s ease; -} - -.terminal-preview .demo-toggle-switch::after { - content: ''; - position: absolute; - top: 1px; - left: 1px; - width: 16px; - height: 16px; - background: white; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0,0,0,0.2); - transition: transform 0.2s ease; -} - -.terminal-preview .demo-toggle-switch.active { - background: var(--color-accent); -} - -.terminal-preview .demo-toggle-switch.active::after { - transform: translateX(16px); -} - -.terminal-preview .demo-toggle-label { - font-size: 0.75rem; - color: var(--color-ash); - transition: color 0.2s ease; -} - -.terminal-preview .demo-toggle-label.active { - color: var(--color-ink); - font-weight: 500; + border-top: 1px solid var(--color-mist); } @keyframes blink { 50% { opacity: 0; } } diff --git a/public/index.html b/public/index.html index 4149a3c9e..b3ef09444 100644 --- a/public/index.html +++ b/public/index.html @@ -34,7 +34,7 @@ Impeccable Style -

One design skill and 15 commands that work across Cursor, Claude Code, Gemini CLI, and Codex CLI. Stop getting generic AI output. Start getting considered design.

+

One design skill and 15 commands that work across Cursor, Claude Code, Gemini CLI, and Codex CLI. Stop getting generic AI output. Start getting intentional design.

Get the Framework
@@ -104,10 +104,10 @@ -
-
+
+
-
+
@@ -127,8 +127,8 @@ Cards on cards
- -
+ +

Introducing

Thoughtful Design

@@ -136,18 +136,18 @@
- -
- With Skills + +
+ With Skills
-
-
- +
+
+ Generic AI Output
-
- +
+ With Design Skills
@@ -164,25 +164,10 @@

The original Claude frontend-design skill says to avoid Arial and Inter. We've expanded it with 60+ specific patterns that fight model bias across every design domain.

-
-

Your AI learns what TO do and what NOT to do. These rules are baked into the skill.

-
- - -

What TO Do

-
+ +
- - -

What NOT to Do

-
- -
- -
-

These rules are embedded in the skill. Your AI internalizes them before writing a single line of code.

-
@@ -295,7 +280,7 @@
05 -

One Framework, Every AI Tool

+

One Framework, Every Agent Harness

We wrote it once, then compiled it for each platform. Same design intelligence, native to your workflow.

diff --git a/public/js/components/glass-terminal.js b/public/js/components/glass-terminal.js index 07f1d29f2..4bc46d56f 100644 --- a/public/js/components/glass-terminal.js +++ b/public/js/components/glass-terminal.js @@ -1,5 +1,5 @@ import { renderCommandDemo } from "../demo-renderer.js"; -import { setupCommandDemoToggles } from "../demo-toggles.js"; +import { initSplitCompare } from "../effects/split-compare.js"; import { commandProcessSteps, commandCategories, commandRelationships } from "../data.js"; export function initGlassTerminal() { @@ -141,9 +141,23 @@ function setupScrollSpy(commands) { }); } +// 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) => @@ -156,6 +170,14 @@ function updateTerminal(cmd, container, allCommands) {
${renderCommandDemo(cmd.id)}
`; - // Re-bind toggles - setupCommandDemoToggles(allCommands, () => {}); + // Initialize split-compare effect for the demo + const splitComparison = container.querySelector('.demo-split-comparison'); + if (splitComparison) { + currentSplitInstance = initSplitCompare(splitComparison, { + defaultPosition: 50, + skewOffset: 8, + minPosition: 5, + maxPosition: 95 + }); + } } diff --git a/public/js/components/lens.js b/public/js/components/lens.js index 569baa90d..6e1fcdfb8 100644 --- a/public/js/components/lens.js +++ b/public/js/components/lens.js @@ -1,63 +1,13 @@ +import { initSplitCompare } from "../effects/split-compare.js"; + export function initLensEffect() { const container = document.getElementById("lens-comparison"); if (!container) return; - const lensContainer = container.querySelector(".lens-container"); - const lensAfter = container.querySelector(".lens-after"); - const lensCircle = container.querySelector(".lens-circle"); - - if (!lensContainer || !lensAfter || !lensCircle) return; - - let isHovering = false; - - function updateLens(x, y) { - const rect = lensContainer.getBoundingClientRect(); - const relX = ((x - rect.left) / rect.width) * 100; - const relY = ((y - rect.top) / rect.height) * 100; - - const clampedX = Math.max(25, Math.min(75, relX)); - const clampedY = Math.max(30, Math.min(70, relY)); - - lensAfter.style.clipPath = `circle(120px at ${clampedX}% ${clampedY}%)`; - lensCircle.style.setProperty("--lens-x", `${clampedX}%`); - lensCircle.style.setProperty("--lens-y", `${clampedY}%`); - lensCircle.style.left = `${clampedX}%`; - lensCircle.style.top = `${clampedY}%`; - } - - lensContainer.addEventListener("mouseenter", () => { - isHovering = true; + initSplitCompare(container, { + defaultPosition: 70, + skewOffset: 8, + minPosition: 5, + maxPosition: 95 }); - - lensContainer.addEventListener("mouseleave", () => { - isHovering = false; - // Reset to default position - updateLens( - lensContainer.getBoundingClientRect().left + - lensContainer.offsetWidth * 0.7, - lensContainer.getBoundingClientRect().top + - lensContainer.offsetHeight * 0.5, - ); - }); - - lensContainer.addEventListener("mousemove", (e) => { - if (isHovering) { - updateLens(e.clientX, e.clientY); - } - }); - - // Touch support - lensContainer.addEventListener("touchmove", (e) => { - e.preventDefault(); - const touch = e.touches[0]; - updateLens(touch.clientX, touch.clientY); - }); - - // Initialize at default position - updateLens( - lensContainer.getBoundingClientRect().left + - lensContainer.offsetWidth * 0.7, - lensContainer.getBoundingClientRect().top + - lensContainer.offsetHeight * 0.5, - ); } diff --git a/public/js/demo-renderer.js b/public/js/demo-renderer.js index 6c1089364..2925452f0 100644 --- a/public/js/demo-renderer.js +++ b/public/js/demo-renderer.js @@ -6,11 +6,11 @@ import { getCommandDemo } from './demos/commands/index.js'; import { getSkillDemo } from './demos/skills/index.js'; /** - * Render a command demo + * Render a command demo with split-screen comparison */ export function renderCommandDemo(commandId) { const demo = getCommandDemo(commandId); - + if (!demo) { return `
@@ -22,20 +22,18 @@ export function renderCommandDemo(commandId) {
`; } - - const viewportClass = demo.viewportClass ? ` ${demo.viewportClass}` : ''; - + + // Use split-screen comparison return ` -
-
-
- Before -
- After +
+
+
+
${demo.before}
-
-
- ${demo.before} +
+
${demo.after || demo.before}
+
+
${demo.caption}
@@ -47,7 +45,7 @@ export function renderCommandDemo(commandId) { */ export function renderSkillDemo(skillId) { const skill = getSkillDemo(skillId); - + if (!skill || !skill.tabs || skill.tabs.length === 0) { return `
@@ -59,21 +57,21 @@ export function renderSkillDemo(skillId) {
`; } - + const showTabs = skill.tabs.length > 1; - + const tabs = showTabs ? skill.tabs.map((tab, i) => ` `).join('') : ''; - + const panels = skill.tabs.map((tab, i) => `
${renderSkillTabDemo(skillId, tab)}
`).join(''); - + return `
${showTabs ? `
${tabs}
` : ''} @@ -90,7 +88,7 @@ export function renderSkillDemo(skillId) { function renderSkillTabDemo(skillId, tab) { const hasToggle = tab.hasToggle !== false; const demoId = `${skillId}-${tab.id}`; - + return `
@@ -118,10 +116,10 @@ export function setupDemoTabs() { tab.addEventListener('click', () => { const tabId = tab.dataset.demoTab; const container = tab.closest('.demo-tabbed-container'); - + container.querySelectorAll('.demo-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); - + container.querySelectorAll('.demo-panel').forEach(p => p.classList.remove('active')); container.querySelector(`[data-demo-panel="${tabId}"]`)?.classList.add('active'); }); diff --git a/public/js/effects/split-compare.js b/public/js/effects/split-compare.js new file mode 100644 index 000000000..cdf77b115 --- /dev/null +++ b/public/js/effects/split-compare.js @@ -0,0 +1,157 @@ +// ============================================ +// SPLIT COMPARE - Reusable before/after split-screen effect +// ============================================ + +/** + * Initialize split comparison effect on a container + * @param {HTMLElement} container - The container element with .split-container inside + * @param {Object} options - Configuration options + */ +export function initSplitCompare(container, options = {}) { + const { + defaultPosition = 70, + skewOffset = 8, + minPosition = 5, + maxPosition = 95, + lerpSpeed = 0.15, + animationThreshold = 40, // Re-trigger animations when crossing this threshold + onCrossThreshold = null // Callback when crossing threshold toward "after" side + } = options; + + const splitContainer = container.querySelector('.split-container'); + const splitAfter = container.querySelector('.split-after'); + const splitDivider = container.querySelector('.split-divider'); + + if (!splitContainer || !splitAfter || !splitDivider) return null; + + let isHovering = false; + let currentX = defaultPosition; + let targetX = defaultPosition; + let animationId = null; + let wasAboveThreshold = defaultPosition > animationThreshold; + + function updateSplit(percent) { + const clampedX = Math.max(minPosition, Math.min(maxPosition, percent)); + + // Check if we crossed the threshold toward the "after" side (moving left) + const isAboveThreshold = clampedX > animationThreshold; + if (wasAboveThreshold && !isAboveThreshold) { + // Crossed threshold - re-trigger animations + retriggerAnimations(); + if (onCrossThreshold) onCrossThreshold(clampedX); + } + wasAboveThreshold = isAboveThreshold; + + // Angled clip-path matching divider's skewX(-10deg) + splitAfter.style.clipPath = `polygon(${clampedX + skewOffset}% 0%, 100% 0%, 100% 100%, ${clampedX - skewOffset}% 100%)`; + splitDivider.style.left = `${clampedX}%`; + } + + function retriggerAnimations() { + // Find all elements with animations in the "after" content and re-trigger them + const afterContent = splitAfter.querySelector('.split-content'); + if (afterContent) { + // Clone and replace to restart all CSS animations + const clone = afterContent.cloneNode(true); + afterContent.parentNode.replaceChild(clone, afterContent); + } + } + + function animate() { + const diff = targetX - currentX; + if (Math.abs(diff) > 0.1) { + currentX += diff * lerpSpeed; + updateSplit(currentX); + animationId = requestAnimationFrame(animate); + } else { + currentX = targetX; + updateSplit(currentX); + animationId = null; + } + } + + function startAnimation() { + if (!animationId) { + animationId = requestAnimationFrame(animate); + } + } + + function handleMouseEnter() { + isHovering = true; + } + + function handleMouseLeave() { + isHovering = false; + targetX = defaultPosition; + startAnimation(); + } + + function handleMouseMove(e) { + if (isHovering) { + const rect = splitContainer.getBoundingClientRect(); + targetX = ((e.clientX - rect.left) / rect.width) * 100; + startAnimation(); + } + } + + function handleTouchStart() { + isHovering = true; + } + + function handleTouchEnd() { + isHovering = false; + targetX = defaultPosition; + startAnimation(); + } + + function handleTouchMove(e) { + e.preventDefault(); + const touch = e.touches[0]; + const rect = splitContainer.getBoundingClientRect(); + targetX = ((touch.clientX - rect.left) / rect.width) * 100; + startAnimation(); + } + + // Attach listeners + splitContainer.addEventListener('mouseenter', handleMouseEnter); + splitContainer.addEventListener('mouseleave', handleMouseLeave); + splitContainer.addEventListener('mousemove', handleMouseMove); + splitContainer.addEventListener('touchstart', handleTouchStart); + splitContainer.addEventListener('touchend', handleTouchEnd); + splitContainer.addEventListener('touchmove', handleTouchMove, { passive: false }); + + // Initialize + updateSplit(defaultPosition); + + // Return cleanup function + return { + destroy() { + splitContainer.removeEventListener('mouseenter', handleMouseEnter); + splitContainer.removeEventListener('mouseleave', handleMouseLeave); + splitContainer.removeEventListener('mousemove', handleMouseMove); + splitContainer.removeEventListener('touchstart', handleTouchStart); + splitContainer.removeEventListener('touchend', handleTouchEnd); + splitContainer.removeEventListener('touchmove', handleTouchMove); + if (animationId) cancelAnimationFrame(animationId); + }, + setPosition(percent) { + targetX = percent; + startAnimation(); + } + }; +} + +/** + * Initialize all split comparisons on the page + */ +export function initAllSplitCompare(selector = '.split-comparison', options = {}) { + const containers = document.querySelectorAll(selector); + const instances = []; + + containers.forEach(container => { + const instance = initSplitCompare(container, options); + if (instance) instances.push(instance); + }); + + return instances; +}