Compare commits

..

3 Commits

Author SHA1 Message Date
174ca69dcd Add confetti animation 2026-03-30 18:21:27 -07:00
f6f9c15e0c Add lavalamp animation 2026-03-30 17:57:32 -07:00
16902f00f4 Add darkbox palette themes 2026-03-30 17:17:52 -07:00
22 changed files with 2198 additions and 693 deletions

View File

@@ -117,7 +117,14 @@ const Stats = () => {
<style jsx>{`
.bg-gradient-text {
background: linear-gradient(90deg, #fbbf24, #f59e0b, #d97706, #b45309, #f59e0b, #fbbf24);
background: linear-gradient(90deg,
rgb(var(--color-yellow-bright)),
rgb(var(--color-orange-bright)),
rgb(var(--color-orange)),
rgb(var(--color-yellow)),
rgb(var(--color-orange-bright)),
rgb(var(--color-yellow-bright))
);
background-size: 200% auto;
color: transparent;
background-clip: text;

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useRef } from "react";
import {
getStoredAnimationId,
getNextAnimation,
saveAnimation,
} from "@/lib/animations/engine";
import { ANIMATION_LABELS } from "@/lib/animations";
export default function AnimationSwitcher() {
const [hovering, setHovering] = useState(false);
const [nextLabel, setNextLabel] = useState("");
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredAnimationId();
setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]);
const handleSwap = () => {
const id = getStoredAnimationId();
committedRef.current = id;
setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]);
};
document.addEventListener("astro:after-swap", handleSwap);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
};
}, []);
const handleClick = () => {
const nextId = getNextAnimation(
committedRef.current as Parameters<typeof getNextAnimation>[0]
);
saveAnimation(nextId);
committedRef.current = nextId;
setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]);
document.dispatchEvent(
new CustomEvent("animation-changed", { detail: { id: nextId } })
);
};
return (
<div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{nextLabel}
</span>
</div>
);
}

View File

@@ -0,0 +1,329 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
color: [number, number, number];
baseColor: [number, number, number];
opacity: number;
dop: number;
elevation: number;
targetElevation: number;
staggerDelay: number;
burst: boolean;
}
const BASE_CONFETTI = 350;
const BASE_AREA = 1920 * 1080;
const PI_2 = 2 * Math.PI;
const TARGET_FPS = 60;
const SPEED_FACTOR = 0.15;
const STAGGER_INTERVAL = 12;
const COLOR_LERP_SPEED = 0.02;
const MOUSE_INFLUENCE_RADIUS = 150;
const ELEVATION_FACTOR = 6;
const ELEVATION_LERP_SPEED = 0.05;
const COLOR_SHIFT_AMOUNT = 30;
const SHADOW_OFFSET_RATIO = 1.1;
function range(a: number, b: number): number {
return (b - a) * Math.random() + a;
}
export class ConfettiEngine implements AnimationEngine {
id = "confetti";
name = "Confetti";
private particles: ConfettiParticle[] = [];
private palette: [number, number, number][] = [];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private mouseXNorm = 0.5;
private elapsed = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
_bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.elapsed = 0;
this.mouseXNorm = 0.5;
this.initParticles();
}
cleanup(): void {
this.particles = [];
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private getParticleCount(): number {
const area = this.width * this.height;
return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA)));
}
private initParticles(): void {
this.particles = [];
const count = this.getParticleCount();
for (let i = 0; i < count; i++) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
this.particles.push({
x: range(-r * 2, this.width - r * 2),
y: range(-20, this.height - r * 2),
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
r,
color: [...baseColor],
baseColor,
opacity: 0,
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
elevation: 0,
targetElevation: 0,
staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL),
burst: false,
});
}
}
private replaceParticle(p: ConfettiParticle): void {
p.opacity = 0;
p.dop = 0.03 * range(1, 4) * SPEED_FACTOR;
p.x = range(-p.r * 2, this.width - p.r * 2);
p.y = range(-20, -p.r * 2);
p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR;
p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR;
p.elevation = 0;
p.targetElevation = 0;
p.baseColor = this.randomColor();
p.color = [...p.baseColor];
p.burst = false;
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / TARGET_FPS);
this.elapsed += deltaTime;
const mouseX = this.mouseX;
const mouseY = this.mouseY;
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
// Stagger gate
if (p.staggerDelay >= 0) {
if (this.elapsed >= p.staggerDelay) {
p.staggerDelay = -1;
} else {
continue;
}
}
// Gravity (capped so falling particles don't accelerate)
const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR;
if (p.vy < maxVy) {
p.vy = Math.min(p.vy + 0.02 * dt, maxVy);
}
// Position update
p.x += p.vx * dt;
p.y += p.vy * dt;
// Fade in only (no fade-out cycle)
if (p.opacity < 1) {
p.opacity += Math.abs(p.dop) * dt;
if (p.opacity > 1) p.opacity = 1;
}
// Past the bottom: burst particles get removed, base particles recycle
if (p.y > this.height + p.r) {
if (p.burst) {
this.particles.splice(i, 1);
i--;
} else {
this.replaceParticle(p);
}
continue;
}
// Horizontal wrap
const xmax = this.width - p.r;
if (p.x < 0 || p.x > xmax) {
p.x = ((p.x % xmax) + xmax) % xmax;
}
// Mouse proximity elevation
const dx = p.x - mouseX;
const dy = p.y - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) {
const influenceFactor = Math.cos(
(dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
);
p.targetElevation =
ELEVATION_FACTOR * influenceFactor * influenceFactor;
const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
p.color = [
Math.min(255, Math.max(0, p.baseColor[0] + shift)),
Math.min(255, Math.max(0, p.baseColor[1] + shift)),
Math.min(255, Math.max(0, p.baseColor[2] + shift)),
];
} else {
p.targetElevation = 0;
p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1;
p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1;
p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1;
}
// Elevation lerp
p.elevation +=
(p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt;
}
}
render(
ctx: CanvasRenderingContext2D,
_width: number,
_height: number
): void {
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue;
const drawX = ~~p.x;
const drawY = ~~p.y - p.elevation;
const [r, g, b] = p.color;
// Shadow
if (p.elevation > 0.5) {
const shadowAlpha =
0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
ctx.globalAlpha = shadowAlpha;
ctx.fillStyle = "rgb(0,0,0)";
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0,0,0,0.1)";
ctx.beginPath();
ctx.arc(
drawX,
drawY + p.elevation * SHADOW_OFFSET_RATIO,
p.r,
0,
PI_2
);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowColor = "transparent";
}
// Main circle
ctx.globalAlpha = p.opacity * 0.9;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, 0, PI_2);
ctx.fill();
// Highlight on elevated particles
if (p.elevation > 0.5) {
const highlightAlpha =
0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
ctx.globalAlpha = highlightAlpha;
ctx.fillStyle = "rgb(255,255,255)";
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, Math.PI, 0);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
const target = this.getParticleCount();
while (this.particles.length < target) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
this.particles.push({
x: range(-r * 2, width - r * 2),
y: range(-20, height - r * 2),
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
r,
color: [...baseColor],
baseColor,
opacity: 0,
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
burst: false,
});
}
if (this.particles.length > target) {
this.particles.length = target;
}
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
if (this.width > 0) {
this.mouseXNorm = Math.max(0, Math.min(1, x / this.width));
}
}
handleMouseDown(x: number, y: number): void {
const count = 12;
for (let i = 0; i < count; i++) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
const angle = (i / count) * PI_2 + range(-0.3, 0.3);
const speed = range(0.3, 1.2);
this.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r,
color: [...baseColor],
baseColor,
opacity: 1,
dop: 0,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
burst: true,
});
}
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
this.mouseXNorm = 0.5;
}
updatePalette(palette: [number, number, number][], _bgColor: string): void {
this.palette = palette;
for (const p of this.particles) {
p.baseColor = palette[Math.floor(Math.random() * palette.length)];
}
}
}

View File

@@ -0,0 +1,615 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
baseColor: [number, number, number];
currentX: number;
currentY: number;
targetX: number;
targetY: number;
opacity: number;
targetOpacity: number;
scale: number;
targetScale: number;
elevation: number;
targetElevation: number;
transitioning: boolean;
transitionComplete: boolean;
rippleEffect: number;
rippleStartTime: number;
rippleDistance: number;
}
interface Grid {
cells: Cell[][];
cols: number;
rows: number;
offsetX: number;
offsetY: number;
}
const CELL_SIZE_MOBILE = 15;
const CELL_SIZE_DESKTOP = 25;
const TARGET_FPS = 60;
const CYCLE_TIME = 3000;
const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05;
const INITIAL_DENSITY = 0.15;
const MOUSE_INFLUENCE_RADIUS = 150;
const COLOR_SHIFT_AMOUNT = 30;
const RIPPLE_ELEVATION_FACTOR = 4;
const ELEVATION_FACTOR = 8;
export class GameOfLifeEngine implements AnimationEngine {
id = "game-of-life";
name = "Game of Life";
private grid: Grid | null = null;
private palette: [number, number, number][] = [];
private bgColor = "rgb(0, 0, 0)";
private mouseX = -1000;
private mouseY = -1000;
private mouseIsDown = false;
private mouseCellX = -1;
private mouseCellY = -1;
private lastCycleTime = 0;
private timeAccumulator = 0;
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
private canvasWidth = 0;
private canvasHeight = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.palette = palette;
this.bgColor = bgColor;
this.canvasWidth = width;
this.canvasHeight = height;
this.lastCycleTime = 0;
this.timeAccumulator = 0;
this.grid = this.initGrid(width, height);
}
cleanup(): void {
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
this.grid = null;
}
private getCellSize(): number {
return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private initGrid(width: number, height: number): Grid {
const cellSize = this.getCellSize();
const cols = Math.floor(width / cellSize);
const rows = Math.floor(height / cellSize);
const offsetX = Math.floor((width - cols * cellSize) / 2);
const offsetY = Math.floor((height - rows * cellSize) / 2);
const cells = Array(cols)
.fill(0)
.map((_, i) =>
Array(rows)
.fill(0)
.map((_, j) => {
const baseColor = this.randomColor();
return {
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: [...baseColor] as [number, number, number],
baseColor,
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
elevation: 0,
targetElevation: 0,
transitioning: false,
transitionComplete: false,
rippleEffect: 0,
rippleStartTime: 0,
rippleDistance: 0,
};
})
);
const grid = { cells, cols, rows, offsetX, offsetY };
this.computeNextState(grid);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const cell = cells[i][j];
if (cell.next) {
cell.alive = true;
const tid = setTimeout(() => {
cell.targetOpacity = 1;
cell.targetScale = 1;
}, Math.random() * 1000);
this.pendingTimeouts.push(tid);
} else {
cell.alive = false;
}
}
}
return grid;
}
private countNeighbors(
grid: Grid,
x: number,
y: number
): { count: number; colors: [number, number, number][] } {
const neighbors = { count: 0, colors: [] as [number, number, number][] };
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows;
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].baseColor);
}
}
}
return neighbors;
}
private averageColors(
colors: [number, number, number][]
): [number, number, number] {
if (colors.length === 0) return [0, 0, 0];
const sum = colors.reduce(
(acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]],
[0, 0, 0]
);
return [
Math.round(sum[0] / colors.length),
Math.round(sum[1] / colors.length),
Math.round(sum[2] / colors.length),
];
}
private computeNextState(grid: Grid): void {
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const { count, colors } = this.countNeighbors(grid, i, j);
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.baseColor = this.averageColors(colors);
cell.color = [...cell.baseColor];
}
}
}
}
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive !== cell.next && !cell.transitioning) {
cell.transitioning = true;
cell.transitionComplete = false;
const delay = Math.random() * 800;
const tid = setTimeout(() => {
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
cell.targetElevation = 0;
} else {
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
}
}, delay);
this.pendingTimeouts.push(tid);
}
}
}
}
private createRippleEffect(
grid: Grid,
centerX: number,
centerY: number
): void {
const currentTime = Date.now();
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const dx = i - centerX;
const dy = j - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (cell.opacity > 0.1) {
cell.rippleStartTime = currentTime + distance * 100;
cell.rippleDistance = distance;
cell.rippleEffect = 0;
}
}
}
}
private spawnCellAtPosition(grid: Grid, x: number, y: number): void {
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
const cell = grid.cells[x][y];
if (!cell.alive && !cell.transitioning) {
cell.alive = true;
cell.next = true;
cell.transitioning = true;
cell.transitionComplete = false;
cell.baseColor = this.randomColor();
cell.color = [...cell.baseColor];
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
this.createRippleEffect(grid, x, y);
}
}
}
update(deltaTime: number): void {
if (!this.grid) return;
this.timeAccumulator += deltaTime;
if (this.timeAccumulator >= CYCLE_TIME) {
this.computeNextState(this.grid);
this.timeAccumulator -= CYCLE_TIME;
}
this.updateCellAnimations(this.grid, deltaTime);
}
private updateCellAnimations(grid: Grid, deltaTime: number): void {
const mouseX = this.mouseX;
const mouseY = this.mouseY;
const cellSize = this.getCellSize();
const transitionFactor =
TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
cell.elevation +=
(cell.targetElevation - cell.elevation) * scaleFactor;
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
const influenceFactor = Math.cos(
(distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
);
cell.targetElevation =
ELEVATION_FACTOR * influenceFactor * influenceFactor;
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)),
] as [number, number, number];
} else {
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
cell.targetElevation = 0;
}
if (cell.transitioning) {
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
} else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
}
}
if (cell.rippleStartTime > 0) {
const elapsedTime = Date.now() - cell.rippleStartTime;
if (elapsedTime > 0) {
const rippleProgress = elapsedTime / 1000;
if (rippleProgress < 1) {
const wavePhase = rippleProgress * Math.PI * 2;
const waveHeight =
Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.rippleEffect = waveHeight;
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
} else {
cell.rippleEffect = waveHeight * 0.3;
}
} else {
cell.rippleEffect = 0;
cell.rippleStartTime = 0;
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.targetElevation = 0;
}
}
}
}
}
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (!this.grid) return;
const grid = this.grid;
const cellSize = this.getCellSize();
const displayCellSize = cellSize * 0.8;
const roundness = displayCellSize * 0.2;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (
(cell.alive || cell.targetOpacity > 0) &&
cell.opacity > 0.01
) {
const [r, g, b] = cell.color;
ctx.globalAlpha = cell.opacity * 0.9;
const scaledSize = displayCellSize * cell.scale;
const xOffset = (displayCellSize - scaledSize) / 2;
const yOffset = (displayCellSize - scaledSize) / 2;
const elevationOffset = cell.elevation;
const x =
grid.offsetX +
i * cellSize +
(cellSize - displayCellSize) / 2 +
xOffset;
const y =
grid.offsetY +
j * cellSize +
(cellSize - displayCellSize) / 2 +
yOffset -
elevationOffset;
const scaledRoundness = roundness * cell.scale;
// Shadow for 3D effect
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
ctx.lineTo(
x + scaledSize - scaledRoundness,
y + elevationOffset * 1.1
);
ctx.quadraticCurveTo(
x + scaledSize,
y + elevationOffset * 1.1,
x + scaledSize,
y + elevationOffset * 1.1 + scaledRoundness
);
ctx.lineTo(
x + scaledSize,
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
);
ctx.quadraticCurveTo(
x + scaledSize,
y + elevationOffset * 1.1 + scaledSize,
x + scaledSize - scaledRoundness,
y + elevationOffset * 1.1 + scaledSize
);
ctx.lineTo(
x + scaledRoundness,
y + elevationOffset * 1.1 + scaledSize
);
ctx.quadraticCurveTo(
x,
y + elevationOffset * 1.1 + scaledSize,
x,
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
);
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
ctx.quadraticCurveTo(
x,
y + elevationOffset * 1.1,
x + scaledRoundness,
y + elevationOffset * 1.1
);
ctx.fill();
}
// Main cell
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(
x + scaledSize,
y + scaledSize,
x + scaledSize - scaledRoundness,
y + scaledSize
);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
// Highlight on elevated cells
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(
x + scaledSize,
y,
x + scaledSize,
y + scaledRoundness
);
ctx.lineTo(x + scaledSize, y + scaledSize / 3);
ctx.lineTo(x, y + scaledSize / 3);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
}
}
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.canvasWidth = width;
this.canvasHeight = height;
const cellSize = this.getCellSize();
if (
!this.grid ||
this.grid.cols !== Math.floor(width / cellSize) ||
this.grid.rows !== Math.floor(height / cellSize)
) {
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
this.grid = this.initGrid(width, height);
}
}
handleMouseMove(x: number, y: number, isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
this.mouseIsDown = isDown;
if (isDown && this.grid) {
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
const cellY = Math.floor((y - grid.offsetY) / cellSize);
if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) {
this.mouseCellX = cellX;
this.mouseCellY = cellY;
if (
cellX >= 0 &&
cellX < grid.cols &&
cellY >= 0 &&
cellY < grid.rows
) {
const cell = grid.cells[cellX][cellY];
if (!cell.alive && !cell.transitioning) {
this.spawnCellAtPosition(grid, cellX, cellY);
}
}
}
}
}
handleMouseDown(x: number, y: number): void {
this.mouseIsDown = true;
if (!this.grid) return;
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
const cellY = Math.floor((y - grid.offsetY) / cellSize);
if (
cellX >= 0 &&
cellX < grid.cols &&
cellY >= 0 &&
cellY < grid.rows
) {
this.mouseCellX = cellX;
this.mouseCellY = cellY;
const cell = grid.cells[cellX][cellY];
if (cell.alive) {
this.createRippleEffect(grid, cellX, cellY);
} else {
this.spawnCellAtPosition(grid, cellX, cellY);
}
}
}
handleMouseUp(): void {
this.mouseIsDown = false;
}
handleMouseLeave(): void {
this.mouseIsDown = false;
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.bgColor = bgColor;
if (this.grid) {
const grid = this.grid;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive && cell.opacity > 0.01) {
cell.baseColor =
palette[Math.floor(Math.random() * palette.length)];
}
}
}
}
}
}

View File

@@ -0,0 +1,498 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface Blob {
x: number;
y: number;
vx: number;
vy: number;
baseRadius: number;
radiusScale: number;
targetRadiusScale: number;
color: [number, number, number];
targetColor: [number, number, number];
phase: number;
phaseSpeed: number;
staggerDelay: number; // -1 means already revealed
}
const BLOB_COUNT = 26;
const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area
const MIN_SPEED = 0.1;
const MAX_SPEED = 0.35;
const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4)
const FIELD_THRESHOLD = 1.0;
const SMOOTHSTEP_RANGE = 0.25;
const MOUSE_REPEL_RADIUS = 150;
const MOUSE_REPEL_FORCE = 0.2;
const COLOR_LERP_SPEED = 0.02;
const DRIFT_AMPLITUDE = 0.2;
const RADIUS_LERP_SPEED = 0.06;
const STAGGER_INTERVAL = 60;
const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn
const CYCLE_MAX_MS = 5000; // max time
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export class LavaLampEngine implements AnimationEngine {
id = "lava-lamp";
name = "Lava Lamp";
private blobs: Blob[] = [];
private palette: [number, number, number][] = [];
private bgRgb: [number, number, number] = [0, 0, 0];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private offCanvas: HTMLCanvasElement | null = null;
private offCtx: CanvasRenderingContext2D | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private elapsed = 0;
private nextCycleTime = 0;
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
private blobX: Float64Array = new Float64Array(0);
private blobY: Float64Array = new Float64Array(0);
private blobR: Float64Array = new Float64Array(0);
private blobCR: Float64Array = new Float64Array(0);
private blobCG: Float64Array = new Float64Array(0);
private blobCB: Float64Array = new Float64Array(0);
private activeBlobCount = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.parseBgColor(bgColor);
this.elapsed = 0;
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
this.initBlobs();
this.initOffscreenCanvas();
}
private parseBgColor(bgColor: string): void {
const match = bgColor.match(/(\d+)/g);
if (match && match.length >= 3) {
this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
}
}
private getMaxBlobs(): number {
const area = this.width * this.height;
const scale = area / 2_073_600; // normalize to 1080p
return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale));
}
private getRadiusRange(): { min: number; max: number } {
const area = this.width * this.height;
const scale = Math.sqrt(area / 2_073_600);
const min = Math.max(8, Math.round(25 * scale));
const max = Math.max(15, Math.round(65 * scale));
return { min, max };
}
private makeBlob(x: number, y: number, radiusOverride?: number): Blob {
const { min, max } = this.getRadiusRange();
const color = this.palette[
Math.floor(Math.random() * this.palette.length)
] || [128, 128, 128];
return {
x,
y,
vx: (Math.random() - 0.5) * 2 * MAX_SPEED,
vy: (Math.random() - 0.5) * 2 * MAX_SPEED,
baseRadius: radiusOverride ?? (min + Math.random() * (max - min)),
radiusScale: 0,
targetRadiusScale: 1,
color: [...color],
targetColor: [...color],
phase: Math.random() * Math.PI * 2,
phaseSpeed: 0.0005 + Math.random() * 0.001,
staggerDelay: -1,
};
}
private initBlobs(): void {
this.blobs = [];
const { max } = this.getRadiusRange();
const minDist = max * 2.5; // minimum distance between blob centers
for (let i = 0; i < BLOB_COUNT; i++) {
let x: number, y: number;
let attempts = 0;
// Try to find a position that doesn't overlap existing blobs
do {
x = Math.random() * this.width;
y = Math.random() * this.height;
attempts++;
} while (attempts < 50 && this.tooCloseToExisting(x, y, minDist));
const blob = this.makeBlob(x, y);
blob.targetRadiusScale = 0;
blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL;
this.blobs.push(blob);
}
}
private tooCloseToExisting(x: number, y: number, minDist: number): boolean {
for (const blob of this.blobs) {
const dx = blob.x - x;
const dy = blob.y - y;
if (dx * dx + dy * dy < minDist * minDist) return true;
}
return false;
}
private initOffscreenCanvas(): void {
const rw = Math.ceil(this.width / RESOLUTION_SCALE);
const rh = Math.ceil(this.height / RESOLUTION_SCALE);
this.offCanvas = document.createElement("canvas");
this.offCanvas.width = rw;
this.offCanvas.height = rh;
this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true });
this.shadowCanvas = document.createElement("canvas");
this.shadowCanvas.width = rw;
this.shadowCanvas.height = rh;
this.shadowCtx = this.shadowCanvas.getContext("2d", {
willReadFrequently: true,
});
}
cleanup(): void {
this.blobs = [];
this.offCanvas = null;
this.offCtx = null;
this.shadowCanvas = null;
this.shadowCtx = null;
}
/** Snapshot active blob data into flat typed arrays for fast inner-loop access */
private syncBlobArrays(): void {
const blobs = this.blobs;
const n = blobs.length;
// Grow arrays if needed
if (this.blobX.length < n) {
const cap = n + 32;
this.blobX = new Float64Array(cap);
this.blobY = new Float64Array(cap);
this.blobR = new Float64Array(cap);
this.blobCR = new Float64Array(cap);
this.blobCG = new Float64Array(cap);
this.blobCB = new Float64Array(cap);
}
let count = 0;
for (let i = 0; i < n; i++) {
const b = blobs[i];
const r = b.baseRadius * b.radiusScale;
if (r < 1) continue; // skip invisible blobs entirely
this.blobX[count] = b.x;
this.blobY[count] = b.y;
this.blobR[count] = r;
this.blobCR[count] = b.color[0];
this.blobCG[count] = b.color[1];
this.blobCB[count] = b.color[2];
count++;
}
this.activeBlobCount = count;
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / 60);
this.elapsed += deltaTime;
for (const blob of this.blobs) {
// Staggered load-in
if (blob.staggerDelay >= 0) {
if (this.elapsed >= blob.staggerDelay) {
blob.targetRadiusScale = 1;
blob.staggerDelay = -1;
}
}
blob.radiusScale +=
(blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt;
blob.phase += blob.phaseSpeed * deltaTime;
const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE;
const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE;
blob.vx += driftX * dt * 0.01;
blob.vy += driftY * dt * 0.01;
blob.vx += (Math.random() - 0.5) * 0.008 * dt;
blob.vy += (Math.random() - 0.5) * 0.008 * dt;
const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy);
if (speed > MAX_SPEED) {
blob.vx = (blob.vx / speed) * MAX_SPEED;
blob.vy = (blob.vy / speed) * MAX_SPEED;
}
if (speed < MIN_SPEED) {
const angle = Math.atan2(blob.vy, blob.vx);
blob.vx = Math.cos(angle) * MIN_SPEED;
blob.vy = Math.sin(angle) * MIN_SPEED;
}
blob.x += blob.vx * dt;
blob.y += blob.vy * dt;
const pad = blob.baseRadius * 0.3;
if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; }
if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; }
if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; }
if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; }
// Mouse repulsion
const dx = blob.x - this.mouseX;
const dy = blob.y - this.mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_REPEL_RADIUS && dist > 0) {
const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt;
blob.vx += (dx / dist) * force;
blob.vy += (dy / dist) * force;
}
for (let c = 0; c < 3; c++) {
blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt;
}
}
// Remove blobs that have fully shrunk away (but not ones still waiting to stagger in)
for (let i = this.blobs.length - 1; i >= 0; i--) {
const b = this.blobs[i];
if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) {
this.blobs.splice(i, 1);
}
}
// Natural spawn/despawn cycle — keeps the scene alive
if (this.elapsed >= this.nextCycleTime) {
// Pick a random visible blob to fade out (skip ones still staggering in)
const visible = [];
for (let i = 0; i < this.blobs.length; i++) {
if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) {
visible.push(i);
}
}
if (visible.length > 0) {
const killIdx = visible[Math.floor(Math.random() * visible.length)];
this.blobs[killIdx].targetRadiusScale = 0;
}
// Spawn a fresh one at a random position
const blob = this.makeBlob(
Math.random() * this.width,
Math.random() * this.height
);
this.blobs.push(blob);
// Schedule next cycle
this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
}
// Prune excess blobs (keep the initial set, drop oldest user-spawned ones)
const maxBlobs = this.getMaxBlobs();
if (this.blobs.length > maxBlobs) {
this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs);
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas)
return;
// Snapshot blob positions/radii into typed arrays for fast pixel loop
this.syncBlobArrays();
const rw = this.offCanvas.width;
const rh = this.offCanvas.height;
// Render shadow layer
const shadowData = this.shadowCtx.createImageData(rw, rh);
this.renderField(shadowData, rw, rh, true);
this.shadowCtx.putImageData(shadowData, 0, 0);
// Render main layer
const imageData = this.offCtx.createImageData(rw, rh);
this.renderField(imageData, rw, rh, false);
this.offCtx.putImageData(imageData, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "medium";
ctx.globalAlpha = 0.2;
ctx.drawImage(this.shadowCanvas, 0, 4, width, height);
ctx.globalAlpha = 1;
ctx.drawImage(this.offCanvas, 0, 0, width, height);
}
private renderField(
imageData: ImageData,
rw: number,
rh: number,
isShadow: boolean
): void {
const data = imageData.data;
const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD;
const bgR = this.bgRgb[0];
const bgG = this.bgRgb[1];
const bgB = this.bgRgb[2];
const scale = RESOLUTION_SCALE;
const n = this.activeBlobCount;
const bx = this.blobX;
const by = this.blobY;
const br = this.blobR;
const bcr = this.blobCR;
const bcg = this.blobCG;
const bcb = this.blobCB;
const threshLow = threshold - SMOOTHSTEP_RANGE;
for (let py = 0; py < rh; py++) {
const wy = py * scale;
for (let px = 0; px < rw; px++) {
const wx = px * scale;
let fieldSum = 0;
let weightedR = 0;
let weightedG = 0;
let weightedB = 0;
for (let i = 0; i < n; i++) {
const dx = wx - bx[i];
const dy = wy - by[i];
const distSq = dx * dx + dy * dy;
const ri = br[i];
const rSq = ri * ri;
// Raw metaball field
const raw = rSq / (distSq + rSq * 0.1);
// Cap per-blob contribution so color stays flat inside the blob
const contribution = raw > 2 ? 2 : raw;
fieldSum += contribution;
if (contribution > 0.01) {
weightedR += bcr[i] * contribution;
weightedG += bcg[i] * contribution;
weightedB += bcb[i] * contribution;
}
}
const idx = (py * rw + px) << 2;
if (fieldSum > threshLow) {
const alpha = smoothstep(threshLow, threshold, fieldSum);
if (isShadow) {
data[idx] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = (alpha * 150) | 0;
} else {
const invField = 1 / fieldSum;
const r = Math.min(255, (weightedR * invField) | 0);
const g = Math.min(255, (weightedG * invField) | 0);
const b = Math.min(255, (weightedB * invField) | 0);
data[idx] = bgR + (r - bgR) * alpha;
data[idx + 1] = bgG + (g - bgG) * alpha;
data[idx + 2] = bgB + (b - bgB) * alpha;
data[idx + 3] = 255;
}
} else {
if (isShadow) {
// data stays 0 (already zeroed by createImageData)
} else {
data[idx] = bgR;
data[idx + 1] = bgG;
data[idx + 2] = bgB;
data[idx + 3] = 255;
}
}
}
}
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
this.initOffscreenCanvas();
const { min, max } = this.getRadiusRange();
for (const blob of this.blobs) {
blob.baseRadius = min + Math.random() * (max - min);
}
}
private sampleColorAt(x: number, y: number): [number, number, number] | null {
let closest: Blob | null = null;
let closestDist = Infinity;
for (const blob of this.blobs) {
const dx = blob.x - x;
const dy = blob.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < blob.baseRadius * 1.5 && dist < closestDist) {
closestDist = dist;
closest = blob;
}
}
return closest ? ([...closest.color] as [number, number, number]) : null;
}
private spawnAt(x: number, y: number): void {
const { max } = this.getRadiusRange();
const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4));
const nearby = this.sampleColorAt(x, y);
if (nearby) {
blob.color = nearby;
blob.targetColor = [...nearby];
}
this.blobs.push(blob);
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
}
handleMouseDown(x: number, y: number): void {
this.spawnAt(x, y);
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.parseBgColor(bgColor);
for (let i = 0; i < this.blobs.length; i++) {
this.blobs[i].targetColor = [
...palette[i % palette.length],
] as [number, number, number];
}
}
}

View File

@@ -1,473 +1,88 @@
import { useEffect, useRef } from "react";
import { GameOfLifeEngine } from "./engines/game-of-life";
import { LavaLampEngine } from "./engines/lava-lamp";
import { ConfettiEngine } from "./engines/confetti";
import { getStoredAnimationId } from "@/lib/animations/engine";
import type { AnimationEngine } from "@/lib/animations/types";
import type { AnimationId } from "@/lib/animations";
interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
baseColor: [number, number, number]; // Original color
currentX: number;
currentY: number;
targetX: number;
targetY: number;
opacity: number;
targetOpacity: number;
scale: number;
targetScale: number;
elevation: number; // For 3D effect
targetElevation: number;
transitioning: boolean;
transitionComplete: boolean;
rippleEffect: number; // For ripple animation
rippleStartTime: number; // When ripple started
rippleDistance: number; // Distance from ripple center
const SIDEBAR_WIDTH = 240;
const FALLBACK_PALETTE: [number, number, number][] = [
[204, 36, 29], [152, 151, 26], [215, 153, 33],
[69, 133, 136], [177, 98, 134], [104, 157, 106],
];
function createEngine(id: AnimationId): AnimationEngine {
switch (id) {
case "lava-lamp":
return new LavaLampEngine();
case "confetti":
return new ConfettiEngine();
case "game-of-life":
default:
return new GameOfLifeEngine();
}
}
interface Grid {
cells: Cell[][];
cols: number;
rows: number;
offsetX: number;
offsetY: number;
function readPaletteFromCSS(): [number, number, number][] {
try {
const style = getComputedStyle(document.documentElement);
const keys = [
"--color-red", "--color-green", "--color-yellow",
"--color-blue", "--color-purple", "--color-aqua",
];
const palette: [number, number, number][] = [];
for (const key of keys) {
const val = style.getPropertyValue(key).trim();
if (val) {
const parts = val.split(" ").map(Number);
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
palette.push([parts[0], parts[1], parts[2]]);
}
}
}
return palette.length > 0 ? palette : FALLBACK_PALETTE;
} catch {
return FALLBACK_PALETTE;
}
}
interface MousePosition {
x: number;
y: number;
isDown: boolean;
lastClickTime: number;
cellX: number;
cellY: number;
function readBgFromCSS(): string {
try {
const val = getComputedStyle(document.documentElement)
.getPropertyValue("--color-background")
.trim();
if (val) {
const [r, g, b] = val.split(" ");
return `rgb(${r}, ${g}, ${b})`;
}
} catch {}
return "rgb(0, 0, 0)";
}
interface BackgroundProps {
layout?: 'index' | 'sidebar';
position?: 'left' | 'right';
layout?: "index" | "sidebar" | "content";
position?: "left" | "right";
}
const CELL_SIZE_MOBILE = 15;
const CELL_SIZE_DESKTOP = 25;
const TARGET_FPS = 60; // Target frame rate
const CYCLE_TIME = 3000; // 3 seconds per full cycle, regardless of FPS
const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05;
const INITIAL_DENSITY = 0.15;
const SIDEBAR_WIDTH = 240;
const MOUSE_INFLUENCE_RADIUS = 150; // Radius of mouse influence in pixels
const COLOR_SHIFT_AMOUNT = 30; // Maximum color shift amount
const RIPPLE_SPEED = 0.02; // Speed of ripple propagation
const RIPPLE_ELEVATION_FACTOR = 4; // Height of ripple wave
const ELEVATION_FACTOR = 8; // Max height for 3D effect - reduced for more subtle effect
const Background: React.FC<BackgroundProps> = ({
layout = 'index',
position = 'left'
layout = "index",
position = "left",
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gridRef = useRef<Grid>();
const engineRef = useRef<AnimationEngine | null>(null);
const animationFrameRef = useRef<number>();
const lastUpdateTimeRef = useRef<number>(0);
const lastCycleTimeRef = useRef<number>(0);
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
const mouseRef = useRef<MousePosition>({
x: -1000,
y: -1000,
isDown: false,
lastClickTime: 0,
cellX: -1,
cellY: -1
});
const dimensionsRef = useRef({ width: 0, height: 0 });
const randomColor = (): [number, number, number] => {
const colors = [
[204, 36, 29], // red
[152, 151, 26], // green
[215, 153, 33], // yellow
[69, 133, 136], // blue
[177, 98, 134], // purple
[104, 157, 106] // aqua
];
return colors[Math.floor(Math.random() * colors.length)];
};
const getCellSize = () => {
// Check if we're on mobile based on screen width
const isMobile = window.innerWidth <= 768;
return isMobile ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
};
const calculateGridDimensions = (width: number, height: number) => {
const cellSize = getCellSize();
const cols = Math.floor(width / cellSize);
const rows = Math.floor(height / cellSize);
const offsetX = Math.floor((width - (cols * cellSize)) / 2);
const offsetY = Math.floor((height - (rows * cellSize)) / 2);
return { cols, rows, offsetX, offsetY };
};
const initGrid = (width: number, height: number): Grid => {
const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height);
const cells = Array(cols).fill(0).map((_, i) =>
Array(rows).fill(0).map((_, j) => {
const baseColor = randomColor();
return {
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: [...baseColor] as [number, number, number],
baseColor: baseColor,
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
elevation: 0,
targetElevation: 0,
transitioning: false,
transitionComplete: false,
rippleEffect: 0,
rippleStartTime: 0,
rippleDistance: 0
};
})
);
const grid = { cells, cols, rows, offsetX, offsetY };
computeNextState(grid);
// Initialize cells with staggered animation
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const cell = cells[i][j];
if (cell.next) {
cell.alive = true;
setTimeout(() => {
cell.targetOpacity = 1;
cell.targetScale = 1;
}, Math.random() * 1000);
} else {
cell.alive = false;
}
}
}
return grid;
};
const countNeighbors = (grid: Grid, x: number, y: number): { count: number, colors: [number, number, number][] } => {
const neighbors = { count: 0, colors: [] as [number, number, number][] };
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows;
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].baseColor);
}
}
}
return neighbors;
};
const averageColors = (colors: [number, number, number][]): [number, number, number] => {
if (colors.length === 0) return [0, 0, 0];
const sum = colors.reduce((acc, color) => [
acc[0] + color[0],
acc[1] + color[1],
acc[2] + color[2]
], [0, 0, 0]);
return [
Math.round(sum[0] / colors.length),
Math.round(sum[1] / colors.length),
Math.round(sum[2] / colors.length)
];
};
const computeNextState = (grid: Grid) => {
// First, calculate the next state for all cells based on standard rules
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const { count, colors } = countNeighbors(grid, i, j);
// Standard Conway's Game of Life rules
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.baseColor = averageColors(colors);
cell.color = [...cell.baseColor];
}
}
}
}
// Then, set up animations for cells that need to change state
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive !== cell.next && !cell.transitioning) {
cell.transitioning = true;
cell.transitionComplete = false;
// Random delay for staggered animation effect
const delay = Math.random() * 800;
setTimeout(() => {
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
cell.targetElevation = 0;
} else {
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
}
}, delay);
}
}
}
};
const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => {
const currentTime = Date.now();
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Calculate distance from cell to ripple center
const dx = i - centerX;
const dy = j - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Only apply ripple to visible cells
if (cell.opacity > 0.1) {
cell.rippleStartTime = currentTime + distance * 100; // Delayed start based on distance
cell.rippleDistance = distance;
cell.rippleEffect = 0;
}
}
}
};
const spawnCellAtPosition = (grid: Grid, x: number, y: number) => {
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
const cell = grid.cells[x][y];
if (!cell.alive && !cell.transitioning) {
cell.alive = true;
cell.next = true;
cell.transitioning = true;
cell.transitionComplete = false;
cell.baseColor = randomColor();
cell.color = [...cell.baseColor];
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
// Create a small ripple from the new cell
createRippleEffect(grid, x, y);
}
}
};
const updateCellAnimations = (grid: Grid, deltaTime: number) => {
const mouseX = mouseRef.current.x;
const mouseY = mouseRef.current.y;
const cellSize = getCellSize();
// Adjust transition speeds based on time
const transitionFactor = TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Smooth transitions
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
cell.elevation += (cell.targetElevation - cell.elevation) * scaleFactor;
// Apply mouse interaction
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
// 3D hill effect based on mouse position
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
// Calculate height based on distance - peak at center, gradually decreasing
const influenceFactor = Math.cos((distanceToMouse / MOUSE_INFLUENCE_RADIUS) * Math.PI / 2);
// Only positive elevation (growing upward)
cell.targetElevation = ELEVATION_FACTOR * influenceFactor * influenceFactor; // squared for more pronounced effect
// Slight color shift as cells rise
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift))
] as [number, number, number];
} else {
// Gradually return to base color and zero elevation when mouse is away
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
cell.targetElevation = 0;
}
// Handle cell state transitions
if (cell.transitioning) {
// When a cell is completely faded out, update its alive state
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
}
// When a new cell is born
else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
}
}
// Handle ripple animation
if (cell.rippleStartTime > 0) {
const elapsedTime = Date.now() - cell.rippleStartTime;
if (elapsedTime > 0) {
// Calculate ripple progress (0 to 1)
const rippleProgress = elapsedTime / 1000; // 1 second for full animation
if (rippleProgress < 1) {
// Create a smooth wave effect
const wavePhase = rippleProgress * Math.PI * 2;
const waveHeight = Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
// Apply wave height to cell elevation only if it's not being overridden by mouse
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.rippleEffect = waveHeight;
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
} else {
cell.rippleEffect = waveHeight * 0.3; // Reduced effect when mouse is influencing
}
} else {
// Reset ripple effects
cell.rippleEffect = 0;
cell.rippleStartTime = 0;
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.targetElevation = 0;
}
}
}
}
}
}
};
const handleMouseDown = (e: MouseEvent) => {
if (!gridRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Ignore clicks outside the canvas bounds
if (mouseX < 0 || mouseX > rect.width || mouseY < 0 || mouseY > rect.height) return;
// Prevent text selection when interacting with the canvas
e.preventDefault();
const cellSize = getCellSize();
mouseRef.current.isDown = true;
mouseRef.current.lastClickTime = Date.now();
const grid = gridRef.current;
// Calculate which cell was clicked
const cellX = Math.floor((mouseX - grid.offsetX) / cellSize);
const cellY = Math.floor((mouseY - grid.offsetY) / cellSize);
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
mouseRef.current.cellX = cellX;
mouseRef.current.cellY = cellY;
const cell = grid.cells[cellX][cellY];
if (cell.alive) {
// Create ripple effect from existing cell
createRippleEffect(grid, cellX, cellY);
} else {
// Spawn new cell at empty position
spawnCellAtPosition(grid, cellX, cellY);
}
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!canvasRef.current || !gridRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const cellSize = getCellSize();
mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top;
// Drawing functionality - place cells while dragging
if (mouseRef.current.isDown) {
const grid = gridRef.current;
// Calculate which cell the mouse is over
const cellX = Math.floor((mouseRef.current.x - grid.offsetX) / cellSize);
const cellY = Math.floor((mouseRef.current.y - grid.offsetY) / cellSize);
// Only draw if we're on a new cell
if (cellX !== mouseRef.current.cellX || cellY !== mouseRef.current.cellY) {
mouseRef.current.cellX = cellX;
mouseRef.current.cellY = cellY;
// Spawn cell at this position if it's empty
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
const cell = grid.cells[cellX][cellY];
if (!cell.alive && !cell.transitioning) {
spawnCellAtPosition(grid, cellX, cellY);
}
}
}
}
};
const handleMouseUp = () => {
mouseRef.current.isDown = false;
};
const handleMouseLeave = () => {
mouseRef.current.isDown = false;
mouseRef.current.x = -1000;
mouseRef.current.y = -1000;
};
const setupCanvas = (canvas: HTMLCanvasElement, width: number, height: number) => {
const ctx = canvas.getContext('2d');
const setupCanvas = (
canvas: HTMLCanvasElement,
width: number,
height: number
) => {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
@@ -485,10 +100,58 @@ const Background: React.FC<BackgroundProps> = ({
const canvas = canvasRef.current;
if (!canvas) return;
// Create an AbortController for cleanup
const controller = new AbortController();
const signal = controller.signal;
const displayWidth =
layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
dimensionsRef.current = { width: displayWidth, height: displayHeight };
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
const palette = readPaletteFromCSS();
const bgColor = readBgFromCSS();
// Initialize engine
if (!engineRef.current) {
const animId = getStoredAnimationId();
engineRef.current = createEngine(animId);
engineRef.current.init(displayWidth, displayHeight, palette, bgColor);
}
// Handle animation switching
const handleAnimationChanged = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.id) return;
if (engineRef.current) {
engineRef.current.cleanup();
}
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const h = window.innerHeight;
engineRef.current = createEngine(detail.id);
engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS());
};
document.addEventListener("animation-changed", handleAnimationChanged, {
signal,
});
// Handle theme changes
const handleThemeChanged = () => {
const newPalette = readPaletteFromCSS();
const newBg = readBgFromCSS();
if (engineRef.current) {
engineRef.current.updatePalette(newPalette, newBg);
}
};
document.addEventListener("theme-changed", handleThemeChanged, { signal });
// Handle resize
const handleResize = () => {
if (signal.aborted) return;
@@ -499,184 +162,122 @@ const Background: React.FC<BackgroundProps> = ({
resizeTimeoutRef.current = setTimeout(() => {
if (signal.aborted) return;
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const h = window.innerHeight;
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
const newCtx = setupCanvas(canvas, w, h);
if (!newCtx) return;
lastUpdateTimeRef.current = 0;
lastCycleTimeRef.current = 0;
dimensionsRef.current = { width: w, height: h };
const cellSize = getCellSize();
// Only initialize new grid if one doesn't exist or dimensions changed
if (!gridRef.current ||
gridRef.current.cols !== Math.floor(displayWidth / cellSize) ||
gridRef.current.rows !== Math.floor(displayHeight / cellSize)) {
gridRef.current = initGrid(displayWidth, displayHeight);
if (engineRef.current) {
engineRef.current.handleResize(w, h);
}
}, 250);
};
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
if (!engineRef.current || !canvas) return;
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
// Don't spawn when clicking interactive elements
const target = e.target as HTMLElement;
if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return;
// Only initialize grid if it doesn't exist
if (!gridRef.current) {
gridRef.current = initGrid(displayWidth, displayHeight);
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (
mouseX < 0 ||
mouseX > rect.width ||
mouseY < 0 ||
mouseY > rect.height
)
return;
e.preventDefault();
engineRef.current.handleMouseDown(mouseX, mouseY);
};
const handleMouseMove = (e: MouseEvent) => {
if (!engineRef.current || !canvas) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
};
const handleMouseUp = () => {
if (engineRef.current) {
engineRef.current.handleMouseUp();
}
};
// Bind to window so mouse events work even when content overlays the canvas
window.addEventListener('mousedown', handleMouseDown, { signal });
window.addEventListener('mousemove', handleMouseMove, { signal });
window.addEventListener('mouseup', handleMouseUp, { signal });
const handleMouseLeave = () => {
if (engineRef.current) {
engineRef.current.handleMouseLeave();
}
};
window.addEventListener("mousedown", handleMouseDown, { signal });
window.addEventListener("mousemove", handleMouseMove, { signal });
window.addEventListener("mouseup", handleMouseUp, { signal });
// Visibility change
const handleVisibilityChange = () => {
if (document.hidden) {
// Tab is hidden
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
} else {
// Tab is visible again
if (!animationFrameRef.current) {
// Reset timing references to prevent catching up
lastUpdateTimeRef.current = performance.now();
lastCycleTimeRef.current = performance.now();
animationFrameRef.current = requestAnimationFrame(animate);
}
}
};
// Animation loop
const animate = (currentTime: number) => {
if (signal.aborted) return;
// Initialize timing if first frame
if (!lastUpdateTimeRef.current) {
lastUpdateTimeRef.current = currentTime;
lastCycleTimeRef.current = currentTime;
}
// Calculate time since last frame
const deltaTime = currentTime - lastUpdateTimeRef.current;
// Limit delta time to prevent large jumps when tab becomes active again
const clampedDeltaTime = Math.min(deltaTime, 100);
lastUpdateTimeRef.current = currentTime;
// Calculate time since last cycle update
const cycleElapsed = currentTime - lastCycleTimeRef.current;
const engine = engineRef.current;
if (engine) {
engine.update(clampedDeltaTime);
if (gridRef.current) {
// Check if it's time for the next life cycle
if (cycleElapsed >= CYCLE_TIME) {
computeNextState(gridRef.current);
lastCycleTimeRef.current = currentTime;
}
updateCellAnimations(gridRef.current, clampedDeltaTime);
}
// Draw frame
ctx.fillStyle = '#000000';
// Clear canvas
const bg = readBgFromCSS();
ctx.fillStyle = bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gridRef.current) {
const grid = gridRef.current;
const cellSize = getCellSize();
const displayCellSize = cellSize * 0.8;
const roundness = displayCellSize * 0.2;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Draw all transitioning cells, even if they're fading out
if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) {
const [r, g, b] = cell.color;
// Base opacity
ctx.globalAlpha = cell.opacity * 0.9;
const scaledSize = displayCellSize * cell.scale;
const xOffset = (displayCellSize - scaledSize) / 2;
const yOffset = (displayCellSize - scaledSize) / 2;
// Apply 3D elevation effect
const elevationOffset = cell.elevation;
const x = grid.offsetX + i * cellSize + (cellSize - displayCellSize) / 2 + xOffset;
const y = grid.offsetY + j * cellSize + (cellSize - displayCellSize) / 2 + yOffset - elevationOffset;
const scaledRoundness = roundness * cell.scale;
// Draw shadow for 3D effect when cell is elevated
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
ctx.lineTo(x + scaledSize - scaledRoundness, y + elevationOffset * 1.1);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset * 1.1, x + scaledSize, y + elevationOffset * 1.1 + scaledRoundness);
ctx.lineTo(x + scaledSize, y + elevationOffset * 1.1 + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset * 1.1 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset * 1.1 + scaledSize);
ctx.lineTo(x + scaledRoundness, y + elevationOffset * 1.1 + scaledSize);
ctx.quadraticCurveTo(x, y + elevationOffset * 1.1 + scaledSize, x, y + elevationOffset * 1.1 + scaledSize - scaledRoundness);
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
ctx.quadraticCurveTo(x, y + elevationOffset * 1.1, x + scaledRoundness, y + elevationOffset * 1.1);
ctx.fill();
}
// Draw main cell
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + scaledSize, x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
// Draw highlight on elevated cells
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize/3);
ctx.lineTo(x, y + scaledSize/3);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
}
// No need for separate ripple drawing since the elevation handles the 3D ripple effect
}
}
}
ctx.globalAlpha = 1;
const { width: rw, height: rh } = dimensionsRef.current;
engine.render(ctx, rw, rh);
}
animationFrameRef.current = requestAnimationFrame(animate);
};
document.addEventListener('visibilitychange', handleVisibilityChange, { signal });
window.addEventListener('resize', handleResize, { signal });
document.addEventListener("visibilitychange", handleVisibilityChange, {
signal,
});
window.addEventListener("resize", handleResize, { signal });
animate(performance.now());
return () => {
controller.abort();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('resize', handleResize);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
@@ -684,27 +285,45 @@ const Background: React.FC<BackgroundProps> = ({
clearTimeout(resizeTimeoutRef.current);
}
};
}, [layout]); // Added layout as a dependency since it's used in the effect
}, [layout]);
const isIndex = layout === "index";
const isSidebar = !isIndex;
const getContainerStyle = (): React.CSSProperties => {
if (isIndex) return {};
// Fade the inner edge so blobs don't hard-cut at the content boundary
return {
maskImage:
position === "left"
? "linear-gradient(to right, black 60%, transparent 100%)"
: "linear-gradient(to left, black 60%, transparent 100%)",
WebkitMaskImage:
position === "left"
? "linear-gradient(to right, black 60%, transparent 100%)"
: "linear-gradient(to left, black 60%, transparent 100%)",
};
};
const getContainerClasses = () => {
if (layout === 'index') {
return 'fixed inset-0 -z-10';
if (isIndex) {
return "fixed inset-0 -z-10";
}
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10';
return position === 'left'
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10";
return position === "left"
? `${baseClasses} left-0`
: `${baseClasses} right-0`;
};
return (
<div className={getContainerClasses()}>
<div className={getContainerClasses()} style={getContainerStyle()}>
<canvas
ref={canvasRef}
className="w-full h-full bg-black"
style={{ cursor: 'default' }} // Changed from cursor-pointer to default
className="w-full h-full bg-background"
style={{ cursor: "default" }}
/>
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" />
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { Links } from "@/components/header/links";
export default function Header() {
export default function Header({ transparent = false }: { transparent?: boolean }) {
const [isClient, setIsClient] = useState(false);
const [visible, setVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
@@ -34,7 +34,7 @@ export default function Header() {
return linkHref !== "/" && path.startsWith(linkHref);
};
const isIndexPage = checkIsActive("/");
const isIndexPage = transparent || checkIsActive("/");
const headerLinks = Links.map((link) => {
const isActive = checkIsActive(link.href);
@@ -44,7 +44,7 @@ export default function Header() {
className={`
relative inline-block
${link.color}
${!isIndexPage ? 'bg-black' : ''}
${!isIndexPage ? 'bg-background' : ''}
`}
>
<a
@@ -94,13 +94,13 @@ export default function Header() {
<div className={`
w-full flex flex-row items-center justify-center
pointer-events-none
${!isIndexPage ? 'bg-black md:bg-transparent' : ''}
${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
`}>
<div className={`
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
pointer-events-none [&_a]:pointer-events-auto
${!isIndexPage ? 'bg-black md:px-20' : ''}
${!isIndexPage ? 'bg-background md:px-20' : ''}
`}>
{headerLinks}
</div>

View File

@@ -0,0 +1,98 @@
import { useRef, useState, useEffect } from "react";
import { getStoredThemeId, getNextTheme, applyTheme } from "@/lib/themes/engine";
const FADE_DURATION = 300;
const LABELS: Record<string, string> = {
darkbox: "classic",
"darkbox-retro": "retro",
"darkbox-dim": "dim",
};
export default function ThemeSwitcher() {
const [hovering, setHovering] = useState(false);
const [nextLabel, setNextLabel] = useState("");
const maskRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredThemeId();
setNextLabel(LABELS[getNextTheme(committedRef.current).id] ?? "");
const handleSwap = () => {
const id = getStoredThemeId();
applyTheme(id);
committedRef.current = id;
setNextLabel(LABELS[getNextTheme(id).id] ?? "");
};
document.addEventListener("astro:after-swap", handleSwap);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
};
}, []);
const handleClick = () => {
if (animatingRef.current) return;
animatingRef.current = true;
const mask = maskRef.current;
if (!mask) return;
const v = getComputedStyle(document.documentElement)
.getPropertyValue("--color-background")
.trim();
const [r, g, b] = v.split(" ").map(Number);
mask.style.backgroundColor = `rgb(${r},${g},${b})`;
mask.style.opacity = "1";
mask.style.visibility = "visible";
mask.style.transition = "none";
const next = getNextTheme(committedRef.current);
applyTheme(next.id);
committedRef.current = next.id;
setNextLabel(LABELS[getNextTheme(next.id).id] ?? "");
mask.offsetHeight;
mask.style.transition = `opacity ${FADE_DURATION}ms ease-out`;
mask.style.opacity = "0";
const onEnd = () => {
mask.removeEventListener("transitionend", onEnd);
mask.style.visibility = "hidden";
mask.style.transition = "none";
animatingRef.current = false;
};
mask.addEventListener("transitionend", onEnd);
};
return (
<>
<div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{nextLabel}
</span>
</div>
<div
ref={maskRef}
className="fixed inset-0 z-[100] pointer-events-none"
style={{ visibility: "hidden", opacity: 0 }}
/>
</>
);
}

View File

@@ -5,6 +5,10 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props {
title: string;
@@ -20,17 +24,13 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
@@ -41,7 +41,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
::view-transition-new(:root) {
animation: none;
}
::view-transition-old(:root) {
animation: 90ms ease-out both fade-out;
}
@@ -50,6 +49,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load />
@@ -65,10 +66,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<div class="mt-auto">
<Footer client:load transition:persist />
</div>
<script>
document.addEventListener("astro:after-navigation", () => {
window.scrollTo(0, 0);
});
</script>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>
</html>

View File

@@ -8,6 +8,10 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props {
title: string;
@@ -23,28 +27,30 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<ClientRouter />
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground">
<Header client:load />
<Header client:load transparent />
<main transition:animate="fade">
<Background layout="index" client:only="react" transition:persist />
<slot />
</main>
<Footer client:load transition:persist fixed=true />
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>
</html>

View File

@@ -5,6 +5,10 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props {
title: string;
@@ -20,17 +24,13 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
@@ -41,7 +41,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
::view-transition-new(:root) {
animation: none;
}
::view-transition-old(:root) {
animation: 90ms ease-out both fade-out;
}
@@ -50,6 +49,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<main class="flex-1 flex flex-col">
@@ -61,10 +62,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<Background layout="content" position="left" client:only="react" transition:persist />
</div>
</main>
<script>
document.addEventListener("astro:after-navigation", () => {
window.scrollTo(0, 0);
});
</script>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>
</html>

View File

@@ -0,0 +1,20 @@
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
import type { AnimationId } from "./index";
export function getStoredAnimationId(): AnimationId {
if (typeof window === "undefined") return DEFAULT_ANIMATION_ID;
const stored = localStorage.getItem("animation");
if (stored && (ANIMATION_IDS as readonly string[]).includes(stored)) {
return stored as AnimationId;
}
return DEFAULT_ANIMATION_ID;
}
export function saveAnimation(id: AnimationId): void {
localStorage.setItem("animation", id);
}
export function getNextAnimation(currentId: AnimationId): AnimationId {
const idx = ANIMATION_IDS.indexOf(currentId);
return ANIMATION_IDS[(idx + 1) % ANIMATION_IDS.length];
}

View File

@@ -0,0 +1,9 @@
export const ANIMATION_IDS = ["game-of-life", "lava-lamp", "confetti"] as const;
export type AnimationId = (typeof ANIMATION_IDS)[number];
export const DEFAULT_ANIMATION_ID: AnimationId = "game-of-life";
export const ANIMATION_LABELS: Record<AnimationId, string> = {
"game-of-life": "life",
"lava-lamp": "lava",
"confetti": "confetti",
};

View File

@@ -0,0 +1,7 @@
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
export const ANIMATION_LOADER_SCRIPT = `(function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";})();`;
export const ANIMATION_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";});`;

View File

@@ -0,0 +1,33 @@
export interface AnimationEngine {
id: string;
name: string;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void;
update(deltaTime: number): void;
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void;
handleResize(width: number, height: number): void;
handleMouseMove(x: number, y: number, isDown: boolean): void;
handleMouseDown(x: number, y: number): void;
handleMouseUp(): void;
handleMouseLeave(): void;
updatePalette(palette: [number, number, number][], bgColor: string): void;
cleanup(): void;
}

View File

@@ -0,0 +1,59 @@
import { THEMES, DEFAULT_THEME_ID } from "./index";
import { CSS_PROPS } from "./props";
import type { Theme } from "./types";
export function getStoredThemeId(): string {
if (typeof window === "undefined") return DEFAULT_THEME_ID;
return localStorage.getItem("theme") || DEFAULT_THEME_ID;
}
export function saveTheme(id: string): void {
localStorage.setItem("theme", id);
}
export function getNextTheme(currentId: string): Theme {
const list = Object.values(THEMES);
const idx = list.findIndex((t) => t.id === currentId);
return list[(idx + 1) % list.length];
}
/** Sets CSS vars and notifies canvas, but does NOT persist to localStorage. */
export function previewTheme(id: string): void {
const theme = THEMES[id];
if (!theme) return;
const root = document.documentElement;
for (const [key, prop] of CSS_PROPS) {
root.style.setProperty(prop, theme.colors[key]);
}
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
}
export function applyTheme(id: string): void {
const theme = THEMES[id];
if (!theme) return;
// Set CSS vars on :root for immediate visual update
const root = document.documentElement;
for (const [key, prop] of CSS_PROPS) {
root.style.setProperty(prop, theme.colors[key]);
}
// Update <style id="theme-vars"> so Astro view transitions don't revert
let el = document.getElementById("theme-vars") as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = "theme-vars";
document.head.appendChild(el);
}
let css = ":root{";
for (const [key, prop] of CSS_PROPS) {
css += `${prop}:${theme.colors[key]};`;
}
css += "}";
el.textContent = css;
saveTheme(id);
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
}

View File

@@ -0,0 +1,58 @@
import type { Theme } from "./types";
export const DEFAULT_THEME_ID = "darkbox";
function theme(
id: string,
name: string,
type: "dark" | "light",
colors: Theme["colors"],
palette: [number, number, number][]
): Theme {
return { id, name, type, colors, canvasPalette: palette };
}
// Three darkbox variants from darkbox.nvim
// Classic (vivid) → Retro (muted) → Dim (deep)
// Each variant's "bright" is the next level up's base.
export const THEMES: Record<string, Theme> = {
darkbox: theme("darkbox", "Darkbox Classic", "dark", {
background: "0 0 0",
foreground: "235 219 178",
red: "251 73 52", redBright: "255 110 85",
orange: "254 128 25", orangeBright: "255 165 65",
green: "184 187 38", greenBright: "210 215 70",
yellow: "250 189 47", yellowBright: "255 215 85",
blue: "131 165 152", blueBright: "165 195 180",
purple: "211 134 155", purpleBright: "235 165 180",
aqua: "142 192 124", aquaBright: "175 220 160",
surface: "60 56 54",
}, [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]]),
"darkbox-retro": theme("darkbox-retro", "Darkbox Retro", "dark", {
background: "0 0 0",
foreground: "189 174 147",
red: "204 36 29", redBright: "251 73 52",
orange: "214 93 14", orangeBright: "254 128 25",
green: "152 151 26", greenBright: "184 187 38",
yellow: "215 153 33", yellowBright: "250 189 47",
blue: "69 133 136", blueBright: "131 165 152",
purple: "177 98 134", purpleBright: "211 134 155",
aqua: "104 157 106", aquaBright: "142 192 124",
surface: "60 56 54",
}, [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]]),
"darkbox-dim": theme("darkbox-dim", "Darkbox Dim", "dark", {
background: "0 0 0",
foreground: "168 153 132",
red: "157 0 6", redBright: "204 36 29",
orange: "175 58 3", orangeBright: "214 93 14",
green: "121 116 14", greenBright: "152 151 26",
yellow: "181 118 20", yellowBright: "215 153 33",
blue: "7 102 120", blueBright: "69 133 136",
purple: "143 63 113", purpleBright: "177 98 134",
aqua: "66 123 88", aquaBright: "104 157 106",
surface: "60 56 54",
}, [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]]),
};

View File

@@ -0,0 +1,25 @@
/**
* Generates the inline <script> content for theme loading.
* Called at build time in Astro frontmatter.
* The script reads "theme" from localStorage, looks up colors, injects a <style> tag.
*/
import { THEMES } from "./index";
import { CSS_PROPS } from "./props";
// Pre-build a { prop: value } map for each theme at build time
const themeVars: Record<string, Record<string, string>> = {};
for (const [id, theme] of Object.entries(THEMES)) {
const vars: Record<string, string> = {};
for (const [key, prop] of CSS_PROPS) {
vars[prop] = theme.colors[key];
}
themeVars[id] = vars;
}
// Sets inline styles on <html> — highest specificity, beats any stylesheet
const APPLY = `var v=t[id];if(!v)return;var s=document.documentElement.style;for(var k in v)s.setProperty(k,v[k])`;
const LOOKUP = `var id=localStorage.getItem("theme");if(!id)return;var t=${JSON.stringify(themeVars)};`;
export const THEME_LOADER_SCRIPT = `(function(){${LOOKUP}${APPLY}})();`;
export const THEME_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){${LOOKUP}${APPLY}});`;

View File

@@ -0,0 +1,21 @@
import type { ThemeColors } from "./types";
export const CSS_PROPS: [keyof ThemeColors, string][] = [
["background", "--color-background"],
["foreground", "--color-foreground"],
["red", "--color-red"],
["redBright", "--color-red-bright"],
["orange", "--color-orange"],
["orangeBright", "--color-orange-bright"],
["green", "--color-green"],
["greenBright", "--color-green-bright"],
["yellow", "--color-yellow"],
["yellowBright", "--color-yellow-bright"],
["blue", "--color-blue"],
["blueBright", "--color-blue-bright"],
["purple", "--color-purple"],
["purpleBright", "--color-purple-bright"],
["aqua", "--color-aqua"],
["aquaBright", "--color-aqua-bright"],
["surface", "--color-surface"],
];

View File

@@ -0,0 +1,27 @@
export interface ThemeColors {
background: string;
foreground: string;
red: string;
redBright: string;
orange: string;
orangeBright: string;
green: string;
greenBright: string;
yellow: string;
yellowBright: string;
blue: string;
blueBright: string;
purple: string;
purpleBright: string;
aqua: string;
aquaBright: string;
surface: string;
}
export interface Theme {
id: string;
name: string;
type: "dark" | "light";
colors: ThemeColors;
canvasPalette: [number, number, number][];
}

View File

@@ -1,3 +1,23 @@
:root {
--color-background: 0 0 0;
--color-foreground: 235 219 178;
--color-red: 251 73 52;
--color-red-bright: 255 110 85;
--color-orange: 254 128 25;
--color-orange-bright: 255 165 65;
--color-green: 184 187 38;
--color-green-bright: 210 215 70;
--color-yellow: 250 189 47;
--color-yellow-bright: 255 215 85;
--color-blue: 131 165 152;
--color-blue-bright: 165 195 180;
--color-purple: 211 134 155;
--color-purple-bright: 235 165 180;
--color-aqua: 142 192 124;
--color-aqua-bright: 175 220 160;
--color-surface: 60 56 54;
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -6,35 +6,35 @@ module.exports = {
"comic-code": ["Comic Code", "monospace"],
},
colors: {
background: "#000000",
foreground: "#ebdbb2",
background: "rgb(var(--color-background) / <alpha-value>)",
foreground: "rgb(var(--color-foreground) / <alpha-value>)",
red: {
DEFAULT: "#cc241d",
bright: "#fb4934"
DEFAULT: "rgb(var(--color-red) / <alpha-value>)",
bright: "rgb(var(--color-red-bright) / <alpha-value>)"
},
orange: {
DEFAULT: "#d65d0e",
bright: "#fe8019"
DEFAULT: "rgb(var(--color-orange) / <alpha-value>)",
bright: "rgb(var(--color-orange-bright) / <alpha-value>)"
},
green: {
DEFAULT: "#98971a",
bright: "#b8bb26"
DEFAULT: "rgb(var(--color-green) / <alpha-value>)",
bright: "rgb(var(--color-green-bright) / <alpha-value>)"
},
yellow: {
DEFAULT: "#d79921",
bright: "#fabd2f"
DEFAULT: "rgb(var(--color-yellow) / <alpha-value>)",
bright: "rgb(var(--color-yellow-bright) / <alpha-value>)"
},
blue: {
DEFAULT: "#458588",
bright: "#83a598"
DEFAULT: "rgb(var(--color-blue) / <alpha-value>)",
bright: "rgb(var(--color-blue-bright) / <alpha-value>)"
},
purple: {
DEFAULT: "#b16286",
bright: "#d3869b"
DEFAULT: "rgb(var(--color-purple) / <alpha-value>)",
bright: "rgb(var(--color-purple-bright) / <alpha-value>)"
},
aqua: {
DEFAULT: "#689d6a",
bright: "#8ec07c"
DEFAULT: "rgb(var(--color-aqua) / <alpha-value>)",
bright: "rgb(var(--color-aqua-bright) / <alpha-value>)"
}
},
keyframes: {
@@ -51,86 +51,82 @@ module.exports = {
"draw-line": "draw-line 0.6s ease-out forwards",
"fade-in": "fade-in 0.3s ease-in-out forwards"
},
typography: (theme) => ({
typography: () => ({
DEFAULT: {
css: {
color: theme("colors.foreground"),
"--tw-prose-body": theme("colors.foreground"),
"--tw-prose-headings": theme("colors.yellow.bright"),
"--tw-prose-links": theme("colors.blue.bright"),
"--tw-prose-bold": theme("colors.orange.bright"),
"--tw-prose-quotes": theme("colors.green.bright"),
"--tw-prose-code": theme("colors.purple.bright"),
"--tw-prose-hr": theme("colors.foreground"),
"--tw-prose-bullets": theme("colors.foreground"),
// Base text color
color: theme("colors.foreground"),
color: "rgb(var(--color-foreground))",
"--tw-prose-body": "rgb(var(--color-foreground))",
"--tw-prose-headings": "rgb(var(--color-yellow-bright))",
"--tw-prose-links": "rgb(var(--color-blue-bright))",
"--tw-prose-bold": "rgb(var(--color-orange-bright))",
"--tw-prose-quotes": "rgb(var(--color-green-bright))",
"--tw-prose-code": "rgb(var(--color-purple-bright))",
"--tw-prose-hr": "rgb(var(--color-foreground))",
"--tw-prose-bullets": "rgb(var(--color-foreground))",
// Headings
h1: {
color: theme("colors.yellow.bright"),
color: "rgb(var(--color-yellow-bright))",
fontWeight: "700",
},
h2: {
color: theme("colors.yellow.bright"),
color: "rgb(var(--color-yellow-bright))",
fontWeight: "600",
},
h3: {
color: theme("colors.yellow.bright"),
color: "rgb(var(--color-yellow-bright))",
fontWeight: "600",
},
h4: {
color: theme("colors.yellow.bright"),
color: "rgb(var(--color-yellow-bright))",
fontWeight: "600",
},
// Links
a: {
color: theme("colors.blue.bright"),
color: "rgb(var(--color-blue-bright))",
"&:hover": {
color: theme("colors.blue.DEFAULT"),
color: "rgb(var(--color-blue))",
},
textDecoration: "none",
borderBottom: `1px solid ${theme("colors.blue.bright")}`,
borderBottom: "1px solid rgb(var(--color-blue-bright))",
transition: "all 0.2s ease-in-out",
},
// Code
'code:not([data-language])': {
color: theme('colors.purple.bright'),
backgroundColor: '#282828',
color: "rgb(var(--color-purple-bright))",
backgroundColor: "rgb(var(--color-surface))",
padding: '0',
borderRadius: '0.25rem',
fontFamily: 'Comic Code, monospace',
fontWeight: '400',
fontSize: 'inherit', // Match the parent text size
fontSize: 'inherit',
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'pre': {
backgroundColor: '#282828',
color: theme("colors.foreground"),
backgroundColor: "rgb(var(--color-surface))",
color: "rgb(var(--color-foreground))",
borderRadius: '0.5rem',
overflow: 'visible', // This allows the copy button to be positioned outside
position: 'relative', // For the copy button positioning
marginTop: '1.5rem', // Space for the copy button and language label
fontSize: 'inherit', // Match the parent font size
overflow: 'visible',
position: 'relative',
marginTop: '1.5rem',
fontSize: 'inherit',
},
'pre code': {
display: 'block',
fontFamily: 'Comic Code, monospace',
fontSize: '1em', // This will inherit from the prose-lg setting
fontSize: '1em',
padding: '0',
overflow: 'auto', // Enable horizontal scrolling
overflow: 'auto',
whiteSpace: 'pre',
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'[data-rehype-pretty-code-fragment]:nth-of-type(2) pre': {
'[data-line]::before': {
content: 'counter(line)',
@@ -148,7 +144,7 @@ module.exports = {
// Bold
strong: {
color: theme("colors.orange.bright"),
color: "rgb(var(--color-orange-bright))",
fontWeight: "600",
},
@@ -156,15 +152,15 @@ module.exports = {
ul: {
li: {
"&::before": {
backgroundColor: theme("colors.foreground"),
backgroundColor: "rgb(var(--color-foreground))",
},
},
},
// Blockquotes
blockquote: {
borderLeftColor: theme("colors.green.bright"),
color: theme("colors.green.bright"),
borderLeftColor: "rgb(var(--color-green-bright))",
color: "rgb(var(--color-green-bright))",
fontStyle: "italic",
quotes: "\"\\201C\"\"\\201D\"\"\\2018\"\"\\2019\"",
p: {
@@ -175,21 +171,21 @@ module.exports = {
// Horizontal rules
hr: {
borderColor: theme("colors.foreground"),
borderColor: "rgb(var(--color-foreground))",
opacity: "0.2",
},
// Table
table: {
thead: {
borderBottomColor: theme("colors.foreground"),
borderBottomColor: "rgb(var(--color-foreground))",
th: {
color: theme("colors.yellow.bright"),
color: "rgb(var(--color-yellow-bright))",
},
},
tbody: {
tr: {
borderBottomColor: theme("colors.foreground"),
borderBottomColor: "rgb(var(--color-foreground))",
},
},
},
@@ -201,7 +197,7 @@ module.exports = {
// Figures
figcaption: {
color: theme("colors.foreground"),
color: "rgb(var(--color-foreground))",
opacity: "0.8",
},
},