Files
minne/html-router/assets/design-polish.js
2026-01-11 18:37:07 +01:00

200 lines
5.8 KiB
JavaScript

/**
* Design Polishing Pass - Interactive Effects
*
* Includes:
* - Scroll-Linked Navbar Shadow
* - HTMX Swap Animation
* - Typewriter AI Response
* - Rubberbanding Scroll
*/
(function() {
'use strict';
// === SCROLL-LINKED NAVBAR SHADOW ===
function initScrollShadow() {
const mainContent = document.querySelector('main');
const navbar = document.querySelector('nav');
if (!mainContent || !navbar) return;
mainContent.addEventListener('scroll', () => {
const scrollTop = mainContent.scrollTop;
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight;
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2));
}, { passive: true });
}
// === HTMX SWAP ANIMATION ===
function initHtmxSwapAnimation() {
document.body.addEventListener('htmx:afterSwap', (event) => {
let target = event.detail.target;
if (!target) return;
// If full body swap (hx-boost), animate only the main content
if (target.tagName === 'BODY') {
const main = document.querySelector('main');
if (main) target = main;
}
// Only animate if target is valid and inside/is main content or a card/panel
// Avoid animating sidebar or navbar updates
if (target && (target.tagName === 'MAIN' || target.closest('main'))) {
if (!target.classList.contains('animate-fade-up')) {
target.classList.add('animate-fade-up');
// Remove class after animation completes to allow re-animation
setTimeout(() => {
target.classList.remove('animate-fade-up');
}, 250);
}
}
});
}
// === TYPEWRITER AI RESPONSE ===
// Works with SSE streaming - buffers text and reveals character by character
window.initTypewriter = function(element, options = {}) {
const {
minDelay = 5,
maxDelay = 15,
showCursor = true
} = options;
let buffer = '';
let isTyping = false;
let cursorElement = null;
if (showCursor) {
cursorElement = document.createElement('span');
cursorElement.className = 'typewriter-cursor';
cursorElement.textContent = '▌';
cursorElement.style.animation = 'blink 1s step-end infinite';
element.appendChild(cursorElement);
}
function typeNextChar() {
if (buffer.length === 0) {
isTyping = false;
return;
}
isTyping = true;
const char = buffer.charAt(0);
buffer = buffer.slice(1);
// Insert before cursor
if (cursorElement && cursorElement.parentNode) {
const textNode = document.createTextNode(char);
element.insertBefore(textNode, cursorElement);
} else {
element.textContent += char;
}
const delay = minDelay + Math.random() * (maxDelay - minDelay);
setTimeout(typeNextChar, delay);
}
return {
append: function(text) {
buffer += text;
if (!isTyping) {
typeNextChar();
}
},
complete: function() {
// Flush remaining buffer immediately
if (cursorElement && cursorElement.parentNode) {
const textNode = document.createTextNode(buffer);
element.insertBefore(textNode, cursorElement);
cursorElement.remove();
} else {
element.textContent += buffer;
}
buffer = '';
isTyping = false;
}
};
};
// === RUBBERBANDING SCROLL ===
function initRubberbanding() {
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
containers.forEach(container => {
let startY = 0;
let pulling = false;
let pullDistance = 0;
const maxPull = 60;
const resistance = 0.4;
container.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
}, { passive: true });
container.addEventListener('touchmove', (e) => {
const currentY = e.touches[0].clientY;
const diff = currentY - startY;
// At top boundary, pulling down
if (container.scrollTop <= 0 && diff > 0) {
pulling = true;
pullDistance = Math.min(diff * resistance, maxPull);
container.style.transform = `translateY(${pullDistance}px)`;
}
// At bottom boundary, pulling up
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
pulling = true;
pullDistance = Math.max(diff * resistance, -maxPull);
container.style.transform = `translateY(${pullDistance}px)`;
}
}, { passive: true });
container.addEventListener('touchend', () => {
if (pulling) {
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)';
container.style.transform = 'translateY(0)';
setTimeout(() => {
container.style.transition = '';
}, 300);
pulling = false;
pullDistance = 0;
}
}, { passive: true });
});
}
// === INITIALIZATION ===
function init() {
initScrollShadow();
initHtmxSwapAnimation();
initRubberbanding();
}
// Run on DOMContentLoaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Re-init rubberbanding after HTMX navigations
document.body.addEventListener('htmx:afterSettle', () => {
initRubberbanding();
});
// Add typewriter cursor blink animation
const style = document.createElement('style');
style.textContent = `
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.typewriter-cursor {
color: var(--color-accent);
font-weight: bold;
}
`;
document.head.appendChild(style);
})();