-
Cameron B. R. Redmore
-
Software Developer from Kingston-Upon-Hull, UK
+
+
+
+
-
- With 8 years of professional experience and over 15 years of passion-driven coding, I build
- robust and efficient software solutions. My journey has taken me from recreational projects to
- developing complex professional applications.
-
-
-
-
-
Interests & Hobbies
-
- Beyond my core work, I have a deep interest in the broader field of computing and
- programming. I'm particularly passionate about the AI and Machine Learning space, focusing
- on integrating Large Language Models (LLMs) in novel ways. In my spare time, I also enjoy
- the logical challenge of puzzles like Sudoku and Nonograms.
-
+
+
+
+
+ Save Pattern
+
+
+ Load Pattern
+
-
-
Programming Skills
-
- HTML
- CSS
- JavaScript
- TypeScript
- C#
- C++
- C
- Java
-
-
-
-
-
-
-
-
-
Music
-
-
-
- Loading music data...
-
-
-
+
+
+ Simulation Speed:
+
+ 10 Hz
diff --git a/src/client.ts b/src/client.ts
index 4e49320..46813de 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,5 +1,6 @@
import { treaty } from '@elysiajs/eden';
import type { App } from './server';
+import './gameoflife';
// Create Eden Treaty client using current page origin
const client = treaty
(window.location.origin);
@@ -251,6 +252,19 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
+ // Card flip logic - attach to orbit buttons
+ const cardContainer = document.querySelector('.card-container');
+ const flipButtons = document.querySelectorAll('icon-mdi-orbit-variant');
+
+ flipButtons.forEach(button => {
+ button.addEventListener('click', (event) => {
+ event.stopPropagation();
+ if (cardContainer) {
+ cardContainer.classList.toggle('is-flipped');
+ }
+ });
+ });
+
// Lazy-load: observe the desktop and mobile containers
const targets = [
document.getElementById('last-song-container'),
diff --git a/src/gameoflife.ts b/src/gameoflife.ts
new file mode 100644
index 0000000..731c8a2
--- /dev/null
+++ b/src/gameoflife.ts
@@ -0,0 +1,589 @@
+// File to implement Game of Life using stored co-ordinates.
+document.addEventListener('DOMContentLoaded', () => {
+ const canvas = document.getElementById('game-of-life-canvas') as HTMLCanvasElement;
+
+ const gameOfLife = new GameOfLife(canvas);
+
+ gameOfLife.start();
+});
+
+interface Cell {
+ x: number;
+ y: number;
+}
+
+class GameOfLife {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+
+ // Define sets of cells (much more efficient for large grids than 2D array)
+ private activeCells: Set = new Set(); // Cells that are currently alive
+ private activeCellsNext: Set = new Set(); // Cells that will be alive in the next generation
+
+ private potentialCells: Set = new Set(); // Cells that could potentially become alive in the next generation
+ private potentialCellsNext: Set = new Set(); // Cells that will be alive in the next generation
+
+ // Viewport and interaction state
+ private offsetX: number = 0;
+ private offsetY: number = 0;
+ private zoomLevel: number = 1;
+ private isPaused: boolean = false;
+
+ private isDragging: boolean = false;
+ private lastMouseX: number = 0;
+ private lastMouseY: number = 0;
+
+ private targetFrameRate: number = 144;
+ private targetSimulationRate: number = 10;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d')!;
+
+ // Set canvas size
+ this.canvas.width = 800;
+ this.canvas.height = 550;
+
+ this.setupEventListeners();
+ this.setupSimulationSpeedSlider();
+ this.setupSaveLoadButtons();
+ this.seedInitialPattern();
+ }
+
+ start() {
+ this.update();
+ this.drawCells();
+ }
+
+ private setupEventListeners() {
+ // Click to add cells
+ this.canvas.addEventListener('click', (e) => {
+ if (!this.isDragging) {
+ this.handleCellClick(e);
+ }
+ });
+
+ // Right click to fill area with random cells
+ this.canvas.addEventListener('contextmenu', (e) => {
+ e.preventDefault(); // Prevent context menu
+ if (!this.isDragging) {
+ this.handleRightClick(e);
+ }
+ });
+
+ // Mouse events for panning
+ this.canvas.addEventListener('mousedown', (e) => {
+ this.isDragging = false;
+ this.lastMouseX = e.clientX;
+ this.lastMouseY = e.clientY;
+ });
+
+ this.canvas.addEventListener('mousemove', (e) => {
+ if (e.buttons === 1) { // Left mouse button pressed
+ const deltaX = e.clientX - this.lastMouseX;
+ const deltaY = e.clientY - this.lastMouseY;
+
+ if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
+ this.isDragging = true;
+ this.offsetX -= deltaX;
+ this.offsetY -= deltaY;
+ }
+
+ this.lastMouseX = e.clientX;
+ this.lastMouseY = e.clientY;
+ }
+ });
+
+ // Zoom with mouse wheel
+ this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const rect = this.canvas.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ // Calculate world coordinates of mouse position before zoom
+ const worldX = (mouseX + this.offsetX) / this.zoomLevel;
+ const worldY = (mouseY + this.offsetY) / this.zoomLevel;
+
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
+ const newZoomLevel = Math.max(0.1, Math.min(5, this.zoomLevel * zoomFactor));
+
+ // Calculate new offset to keep the mouse position at the same world coordinates
+ this.offsetX = (worldX * newZoomLevel) - mouseX;
+ this.offsetY = (worldY * newZoomLevel) - mouseY;
+
+ this.zoomLevel = newZoomLevel;
+ });
+
+ // Keyboard controls
+ document.addEventListener('keydown', (e) => {
+ if (e.code === 'Space') {
+ e.preventDefault();
+ this.isPaused = !this.isPaused;
+ }
+ });
+ }
+
+ private setupSimulationSpeedSlider() {
+ const speedSlider = document.getElementById('simulation-speed') as HTMLInputElement;
+ const speedDisplay = document.getElementById('speed-display') as HTMLSpanElement;
+
+ if (speedSlider && speedDisplay) {
+ // Update display with initial value
+ speedDisplay.textContent = `${speedSlider.value} Hz`;
+
+ // Listen for slider changes
+ speedSlider.addEventListener('input', (e) => {
+ const target = e.target as HTMLInputElement;
+ const newSpeed = parseInt(target.value);
+ this.targetSimulationRate = newSpeed;
+ speedDisplay.textContent = `${newSpeed} Hz`;
+ });
+ }
+ }
+
+ private setupSaveLoadButtons() {
+ const saveButton = document.getElementById('save-pattern') as HTMLButtonElement;
+ const loadButton = document.getElementById('load-pattern') as HTMLButtonElement;
+ const fileInput = document.getElementById('pattern-file') as HTMLInputElement;
+
+ if (saveButton && loadButton) {
+ saveButton.addEventListener('click', async () => {
+ try {
+ const patternBlob = await this.savePattern();
+
+ // Create a download link
+ const url = URL.createObjectURL(patternBlob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `game-of-life-pattern-${Date.now()}.gol.zz`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ // Show success feedback
+ saveButton.textContent = 'Downloaded!';
+ setTimeout(() => {
+ saveButton.textContent = 'Save Pattern';
+ }, 1000);
+ } catch (error) {
+ console.error('Failed to save pattern:', error);
+ saveButton.textContent = 'Save Failed';
+ setTimeout(() => {
+ saveButton.textContent = 'Save Pattern';
+ }, 1000);
+ }
+ });
+
+ loadButton.addEventListener('click', () => {
+ // Trigger the file input
+ if (fileInput) {
+ fileInput.click();
+ } else {
+ // Create a temporary file input if none exists
+ const tempInput = document.createElement('input');
+ tempInput.type = 'file';
+ tempInput.accept = '.gol.zz,.zz';
+ tempInput.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (file) {
+ await this.handleFileLoad(file, loadButton);
+ }
+ });
+ tempInput.click();
+ }
+ });
+
+ // Set up file input if it exists
+ if (fileInput) {
+ fileInput.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (file) {
+ await this.handleFileLoad(file, loadButton);
+ }
+ });
+ }
+ }
+ }
+
+ private async handleFileLoad(file: File, loadButton: HTMLButtonElement) {
+ try {
+ await this.loadPattern(file);
+ loadButton.textContent = 'Loaded!';
+ setTimeout(() => {
+ loadButton.textContent = 'Load Pattern';
+ }, 1000);
+ } catch (error) {
+ console.error('Failed to load pattern:', error);
+ loadButton.textContent = 'Load Failed';
+ setTimeout(() => {
+ loadButton.textContent = 'Load Pattern';
+ }, 1000);
+ }
+ }
+
+ private seedInitialPattern() {
+ // Add a simple glider pattern to start
+ const glider = [
+ { x: 1, y: 0 },
+ { x: 2, y: 1 },
+ { x: 0, y: 2 },
+ { x: 1, y: 2 },
+ { x: 2, y: 2 }
+ ];
+
+ for (const cell of glider) {
+ this.activeCells.add(this.cellToString(cell));
+ this.potentialCells.add(this.cellToString(cell));
+ }
+ }
+
+ private cellToString(cell: Cell): string {
+ return `${cell.x},${cell.y}`;
+ }
+
+ private stringToCell(str: string): Cell {
+ const [x, y] = str.split(',').map(Number);
+ return { x, y };
+ }
+
+ private async savePattern(): Promise {
+ // Convert active cells to an array of coordinates
+ const cells = Array.from(this.activeCells).map(cellKey => {
+ const cell = this.stringToCell(cellKey);
+ return [cell.x, cell.y];
+ });
+
+ // Convert to JSON
+ const jsonData = JSON.stringify(cells);
+ const encoder = new TextEncoder();
+ const jsonBytes = encoder.encode(jsonData);
+
+ // Compress using deflate
+ const compressionStream = new CompressionStream('deflate-raw');
+ const writer = compressionStream.writable.getWriter();
+ const reader = compressionStream.readable.getReader();
+
+ // Write the data to the compression stream
+ writer.write(jsonBytes);
+ writer.close();
+
+ // Read the compressed data
+ const compressedChunks: Uint8Array[] = [];
+ let done = false;
+
+ while (!done) {
+ const { value, done: readerDone } = await reader.read();
+ done = readerDone;
+ if (value) {
+ compressedChunks.push(value);
+ }
+ }
+
+ // Combine all chunks into a single blob
+ const combinedArray = new Uint8Array(
+ compressedChunks.reduce((totalLength, chunk) => totalLength + chunk.length, 0)
+ );
+ let offset = 0;
+ for (const chunk of compressedChunks) {
+ combinedArray.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ return new Blob([combinedArray], { type: 'application/deflate' });
+ }
+
+ private async loadPattern(file: File): Promise {
+ try {
+ // Read the file as array buffer
+ const compressedData = await file.arrayBuffer();
+
+ // Decompress using deflate
+ const decompressionStream = new DecompressionStream('deflate-raw');
+ const writer = decompressionStream.writable.getWriter();
+ const reader = decompressionStream.readable.getReader();
+
+ // Write the compressed data to the decompression stream
+ writer.write(new Uint8Array(compressedData));
+ writer.close();
+
+ // Read the decompressed data
+ const decompressedChunks: Uint8Array[] = [];
+ let done = false;
+
+ while (!done) {
+ const { value, done: readerDone } = await reader.read();
+ done = readerDone;
+ if (value) {
+ decompressedChunks.push(value);
+ }
+ }
+
+ // Combine chunks and decode to string
+ const totalLength = decompressedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const combinedArray = new Uint8Array(totalLength);
+ let offset = 0;
+
+ for (const chunk of decompressedChunks) {
+ combinedArray.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ const decoder = new TextDecoder();
+ const jsonData = decoder.decode(combinedArray);
+ const cells = JSON.parse(jsonData) as number[][];
+
+ // Clear current pattern
+ this.activeCells.clear();
+ this.activeCellsNext.clear();
+ this.potentialCells.clear();
+ this.potentialCellsNext.clear();
+
+ // Load new pattern
+ for (const [x, y] of cells) {
+ const cellKey = this.cellToString({ x, y });
+ this.activeCells.add(cellKey);
+ this.potentialCells.add(cellKey);
+
+ // Add neighbors to potential cells
+ const neighbors = this.getNeighbors({ x, y });
+ for (const neighbor of neighbors) {
+ this.potentialCells.add(this.cellToString(neighbor));
+ }
+ }
+
+ // Initialize next generation sets
+ this.activeCellsNext = new Set(this.activeCells);
+ this.potentialCellsNext = new Set(this.potentialCells);
+
+ } catch (error) {
+ throw new Error('Invalid pattern file or decompression failed');
+ }
+ }
+
+ private handleCellClick(e: MouseEvent) {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ // Convert screen coordinates to grid coordinates
+ const cellSize = 10 * this.zoomLevel;
+ const gridX = Math.floor((x + this.offsetX) / cellSize);
+ const gridY = Math.floor((y + this.offsetY) / cellSize);
+
+ const cellKey = this.cellToString({ x: gridX, y: gridY });
+
+ // Toggle cell state
+ if (this.activeCells.has(cellKey)) {
+ this.activeCells.delete(cellKey);
+ this.activeCellsNext.delete(cellKey);
+ this.potentialCellsNext.add(cellKey);
+
+ const neighbors = this.getNeighbors({ x: gridX, y: gridY });
+ for (const neighbor of neighbors) {
+ this.potentialCellsNext.add(this.cellToString(neighbor));
+ }
+ } else {
+ this.activeCells.add(cellKey);
+ this.activeCellsNext.add(cellKey);
+ this.potentialCellsNext.add(cellKey);
+
+ // Add neighbors to potential cells
+ const neighbors = this.getNeighbors({ x: gridX, y: gridY });
+ for (const neighbor of neighbors) {
+ this.potentialCellsNext.add(this.cellToString(neighbor));
+ }
+ }
+ }
+
+ private handleRightClick(e: MouseEvent) {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ // Convert screen coordinates to grid coordinates
+ const cellSize = 10 * this.zoomLevel;
+ const centerX = Math.floor((x + this.offsetX) / cellSize);
+ const centerY = Math.floor((y + this.offsetY) / cellSize);
+
+ // Define the area size (radius around the cursor) - scale inversely with zoom level
+ // Base radius of 5 at zoom level 1, scales up when zoomed out
+ const areaRadius = Math.max(2, Math.round(5 / this.zoomLevel));
+ const fillDensity = 0.3; // 30% chance for each cell to be filled
+
+ // Fill random cells in the area
+ for (let dx = -areaRadius; dx <= areaRadius; dx++) {
+ for (let dy = -areaRadius; dy <= areaRadius; dy++) {
+ // Only fill cells within a circular area
+ if (dx * dx + dy * dy <= areaRadius * areaRadius) {
+ if (Math.random() < fillDensity) {
+ const gridX = centerX + dx;
+ const gridY = centerY + dy;
+ const cellKey = this.cellToString({ x: gridX, y: gridY });
+
+ // Add the cell if it's not already alive
+ if (!this.activeCells.has(cellKey)) {
+ this.activeCells.add(cellKey);
+ this.activeCellsNext.add(cellKey);
+ this.potentialCellsNext.add(cellKey);
+
+ // Add neighbors to potential cells
+ const neighbors = this.getNeighbors({ x: gridX, y: gridY });
+ for (const neighbor of neighbors) {
+ this.potentialCellsNext.add(this.cellToString(neighbor));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private update() {
+ if (!this.isPaused) {
+ this.updateCells();
+ }
+
+ setTimeout(() => requestAnimationFrame(this.update.bind(this)), 1000 / this.targetSimulationRate);
+ }
+
+ private updateCells() {
+ //Check if the card is currently flipped, otherwise do not update cells.
+ const isFlipped = document.querySelector('main')?.classList.contains('is-flipped');
+ if (!isFlipped) {
+ return;
+ }
+
+ this.activeCells = new Set(this.activeCellsNext);
+ this.activeCellsNext = new Set();
+
+ this.potentialCells = new Set(this.potentialCellsNext);
+
+ // Each current cell has the potential to cause change on this update.
+ this.potentialCellsNext = new Set(this.activeCells);
+
+ for (const cellKey of this.potentialCells) {
+ const cell = this.stringToCell(cellKey);
+ const neighbors = this.getNeighbors(cell);
+ const aliveNeighbors = neighbors.filter(n => this.activeCells.has(this.cellToString(n))).length;
+
+ if (this.activeCells.has(cellKey)) {
+ // Cell is currently alive
+ if (aliveNeighbors < 2 || aliveNeighbors > 3) {
+ // Cell dies, so we don't add it to the next generation
+
+ // However, this means the surrounding cells could change on the next generation.
+ for (const neighbor of neighbors) {
+ // Add neighbors to potential cells for next generation
+ this.potentialCellsNext.add(this.cellToString(neighbor));
+ }
+ this.potentialCellsNext.add(cellKey);
+ continue;
+ } else {
+ // Cell stays alive
+ this.activeCellsNext.add(cellKey);
+ }
+ } else {
+ // Cell is currently dead
+ if (aliveNeighbors === 3) {
+ // Cell becomes alive
+ this.activeCellsNext.add(cellKey);
+ this.potentialCellsNext.add(cellKey);
+
+ for (const neighbor of neighbors) {
+ // Add neighbors to potential cells for next generation
+ this.potentialCellsNext.add(this.cellToString(neighbor));
+ }
+ }
+ }
+ }
+ }
+
+ private getNeighbors(cell: Cell): Cell[] {
+ const neighbors: Cell[] = [];
+ for (let x = -1; x <= 1; x++) {
+ for (let y = -1; y <= 1; y++) {
+ // Ignore the cell itself.
+ if (x === 0 && y === 0)
+ {
+ continue;
+ }
+ neighbors.push({ x: cell.x + x, y: cell.y + y });
+ }
+ }
+ return neighbors;
+ }
+
+ private drawCells() {
+ const isFlipped = document.querySelector('main')?.classList.contains('is-flipped');
+ if (!isFlipped) {
+ setTimeout(() => requestAnimationFrame(this.drawCells.bind(this)), 1000 / this.targetFrameRate);
+ return;
+ }
+
+ // Draw the cells, taking into account viewport offset and zoom level.
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // Find what range of cells are currently visible based on the canvas size and zoom level.
+ let cellSize = 10; // Default size of each cell in pixels
+
+ // Adjust size by zoom level
+ cellSize *= this.zoomLevel;
+
+ // Calculate the visible range of cells
+ const startX = Math.floor(this.offsetX / cellSize);
+ const startY = Math.floor(this.offsetY / cellSize);
+ const endX = Math.ceil((this.offsetX + this.canvas.width) / cellSize);
+ const endY = Math.ceil((this.offsetY + this.canvas.height) / cellSize);
+
+ // Draw grid lines for better visibility
+ this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
+ this.ctx.lineWidth = 1;
+
+ for (let x = startX; x <= endX; x++) {
+ const screenX = (x * cellSize) - this.offsetX;
+ this.ctx.beginPath();
+ this.ctx.moveTo(screenX, 0);
+ this.ctx.lineTo(screenX, this.canvas.height);
+ this.ctx.stroke();
+ }
+
+ for (let y = startY; y <= endY; y++) {
+ const screenY = (y * cellSize) - this.offsetY;
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, screenY);
+ this.ctx.lineTo(this.canvas.width, screenY);
+ this.ctx.stroke();
+ }
+
+ this.ctx.fillStyle = '#67e8f9'; // cyan color to match theme
+
+ // Draw active cells
+ for (let x = startX; x < endX; x++) {
+ for (let y = startY; y < endY; y++) {
+ const cellKey = this.cellToString({ x, y });
+ if (this.activeCells.has(cellKey)) {
+ const screenX = (x * cellSize) - this.offsetX;
+ const screenY = (y * cellSize) - this.offsetY;
+ this.ctx.fillRect(screenX, screenY, cellSize - 1, cellSize - 1);
+ }
+ }
+ }
+
+ this.ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
+ this.ctx.font = '20px Exo 2 Variable, sans-serif';
+
+ // Draw pause indicator
+ if (this.isPaused) {
+ this.ctx.fillText('PAUSED - Press SPACE to resume', 10, 30);
+ }
+
+ // Draw current X, Y and zoom level along with total number of active cells in bottom left
+ this.ctx.fillText(`X: ${Math.floor(this.offsetX / cellSize)}, Y: ${Math.floor(this.offsetY / cellSize)}, Zoom: ${this.zoomLevel.toFixed(2)}`, 10, this.canvas.height - 10);
+ this.ctx.fillText(`Active Cells: ${this.activeCells.size}`, 10, this.canvas.height - 30);
+
+
+ setTimeout(() => requestAnimationFrame(this.drawCells.bind(this)), 1000 / this.targetFrameRate);
+ }
+}
\ No newline at end of file
diff --git a/src/style.css b/src/style.css
index 4222afc..a5468ee 100644
--- a/src/style.css
+++ b/src/style.css
@@ -219,4 +219,85 @@ details.history ul li {
font-weight: 400;
color: #cbd5e0;
font-size: 12px;
+}
+
+/* Card flip styles */
+.card-container {
+ perspective: 1500px;
+}
+
+.card-flipper {
+ position: relative;
+ width: 100%;
+ min-height: 720px;
+ transform-style: preserve-3d;
+ transition: transform 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+}
+
+.card-container.is-flipped .card-flipper {
+ transform: rotateY(-180deg);
+}
+
+.card-front,
+.card-back {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ backface-visibility: hidden;
+ -webkit-backface-visibility: hidden;
+}
+
+.card-front {
+ z-index: 2;
+ transform: rotateY(0deg);
+}
+
+.card-back {
+ transform: rotateY(-180deg);
+ z-index: 1;
+}
+
+/* Slider styles for simulation speed control */
+.slider {
+ background: rgba(255, 255, 255, 0.2);
+ outline: none;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.slider::-webkit-slider-thumb {
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #67e8f9;
+ cursor: pointer;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+ transition: all 0.3s ease;
+}
+
+.slider::-webkit-slider-thumb:hover {
+ background: #4dd0e1;
+ transform: scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.slider::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #67e8f9;
+ cursor: pointer;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+ transition: all 0.3s ease;
+}
+
+.slider::-moz-range-thumb:hover {
+ background: #4dd0e1;
+ transform: scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
\ No newline at end of file