Files
minne/html-router/assets/design-polish.js
T

223 lines
5.4 KiB
JavaScript

/**
* Design Polishing Pass - Interactive Effects
*
* Includes:
* - Scroll-Linked Navbar Shadow
* - HTMX Swap Animation
* - Typewriter AI Response
* - Rubberbanding Scroll
*/
(() => {
// === 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 = (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: (text) => {
buffer += text;
if (!isTyping) {
typeNextChar();
}
},
complete: () => {
// 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 attachRubberbanding(
container,
{ maxPull = 60, resistance = 0.4 } = {},
) {
let startY = 0;
let pulling = false;
function applyPull(distance) {
container.style.transform = `translateY(${distance}px)`;
}
function release() {
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;
}
function isAtTop() {
return container.scrollTop <= 0;
}
function isAtBottom() {
return (
container.scrollTop + container.clientHeight >= container.scrollHeight
);
}
container.addEventListener(
"touchstart",
(e) => {
startY = e.touches[0].clientY;
},
{ passive: true },
);
container.addEventListener(
"touchmove",
(e) => {
const diff = e.touches[0].clientY - startY;
const isPullingDown = diff > 0 && isAtTop();
const isPullingUp = diff < 0 && isAtBottom();
if (isPullingDown) {
pulling = true;
applyPull(Math.min(diff * resistance, maxPull));
} else if (isPullingUp) {
pulling = true;
applyPull(Math.max(diff * resistance, -maxPull));
}
},
{ passive: true },
);
container.addEventListener(
"touchend",
() => {
if (pulling) release();
},
{ passive: true },
);
}
function initRubberbanding() {
document
.querySelectorAll("#chat-scroll-container, .content-scroll-container")
.forEach((container) => attachRubberbanding(container));
}
// === 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);
})();