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 413c0cdcb8..80ee956325 100644 --- a/assets/js/loading.js +++ b/assets/js/loading.js @@ -27,6 +27,10 @@ // Intersection Observer for fade-in animations function setupScrollAnimations() { + const animatedElements = document.querySelectorAll('.fade-in-on-scroll'); + // Content stays visible by default; intersection only adds an animation. + if (!('IntersectionObserver' in window)) return; + const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -50px 0px' @@ -41,7 +45,6 @@ }); }, observerOptions); - const animatedElements = document.querySelectorAll('.fade-in-on-scroll'); animatedElements.forEach(el => observer.observe(el)); } 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, []); +});