diff --git a/assets/js/button-generator.js b/assets/js/button-generator.js
new file mode 100644
index 0000000..f33b460
--- /dev/null
+++ b/assets/js/button-generator.js
@@ -0,0 +1,1299 @@
+(function () {
+ // Wait for DOM to be ready
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+
+ function init() {
+ setupCollapsible();
+ setupButtonGenerator();
+ }
+
+ // Collapsible sections functionality
+ function setupCollapsible() {
+ const headers = document.querySelectorAll(".control-group-header");
+ console.log("Found", headers.length, "collapsible headers");
+
+ headers.forEach((header) => {
+ header.addEventListener("click", () => {
+ const controlGroup = header.closest(".control-group");
+ if (controlGroup) {
+ controlGroup.classList.toggle("collapsed");
+ console.log("Toggled collapsed on", header.textContent.trim());
+ }
+ });
+ });
+ }
+
+ function setupButtonGenerator() {
+ const canvas = document.getElementById("button-canvas");
+ const ctx = canvas.getContext("2d");
+
+ // Preload all web fonts for canvas rendering
+ const fonts = [
+ "Lato",
+ "Roboto",
+ "Open Sans",
+ "Montserrat",
+ "Oswald",
+ "Bebas Neue",
+ "Roboto Mono",
+ "VT323",
+ "Press Start 2P",
+ "DSEG7-Classic",
+ ];
+
+ // Load fonts using CSS Font Loading API
+ const fontPromises = fonts.flatMap((font) => [
+ document.fonts.load(`400 12px "${font}"`),
+ document.fonts.load(`700 12px "${font}"`),
+ document.fonts.load(`italic 400 12px "${font}"`),
+ ]);
+
+ Promise.all(fontPromises).then(() => {
+ console.log("All fonts loaded for canvas");
+ drawButton();
+ });
+
+ // Animation configuration
+ const ANIMATION_CONFIG = {
+ fps: 20,
+ duration: 2, // seconds
+ get totalFrames() {
+ return this.fps * this.duration;
+ }, // 40 frames
+ };
+
+ // Animation state class - passed to drawToContext for frame-based rendering
+ class AnimationState {
+ constructor(frameNumber = 0, totalFrames = 40) {
+ this.frame = frameNumber;
+ this.totalFrames = totalFrames;
+ this.progress = frameNumber / totalFrames; // 0 to 1
+ this.time = (frameNumber / ANIMATION_CONFIG.fps) * 1000; // milliseconds
+ }
+
+ // Helper to get phase for periodic animations (0 to 2π)
+ getPhase(speed = 1.0) {
+ return this.progress * speed * Math.PI * 2;
+ }
+ }
+
+ // Get all controls
+ const controls = {
+ text: document.getElementById("button-text"),
+ textEnabled: document.getElementById("text-enabled"),
+ fontSize: document.getElementById("font-size"),
+ textX: document.getElementById("text-x"),
+ textY: document.getElementById("text-y"),
+ textColorType: document.getElementById("text-color-type"),
+ textColor: document.getElementById("text-color"),
+ textGradientColor1: document.getElementById("text-gradient-color1"),
+ textGradientColor2: document.getElementById("text-gradient-color2"),
+ textGradientAngle: document.getElementById("text-gradient-angle"),
+ textOutline: document.getElementById("text-outline"),
+ outlineColor: document.getElementById("outline-color"),
+ fontFamily: document.getElementById("font-family"),
+ fontBold: document.getElementById("font-bold"),
+ fontItalic: document.getElementById("font-italic"),
+ text2: document.getElementById("button-text2"),
+ text2Enabled: document.getElementById("text2-enabled"),
+ fontSize2: document.getElementById("font-size2"),
+ text2X: document.getElementById("text2-x"),
+ text2Y: document.getElementById("text2-y"),
+ text2ColorType: document.getElementById("text2-color-type"),
+ text2Color: document.getElementById("text2-color"),
+ text2GradientColor1: document.getElementById("text2-gradient-color1"),
+ text2GradientColor2: document.getElementById("text2-gradient-color2"),
+ text2GradientAngle: document.getElementById("text2-gradient-angle"),
+ text2Outline: document.getElementById("text2-outline"),
+ outline2Color: document.getElementById("outline2-color"),
+ fontFamily2: document.getElementById("font-family2"),
+ fontBold2: document.getElementById("font-bold2"),
+ fontItalic2: document.getElementById("font-italic2"),
+ bgType: document.getElementById("bg-type"),
+ bgColor: document.getElementById("bg-color"),
+ gradientColor1: document.getElementById("gradient-color1"),
+ gradientColor2: document.getElementById("gradient-color2"),
+ gradientAngle: document.getElementById("gradient-angle"),
+ textureType: document.getElementById("texture-type"),
+ textureColor1: document.getElementById("texture-color1"),
+ textureColor2: document.getElementById("texture-color2"),
+ textureScale: document.getElementById("texture-scale"),
+ borderWidth: document.getElementById("border-width"),
+ borderColor: document.getElementById("border-color"),
+ borderStyle: document.getElementById("border-style"),
+ // Animation controls
+ animateTextWave: document.getElementById("animate-text-wave"),
+ waveAmplitude: document.getElementById("wave-amplitude"),
+ waveSpeed: document.getElementById("wave-speed"),
+ animateTextWave2: document.getElementById("animate-text-wave2"),
+ waveAmplitude2: document.getElementById("wave-amplitude2"),
+ waveSpeed2: document.getElementById("wave-speed2"),
+ animateBgRainbow: document.getElementById("animate-bg-rainbow"),
+ rainbowSpeed: document.getElementById("rainbow-speed"),
+ animateBgRainbowGradient: document.getElementById(
+ "animate-bg-rainbow-gradient",
+ ),
+ animateTextRainbow: document.getElementById("animate-text-rainbow"),
+ textRainbowSpeed: document.getElementById("text-rainbow-speed"),
+ animateTextRainbow2: document.getElementById("animate-text-rainbow2"),
+ textRainbowSpeed2: document.getElementById("text-rainbow-speed2"),
+ animateGlitch: document.getElementById("animate-glitch"),
+ glitchIntensity: document.getElementById("glitch-intensity"),
+ animatePulse: document.getElementById("animate-pulse"),
+ pulseScale: document.getElementById("pulse-scale"),
+ animateShimmer: document.getElementById("animate-shimmer"),
+ animateScanline: document.getElementById("animate-scanline"),
+ scanlineIntensity: document.getElementById("scanline-intensity"),
+ scanlineSpeed: document.getElementById("scanline-speed"),
+ animateRgbSplit: document.getElementById("animate-rgb-split"),
+ rgbSplitIntensity: document.getElementById("rgb-split-intensity"),
+ animateNoise: document.getElementById("animate-noise"),
+ noiseIntensity: document.getElementById("noise-intensity"),
+ animateRotate: document.getElementById("animate-rotate"),
+ rotateAngle: document.getElementById("rotate-angle"),
+ rotateSpeed: document.getElementById("rotate-speed"),
+ };
+
+ // Update value displays
+ const updateValueDisplay = (id, value) => {
+ const display = document.getElementById(id + "-value");
+ if (display) display.textContent = value;
+ };
+
+ // Show/hide controls based on background type
+ controls.bgType.addEventListener("change", () => {
+ const solidControls = document.getElementById("solid-controls");
+ const gradientControls = document.getElementById("gradient-controls");
+ const textureControls = document.getElementById("texture-controls");
+
+ solidControls.style.display = "none";
+ gradientControls.style.display = "none";
+ textureControls.style.display = "none";
+
+ if (controls.bgType.value === "solid") {
+ solidControls.style.display = "block";
+ } else if (controls.bgType.value === "gradient") {
+ gradientControls.style.display = "block";
+ } else if (controls.bgType.value === "texture") {
+ textureControls.style.display = "block";
+ }
+
+ drawButton();
+ });
+
+ // Show/hide text color controls
+ controls.textColorType.addEventListener("change", () => {
+ const textSolidColor = document.getElementById("text-solid-color");
+ const textGradientColor = document.getElementById("text-gradient-color");
+
+ if (controls.textColorType.value === "solid") {
+ textSolidColor.style.display = "block";
+ textGradientColor.style.display = "none";
+ } else {
+ textSolidColor.style.display = "none";
+ textGradientColor.style.display = "block";
+ }
+ drawButton();
+ });
+
+ controls.text2ColorType.addEventListener("change", () => {
+ const text2SolidColor = document.getElementById("text2-solid-color");
+ const text2GradientColor = document.getElementById(
+ "text2-gradient-color",
+ );
+
+ if (controls.text2ColorType.value === "solid") {
+ text2SolidColor.style.display = "block";
+ text2GradientColor.style.display = "none";
+ } else {
+ text2SolidColor.style.display = "none";
+ text2GradientColor.style.display = "block";
+ }
+ drawButton();
+ });
+
+ // Show/hide outline color
+ controls.textOutline.addEventListener("change", () => {
+ controls.outlineColor.style.display = controls.textOutline.checked
+ ? "block"
+ : "none";
+ drawButton();
+ });
+
+ controls.text2Outline.addEventListener("change", () => {
+ controls.outline2Color.style.display = controls.text2Outline.checked
+ ? "block"
+ : "none";
+ drawButton();
+ });
+
+ // Draw texture patterns
+ function drawTexture(type, color1, color2, scale) {
+ const tempCanvas = document.createElement("canvas");
+ tempCanvas.width = 88;
+ tempCanvas.height = 31;
+ const tempCtx = tempCanvas.getContext("2d");
+
+ tempCtx.fillStyle = color1;
+ tempCtx.fillRect(0, 0, 88, 31);
+
+ tempCtx.fillStyle = color2;
+ const size = Math.max(2, Math.floor(scale / 10));
+
+ switch (type) {
+ case "dots":
+ for (let y = 0; y < 31; y += size * 2) {
+ for (let x = 0; x < 88; x += size * 2) {
+ tempCtx.beginPath();
+ tempCtx.arc(x, y, size / 2, 0, Math.PI * 2);
+ tempCtx.fill();
+ }
+ }
+ break;
+ case "grid":
+ for (let x = 0; x < 88; x += size) {
+ tempCtx.fillRect(x, 0, 1, 31);
+ }
+ for (let y = 0; y < 31; y += size) {
+ tempCtx.fillRect(0, y, 88, 1);
+ }
+ break;
+ case "diagonal":
+ for (let i = -31; i < 88; i += size) {
+ tempCtx.fillRect(i, 0, 2, 31);
+ tempCtx.save();
+ tempCtx.translate(i + 1, 0);
+ tempCtx.rotate(Math.PI / 4);
+ tempCtx.fillRect(0, 0, 2, 100);
+ tempCtx.restore();
+ }
+ break;
+ case "checkerboard":
+ for (let y = 0; y < 31; y += size) {
+ for (let x = 0; x < 88; x += size) {
+ if ((x / size + y / size) % 2 === 0) {
+ tempCtx.fillRect(x, y, size, size);
+ }
+ }
+ }
+ break;
+ case "noise":
+ for (let y = 0; y < 31; y++) {
+ for (let x = 0; x < 88; x++) {
+ if (Math.random() > 0.5) {
+ tempCtx.fillRect(x, y, 1, 1);
+ }
+ }
+ }
+ break;
+ case "stars":
+ for (let i = 0; i < scale; i++) {
+ const x = Math.floor(Math.random() * 88);
+ const y = Math.floor(Math.random() * 31);
+ tempCtx.fillRect(x, y, 1, 1);
+ tempCtx.fillRect(x - 1, y, 1, 1);
+ tempCtx.fillRect(x + 1, y, 1, 1);
+ tempCtx.fillRect(x, y - 1, 1, 1);
+ tempCtx.fillRect(x, y + 1, 1, 1);
+ }
+ break;
+ }
+
+ return tempCanvas;
+ }
+
+ // Helper function to draw text line with optional wave animation
+ function drawTextLine(context, lineNumber, animState) {
+ const prefix = lineNumber === 1 ? "" : "2";
+ const text = controls[`text${prefix}`].value;
+ const enabled = controls[`text${prefix}Enabled`].checked;
+
+ if (!text || !enabled) return;
+
+ const fontSize = parseFloat(controls[`fontSize${prefix}`].value);
+ const fontWeight = controls[`fontBold${prefix}`].checked
+ ? "bold"
+ : "normal";
+ const fontStyle = controls[`fontItalic${prefix}`].checked
+ ? "italic"
+ : "normal";
+ const fontFamily = controls[`fontFamily${prefix}`].value;
+
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+
+ const baseX = (parseFloat(controls[`text${prefix}X`].value) / 100) * 88;
+ const baseY = (parseFloat(controls[`text${prefix}Y`].value) / 100) * 31;
+
+ // Check if wave animation is enabled
+ const waveEnabled =
+ animState && controls[`animateTextWave${prefix}`]?.checked;
+
+ if (waveEnabled) {
+ drawWaveText(
+ context,
+ text,
+ baseX,
+ baseY,
+ fontSize,
+ animState,
+ lineNumber,
+ );
+ } else {
+ drawStandardText(
+ context,
+ text,
+ baseX,
+ baseY,
+ fontSize,
+ animState,
+ lineNumber,
+ );
+ }
+ }
+
+ // Draw standard (non-wave) text
+ function drawStandardText(
+ context,
+ text,
+ x,
+ y,
+ fontSize,
+ animState,
+ lineNumber,
+ ) {
+ const prefix = lineNumber === 1 ? "" : "2";
+
+ // Get colors (with potential rainbow animation)
+ const colors = getTextColors(
+ lineNumber,
+ animState,
+ context,
+ text,
+ x,
+ y,
+ fontSize,
+ );
+
+ if (controls[`text${prefix}Outline`].checked) {
+ context.strokeStyle = colors.strokeStyle;
+ context.lineWidth = 2;
+ context.strokeText(text, x, y);
+ }
+
+ context.fillStyle = colors.fillStyle;
+ context.fillText(text, x, y);
+ }
+
+ // Draw wave-animated text (character by character)
+ function drawWaveText(
+ context,
+ text,
+ baseX,
+ baseY,
+ fontSize,
+ animState,
+ lineNumber,
+ ) {
+ const prefix = lineNumber === 1 ? "" : "2";
+ const amplitude = parseFloat(controls[`waveAmplitude${prefix}`].value);
+ const speed = parseFloat(controls[`waveSpeed${prefix}`].value);
+
+ // Measure total width for centering
+ const totalWidth = context.measureText(text).width;
+ let currentX = baseX - totalWidth / 2;
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i];
+ const charWidth = context.measureText(char).width;
+
+ // Calculate wave offset for this character
+ const phase = animState.getPhase(speed);
+ const charOffset = i / text.length; // 0 to 1
+ const waveY = Math.sin(phase + charOffset * Math.PI * 2) * amplitude;
+
+ const charX = currentX + charWidth / 2;
+ const charY = baseY + waveY;
+
+ // Get colors for this character
+ const colors = getTextColors(
+ lineNumber,
+ animState,
+ context,
+ char,
+ charX,
+ charY,
+ fontSize,
+ );
+
+ // Draw outline if enabled
+ if (controls[`text${prefix}Outline`].checked) {
+ context.strokeStyle = colors.strokeStyle;
+ context.lineWidth = 2;
+ context.strokeText(char, charX, charY);
+ }
+
+ // Draw character
+ context.fillStyle = colors.fillStyle;
+ context.fillText(char, charX, charY);
+
+ currentX += charWidth;
+ }
+ }
+
+ // Get text colors (solid, gradient, or rainbow)
+ function getTextColors(
+ lineNumber,
+ animState,
+ context,
+ text,
+ x,
+ y,
+ fontSize,
+ ) {
+ const prefix = lineNumber === 1 ? "" : "2";
+ const colorType = controls[`text${prefix}ColorType`].value;
+
+ let fillStyle, strokeStyle;
+
+ // Rainbow text animation overrides other colors
+ if (animState && controls[`animateTextRainbow${prefix}`]?.checked) {
+ const hue =
+ (animState.progress *
+ parseFloat(controls[`textRainbowSpeed${prefix}`].value) *
+ 360) %
+ 360;
+ fillStyle = `hsl(${hue}, 80%, 60%)`;
+ strokeStyle = `hsl(${hue}, 80%, 30%)`;
+ } else if (colorType === "solid") {
+ fillStyle = controls[`text${prefix}Color`].value;
+ strokeStyle = controls[`outline${prefix}Color`].value;
+ } else {
+ // Gradient
+ const angle =
+ parseFloat(controls[`text${prefix}GradientAngle`].value) *
+ (Math.PI / 180);
+ const textWidth = context.measureText(text).width;
+ const x1 = x - textWidth / 2 + (Math.cos(angle) * textWidth) / 2;
+ const y1 = y - fontSize / 2 + (Math.sin(angle) * fontSize) / 2;
+ const x2 = x + textWidth / 2 - (Math.cos(angle) * textWidth) / 2;
+ const y2 = y + fontSize / 2 - (Math.sin(angle) * fontSize) / 2;
+
+ const textGradient = context.createLinearGradient(x1, y1, x2, y2);
+ textGradient.addColorStop(
+ 0,
+ controls[`text${prefix}GradientColor1`].value,
+ );
+ textGradient.addColorStop(
+ 1,
+ controls[`text${prefix}GradientColor2`].value,
+ );
+ fillStyle = textGradient;
+ strokeStyle = controls[`outline${prefix}Color`].value;
+ }
+
+ return { fillStyle, strokeStyle };
+ }
+
+ // Helper function to draw to a single context
+ function drawToContext(context, animState = null) {
+ context.clearRect(0, 0, 88, 31);
+
+ // Apply rotate effect (must be before drawing)
+ if (animState && controls.animateRotate?.checked) {
+ const maxAngle = parseFloat(controls.rotateAngle.value);
+ const speed = parseFloat(controls.rotateSpeed.value);
+ const angle =
+ Math.sin(animState.getPhase(speed)) * maxAngle * (Math.PI / 180);
+
+ context.save();
+ context.translate(44, 15.5);
+ context.rotate(angle);
+ context.translate(-44, -15.5);
+ }
+
+ // Draw background
+ if (controls.bgType.value === "solid") {
+ // Rainbow flash for solid colors
+ if (animState && controls.animateBgRainbow?.checked) {
+ const hue =
+ (animState.progress *
+ parseFloat(controls.rainbowSpeed.value) *
+ 360) %
+ 360;
+ context.fillStyle = `hsl(${hue}, 70%, 50%)`;
+ } else {
+ context.fillStyle = controls.bgColor.value;
+ }
+ context.fillRect(0, 0, 88, 31);
+ } else if (controls.bgType.value === "gradient") {
+ const angle =
+ parseFloat(controls.gradientAngle.value) * (Math.PI / 180);
+ const x1 = 44 + Math.cos(angle) * 44;
+ const y1 = 15.5 + Math.sin(angle) * 15.5;
+ const x2 = 44 - Math.cos(angle) * 44;
+ const y2 = 15.5 - Math.sin(angle) * 15.5;
+
+ const gradient = context.createLinearGradient(x1, y1, x2, y2);
+
+ // Rainbow flash for gradients
+ if (animState && controls.animateBgRainbow?.checked) {
+ const hue =
+ (animState.progress *
+ parseFloat(controls.rainbowSpeed.value) *
+ 360) %
+ 360;
+ gradient.addColorStop(0, `hsl(${hue}, 70%, 50%)`);
+ gradient.addColorStop(1, `hsl(${(hue + 60) % 360}, 70%, 60%)`);
+ } else {
+ gradient.addColorStop(0, controls.gradientColor1.value);
+ gradient.addColorStop(1, controls.gradientColor2.value);
+ }
+
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, 88, 31);
+ } else if (controls.bgType.value === "texture") {
+ const texture = drawTexture(
+ controls.textureType.value,
+ controls.textureColor1.value,
+ controls.textureColor2.value,
+ parseFloat(controls.textureScale.value),
+ );
+ context.drawImage(texture, 0, 0);
+ }
+
+ // Rainbow gradient overlay (travels across background)
+ if (animState && controls.animateBgRainbowGradient?.checked) {
+ // Map progress to position (-100 to 100)
+ const position = animState.progress * 200 - 100;
+
+ // Create a horizontal gradient that sweeps across
+ const rainbowGradient = context.createLinearGradient(
+ position - 50,
+ 0,
+ position + 50,
+ 0,
+ );
+
+ // Create rainbow stops that also cycle through colors
+ const hueOffset = animState.progress * 360;
+ rainbowGradient.addColorStop(
+ 0,
+ `hsla(${(hueOffset + 0) % 360}, 80%, 50%, 0)`,
+ );
+ rainbowGradient.addColorStop(
+ 0.2,
+ `hsla(${(hueOffset + 60) % 360}, 80%, 50%, 0.6)`,
+ );
+ rainbowGradient.addColorStop(
+ 0.4,
+ `hsla(${(hueOffset + 120) % 360}, 80%, 50%, 0.8)`,
+ );
+ rainbowGradient.addColorStop(
+ 0.6,
+ `hsla(${(hueOffset + 180) % 360}, 80%, 50%, 0.8)`,
+ );
+ rainbowGradient.addColorStop(
+ 0.8,
+ `hsla(${(hueOffset + 240) % 360}, 80%, 50%, 0.6)`,
+ );
+ rainbowGradient.addColorStop(
+ 1,
+ `hsla(${(hueOffset + 300) % 360}, 80%, 50%, 0)`,
+ );
+
+ context.fillStyle = rainbowGradient;
+ context.fillRect(0, 0, 88, 31);
+ }
+
+ // Draw border
+ const borderWidth = parseFloat(controls.borderWidth.value);
+ if (borderWidth > 0) {
+ const style = controls.borderStyle.value;
+
+ if (style === "solid") {
+ context.strokeStyle = controls.borderColor.value;
+ context.lineWidth = borderWidth;
+ context.strokeRect(
+ borderWidth / 2,
+ borderWidth / 2,
+ 88 - borderWidth,
+ 31 - borderWidth,
+ );
+ } else if (style === "inset" || style === "outset") {
+ const light = style === "outset";
+ context.strokeStyle = light ? "#ffffff" : "#000000";
+ context.lineWidth = borderWidth;
+ context.beginPath();
+ context.moveTo(0, 31);
+ context.lineTo(0, 0);
+ context.lineTo(88, 0);
+ context.stroke();
+
+ context.strokeStyle = light ? "#000000" : "#ffffff";
+ context.beginPath();
+ context.moveTo(88, 0);
+ context.lineTo(88, 31);
+ context.lineTo(0, 31);
+ context.stroke();
+ } else if (style === "ridge") {
+ context.strokeStyle = "#ffffff";
+ context.lineWidth = borderWidth / 2;
+ context.strokeRect(
+ borderWidth / 4,
+ borderWidth / 4,
+ 88 - borderWidth / 2,
+ 31 - borderWidth / 2,
+ );
+
+ context.strokeStyle = "#000000";
+ context.strokeRect(
+ (borderWidth * 3) / 4,
+ (borderWidth * 3) / 4,
+ 88 - borderWidth * 1.5,
+ 31 - borderWidth * 1.5,
+ );
+ }
+ }
+
+ // Apply pulse effect (scale before drawing text)
+ if (animState && controls.animatePulse?.checked) {
+ const maxScale = parseFloat(controls.pulseScale.value);
+ const minScale = 1.0;
+ const scale =
+ minScale +
+ (maxScale - minScale) *
+ (Math.sin(animState.getPhase(1.0)) * 0.5 + 0.5);
+
+ context.save();
+ context.translate(44, 15.5);
+ context.scale(scale, scale);
+ context.translate(-44, -15.5);
+ }
+
+ // Draw text line 1
+ drawTextLine(context, 1, animState);
+
+ // Draw text line 2
+ drawTextLine(context, 2, animState);
+
+ // Restore context if pulse was applied
+ if (animState && controls.animatePulse?.checked) {
+ context.restore();
+ }
+
+ // Apply shimmer effect
+ if (animState && controls.animateShimmer?.checked) {
+ const shimmerX = animState.progress * 120 - 20; // Sweep from -20 to 100
+
+ const shimmerGradient = context.createLinearGradient(
+ shimmerX - 15,
+ 0,
+ shimmerX + 15,
+ 31,
+ );
+ shimmerGradient.addColorStop(0, "rgba(255, 255, 255, 0)");
+ shimmerGradient.addColorStop(0.5, "rgba(255, 255, 255, 0.3)");
+ shimmerGradient.addColorStop(1, "rgba(255, 255, 255, 0)");
+
+ context.fillStyle = shimmerGradient;
+ context.fillRect(0, 0, 88, 31);
+ }
+
+ // Apply glitch effect
+ if (animState && controls.animateGlitch?.checked) {
+ applyGlitchEffect(context, animState);
+ }
+
+ // Apply scanline effect
+ if (animState && controls.animateScanline?.checked) {
+ applyScanlineEffect(context, animState);
+ }
+
+ // Apply RGB split effect
+ if (animState && controls.animateRgbSplit?.checked) {
+ applyRgbSplitEffect(context, animState);
+ }
+
+ // Apply noise effect
+ if (animState && controls.animateNoise?.checked) {
+ applyNoiseEffect(context, animState);
+ }
+
+ // Restore context if rotate was applied
+ if (animState && controls.animateRotate?.checked) {
+ context.restore();
+ }
+ }
+
+ // Apply glitch effect (scanline displacement)
+ function applyGlitchEffect(context, animState) {
+ const intensity = parseFloat(controls.glitchIntensity.value);
+ const imageData = context.getImageData(0, 0, 88, 31);
+
+ // Randomly glitch ~10% of scanlines per frame
+ const glitchProbability = 0.1;
+ const maxOffset = intensity;
+
+ for (let y = 0; y < 31; y++) {
+ if (Math.random() < glitchProbability) {
+ const offset = Math.floor((Math.random() - 0.5) * maxOffset * 2);
+ shiftScanline(imageData, y, offset);
+ }
+ }
+
+ context.putImageData(imageData, 0, 0);
+ }
+
+ // Shift a horizontal scanline by offset pixels (with wrapping)
+ function shiftScanline(imageData, y, offset) {
+ const width = imageData.width;
+ const rowStart = y * width * 4;
+ const rowData = new Uint8ClampedArray(width * 4);
+
+ // Copy row
+ for (let i = 0; i < width * 4; i++) {
+ rowData[i] = imageData.data[rowStart + i];
+ }
+
+ // Shift and wrap
+ for (let x = 0; x < width; x++) {
+ let sourceX = (x - offset + width) % width;
+ let destIdx = rowStart + x * 4;
+ let srcIdx = sourceX * 4;
+
+ imageData.data[destIdx] = rowData[srcIdx];
+ imageData.data[destIdx + 1] = rowData[srcIdx + 1];
+ imageData.data[destIdx + 2] = rowData[srcIdx + 2];
+ imageData.data[destIdx + 3] = rowData[srcIdx + 3];
+ }
+ }
+
+ // Apply scanline effect (CRT-style horizontal lines)
+ function applyScanlineEffect(context, animState) {
+ const intensity = parseFloat(controls.scanlineIntensity.value);
+ const speed = parseFloat(controls.scanlineSpeed.value);
+
+ // Create overlay with scanlines
+ context.globalCompositeOperation = "multiply";
+ context.fillStyle = "rgba(0, 0, 0, " + intensity + ")";
+
+ // Animate scanline position
+ const offset = (animState.progress * speed * 31) % 2;
+
+ for (let y = offset; y < 31; y += 2) {
+ context.fillRect(0, Math.floor(y), 88, 1);
+ }
+
+ context.globalCompositeOperation = "source-over";
+ }
+
+ // Apply RGB split/chromatic aberration effect
+ function applyRgbSplitEffect(context, animState) {
+ const intensity = parseFloat(controls.rgbSplitIntensity.value);
+ const imageData = context.getImageData(0, 0, 88, 31);
+ const result = context.createImageData(88, 31);
+
+ // Oscillating offset
+ const phase = Math.sin(animState.getPhase(1.0));
+ const offsetX = Math.round(phase * intensity);
+
+ for (let y = 0; y < 31; y++) {
+ for (let x = 0; x < 88; x++) {
+ const idx = (y * 88 + x) * 4;
+
+ // Red channel - shift left
+ const redX = Math.max(0, Math.min(87, x - offsetX));
+ const redIdx = (y * 88 + redX) * 4;
+ result.data[idx] = imageData.data[redIdx];
+
+ // Green channel - no shift
+ result.data[idx + 1] = imageData.data[idx + 1];
+
+ // Blue channel - shift right
+ const blueX = Math.max(0, Math.min(87, x + offsetX));
+ const blueIdx = (y * 88 + blueX) * 4;
+ result.data[idx + 2] = imageData.data[blueIdx + 2];
+
+ // Alpha channel
+ result.data[idx + 3] = imageData.data[idx + 3];
+ }
+ }
+
+ context.putImageData(result, 0, 0);
+ }
+
+ // Apply noise/static effect
+ function applyNoiseEffect(context, animState) {
+ const intensity = parseFloat(controls.noiseIntensity.value);
+ const imageData = context.getImageData(0, 0, 88, 31);
+
+ for (let i = 0; i < imageData.data.length; i += 4) {
+ // Random noise value
+ const noise = (Math.random() - 0.5) * 255 * intensity;
+
+ imageData.data[i] = Math.max(
+ 0,
+ Math.min(255, imageData.data[i] + noise),
+ );
+ imageData.data[i + 1] = Math.max(
+ 0,
+ Math.min(255, imageData.data[i + 1] + noise),
+ );
+ imageData.data[i + 2] = Math.max(
+ 0,
+ Math.min(255, imageData.data[i + 2] + noise),
+ );
+ // Alpha unchanged
+ }
+
+ context.putImageData(imageData, 0, 0);
+ }
+
+ // Animated preview state
+ let previewAnimationId = null;
+
+ // Update preview (static or animated based on settings)
+ function updatePreview() {
+ if (hasAnimationsEnabled()) {
+ startAnimatedPreview();
+ } else {
+ stopAnimatedPreview();
+ drawToContext(ctx);
+ }
+ updateDownloadButtonLabel();
+ }
+
+ // Start animated preview loop
+ function startAnimatedPreview() {
+ stopAnimatedPreview(); // Clear any existing
+
+ let frameNum = 0;
+ let lastFrameTime = performance.now();
+ const frameDelay = 1000 / ANIMATION_CONFIG.fps; // ms per frame
+
+ const animate = (currentTime) => {
+ const elapsed = currentTime - lastFrameTime;
+
+ // Only advance frame if enough time has passed
+ if (elapsed >= frameDelay) {
+ const animState = new AnimationState(
+ frameNum,
+ ANIMATION_CONFIG.totalFrames,
+ );
+ drawToContext(ctx, animState);
+
+ frameNum = (frameNum + 1) % ANIMATION_CONFIG.totalFrames;
+ lastFrameTime = currentTime - (elapsed % frameDelay); // Carry over extra time
+ }
+
+ previewAnimationId = requestAnimationFrame(animate);
+ };
+
+ previewAnimationId = requestAnimationFrame(animate);
+ }
+
+ // Stop animated preview
+ function stopAnimatedPreview() {
+ if (previewAnimationId) {
+ cancelAnimationFrame(previewAnimationId);
+ previewAnimationId = null;
+ }
+ }
+
+ // Main draw function
+ function drawButton() {
+ updatePreview();
+ }
+
+ // Check if any animations are enabled
+ function hasAnimationsEnabled() {
+ return !!(
+ controls.animateTextWave?.checked ||
+ controls.animateTextWave2?.checked ||
+ controls.animateBgRainbow?.checked ||
+ controls.animateBgRainbowGradient?.checked ||
+ controls.animateTextRainbow?.checked ||
+ controls.animateTextRainbow2?.checked ||
+ controls.animateGlitch?.checked ||
+ controls.animatePulse?.checked ||
+ controls.animateShimmer?.checked ||
+ controls.animateScanline?.checked ||
+ controls.animateRgbSplit?.checked ||
+ controls.animateNoise?.checked ||
+ controls.animateRotate?.checked
+ );
+ }
+
+ // Update download button label
+ function updateDownloadButtonLabel() {
+ const btn = document.getElementById("download-button");
+ btn.textContent = "Download GIF";
+ }
+
+ // Export as animated GIF
+ async function exportAsGif() {
+ const downloadBtn = document.getElementById("download-button");
+ const originalText = downloadBtn.textContent;
+ downloadBtn.disabled = true;
+ downloadBtn.textContent = "Generating GIF...";
+
+ try {
+ // Create temporary canvas for frame generation
+ const frameCanvas = document.createElement("canvas");
+ frameCanvas.width = 88;
+ frameCanvas.height = 31;
+ const frameCtx = frameCanvas.getContext("2d");
+
+ // Initialize gif.js
+ const gif = new GIF({
+ workers: 2,
+ quality: 10,
+ workerScript: "/js/gif.worker.js",
+ width: 88,
+ height: 31,
+ });
+
+ // Generate frames
+ const totalFrames = ANIMATION_CONFIG.totalFrames;
+ for (let i = 0; i < totalFrames; i++) {
+ const animState = new AnimationState(i, totalFrames);
+ drawToContext(frameCtx, animState);
+
+ // Add frame to GIF (delay in ms)
+ gif.addFrame(frameCtx, {
+ delay: 1000 / ANIMATION_CONFIG.fps,
+ copy: true,
+ });
+
+ // Update progress
+ const progress = Math.round((i / totalFrames) * 100);
+ downloadBtn.textContent = `Generating: ${progress}%`;
+
+ // Yield to browser to keep UI responsive
+ if (i % 5 === 0) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ }
+
+ // Render GIF
+ gif.on("finished", (blob) => {
+ // Download
+ const link = document.createElement("a");
+ link.download = "button-88x31.gif";
+ link.href = URL.createObjectURL(blob);
+ link.click();
+
+ // Cleanup
+ URL.revokeObjectURL(link.href);
+ downloadBtn.disabled = false;
+ downloadBtn.textContent = originalText;
+ });
+
+ gif.on("progress", (progress) => {
+ const percent = Math.round(progress * 100);
+ downloadBtn.textContent = `Encoding: ${percent}%`;
+ });
+
+ gif.render();
+ } catch (error) {
+ console.error("Error generating GIF:", error);
+ downloadBtn.disabled = false;
+ downloadBtn.textContent = originalText;
+ alert("Error generating GIF. Please try again.");
+ }
+ }
+
+ // Download function
+ document
+ .getElementById("download-button")
+ .addEventListener("click", async () => {
+ await exportAsGif();
+ });
+
+ // Preset buttons
+ document.getElementById("preset-random").addEventListener("click", () => {
+ const randomColor = () =>
+ "#" +
+ Math.floor(Math.random() * 16777215)
+ .toString(16)
+ .padStart(6, "0");
+
+ // Random background
+ controls.bgType.value = ["solid", "gradient", "texture"][
+ Math.floor(Math.random() * 3)
+ ];
+ controls.bgColor.value = randomColor();
+ controls.gradientColor1.value = randomColor();
+ controls.gradientColor2.value = randomColor();
+ controls.gradientAngle.value = Math.floor(Math.random() * 360);
+ controls.textureColor1.value = randomColor();
+ controls.textureColor2.value = randomColor();
+ controls.textureType.value = [
+ "dots",
+ "grid",
+ "diagonal",
+ "checkerboard",
+ "noise",
+ "stars",
+ ][Math.floor(Math.random() * 6)];
+
+ // Random text 1 color (50% chance of gradient)
+ controls.textColorType.value = Math.random() > 0.5 ? "gradient" : "solid";
+ controls.textColor.value = randomColor();
+ controls.textGradientColor1.value = randomColor();
+ controls.textGradientColor2.value = randomColor();
+ controls.textGradientAngle.value = Math.floor(Math.random() * 360);
+
+ // Random text 2 color (50% chance of gradient)
+ controls.text2ColorType.value =
+ Math.random() > 0.5 ? "gradient" : "solid";
+ controls.text2Color.value = randomColor();
+ controls.text2GradientColor1.value = randomColor();
+ controls.text2GradientColor2.value = randomColor();
+ controls.text2GradientAngle.value = Math.floor(Math.random() * 360);
+
+ // Random border
+ controls.borderColor.value = randomColor();
+ controls.borderWidth.value = Math.floor(Math.random() * 6);
+ controls.borderStyle.value = ["solid", "inset", "outset", "ridge"][
+ Math.floor(Math.random() * 4)
+ ];
+
+ // Random text styles
+ controls.fontBold.checked = Math.random() > 0.5;
+ controls.fontItalic.checked = Math.random() > 0.5;
+ controls.fontBold2.checked = Math.random() > 0.5;
+ controls.fontItalic2.checked = Math.random() > 0.5;
+
+ // Update displays
+ updateValueDisplay("gradient-angle", controls.gradientAngle.value);
+ updateValueDisplay(
+ "text-gradient-angle",
+ controls.textGradientAngle.value,
+ );
+ updateValueDisplay(
+ "text2-gradient-angle",
+ controls.text2GradientAngle.value,
+ );
+ updateValueDisplay("border-width", controls.borderWidth.value);
+
+ controls.bgType.dispatchEvent(new Event("change"));
+ controls.textColorType.dispatchEvent(new Event("change"));
+ controls.text2ColorType.dispatchEvent(new Event("change"));
+ drawButton();
+ });
+
+ document.getElementById("preset-classic").addEventListener("click", () => {
+ // Classic 90s web button style
+ controls.bgType.value = "gradient";
+ controls.gradientColor1.value = "#6e6e6eff";
+ controls.gradientColor2.value = "#979797";
+ controls.gradientAngle.value = 90;
+
+ controls.textColorType.value = "solid";
+ controls.textColor.value = "#000000";
+ controls.text2ColorType.value = "solid";
+ controls.text2Color.value = "#000";
+
+ controls.borderWidth.value = 2;
+ controls.borderColor.value = "#000000";
+ controls.borderStyle.value = "outset";
+
+ controls.fontFamily.value = "VT323";
+ controls.fontFamily2.value = "VT323";
+ controls.fontBold.checked = false;
+ controls.fontBold2.checked = false;
+ controls.fontItalic.checked = false;
+ controls.fontItalic2.checked = false;
+
+ //controls.text.value = "RITUAL.SH";
+ //controls.text2.value = "FREE THE WEB";
+ controls.textEnabled.checked = true;
+ controls.text2Enabled.checked = true;
+ controls.fontSize.value = 12;
+ controls.fontSize2.value = 8;
+ controls.textY.value = 50;
+ controls.text2Y.value = 65;
+
+ updateValueDisplay("font-size", 12);
+ updateValueDisplay("font-size2", 8);
+ updateValueDisplay("gradient-angle", 90);
+ updateValueDisplay("text-y", 35);
+ updateValueDisplay("text2-y", 65);
+
+ controls.bgType.dispatchEvent(new Event("change"));
+ controls.textColorType.dispatchEvent(new Event("change"));
+ controls.text2ColorType.dispatchEvent(new Event("change"));
+ drawButton();
+ });
+
+ document.getElementById("preset-modern").addEventListener("click", () => {
+ controls.bgType.value = "gradient";
+ controls.gradientColor1.value = "#0a0a0a";
+ controls.gradientColor2.value = "#1a0a2e";
+ controls.gradientAngle.value = 135;
+
+ controls.textColorType.value = "gradient";
+ controls.textGradientColor1.value = "#00ffaa";
+ controls.textGradientColor2.value = "#00ffff";
+ controls.textGradientAngle.value = 90;
+
+ controls.text2ColorType.value = "gradient";
+ controls.text2GradientColor1.value = "#ff00ff";
+ controls.text2GradientColor2.value = "#ff6600";
+ controls.text2GradientAngle.value = 0;
+
+ controls.borderWidth.value = 1;
+ controls.borderColor.value = "#00ffaa";
+ controls.borderStyle.value = "solid";
+
+ controls.fontFamily.value = "Roboto Mono";
+ controls.fontFamily2.value = "Roboto Mono";
+ controls.fontBold.checked = true;
+ controls.fontBold2.checked = false;
+ controls.fontItalic.checked = false;
+ controls.fontItalic2.checked = false;
+
+ //controls.text.value = "RITUAL.SH";
+ //controls.text2.value = "EST. 2024";
+ controls.textEnabled.checked = true;
+ controls.text2Enabled.checked = true;
+ controls.fontSize.value = 11;
+ controls.fontSize2.value = 9;
+ controls.textY.value = 35;
+ controls.text2Y.value = 65;
+
+ updateValueDisplay("font-size", 11);
+ updateValueDisplay("font-size2", 9);
+ updateValueDisplay("gradient-angle", 135);
+ updateValueDisplay("text-gradient-angle", 90);
+ updateValueDisplay("text2-gradient-angle", 0);
+ updateValueDisplay("text-y", 35);
+ updateValueDisplay("text2-y", 65);
+
+ controls.textColorType.dispatchEvent(new Event("change"));
+ controls.text2ColorType.dispatchEvent(new Event("change"));
+ controls.bgType.dispatchEvent(new Event("change"));
+ drawButton();
+ });
+
+ // Add event listeners to all controls
+ Object.values(controls).forEach((control) => {
+ if (control) {
+ control.addEventListener("input", drawButton);
+ control.addEventListener("change", drawButton);
+
+ if (control.type === "range") {
+ control.addEventListener("input", (e) => {
+ updateValueDisplay(e.target.id, e.target.value);
+ });
+ }
+ }
+ });
+
+ // Animation control show/hide listeners
+ if (controls.animateTextWave) {
+ controls.animateTextWave.addEventListener("change", () => {
+ document.getElementById("wave-controls").style.display = controls
+ .animateTextWave.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateTextWave2) {
+ controls.animateTextWave2.addEventListener("change", () => {
+ document.getElementById("wave-controls2").style.display = controls
+ .animateTextWave2.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateBgRainbow) {
+ controls.animateBgRainbow.addEventListener("change", () => {
+ document.getElementById("rainbow-bg-controls").style.display = controls
+ .animateBgRainbow.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateTextRainbow) {
+ controls.animateTextRainbow.addEventListener("change", () => {
+ document.getElementById("rainbow-text-controls").style.display =
+ controls.animateTextRainbow.checked ? "block" : "none";
+ });
+ }
+
+ if (controls.animateTextRainbow2) {
+ controls.animateTextRainbow2.addEventListener("change", () => {
+ document.getElementById("rainbow-text2-controls").style.display =
+ controls.animateTextRainbow2.checked ? "block" : "none";
+ });
+ }
+
+ if (controls.animateGlitch) {
+ controls.animateGlitch.addEventListener("change", () => {
+ document.getElementById("glitch-controls").style.display = controls
+ .animateGlitch.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animatePulse) {
+ controls.animatePulse.addEventListener("change", () => {
+ document.getElementById("pulse-controls").style.display = controls
+ .animatePulse.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateScanline) {
+ controls.animateScanline.addEventListener("change", () => {
+ document.getElementById("scanline-controls").style.display = controls
+ .animateScanline.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateRgbSplit) {
+ controls.animateRgbSplit.addEventListener("change", () => {
+ document.getElementById("rgb-split-controls").style.display = controls
+ .animateRgbSplit.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateNoise) {
+ controls.animateNoise.addEventListener("change", () => {
+ document.getElementById("noise-controls").style.display = controls
+ .animateNoise.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ if (controls.animateRotate) {
+ controls.animateRotate.addEventListener("change", () => {
+ document.getElementById("rotate-controls").style.display = controls
+ .animateRotate.checked
+ ? "block"
+ : "none";
+ });
+ }
+
+ // Initial draw
+ drawButton();
+ } // end setupButtonGenerator
+})();
diff --git a/assets/sass/pages/button-generator.scss b/assets/sass/pages/button-generator.scss
new file mode 100644
index 0000000..c6aac4b
--- /dev/null
+++ b/assets/sass/pages/button-generator.scss
@@ -0,0 +1,528 @@
+@import url(https://fonts.bunny.net/css?family=bebas-neue:400|lato:400,400i,700,700i|montserrat:400,400i,700,700i|open-sans:400,400i,700,700i|oswald:400,700|press-start-2p:400|roboto:400,400i,700,700i|roboto-mono:400,400i,700,700i|vt323:400);
+
+#button-generator-app {
+ margin: 2rem 0;
+ padding: 2rem;
+ background: linear-gradient(
+ 145deg,
+ rgba(5, 15, 30, 0.9) 0%,
+ rgba(10, 20, 40, 0.95) 100%
+ );
+ border-radius: 6px;
+ border: 1px solid rgba(0, 150, 255, 0.4);
+ box-shadow:
+ 0 0 30px rgba(0, 150, 255, 0.15),
+ 0 0 50px rgba(255, 120, 0, 0.08),
+ inset 0 0 60px rgba(0, 100, 200, 0.05);
+ position: relative;
+ overflow: visible;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(0, 150, 255, 0.6),
+ rgba(255, 120, 0, 0.6),
+ transparent
+ );
+ }
+
+ .generator-container {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: 2rem;
+ margin-top: 1rem;
+ position: relative;
+ z-index: 1;
+
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ .preview-section {
+ background: linear-gradient(
+ 135deg,
+ rgba(0, 100, 180, 0.15) 0%,
+ rgba(0, 80, 150, 0.1) 100%
+ );
+ padding: 1.5rem;
+ border-radius: 6px;
+ border: 1px solid rgba(0, 150, 255, 0.3);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ position: sticky;
+ top: 1rem;
+ align-self: flex-start;
+ max-height: calc(100vh - 2rem);
+ overflow-y: auto;
+ box-shadow:
+ 0 0 20px rgba(0, 150, 255, 0.1),
+ inset 0 0 40px rgba(0, 100, 200, 0.05);
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ background: linear-gradient(
+ 180deg,
+ rgba(0, 150, 255, 0.7),
+ rgba(255, 120, 0, 0.5)
+ );
+ border-radius: 6px 0 0 6px;
+ }
+
+ h3 {
+ margin-top: 0;
+ color: #0096ff;
+ font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ text-shadow: 0 0 15px rgba(0, 150, 255, 0.5);
+ font-size: 1.2rem;
+ }
+ }
+
+ .presets-container {
+ margin-top: 1.5rem;
+ padding-top: 1.5rem;
+ border-top: 2px solid;
+ border-image: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(0, 150, 255, 0.5),
+ transparent
+ )
+ 1;
+
+ h3 {
+ margin-top: 0;
+ margin-bottom: 1rem;
+ color: rgba(100, 180, 255, 0.9);
+ font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ text-shadow: 0 0 10px rgba(0, 150, 255, 0.4);
+ font-size: 1rem;
+ }
+ }
+
+ .preview-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .preview-wrapper {
+ background: repeating-conic-gradient(
+ rgba(20, 30, 50, 0.8) 0% 25%,
+ rgba(10, 20, 40, 0.9) 0% 50%
+ )
+ 50% / 20px 20px;
+ padding: 2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 4px;
+ border: 1px solid rgba(0, 150, 255, 0.2);
+ }
+
+ #button-canvas {
+ image-rendering: pixelated;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: crisp-edges;
+ //border: 1px solid rgba(0, 150, 255, 0.4);
+ box-shadow: 0 0 15px rgba(0, 150, 255, 0.2);
+ }
+
+ .controls-section {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ }
+
+ .control-group {
+ background: linear-gradient(
+ 135deg,
+ rgba(0, 100, 180, 0.15) 0%,
+ rgba(0, 80, 150, 0.1) 100%
+ );
+ padding: 1rem;
+ border-radius: 6px;
+ border: 1px solid rgba(0, 150, 255, 0.3);
+ box-shadow:
+ 0 0 20px rgba(0, 150, 255, 0.1),
+ inset 0 0 40px rgba(0, 100, 200, 0.05);
+ position: relative;
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 3px;
+ height: 100%;
+ background: linear-gradient(
+ 180deg,
+ rgba(0, 150, 255, 0.7),
+ rgba(255, 120, 0, 0.5)
+ );
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ }
+
+ &:hover::before {
+ opacity: 1;
+ }
+
+ h3 {
+ margin-top: 0;
+ margin-bottom: 1rem;
+ }
+
+ label {
+ display: block;
+ margin-top: 0.75rem;
+ margin-bottom: 0.25rem;
+ font-weight: 500;
+ font-size: 0.85rem;
+ color: rgba(100, 180, 255, 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ input[type="text"],
+ select {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid rgba(0, 150, 255, 0.4);
+ border-radius: 4px;
+ font-size: 0.9rem;
+ background: rgba(5, 15, 30, 0.7);
+ color: rgba(200, 220, 255, 0.95);
+ transition: all 0.3s ease;
+ box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
+
+ &:focus {
+ outline: none;
+ border-color: rgba(0, 150, 255, 0.7);
+ box-shadow:
+ 0 0 15px rgba(0, 150, 255, 0.3),
+ inset 0 2px 8px rgba(0, 0, 0, 0.4);
+ background: rgba(5, 15, 30, 0.9);
+ }
+ }
+
+ select {
+ cursor: pointer;
+
+ option {
+ background: rgba(5, 15, 30, 0.95);
+ color: rgba(200, 220, 255, 0.95);
+ }
+ }
+
+ // Font preview in dropdowns
+ select#font-family,
+ select#font-family2 {
+ option[value="Lato"] {
+ font-family: "Lato", sans-serif;
+ }
+ option[value="Roboto"] {
+ font-family: "Roboto", sans-serif;
+ }
+ option[value="Open Sans"] {
+ font-family: "Open Sans", sans-serif;
+ }
+ option[value="Montserrat"] {
+ font-family: "Montserrat", sans-serif;
+ }
+ option[value="Oswald"] {
+ font-family: "Oswald", sans-serif;
+ }
+ option[value="Bebas Neue"] {
+ font-family: "Bebas Neue", display;
+ }
+ option[value="Roboto Mono"] {
+ font-family: "Roboto Mono", monospace;
+ }
+ option[value="VT323"] {
+ font-family: "VT323", monospace;
+ }
+ option[value="Press Start 2P"] {
+ font-family: "Press Start 2P", display;
+ }
+ }
+
+ input[type="range"] {
+ width: 100%;
+ margin-top: 0.25rem;
+ -webkit-appearance: none;
+ appearance: none;
+ background: rgba(0, 100, 180, 0.2);
+ border-radius: 4px;
+ height: 6px;
+ outline: none;
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 18px;
+ height: 18px;
+ background: linear-gradient(135deg, #0096ff, #66ccff);
+ cursor: pointer;
+ border-radius: 50%;
+ box-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
+ border: 2px solid rgba(0, 150, 255, 0.8);
+ }
+
+ &::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ background: linear-gradient(135deg, #0096ff, #66ccff);
+ cursor: pointer;
+ border-radius: 50%;
+ box-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
+ border: 2px solid rgba(0, 150, 255, 0.8);
+ }
+ }
+
+ input[type="color"] {
+ width: 100%;
+ height: 50px;
+ border: 1px solid rgba(0, 150, 255, 0.4);
+ border-radius: 4px;
+ cursor: pointer;
+ background: rgba(5, 15, 30, 0.7);
+ padding: 4px;
+ transition: all 0.3s ease;
+
+ &:hover {
+ border-color: rgba(0, 150, 255, 0.7);
+ box-shadow: 0 0 15px rgba(0, 150, 255, 0.3);
+ }
+ }
+
+ input[type="checkbox"] {
+ width: auto;
+ margin-left: 0.5rem;
+ cursor: pointer;
+ accent-color: #0096ff;
+ }
+ }
+
+ .control-group-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ margin: 0;
+ padding: 0.5rem 0;
+ border-bottom: 2px solid;
+ border-image: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(0, 150, 255, 0.5),
+ transparent
+ )
+ 1;
+ margin-bottom: 1rem;
+ color: rgba(100, 180, 255, 0.9);
+ font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ transition: all 0.3s ease;
+
+ &:hover {
+ color: #66ccff;
+ text-shadow: 0 0 10px rgba(0, 150, 255, 0.5);
+ }
+ }
+
+ .toggle-icon {
+ font-size: 1.5rem;
+ font-weight: bold;
+ transition: transform 0.3s ease;
+ color: rgba(0, 150, 255, 0.8);
+ }
+
+ .control-group.collapsed .toggle-icon {
+ transform: rotate(180deg);
+ }
+
+ .control-group-content {
+ overflow: hidden;
+ max-height: 2000px;
+ transition:
+ max-height 0.3s ease,
+ opacity 0.3s ease;
+ }
+
+ .control-group.collapsed .control-group-content {
+ max-height: 0;
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .info-text {
+ font-size: 0.85rem;
+ color: rgba(150, 200, 255, 0.7);
+ font-style: italic;
+ margin-bottom: 1rem;
+ padding: 0.5rem;
+ background: rgba(0, 100, 180, 0.1);
+ border-left: 3px solid rgba(0, 150, 255, 0.5);
+ border-radius: 2px;
+ }
+
+ h4 {
+ margin-top: 1.5rem;
+ margin-bottom: 0.75rem;
+ color: rgba(100, 180, 255, 0.85);
+ font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.9rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid rgba(0, 150, 255, 0.3);
+ }
+
+ .checkbox-row {
+ display: flex;
+ gap: 1.5rem;
+ margin-top: 0.75rem;
+ }
+
+ .checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-weight: 500;
+ font-size: 0.85rem;
+ cursor: pointer;
+ margin: 0;
+ color: rgba(100, 180, 255, 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ transition: color 0.3s ease;
+
+ &:hover {
+ color: #66ccff;
+ }
+
+ input[type="checkbox"] {
+ margin: 0;
+ cursor: pointer;
+ }
+
+ span {
+ user-select: none;
+ }
+ }
+
+ .btn-primary,
+ .btn-secondary {
+ width: 100%;
+ padding: 0.75rem 1.5rem;
+ font-size: 0.95rem;
+ font-weight: 600;
+ border: 1px solid rgba(0, 150, 255, 0.5);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(0, 150, 255, 0.3),
+ transparent
+ );
+ transition: left 0.5s ease;
+ }
+
+ &:hover::before {
+ left: 100%;
+ }
+ }
+
+ .btn-primary {
+ background: linear-gradient(
+ 135deg,
+ rgba(0, 120, 200, 0.3) 0%,
+ rgba(0, 100, 180, 0.2) 100%
+ );
+ color: #66ccff;
+
+ &:hover {
+ background: linear-gradient(
+ 135deg,
+ rgba(0, 150, 255, 0.4) 0%,
+ rgba(255, 100, 0, 0.3) 100%
+ );
+ border-color: rgba(0, 150, 255, 0.8);
+ box-shadow:
+ 0 0 20px rgba(0, 150, 255, 0.4),
+ 0 0 30px rgba(255, 120, 0, 0.2);
+ color: #fff;
+ text-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
+ transform: translateY(-2px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+
+ .btn-secondary {
+ background: linear-gradient(
+ 135deg,
+ rgba(0, 100, 180, 0.2) 0%,
+ rgba(0, 80, 150, 0.15) 100%
+ );
+ color: #66ccff;
+ margin-top: 0.5rem;
+
+ &:hover {
+ background: linear-gradient(
+ 135deg,
+ rgba(0, 120, 200, 0.3) 0%,
+ rgba(255, 100, 0, 0.2) 100%
+ );
+ border-color: rgba(0, 150, 255, 0.6);
+ transform: translateY(-2px);
+ box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
+ color: #fff;
+ text-shadow: 0 0 8px rgba(0, 150, 255, 0.5);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+}
diff --git a/assets/sass/pages/homepage.scss b/assets/sass/pages/homepage.scss
index 0aa1a23..e06bfd3 100644
--- a/assets/sass/pages/homepage.scss
+++ b/assets/sass/pages/homepage.scss
@@ -244,6 +244,7 @@
inset 0 1px 2px rgba(255, 255, 255, 0.1);
border-radius: 1em;
cursor: pointer;
+ position: relative;
&::after {
content: "Interests and Tools";
@@ -267,7 +268,7 @@
@include media-down(lg) {
margin-left: -0.5em;
opacity: 1;
- bottom: 0;
+ bottom: -40px;
font-size: 20px;
}
}
diff --git a/assets/sass/pages/resources.scss b/assets/sass/pages/resources.scss
index b7aa8a7..a44b2f3 100644
--- a/assets/sass/pages/resources.scss
+++ b/assets/sass/pages/resources.scss
@@ -84,7 +84,7 @@
max-width: 1400px;
margin: 0 auto;
position: relative;
- z-index: 1;
+ z-index: 10;
}
// Portal Header with portals on either side
@@ -97,7 +97,7 @@
position: relative;
.portal-title {
- font-size: 3.5rem;
+ font-size: 2.5rem;
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
font-weight: 700;
text-transform: uppercase;
@@ -108,11 +108,13 @@
0 0 40px rgba(255, 255, 255, 0.4),
0 2px 4px rgba(0, 0, 0, 0.3);
margin: 0;
+ margin-top: 50px;
position: relative;
z-index: 2;
@include media-up(md) {
font-size: 4rem;
+ margin-top: 0;
}
}
@@ -192,7 +194,7 @@
left: 5%;
bottom: 5%;
width: 150px;
- z-index: -1;
+ z-index: 1;
.heart-icon {
font-size: 2em;
@@ -257,6 +259,7 @@
.portal-sign {
position: relative;
padding: 3rem 2rem;
+ z-index: 10;
border: 10px solid black;
background: white;
@@ -479,6 +482,38 @@
}
}
+ &.button-generator {
+ background: #000;
+ height: 40%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ width: 90%;
+ height: 30%;
+ top: 15%;
+ left: 5%;
+ background: #fff;
+ animation: button 2s ease-in-out infinite;
+ }
+
+ &::after {
+ bottom: 15%;
+ top: auto;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 50%;
+ animation: button 1.2s ease-in-out infinite;
+ }
+ }
+
// Last.fm stats icon
&.lastfm-stats {
background: black;
@@ -503,13 +538,13 @@
&::before {
left: 20px;
height: 35px;
- animation: equalizer-1 0.8s ease-in-out infinite;
+ animation: equalizer-1 3s ease-in-out infinite;
}
&::after {
left: 35px;
height: 25px;
- animation: equalizer-2 0.8s ease-in-out infinite 0.2s;
+ animation: equalizer-2 1s ease-in-out infinite 0.2s;
}
}
}
@@ -524,6 +559,16 @@
}
}
+@keyframes button {
+ 0%,
+ 100% {
+ scale: 1;
+ }
+ 50% {
+ scale: 0.95;
+ }
+}
+
@keyframes equalizer-1 {
0%,
100% {
@@ -579,7 +624,8 @@
inset 0 0 100px rgba(0, 100, 200, 0.05),
0 8px 32px rgba(0, 0, 0, 0.4);
position: relative;
- overflow: hidden;
+ overflow: visible;
+ z-index: 10;
// Subtle tech panel grid pattern
&::before {
@@ -699,26 +745,26 @@
&::before {
content: "";
position: absolute;
- left: -4rem;
+ left: -2rem;
top: 50%;
transform: translateY(-50%);
width: 3rem;
height: 3px;
background: linear-gradient(90deg, transparent, #0096ff);
- box-shadow: 0 0 10px rgba(0, 150, 255, 0.8);
+ //box-shadow: 0 0 10px rgba(0, 150, 255, 0.8);
}
// Orange line on the right
&::after {
content: "";
position: absolute;
- right: -4rem;
+ right: -2rem;
top: 50%;
transform: translateY(-50%);
width: 3rem;
height: 3px;
background: linear-gradient(90deg, #ff7800, transparent);
- box-shadow: 0 0 10px rgba(255, 120, 0, 0.8);
+ //box-shadow: 0 0 10px rgba(255, 120, 0, 0.8);
}
@include media-up(md) {
@@ -726,15 +772,15 @@
&::before,
&::after {
- width: 5rem;
+ width: 6rem;
}
&::before {
- left: -6rem;
+ left: -2rem;
}
&::after {
- right: -6rem;
+ right: -2rem;
}
}
}
diff --git a/assets/sass/style.scss b/assets/sass/style.scss
index e165389..e5591e9 100644
--- a/assets/sass/style.scss
+++ b/assets/sass/style.scss
@@ -26,6 +26,7 @@
@import "pages/blog";
@import "pages/media";
@import "pages/resources";
+@import "pages/button-generator";
@import url(https://fonts.bunny.net/css?family=abel:400|barlow-condensed:400,500|caveat:400|lato:300,300i,400,400i|neonderthaw:400);
diff --git a/content/blog/2026-01-06-week-3-bit-chilly-out/index.md b/content/blog/2026-01-06-week-3-bit-chilly-out/index.md
index 0106157..f0dd967 100644
--- a/content/blog/2026-01-06-week-3-bit-chilly-out/index.md
+++ b/content/blog/2026-01-06-week-3-bit-chilly-out/index.md
@@ -9,6 +9,8 @@ draft: true
- ❄️ It's been pretty cold and we've had the lightest sprinkling of snow.
- 📱 I did a mobile pass on some of the pages on this website, it works a bit better on phones now.
+- 🟠 Added the [resources](/resources) section with a script to pull weekly last.fm stats (see example output below!)
+- 🆒 Started working on an [88x31 button creator](/resources/button-generator/), it's got a decent amount of functionality so far.
## Links I Found Interesting
diff --git a/content/media/taskmaster-champion-of-champions-4/canvas.png b/content/media/taskmaster-champion-of-champions-4/canvas.png
new file mode 100644
index 0000000..a96b8e7
Binary files /dev/null and b/content/media/taskmaster-champion-of-champions-4/canvas.png differ
diff --git a/content/resources/button-generator/index.md b/content/resources/button-generator/index.md
new file mode 100644
index 0000000..9f5d5d2
--- /dev/null
+++ b/content/resources/button-generator/index.md
@@ -0,0 +1,23 @@
+---
+title: "88x31 Button Creator"
+date: 2026-01-08
+description: "Make custom 88x31 pixel buttons with text, colors, gradients, and textures"
+icon: "button-generator"
+demo_url: ""
+source_url: ""
+draft: false
+---
+
+Welcome to my 88x31 button creator, this is a pretty rough and ready implementation so it could be buggy, please let me know if you find any issues.
+
+This supports gif despite the basic `canvas` tag limitation courtesy of [gif.js](https://github.com/jnordberg/gif.js) - none of this would be possible without that project.
+
+Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x31) and everyone who made the buttons that appear there. You should check it out if you need inspiration for your button!
+
+{{< button-generator >}}
+
+---
+
+### Changelog
+
+- 08/01/2025 - Initial release.
diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html
index 5546f2c..0ac2b0f 100755
--- a/layouts/_default/baseof.html
+++ b/layouts/_default/baseof.html
@@ -33,5 +33,12 @@
{{ block "main" . }}{{ end }}
{{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }} {{ block
"scripts" . }}{{ partial "site-scripts.html" . }}{{ end }}
+
+
+ {{ if or (findRE "{{<\\s*button-generator" .RawContent) (findRE "{{%\\s*button-generator" .RawContent) }}
+
+ {{ $buttonGenerator := resources.Get "js/button-generator.js" | resources.Minify | resources.Fingerprint }}
+
+ {{ end }}