From 6457e87eb0386e603d20529917b312515195c6f6 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 6 Oct 2026 12:12:44 +0000 Subject: [PATCH 1/2] fix(home): reveal fade-in sections without scroll events Full-page screenshot/preview renderers never scroll, so IntersectionObserver left the Latest Posts/Projects sections at opacity 0. Reveal after 2.5s, on print, and when IntersectionObserver is unavailable. Git-Session-Id: a32b --- assets/js/loading.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/assets/js/loading.js b/assets/js/loading.js index 413c0cdcb8..661acb26a6 100644 --- a/assets/js/loading.js +++ b/assets/js/loading.js @@ -27,6 +27,18 @@ // Intersection Observer for fade-in animations function setupScrollAnimations() { + const animatedElements = document.querySelectorAll('.fade-in-on-scroll'); + const revealAll = () => animatedElements.forEach(el => el.classList.add('visible')); + + // Renderers without IntersectionObserver, and full-page screenshot/preview + // tools that never scroll, would otherwise leave these sections hidden. + if (!('IntersectionObserver' in window)) { + revealAll(); + return; + } + setTimeout(revealAll, 2500); + window.addEventListener('beforeprint', revealAll); + const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -50px 0px' @@ -41,7 +53,6 @@ }); }, observerOptions); - const animatedElements = document.querySelectorAll('.fade-in-on-scroll'); animatedElements.forEach(el => observer.observe(el)); } From 3194ee4183c89fbe400232dd7cb0244e04f70fd0 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 7 Oct 2026 10:42:13 +0000 Subject: [PATCH 2/2] fix(home): animate on intersection without hiding unread sections Git-Session-Id: 64ecba80-d69e-5e61-a10e-eb3919221b38 --- assets/css/loading.css | 16 ++++++------ assets/js/loading.js | 12 ++------- tests/test_loading.js | 55 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 tests/test_loading.js diff --git a/assets/css/loading.css b/assets/css/loading.css index a27dba709c..b1137d6fb4 100644 --- a/assets/css/loading.css +++ b/assets/css/loading.css @@ -191,19 +191,19 @@ img[loading="lazy"].loaded { width: 80%; } -/* Intersection Observer fade-in - only animate when JS is loaded */ -.js-loaded .fade-in-on-scroll { - opacity: 0; - transform: translateY(20px); - transition: opacity 0.5s ease-out, transform 0.5s ease-out; -} - -.js-loaded .fade-in-on-scroll.visible, +/* Never hide unread content: static renderers may never scroll it into view. */ .fade-in-on-scroll { opacity: 1; transform: translateY(0); } +/* Animate once on intersection, without a timer revealing offscreen sections. */ +@media screen and (prefers-reduced-motion: no-preference) { + .js-loaded .fade-in-on-scroll.visible { + animation: fade-in 0.5s ease-out; + } +} + /* Reduce motion for accessibility */ @media (prefers-reduced-motion: reduce) { *, diff --git a/assets/js/loading.js b/assets/js/loading.js index 661acb26a6..80ee956325 100644 --- a/assets/js/loading.js +++ b/assets/js/loading.js @@ -28,16 +28,8 @@ // Intersection Observer for fade-in animations function setupScrollAnimations() { const animatedElements = document.querySelectorAll('.fade-in-on-scroll'); - const revealAll = () => animatedElements.forEach(el => el.classList.add('visible')); - - // Renderers without IntersectionObserver, and full-page screenshot/preview - // tools that never scroll, would otherwise leave these sections hidden. - if (!('IntersectionObserver' in window)) { - revealAll(); - return; - } - setTimeout(revealAll, 2500); - window.addEventListener('beforeprint', revealAll); + // Content stays visible by default; intersection only adds an animation. + if (!('IntersectionObserver' in window)) return; const observerOptions = { threshold: 0.1, diff --git a/tests/test_loading.js b/tests/test_loading.js new file mode 100644 index 0000000000..c0d2e5e7ab --- /dev/null +++ b/tests/test_loading.js @@ -0,0 +1,55 @@ +// Run with: node --test tests/test_loading.js +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const source = readFileSync(join(__dirname, '../assets/js/loading.js'), 'utf8'); + +function setup(withObserver = true) { + const classes = new Set(); + const element = { classList: { add: value => classes.add(value) } }; + const timers = []; + let intersect; + const observed = []; + const unobserved = []; + const window = { addEventListener() {} }; + if (withObserver) { + window.IntersectionObserver = class { + constructor(callback) { intersect = callback; } + observe(el) { observed.push(el); } + unobserve(el) { unobserved.push(el); } + }; + } + vm.runInNewContext(source, { + window, + IntersectionObserver: window.IntersectionObserver, + MutationObserver: class { observe() {} }, + document: { + readyState: 'complete', + body: {}, + documentElement: { classList: { add() {} } }, + querySelectorAll: selector => selector === '.fade-in-on-scroll' ? [element] : [], + }, + setTimeout: (callback, delay) => timers.push({ callback, delay }), + }); + return { classes, element, timers, intersect, observed, unobserved }; +} + +test('slow readers retain the intersection-triggered animation without a reveal timer', () => { + const state = setup(); + assert.equal(state.timers.length, 0, 'do not reveal offscreen sections on a clock'); + assert.deepEqual(state.observed, [state.element]); + state.intersect([{ target: state.element, isIntersecting: false }]); + assert.equal(state.classes.has('visible'), false); + state.intersect([{ target: state.element, isIntersecting: true }]); + assert.equal(state.classes.has('visible'), true); + assert.deepEqual(state.unobserved, [state.element]); +}); + +test('missing IntersectionObserver does not interrupt initialization', () => { + const state = setup(false); + assert.equal(state.timers.length, 0); + assert.deepEqual(state.observed, []); +});