summaryrefslogtreecommitdiff
path: root/src/ts
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-14 17:23:02 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-14 17:23:02 -0800
commitac49e3a48fb18d95f7f3609107bbf05dc9e170ea (patch)
treeea0029c8f1208e01ddc01afea7dfa3ff75db58b5 /src/ts
parent17708f1430fd63e9350af82abe40a7dd78b15b8c (diff)
downloadadelie-ac49e3a48fb18d95f7f3609107bbf05dc9e170ea.tar.gz
adelie-ac49e3a48fb18d95f7f3609107bbf05dc9e170ea.zip
Code cleanup
Diffstat (limited to 'src/ts')
-rw-r--r--src/ts/oneko.ts493
-rw-r--r--src/ts/script.ts163
2 files changed, 404 insertions, 252 deletions
diff --git a/src/ts/oneko.ts b/src/ts/oneko.ts
index 236c7cb..2cf3a9b 100644
--- a/src/ts/oneko.ts
+++ b/src/ts/oneko.ts
@@ -1,182 +1,179 @@
// oneko.js: https://github.com/adryd325/oneko.js
-export function initOneko(): void {
- const isReducedMotion =
- window.matchMedia('(prefers-reduced-motion: reduce)') === true ||
- window.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
+type SpriteOffset = readonly [number, number];
- if (isReducedMotion) return;
+type Direction = 'N' | 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW';
- const nekoEl = document.createElement('div');
- let persistPosition = true;
+type IdleAnimation =
+ | 'sleeping'
+ | 'scratchSelf'
+ | 'scratchWallN'
+ | 'scratchWallS'
+ | 'scratchWallE'
+ | 'scratchWallW';
- let nekoPosX = 32;
- let nekoPosY = 32;
+type SavedState = {
+ nekoPosX: number;
+ nekoPosY: number;
+ mousePosX: number;
+ mousePosY: number;
+ frameCount: number;
+ idleTime: number;
+ idleAnimation: IdleAnimation | null;
+ idleAnimationFrame: number;
+ bgPos: string;
+};
- let mousePosX = 0;
- let mousePosY = 0;
+const NEKO_STORAGE_KEY = 'oneko';
+const FRAME_INTERVAL_MS = 100;
- let frameCount = 0;
- let idleTime = 0;
- let idleAnimation: string | null = null;
- let idleAnimationFrame = 0;
+const NEKO_SIZE_PX = 32;
+const NEKO_HALF_SIZE_PX = NEKO_SIZE_PX / 2;
+const NEKO_SPEED_PX = 10;
- const nekoSpeed = 10;
- const spriteSets: Record<string, [number, number][]> = {
- idle: [[-3, -3]],
- alert: [[-7, -3]],
- scratchSelf: [
- [-5, 0],
- [-6, 0],
- [-7, 0],
- ],
- scratchWallN: [
- [0, 0],
- [0, -1],
- ],
- scratchWallS: [
- [-7, -1],
- [-6, -2],
- ],
- scratchWallE: [
- [-2, -2],
- [-2, -3],
- ],
- scratchWallW: [
- [-4, 0],
- [-4, -1],
- ],
- tired: [[-3, -2]],
- sleeping: [
- [-2, 0],
- [-2, -1],
- ],
- N: [
- [-1, -2],
- [-1, -3],
- ],
- NE: [
- [0, -2],
- [0, -3],
- ],
- E: [
- [-3, 0],
- [-3, -1],
- ],
- SE: [
- [-5, -1],
- [-5, -2],
- ],
- S: [
- [-6, -3],
- [-7, -2],
- ],
- SW: [
- [-5, -3],
- [-6, -1],
- ],
- W: [
- [-4, -2],
- [-4, -3],
- ],
- NW: [
- [-1, 0],
- [-1, -1],
- ],
- };
+const IDLE_DISTANCE_THRESHOLD_PX = 48;
- function init(): void {
- const assetBase = window.ASSET_BASE || '';
- let nekoFile = `${assetBase}/oneko/oneko.gif`;
- const curScript = document.currentScript as HTMLScriptElement;
- if (curScript?.dataset.cat) {
- nekoFile = curScript.dataset.cat;
- }
- if (curScript?.dataset.persistPosition) {
- if (curScript.dataset.persistPosition === '') {
- persistPosition = true;
- } else {
- persistPosition = JSON.parse(curScript.dataset.persistPosition.toLowerCase());
- }
- }
+const spriteSets = {
+ idle: [[-3, -3]],
+ alert: [[-7, -3]],
+ scratchSelf: [
+ [-5, 0],
+ [-6, 0],
+ [-7, 0],
+ ],
+ scratchWallN: [
+ [0, 0],
+ [0, -1],
+ ],
+ scratchWallS: [
+ [-7, -1],
+ [-6, -2],
+ ],
+ scratchWallE: [
+ [-2, -2],
+ [-2, -3],
+ ],
+ scratchWallW: [
+ [-4, 0],
+ [-4, -1],
+ ],
+ tired: [[-3, -2]],
+ sleeping: [
+ [-2, 0],
+ [-2, -1],
+ ],
+ N: [
+ [-1, -2],
+ [-1, -3],
+ ],
+ NE: [
+ [0, -2],
+ [0, -3],
+ ],
+ E: [
+ [-3, 0],
+ [-3, -1],
+ ],
+ SE: [
+ [-5, -1],
+ [-5, -2],
+ ],
+ S: [
+ [-6, -3],
+ [-7, -2],
+ ],
+ SW: [
+ [-5, -3],
+ [-6, -1],
+ ],
+ W: [
+ [-4, -2],
+ [-4, -3],
+ ],
+ NW: [
+ [-1, 0],
+ [-1, -1],
+ ],
+} as const satisfies Record<string, readonly SpriteOffset[]>;
- if (persistPosition) {
- const storedNekoStr = window.localStorage.getItem('oneko');
- const storedNeko = storedNekoStr ? JSON.parse(storedNekoStr) : null;
- if (storedNeko !== null) {
- nekoPosX = storedNeko.nekoPosX;
- nekoPosY = storedNeko.nekoPosY;
- mousePosX = storedNeko.mousePosX;
- mousePosY = storedNeko.mousePosY;
- frameCount = storedNeko.frameCount;
- idleTime = storedNeko.idleTime;
- idleAnimation = storedNeko.idleAnimation;
- idleAnimationFrame = storedNeko.idleAnimationFrame;
- nekoEl.style.backgroundPosition = storedNeko.bgPos;
- }
- }
+type SpriteName = keyof typeof spriteSets;
- nekoEl.id = 'oneko';
- nekoEl.ariaHidden = 'true';
- nekoEl.style.width = '32px';
- nekoEl.style.height = '32px';
- nekoEl.style.position = 'fixed';
- nekoEl.style.pointerEvents = 'none';
- nekoEl.style.imageRendering = 'pixelated';
- nekoEl.style.left = `${nekoPosX - 16}px`;
- nekoEl.style.top = `${nekoPosY - 16}px`;
- nekoEl.style.zIndex = '2147483647';
+function prefersReducedMotion(): boolean {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
- nekoEl.style.backgroundImage = `url(${nekoFile})`;
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(min, value), max);
+}
- document.body.appendChild(nekoEl);
+function safeJsonParse<T>(value: string | null): T | null {
+ if (!value) return null;
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return null;
+ }
+}
- document.addEventListener('mousemove', (event: MouseEvent) => {
- mousePosX = event.clientX;
- mousePosY = event.clientY;
- });
+function parseBooleanAttribute(value: string): boolean | null {
+ const normalized = value.trim().toLowerCase();
+ if (normalized === 'true') return true;
+ if (normalized === 'false') return false;
+ return null;
+}
- if (persistPosition) {
- window.addEventListener('beforeunload', () => {
- window.localStorage.setItem(
- 'oneko',
- JSON.stringify({
- nekoPosX: nekoPosX,
- nekoPosY: nekoPosY,
- mousePosX: mousePosX,
- mousePosY: mousePosY,
- frameCount: frameCount,
- idleTime: idleTime,
- idleAnimation: idleAnimation,
- idleAnimationFrame: idleAnimationFrame,
- bgPos: nekoEl.style.backgroundPosition,
- })
- );
- });
- }
+function sample<T>(items: readonly T[]): T {
+ return items[Math.floor(Math.random() * items.length)];
+}
- window.requestAnimationFrame(onAnimationFrame);
+function directionFromVector(dx: number, dy: number): Direction {
+ // Screen coordinates: +x is right, +y is down.
+ // atan2 gives: 0 = E, π/2 = S, π = W, -π/2 = N.
+ const angle = Math.atan2(dy, dx);
+ const octant = (Math.round((8 * angle) / (2 * Math.PI)) + 8) % 8;
+
+ switch (octant) {
+ case 0:
+ return 'E';
+ case 1:
+ return 'SE';
+ case 2:
+ return 'S';
+ case 3:
+ return 'SW';
+ case 4:
+ return 'W';
+ case 5:
+ return 'NW';
+ case 6:
+ return 'N';
+ case 7:
+ return 'NE';
+ default:
+ return 'E';
}
+}
- let lastFrameTimestamp: number | undefined;
+export function initOneko(): void {
+ if (prefersReducedMotion()) return;
- function onAnimationFrame(timestamp: number): void {
- if (!nekoEl.isConnected) {
- return;
- }
- if (!lastFrameTimestamp) {
- lastFrameTimestamp = timestamp;
- }
- if (lastFrameTimestamp && timestamp - lastFrameTimestamp > 100) {
- lastFrameTimestamp = timestamp;
- frame();
- }
- window.requestAnimationFrame(onAnimationFrame);
- }
+ const nekoEl = document.createElement('div');
+ let persistPosition = true;
+
+ let nekoPosX = NEKO_SIZE_PX;
+ let nekoPosY = NEKO_SIZE_PX;
- function setSprite(name: string, frame: number): void {
+ let mousePosX = 0;
+ let mousePosY = 0;
+
+ let frameCount = 0;
+ let idleTime = 0;
+ let idleAnimation: IdleAnimation | null = null;
+ let idleAnimationFrame = 0;
+
+ function setSprite(name: SpriteName, frame: number): void {
const sprite = spriteSets[name][frame % spriteSets[name].length];
- nekoEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`;
+ nekoEl.style.backgroundPosition = `${sprite[0] * NEKO_SIZE_PX}px ${sprite[1] * NEKO_SIZE_PX}px`;
}
function resetIdleAnimation(): void {
@@ -184,26 +181,83 @@ export function initOneko(): void {
idleAnimationFrame = 0;
}
+ function loadPersistedState(): void {
+ if (!persistPosition) return;
+
+ const stored = safeJsonParse<Partial<SavedState>>(
+ window.localStorage.getItem(NEKO_STORAGE_KEY)
+ );
+ if (!stored) return;
+
+ if (typeof stored.nekoPosX === 'number') nekoPosX = stored.nekoPosX;
+ if (typeof stored.nekoPosY === 'number') nekoPosY = stored.nekoPosY;
+ if (typeof stored.mousePosX === 'number') mousePosX = stored.mousePosX;
+ if (typeof stored.mousePosY === 'number') mousePosY = stored.mousePosY;
+ if (typeof stored.frameCount === 'number') frameCount = stored.frameCount;
+ if (typeof stored.idleTime === 'number') idleTime = stored.idleTime;
+ if (typeof stored.idleAnimationFrame === 'number')
+ idleAnimationFrame = stored.idleAnimationFrame;
+
+ if (stored.idleAnimation === null) {
+ idleAnimation = null;
+ } else if (typeof stored.idleAnimation === 'string') {
+ const allowed: IdleAnimation[] = [
+ 'sleeping',
+ 'scratchSelf',
+ 'scratchWallN',
+ 'scratchWallS',
+ 'scratchWallE',
+ 'scratchWallW',
+ ];
+ if (allowed.includes(stored.idleAnimation as IdleAnimation)) {
+ idleAnimation = stored.idleAnimation as IdleAnimation;
+ }
+ }
+
+ if (typeof stored.bgPos === 'string') {
+ nekoEl.style.backgroundPosition = stored.bgPos;
+ }
+ }
+
+ function saveState(): void {
+ if (!persistPosition) return;
+
+ const state: SavedState = {
+ nekoPosX,
+ nekoPosY,
+ mousePosX,
+ mousePosY,
+ frameCount,
+ idleTime,
+ idleAnimation,
+ idleAnimationFrame,
+ bgPos: nekoEl.style.backgroundPosition,
+ };
+
+ window.localStorage.setItem(NEKO_STORAGE_KEY, JSON.stringify(state));
+ }
+
function idle(): void {
idleTime += 1;
- // every ~ 20 seconds
- if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation == null) {
- const avalibleIdleAnimations: string[] = ['sleeping', 'scratchSelf'];
- if (nekoPosX < 32) {
- avalibleIdleAnimations.push('scratchWallW');
+ // Every ~20 seconds, pick an idle animation.
+ if (idleTime > 10 && Math.floor(Math.random() * 200) === 0 && idleAnimation === null) {
+ const availableIdleAnimations: IdleAnimation[] = ['sleeping', 'scratchSelf'];
+
+ if (nekoPosX < NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallW');
}
- if (nekoPosY < 32) {
- avalibleIdleAnimations.push('scratchWallN');
+ if (nekoPosY < NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallN');
}
- if (nekoPosX > window.innerWidth - 32) {
- avalibleIdleAnimations.push('scratchWallE');
+ if (nekoPosX > window.innerWidth - NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallE');
}
- if (nekoPosY > window.innerHeight - 32) {
- avalibleIdleAnimations.push('scratchWallS');
+ if (nekoPosY > window.innerHeight - NEKO_SIZE_PX) {
+ availableIdleAnimations.push('scratchWallS');
}
- idleAnimation =
- avalibleIdleAnimations[Math.floor(Math.random() * avalibleIdleAnimations.length)];
+
+ idleAnimation = sample(availableIdleAnimations);
}
switch (idleAnimation) {
@@ -212,6 +266,7 @@ export function initOneko(): void {
setSprite('tired', 0);
break;
}
+
setSprite('sleeping', Math.floor(idleAnimationFrame / 4));
if (idleAnimationFrame > 192) {
resetIdleAnimation();
@@ -231,46 +286,112 @@ export function initOneko(): void {
setSprite('idle', 0);
return;
}
+
idleAnimationFrame += 1;
}
function frame(): void {
frameCount += 1;
+
const diffX = nekoPosX - mousePosX;
const diffY = nekoPosY - mousePosY;
const distance = Math.sqrt(diffX ** 2 + diffY ** 2);
- if (distance < nekoSpeed || distance < 48) {
+ if (distance < IDLE_DISTANCE_THRESHOLD_PX || distance < NEKO_SPEED_PX) {
idle();
return;
}
- idleAnimation = null;
- idleAnimationFrame = 0;
+ resetIdleAnimation();
if (idleTime > 1) {
setSprite('alert', 0);
- // count down after being alerted before moving
+ // Count down after being alerted before moving.
idleTime = Math.min(idleTime, 7);
idleTime -= 1;
return;
}
- let direction = '';
- direction += diffY / distance > 0.5 ? 'N' : '';
- direction += diffY / distance < -0.5 ? 'S' : '';
- direction += diffX / distance > 0.5 ? 'W' : '';
- direction += diffX / distance < -0.5 ? 'E' : '';
+ const targetDx = mousePosX - nekoPosX;
+ const targetDy = mousePosY - nekoPosY;
+ const direction = directionFromVector(targetDx, targetDy);
setSprite(direction, frameCount);
- nekoPosX -= (diffX / distance) * nekoSpeed;
- nekoPosY -= (diffY / distance) * nekoSpeed;
+ nekoPosX -= (diffX / distance) * NEKO_SPEED_PX;
+ nekoPosY -= (diffY / distance) * NEKO_SPEED_PX;
- nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16);
- nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16);
+ nekoPosX = clamp(nekoPosX, NEKO_HALF_SIZE_PX, window.innerWidth - NEKO_HALF_SIZE_PX);
+ nekoPosY = clamp(nekoPosY, NEKO_HALF_SIZE_PX, window.innerHeight - NEKO_HALF_SIZE_PX);
- nekoEl.style.left = `${nekoPosX - 16}px`;
- nekoEl.style.top = `${nekoPosY - 16}px`;
+ nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`;
+ nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`;
+ }
+
+ function init(): void {
+ const assetBase = window.ASSET_BASE ?? '';
+ let nekoFile = `${assetBase}/oneko/oneko.gif`;
+
+ const curScript = document.currentScript as HTMLScriptElement | null;
+ if (curScript?.dataset.cat) {
+ nekoFile = curScript.dataset.cat;
+ }
+
+ const persistPositionAttr = curScript?.dataset.persistPosition;
+ if (persistPositionAttr !== undefined) {
+ if (persistPositionAttr === '') {
+ persistPosition = true;
+ } else {
+ const parsed = parseBooleanAttribute(persistPositionAttr);
+ if (parsed !== null) {
+ persistPosition = parsed;
+ }
+ }
+ }
+
+ nekoEl.id = 'oneko';
+ nekoEl.ariaHidden = 'true';
+ nekoEl.style.width = `${NEKO_SIZE_PX}px`;
+ nekoEl.style.height = `${NEKO_SIZE_PX}px`;
+ nekoEl.style.position = 'fixed';
+ nekoEl.style.pointerEvents = 'none';
+ nekoEl.style.imageRendering = 'pixelated';
+ nekoEl.style.zIndex = '2147483647';
+ nekoEl.style.backgroundImage = `url(${nekoFile})`;
+
+ loadPersistedState();
+
+ nekoEl.style.left = `${nekoPosX - NEKO_HALF_SIZE_PX}px`;
+ nekoEl.style.top = `${nekoPosY - NEKO_HALF_SIZE_PX}px`;
+
+ document.body.appendChild(nekoEl);
+
+ document.addEventListener('mousemove', (event: MouseEvent) => {
+ mousePosX = event.clientX;
+ mousePosY = event.clientY;
+ });
+
+ if (persistPosition) {
+ window.addEventListener('beforeunload', saveState);
+ }
+
+ window.requestAnimationFrame(onAnimationFrame);
+ }
+
+ let lastFrameTimestamp: number | undefined;
+
+ function onAnimationFrame(timestamp: number): void {
+ if (!nekoEl.isConnected) return;
+
+ if (lastFrameTimestamp === undefined) {
+ lastFrameTimestamp = timestamp;
+ }
+
+ if (timestamp - lastFrameTimestamp >= FRAME_INTERVAL_MS) {
+ lastFrameTimestamp = timestamp;
+ frame();
+ }
+
+ window.requestAnimationFrame(onAnimationFrame);
}
init();
diff --git a/src/ts/script.ts b/src/ts/script.ts
index 56c6d63..81c61f7 100644
--- a/src/ts/script.ts
+++ b/src/ts/script.ts
@@ -1,92 +1,123 @@
import Prism from 'prismjs';
-import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-css';
+import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';
import { initOneko } from './oneko';
-// Auto-detect asset base from the bundled script's origin
-(() => {
- window.ASSET_BASE = '';
- const bundleScript = document.querySelector('script[src*="bundle"]');
- if (bundleScript?.src) {
- try {
- const url = new URL(bundleScript.src, window.location.href);
- window.ASSET_BASE = url.origin;
- } catch {
- // Fall back to empty string
- }
- }
-})();
+type Theme = 'light' | 'dark';
-(() => {
- const toggleButton = document.getElementById('theme-toggle') as HTMLInputElement;
- const html = document.documentElement;
+const THEME_STORAGE_KEY = 'theme';
+const DARK_THEME_ATTRIBUTE = 'data-theme';
- const sessionTheme = sessionStorage.getItem('theme');
- const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+function detectAssetBase(): string {
+ const currentScript = document.currentScript as HTMLScriptElement | null;
+ const scriptSrc =
+ currentScript?.src ??
+ document.querySelector<HTMLScriptElement>('script[src*="bundle"]')?.src ??
+ '';
- const initialTheme = sessionTheme || (systemPrefersDark ? 'dark' : 'light');
+ if (!scriptSrc) return '';
- if (initialTheme === 'dark') {
- html.setAttribute('data-theme', 'dark');
- toggleButton.checked = true;
+ try {
+ return new URL(scriptSrc, window.location.href).origin;
+ } catch {
+ return '';
}
+}
+
+function setAssetBase(): void {
+ window.ASSET_BASE = detectAssetBase();
+}
+
+function getSystemTheme(): Theme {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function applyTheme(theme: Theme): void {
+ if (theme === 'dark') {
+ document.documentElement.setAttribute(DARK_THEME_ATTRIBUTE, 'dark');
+ } else {
+ document.documentElement.removeAttribute(DARK_THEME_ATTRIBUTE);
+ }
+}
+
+function getStoredTheme(): Theme | null {
+ const theme = sessionStorage.getItem(THEME_STORAGE_KEY);
+ return theme === 'dark' || theme === 'light' ? theme : null;
+}
+
+function initThemeToggle(): void {
+ const toggleButton = document.getElementById('theme-toggle');
+ if (!(toggleButton instanceof HTMLInputElement)) return;
+
+ const initialTheme = getStoredTheme() ?? getSystemTheme();
+ applyTheme(initialTheme);
+ toggleButton.checked = initialTheme === 'dark';
toggleButton.addEventListener('change', () => {
- const theme = html.getAttribute('data-theme');
-
- if (theme === 'dark') {
- html.removeAttribute('data-theme');
- sessionStorage.setItem('theme', 'light');
- toggleButton.checked = false;
- } else {
- html.setAttribute('data-theme', 'dark');
- sessionStorage.setItem('theme', 'dark');
- toggleButton.checked = true;
- }
+ const nextTheme: Theme = toggleButton.checked ? 'dark' : 'light';
+ applyTheme(nextTheme);
+ sessionStorage.setItem(THEME_STORAGE_KEY, nextTheme);
});
-})();
+}
-(() => {
- const colors = ['#ff69b4', '#b19cd9', '#8b6f47', '#ff85c0', '#c4b5fd', '#d4a574'];
- const shapes = ['❀', '✿', '✽', '✾', '✻', '❊', '❋', '✼'];
+function sample<T>(items: readonly T[]): T {
+ return items[Math.floor(Math.random() * items.length)];
+}
- document.addEventListener('mousemove', (e: MouseEvent) => {
- createParticle(e.clientX, e.clientY);
- });
+function initFairyDust(): void {
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
- const createParticle = (x: number, y: number) => {
+ const colors = ['#ff69b4', '#b19cd9', '#8b6f47', '#ff85c0', '#c4b5fd', '#d4a574'] as const;
+ const shapes = ['❀', '✿', '✽', '✾', '✻', '❊', '❋', '✼'] as const;
+
+ const particleLifetimeMs = 800;
+
+ function createParticle(x: number, y: number): void {
const particle = document.createElement('div');
particle.className = 'fairy-dust';
- const shape = shapes[Math.floor(Math.random() * shapes.length)];
- const size = Math.random() * 8 + 6;
- const color = colors[Math.floor(Math.random() * colors.length)];
const offsetX = (Math.random() - 0.5) * 20;
const offsetY = (Math.random() - 0.5) * 20;
- const rotation = Math.random() * 360;
-
- particle.textContent = shape;
- particle.style.cssText = `
- position: fixed;
- left: ${x + offsetX}px;
- top: ${y + offsetY}px;
- font-size: ${size}px;
- color: ${color};
- opacity: 0.4;
- pointer-events: none;
- z-index: 9001; /* it's over 9000 */
- line-height: 1;
- transform: rotate(${rotation}deg);
- animation: fairy-float 0.8s ease-out forwards;
- `;
+ const rotationDeg = Math.random() * 360;
+ const sizePx = Math.random() * 8 + 6;
+
+ particle.textContent = sample(shapes);
+ particle.style.left = `${x + offsetX}px`;
+ particle.style.top = `${y + offsetY}px`;
+ particle.style.fontSize = `${sizePx}px`;
+ particle.style.color = sample(colors);
+ particle.style.transform = `rotate(${rotationDeg}deg)`;
document.body.appendChild(particle);
- setTimeout(() => particle.remove(), 800);
- };
-})();
+ window.setTimeout(() => particle.remove(), particleLifetimeMs);
+ }
+
+ // rAF throttle to avoid creating a particle per mouse event.
+ let latestX = 0;
+ let latestY = 0;
+ let scheduled = false;
-document.addEventListener('DOMContentLoaded', () => {
+ document.addEventListener('mousemove', (event: MouseEvent) => {
+ latestX = event.clientX;
+ latestY = event.clientY;
+
+ if (scheduled) return;
+ scheduled = true;
+
+ window.requestAnimationFrame(() => {
+ scheduled = false;
+ createParticle(latestX, latestY);
+ });
+ });
+}
+
+function init(): void {
+ setAssetBase();
+ initThemeToggle();
+ initFairyDust();
Prism.highlightAll();
initOneko();
-});
+}
+
+document.addEventListener('DOMContentLoaded', init);