Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/lib/components/Snowfall.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';

let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;

interface Flake {
x: number;
y: number;
r: number;
vx: number;
vy: number;
phase: number;
opacity: number;
}

let flakes: Flake[] = [];

function makeFlake(): Flake {
const r = Math.random() * 2.2 + 0.8; // 0.8 - 3.0 px
const speed = 0.4 + r * 0.35; // tie speed to size
return {
x: Math.random() * width,
y: -10 - Math.random() * height,
r,
vx: (Math.random() - 0.5) * 0.4,
vy: speed,
phase: Math.random() * Math.PI * 2,
opacity: 0.6 + Math.random() * 0.4
};
Comment on lines +23 to +34
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The makeFlake function uses magic numbers (2.2, 0.8, 0.4, 0.35, etc.) for snowflake properties without explanation. These should be extracted as named constants to improve code readability and make it easier to adjust the visual effect.

Copilot uses AI. Check for mistakes.
}

function setCanvasSize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx && ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

function populateFlakes(target: number) {
flakes.length = 0;
for (let i = 0; i < target; i++) {
const f = makeFlake();
f.y = Math.random() * height;
flakes.push(f);
}
}

function draw() {
if (!ctx) return;
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The draw function is called unconditionally in every animation frame, even when the canvas size is zero or the context is invalid. While there's an early return if ctx is null, the function should also check if width or height are zero to avoid unnecessary work.

Suggested change
if (!ctx) return;
if (!ctx || width <= 0 || height <= 0) return;

Copilot uses AI. Check for mistakes.
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#fff';
for (const f of flakes) {
f.phase += 0.01 + f.r * 0.002;
f.x += f.vx + Math.sin(f.phase) * 0.3;
f.y += f.vy;

if (f.y - f.r > height) {
f.x = Math.random() * width;
f.y = -f.r - Math.random() * 40;
f.vx = (Math.random() - 0.5) * 0.4;
f.phase = Math.random() * Math.PI * 2;
}

ctx.globalAlpha = f.opacity;
ctx.beginPath();
ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}

function loop() {
draw();
raf = requestAnimationFrame(loop);
}

function handleResize() {
setCanvasSize();
const density = Math.min(220, Math.max(60, Math.floor((width * height) / 15000)));
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The density calculation uses magic numbers (220, 60, 15000) without explanation. These values should be extracted as named constants with comments explaining their purpose and how they were chosen, improving code maintainability.

Copilot uses AI. Check for mistakes.
populateFlakes(density);
}

onMount(() => {
if (typeof window === 'undefined') return;
dpr = Math.min(2, window.devicePixelRatio || 1);
ctx = canvas.getContext('2d');
if (!ctx) return;
setCanvasSize();
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleResize function is called twice during initialization - once directly at line 97 and once again at line 96 via setCanvasSize (which sets dimensions), making the line 97 call redundant since handleResize already calls setCanvasSize at line 86. This causes unnecessary duplication of work during component initialization.

Suggested change
setCanvasSize();

Copilot uses AI. Check for mistakes.
handleResize();
window.addEventListener('resize', handleResize, { passive: true });
raf = requestAnimationFrame(loop);
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The animation loop continues running even when the canvas is hidden due to prefers-reduced-motion. The animation should be paused or stopped when this media query is active to avoid unnecessary CPU usage and respect user preferences for reduced motion.

Copilot uses AI. Check for mistakes.
return () => {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf);
window.removeEventListener('resize', handleResize);
};
Comment on lines +100 to +103
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup function returned from onMount will not be executed. In Svelte, onMount does not support returning a cleanup function. The cleanup logic should be moved to the onDestroy hook instead, which already exists at lines 106-109. The current implementation has duplication between the onMount return statement and the onDestroy hook.

Suggested change
return () => {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf);
window.removeEventListener('resize', handleResize);
};

Copilot uses AI. Check for mistakes.
});

onDestroy(() => {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf);
if (typeof window !== 'undefined') window.removeEventListener('resize', handleResize);
});
</script>

<style>
canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.6));
}
@media (prefers-reduced-motion: reduce) {
canvas { display: none; }
}
</style>

<canvas bind:this={canvas} aria-hidden="true"></canvas>
21 changes: 20 additions & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
<script lang="ts">
import '../app.css';
import Snowfall from '$lib/components/Snowfall.svelte';

let { children } = $props();
</script>

{@render children?.()}
<Snowfall />

<div class="app-content">
{@render children?.()}
</div>

<style>
.app-content {
position: relative;
z-index: 1;
min-height: 100dvh;
}
:global(html, body) {
min-height: 100%;
}
:global(body) {
position: relative;
}
</style>