Artur Mukhamadiev 3ef50c4448 frontend: thinking-orbs loading indicator and CSS book logo
Replace the gear spinner with the thinking-orbs canvas engine (vendored
framework-free build, driven by js/orb.js in the "solving" state) and the
inline SVG brand icon with a pure CSS book whose cover flips open on page
load and while the brand is hovered (js/logo.js).
2026-09-15 12:00:26 +03:00

73 lines
2.5 KiB
JavaScript

/**
* Book logo animation control.
*
* The logo is pure CSS (see `.book` in css/style.css); the cover flips while
* the `book-open` class is present. Playback runs briefly when the page opens
* and while the brand is hovered. Stops are deferred to the end of the
* current loop (the `animationiteration` event) so the cover never snaps shut
* mid-flip.
*/
const OPEN_PLAY_MS = 2000;
const LOOP_MS = 1300; // one `book-flip` cycle (1.2s) plus slack, used if the animation clock is unavailable
const OPEN_CLASS = 'book-open';
/**
* @param {HTMLElement} brand Hover target wrapping the logo.
* @param {HTMLElement} book The `.book` element.
*/
export function initBookLogo(brand, book) {
if (!brand || !book) return;
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let stopRequested = false;
let hovering = false;
let openTimer = 0;
let fallbackTimer = 0;
const stopNow = () => {
window.clearTimeout(fallbackTimer);
fallbackTimer = 0;
stopRequested = false;
book.classList.remove(OPEN_CLASS);
};
const play = () => {
window.clearTimeout(fallbackTimer);
fallbackTimer = 0;
stopRequested = false;
book.classList.add(OPEN_CLASS);
};
const msUntilLoopEnd = () => {
const cover = book.querySelector('.book-cover');
const animation = cover && typeof cover.getAnimations === 'function' ? cover.getAnimations()[0] : null;
const timing = animation && animation.effect && typeof animation.effect.getTiming === 'function' ? animation.effect.getTiming() : null;
const duration = timing && typeof timing.duration === 'number' ? timing.duration : 0;
if (!animation || !duration || typeof animation.currentTime !== 'number') return LOOP_MS;
return duration - (animation.currentTime % duration) + 50;
};
const stopAtLoopEnd = () => {
stopRequested = true;
// Iteration events only arrive while the tab is rendering frames; in a
// hidden or throttled tab, fall back to a timer aligned to the loop end.
window.clearTimeout(fallbackTimer);
fallbackTimer = window.setTimeout(stopNow, msUntilLoopEnd());
};
book.addEventListener('animationiteration', () => {
if (stopRequested && !hovering) stopNow();
});
brand.addEventListener('mouseenter', () => {
hovering = true;
window.clearTimeout(openTimer);
play();
});
brand.addEventListener('mouseleave', () => {
hovering = false;
stopAtLoopEnd();
});
play();
openTimer = window.setTimeout(stopAtLoopEnd, OPEN_PLAY_MS);
}