release: 1.0.0

This commit is contained in:
Per Stark
2026-01-11 18:37:07 +01:00
parent db43be1606
commit 8fe4ac9fec
53 changed files with 757 additions and 630 deletions

View File

@@ -0,0 +1,199 @@
/**
* 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);
})();

Binary file not shown.

Binary file not shown.

View File

@@ -1,144 +0,0 @@
//==========================================================
// head-support.js
//
// An extension to add head tag merging.
//==========================================================
(function(){
var api = null;
function log() {
//console.log(arguments);
}
function mergeHead(newContent, defaultMergeStrategy) {
if (newContent && newContent.indexOf('<head') > -1) {
const htmlDoc = document.createElement("html");
// remove svgs to avoid conflicts
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// extract head tag
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
// if the head tag exists...
if (headTag) {
var added = []
var removed = []
var preserved = []
var nodesToAppend = []
htmlDoc.innerHTML = headTag;
var newHeadTag = htmlDoc.querySelector("head");
var currentHead = document.head;
if (newHeadTag == null) {
return;
} else {
// put all new head elements into a Map, by their outerHTML
var srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
}
// determine merge strategy
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
// get the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (mergeStrategy === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the tremaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
log(newElt);
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
currentHead.appendChild(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
currentHead.removeChild(removedElement);
}
}
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
}
}
}
htmx.defineExtension("head-support", {
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
htmx.on('htmx:afterSwap', function(evt){
let xhr = evt.detail.xhr;
if (xhr) {
var serverResponse = xhr.response;
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
}
}
})
htmx.on('htmx:historyRestore', function(evt){
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
if (evt.detail.cacheMiss) {
mergeHead(evt.detail.serverResponse, "merge");
} else {
mergeHead(evt.detail.item.head, "merge");
}
}
})
htmx.on('htmx:historyItemCreated', function(evt){
var historyItem = evt.detail.item;
historyItem.head = document.head.outerHTML;
})
}
});
})()

File diff suppressed because one or more lines are too long