+
Recent Media
{{ range .Paginator.Pages }}
-
-
-
{{ end }}
diff --git a/layouts/partials/contact-section.html b/layouts/partials/contact-section.html
new file mode 100644
index 0000000..76797c6
--- /dev/null
+++ b/layouts/partials/contact-section.html
@@ -0,0 +1,27 @@
+
diff --git a/layouts/partials/elements/companion-cube.html b/layouts/partials/elements/companion-cube.html
new file mode 100644
index 0000000..0e6327a
--- /dev/null
+++ b/layouts/partials/elements/companion-cube.html
@@ -0,0 +1,41 @@
+
diff --git a/layouts/partials/site-header.html b/layouts/partials/site-header.html
index e8b6cc4..72ff1f1 100755
--- a/layouts/partials/site-header.html
+++ b/layouts/partials/site-header.html
@@ -1,15 +1,3 @@
\ No newline at end of file
+
{{ partial "site-navigation.html" . }}
+
diff --git a/layouts/partials/site-scripts.html b/layouts/partials/site-scripts.html
index 5f3690d..f61de47 100644
--- a/layouts/partials/site-scripts.html
+++ b/layouts/partials/site-scripts.html
@@ -7,11 +7,18 @@
{{ $filtered := slice }}
{{ range $remaining }}
{{ $path := .RelPermalink }}
- {{ if and (not (strings.Contains $path "/terminal.js")) (not (strings.Contains $path "/init.js")) }}
+ {{ if and (not (strings.Contains $path "/terminal.js")) (not (strings.Contains $path "/init.js")) (not (strings.Contains $path "/button-generator.js")) (not (strings.Contains $path "/adoptables/")) (not (strings.Contains $path "/guestbook.js")) }}
{{ $filtered = $filtered | append . }}
{{ end }}
{{ end }}
-{{ $allFiles := slice $terminalShell | append $filtered | append $init | append $commandFiles | append $subfolderFiles }}
+{{ $filteredSubfolders := slice }}
+{{ range $subfolderFiles }}
+ {{ $path := .RelPermalink }}
+ {{ if not (strings.Contains $path "/adoptables/") }}
+ {{ $filteredSubfolders = $filteredSubfolders | append . }}
+ {{ end }}
+{{ end }}
+{{ $allFiles := slice $terminalShell | append $filtered | append $init | append $commandFiles | append $filteredSubfolders }}
{{ $terminalBundle := $allFiles | resources.Concat "js/terminal-bundle.js" | resources.Minify | resources.Fingerprint }}
diff --git a/layouts/shortcodes/lastfm-stats-form.html b/layouts/shortcodes/lastfm-stats-form.html
new file mode 100644
index 0000000..65dd0f8
--- /dev/null
+++ b/layouts/shortcodes/lastfm-stats-form.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
Loading your stats...
+
+
+
+
Your Last.fm Stats
+
+
+
+
+
+
+
+ Copy as Markdown
+ Copy as Plain Text
+
+
+
+
+
diff --git a/layouts/shortcodes/lavalamp-adoptable.html b/layouts/shortcodes/lavalamp-adoptable.html
new file mode 100644
index 0000000..c256317
--- /dev/null
+++ b/layouts/shortcodes/lavalamp-adoptable.html
@@ -0,0 +1,124 @@
+
+
+
+
+
Embed Code
+
Copy this code and paste it anywhere on your website:
+
+
+
diff --git a/send-webmentions.sh b/send-webmentions.sh
new file mode 100755
index 0000000..f5ba12e
--- /dev/null
+++ b/send-webmentions.sh
@@ -0,0 +1,96 @@
+#!/bin/bash
+
+# Load environment variables from .env file
+if [ -f .env ]; then
+ export $(cat .env | grep -v '^#' | xargs)
+else
+ echo "⚠️ Warning: .env file not found, using defaults"
+fi
+
+# Use environment variables with fallback defaults
+WEBMENTIONS_FILE="${WEBMENTIONS_FILE:-public/webmentions.json}"
+SENT_CACHE="${SENT_CACHE:-.webmentions-sent}"
+API_ENDPOINT="${API_ENDPOINT:-https://api.ritual.sh/webmention/send}"
+API_KEY="${API_KEY:-your-secret-key}"
+
+# Check for dry-run flag
+DRY_RUN=false
+if [ "$1" = "--dry-run" ] || [ "$1" = "-n" ]; then
+ DRY_RUN=true
+ echo "🔍 DRY RUN MODE - No webmentions will be sent"
+ echo "================================================"
+fi
+
+# Create cache file if it doesn't exist
+touch "$SENT_CACHE"
+
+# Read the webmentions JSON
+if [ ! -f "$WEBMENTIONS_FILE" ]; then
+ echo "No webmentions.json found"
+ exit 0
+fi
+
+# Count totals
+TOTAL=0
+ALREADY_SENT=0
+TO_SEND=0
+
+# Process each link
+jq -c '.[]' "$WEBMENTIONS_FILE" | while read -r mention; do
+ source=$(echo "$mention" | jq -r '.source')
+ target=$(echo "$mention" | jq -r '.target')
+
+ TOTAL=$((TOTAL + 1))
+
+ # Create unique key for this source->target pair
+ key="${source}|${target}"
+
+ # Check if already sent
+ if grep -Fxq "$key" "$SENT_CACHE"; then
+ if [ "$DRY_RUN" = true ]; then
+ echo "⏭️ Already sent: $source -> $target"
+ else
+ echo "Already sent: $source -> $target"
+ fi
+ ALREADY_SENT=$((ALREADY_SENT + 1))
+ continue
+ fi
+
+ TO_SEND=$((TO_SEND + 1))
+
+ if [ "$DRY_RUN" = true ]; then
+ echo "📤 Would send: $source -> $target"
+ else
+ echo "Sending webmention: $source -> $target"
+
+ # Send to your API
+ response=$(curl -s -w "\n%{http_code}" -X POST "$API_ENDPOINT" \
+ -H "Content-Type: application/json" \
+ -d "{\"auth\":\"$API_KEY\",\"source\":\"$source\",\"target\":\"$target\"}")
+
+ http_code=$(echo "$response" | tail -n1)
+
+ # If successful (200, 201, or 202), add to cache
+ if [ "$http_code" = "200" ] || [ "$http_code" = "201" ] || [ "$http_code" = "202" ]; then
+ echo "$key" >> "$SENT_CACHE"
+ echo "✓ Sent successfully"
+ else
+ echo "✗ Failed with status $http_code"
+ fi
+
+ # Be nice to endpoints - don't spam
+ sleep 1
+ fi
+done
+
+# Summary
+echo ""
+echo "================================================"
+if [ "$DRY_RUN" = true ]; then
+ echo "🔍 DRY RUN SUMMARY"
+else
+ echo "✅ Webmentions processing complete"
+fi
+echo "Total links found: $TOTAL"
+echo "Already sent: $ALREADY_SENT"
+echo "To send: $TO_SEND"
\ No newline at end of file
diff --git a/static/js/adoptables/lavalamp.js b/static/js/adoptables/lavalamp.js
new file mode 100644
index 0000000..6c55277
--- /dev/null
+++ b/static/js/adoptables/lavalamp.js
@@ -0,0 +1,364 @@
+(function () {
+ "use strict";
+
+ const currentScript = document.currentScript;
+ if (!currentScript) {
+ console.error("Lava Lamp: Could not find current script tag");
+ return;
+ }
+
+ // Read configuration from data attributes with defaults
+ const config = {
+ bgColor1: currentScript.dataset["bgColor-1"] || "#F8E45C",
+ bgColor2: currentScript.dataset["bgColor-2"] || "#FF7800",
+ blobColor1: currentScript.dataset["blobColor-1"] || "#FF4500",
+ blobColor2: currentScript.dataset["blobColor-2"] || "#FF6347",
+ caseColor: currentScript.dataset.caseColor || "#333333",
+ blobCount: parseInt(currentScript.dataset.blobCount) || 6,
+ speed: parseFloat(currentScript.dataset.speed) || 1.0,
+ blobSize: parseFloat(currentScript.dataset.blobSize) || 1.0,
+ pixelate: currentScript.dataset.pixelate === "true" || false,
+ pixelSize: parseInt(currentScript.dataset.pixelSize) || 4,
+ };
+
+ // Helper function to adjust color brightness
+ function adjustBrightness(color, percent) {
+ const num = parseInt(color.replace("#", ""), 16);
+ const amt = Math.round(2.55 * percent);
+ const R = (num >> 16) + amt;
+ const G = ((num >> 8) & 0x00ff) + amt;
+ const B = (num & 0x0000ff) + amt;
+ return (
+ "#" +
+ (
+ 0x1000000 +
+ (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
+ (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
+ (B < 255 ? (B < 1 ? 0 : B) : 255)
+ )
+ .toString(16)
+ .slice(1)
+ );
+ }
+
+ // Create a host element with shadow DOM for isolation
+ const host = document.createElement("div");
+ host.style.width = "100%";
+ host.style.height = "100%";
+ host.style.display = "block";
+
+ if (config.pixelate) {
+ host.style.overflow = "hidden";
+ }
+
+ // Attach shadow DOM
+ const shadowRoot = host.attachShadow({ mode: "open" });
+
+ currentScript.parentNode.insertBefore(host, currentScript.nextSibling);
+
+ const gooFilterId = "goo-filter";
+
+ // Inject CSS into shadow DOM
+ const style = document.createElement("style");
+ style.textContent = `
+ .lavalamp-container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: relative;
+ ${config.pixelate ? "filter: url(#pixelate-filter);" : ""}
+ }
+
+ .lamp-cap {
+ width: 60%;
+ height: 8%;
+ flex-shrink: 0;
+ border-radius: 50% 50% 0 0;
+ box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3);
+ position: relative;
+ z-index: 10;
+ }
+
+ .lamp-body {
+ position: relative;
+ width: 100%;
+ flex: 1;
+ clip-path: polygon(20% 0, 80% 0, 100% 101%, 0% 101%);
+ overflow: hidden;
+ }
+
+ .lamp-body::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0.15) 20%,
+ rgba(255, 255, 255, 0.05) 40%,
+ transparent 60%
+ );
+ pointer-events: none;
+ }
+
+ .blobs-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ filter: url(#${gooFilterId});
+ pointer-events: none;
+ z-index: 2;
+ }
+
+ .blob {
+ position: absolute;
+ border-radius: 50%;
+ animation: lavalamp-float var(--duration) ease-in-out infinite;
+ opacity: 0.95;
+ mix-blend-mode: normal;
+ z-index: 3;
+ }
+
+ .lamp-base {
+ width: 100%;
+ height: 15%;
+ flex-shrink: 0;
+ border-radius: 0 0 50% 50%;
+ box-shadow:
+ inset 0 2px 5px rgba(255, 255, 255, 0.2),
+ inset 0 -2px 5px rgba(0, 0, 0, 0.5);
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ @keyframes lavalamp-float {
+ 0%, 100% {
+ transform: translate(var(--start-x), var(--start-y)) scale(1);
+ }
+ 25% {
+ transform: translate(var(--mid1-x), var(--mid1-y)) scale(var(--scale1, 1.1));
+ }
+ 50% {
+ transform: translate(var(--mid2-x), var(--mid2-y)) scale(var(--scale2, 0.9));
+ }
+ 75% {
+ transform: translate(var(--mid3-x), var(--mid3-y)) scale(var(--scale3, 1.05));
+ }
+ }
+ `;
+ shadowRoot.appendChild(style);
+
+ // Create the HTML structure
+ const container = document.createElement("div");
+ container.className = "lavalamp-container";
+
+ // SVG filters for goo effect and pixelation
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.style.position = "absolute";
+ svg.style.width = "0";
+ svg.style.height = "0";
+ svg.innerHTML = `
+
+
+
+
+
+
+ ${
+ config.pixelate
+ ? `
+
+
+
+
+
+
+
+ `
+ : ""
+ }
+
+ `;
+
+ const lampCap = document.createElement("div");
+ lampCap.className = "lamp-cap";
+ lampCap.style.background = `linear-gradient(180deg, ${adjustBrightness(config.caseColor, 40)} 0%, ${config.caseColor} 50%, ${adjustBrightness(config.caseColor, -20)} 100%)`;
+
+ const lampBody = document.createElement("div");
+ lampBody.className = "lamp-body";
+ lampBody.style.background = `linear-gradient(180deg, ${config.bgColor1} 0%, ${config.bgColor2} 100%)`;
+
+ const blobsContainer = document.createElement("div");
+ blobsContainer.className = "blobs-container";
+
+ const lampBase = document.createElement("div");
+ lampBase.className = "lamp-base";
+ lampBase.style.background = `linear-gradient(180deg, ${config.caseColor} 0%, ${adjustBrightness(config.caseColor, -20)} 40%, ${adjustBrightness(config.caseColor, -40)} 100%)`;
+ lampBase.style.borderTop = `1px solid ${adjustBrightness(config.bgColor2, -30)}`;
+
+ // Assemble the structure
+ lampBody.appendChild(blobsContainer);
+ container.appendChild(svg);
+ container.appendChild(lampCap);
+ container.appendChild(lampBody);
+ container.appendChild(lampBase);
+
+ // Append to shadow DOM
+ shadowRoot.appendChild(container);
+
+ // Blob creation and animation
+ let blobs = [];
+
+ function createBlob() {
+ const blob = document.createElement("div");
+ blob.className = "blob";
+
+ // Get container dimensions
+ const containerWidth = lampBody.offsetWidth;
+ const containerHeight = lampBody.offsetHeight;
+
+ // Size relative to container width (25-40% of width)
+ const size =
+ (Math.random() * 0.15 + 0.25) * containerWidth * config.blobSize;
+ const duration = (Math.random() * 8 + 12) / config.speed;
+
+ // Max X position accounting for blob size and container margins
+ const maxX = Math.max(0, containerWidth - size);
+ const startX = maxX > 0 ? Math.random() * maxX : 0;
+
+ // Create gradient for blob
+ const blobGradient = `radial-gradient(circle at 30% 30%, ${config.blobColor1}, ${config.blobColor2})`;
+
+ blob.style.width = `${size}px`;
+ blob.style.height = `${size}px`;
+ blob.style.left = `${startX}px`;
+ blob.style.bottom = "10px";
+ blob.style.position = "absolute";
+ blob.style.background = blobGradient;
+ blob.style.setProperty("--duration", `${duration}s`);
+ blob.style.setProperty("--start-x", "0px");
+ blob.style.setProperty("--start-y", "0px");
+
+ // Movement waypoints
+ blob.style.setProperty(
+ "--mid1-x",
+ `${(Math.random() * 0.3 - 0.15) * containerWidth}px`,
+ );
+ blob.style.setProperty(
+ "--mid1-y",
+ `${-(Math.random() * 0.15 + 0.25) * containerHeight}px`,
+ );
+
+ blob.style.setProperty(
+ "--mid2-x",
+ `${(Math.random() * 0.4 - 0.2) * containerWidth}px`,
+ );
+ blob.style.setProperty(
+ "--mid2-y",
+ `${-(Math.random() * 0.2 + 0.5) * containerHeight}px`,
+ );
+
+ blob.style.setProperty(
+ "--mid3-x",
+ `${(Math.random() * 0.3 - 0.15) * containerWidth}px`,
+ );
+ blob.style.setProperty(
+ "--mid3-y",
+ `${-(Math.random() * 0.15 + 0.8) * containerHeight}px`,
+ );
+
+ // Scale variations
+ blob.style.setProperty("--scale1", (Math.random() * 0.3 + 1.0).toFixed(2));
+ blob.style.setProperty("--scale2", (Math.random() * 0.3 + 0.85).toFixed(2));
+ blob.style.setProperty("--scale3", (Math.random() * 0.3 + 0.95).toFixed(2));
+
+ // Random delay to stagger animations
+ blob.style.animationDelay = `${Math.random() * -20}s`;
+
+ return blob;
+ }
+
+ function updateBlobCount() {
+ while (blobs.length > config.blobCount) {
+ const blob = blobs.pop();
+ blobsContainer.removeChild(blob);
+ }
+ while (blobs.length < config.blobCount) {
+ const blob = createBlob();
+ blobs.push(blob);
+ blobsContainer.appendChild(blob);
+ }
+ }
+
+ // Initialize
+ function init() {
+ // Wait for container to have dimensions
+ // Not sure if this is a great idea, what if this never happens for some reason
+ // This is as good as I can come up with right now
+ if (lampBody.offsetWidth === 0 || lampBody.offsetHeight === 0) {
+ setTimeout(init, 100);
+ return;
+ }
+
+ // Scale goo filter blur based on container width
+ const containerWidth = lampBody.offsetWidth;
+
+ // Apply scaled goo filter
+ let blurAmount, alphaMultiplier, alphaBias;
+
+ if (containerWidth < 80) {
+ blurAmount = 3;
+ alphaMultiplier = 12;
+ alphaBias = -5;
+ } else {
+ blurAmount = Math.max(4, Math.min(7, containerWidth / 20));
+ alphaMultiplier = 18;
+ alphaBias = -7;
+ }
+
+ const gooFilter = svg.querySelector(`#${gooFilterId} feGaussianBlur`);
+ if (gooFilter) {
+ gooFilter.setAttribute("stdDeviation", blurAmount);
+ }
+
+ const colorMatrix = svg.querySelector(`#${gooFilterId} feColorMatrix`);
+ if (colorMatrix) {
+ colorMatrix.setAttribute(
+ "values",
+ `
+ 1 0 0 0 0
+ 0 1 0 0 0
+ 0 0 1 0 0
+ 0 0 0 ${alphaMultiplier} ${alphaBias}
+ `,
+ );
+ }
+
+ updateBlobCount();
+ }
+
+ // Start when DOM is ready
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})();
diff --git a/static/js/button-generator/button-generator-core.js b/static/js/button-generator/button-generator-core.js
new file mode 100644
index 0000000..292bc4f
--- /dev/null
+++ b/static/js/button-generator/button-generator-core.js
@@ -0,0 +1,434 @@
+import { ButtonEffect } from './effect-base.js';
+
+import { ColorQuantizer } from './color-quantizer.js';
+
+/**
+ * Animation state class - passed to effects for frame-based rendering
+ */
+export class AnimationState {
+ constructor(frameNumber = 0, totalFrames = 40, fps = 20) {
+ this.frame = frameNumber;
+ this.totalFrames = totalFrames;
+ this.progress = totalFrames > 1 ? frameNumber / (totalFrames - 1) : 0; // 0 to 1, inclusive of last frame
+ this.fps = fps;
+ this.time = (frameNumber / fps) * 1000; // milliseconds
+ }
+
+ /**
+ * Helper to get phase for periodic animations (0 to 2π)
+ * @param {number} speed - Speed multiplier
+ * @returns {number} Phase in radians
+ */
+ getPhase(speed = 1.0) {
+ return this.progress * speed * Math.PI * 2;
+ }
+}
+
+/**
+ * Main ButtonGenerator class with effect registry system
+ */
+export class ButtonGenerator {
+ constructor(canvas, config = {}) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+
+ // Animation configuration
+ this.animConfig = {
+ fps: config.fps || 20,
+ duration: config.duration || 2, // seconds
+ get totalFrames() {
+ return this.fps * this.duration;
+ }
+ };
+
+ // GIF export configuration
+ this.gifConfig = {
+ quality: config.gifQuality || 1, // 1-30, lower is better quality quantization
+ dither: config.gifDither || false, // false, 'FloydSteinberg', 'FalseFloydSteinberg', 'Stucki', 'Atkinson'
+ colorCount: config.gifColorCount || 256, // 2-256, number of colors to reduce to (custom quantization)
+ };
+
+ // Effect registry organized by type
+ this.effects = {
+ transform: [],
+ background: [],
+ border: [],
+ text: [],
+ text2: [],
+ general: []
+ };
+
+ // Registered effects by ID for quick lookup
+ this.effectsById = new Map();
+
+ // Control elements cache
+ this.controlElements = {};
+
+ // Animation state
+ this.previewAnimationId = null;
+
+ // Font list for preloading
+ this.fonts = config.fonts || [
+ 'Lato', 'Roboto', 'Open Sans', 'Montserrat', 'Oswald',
+ 'Bebas Neue', 'Roboto Mono', 'VT323', 'Press Start 2P', 'DSEG7-Classic'
+ ];
+ }
+
+ /**
+ * Register an effect with the generator
+ * @param {ButtonEffect} effect - Effect instance to register
+ */
+ registerEffect(effect) {
+ if (!(effect instanceof ButtonEffect)) {
+ throw new Error('Effect must extend ButtonEffect class');
+ }
+
+ if (this.effectsById.has(effect.id)) {
+ console.warn(`Effect with ID "${effect.id}" is already registered. Skipping.`);
+ return;
+ }
+
+ // Add to type-specific array
+ const type = effect.type;
+ if (!this.effects[type]) {
+ this.effects[type] = [];
+ }
+
+ this.effects[type].push(effect);
+ this.effectsById.set(effect.id, effect);
+
+ // Sort by render order
+ this.effects[type].sort((a, b) => a.renderOrder - b.renderOrder);
+
+ console.log(`Registered effect: ${effect.name} (${effect.id}) [${type}]`);
+ }
+
+ /**
+ * Get all registered effects
+ * @returns {Array
}
+ */
+ getAllEffects() {
+ return Array.from(this.effectsById.values());
+ }
+
+ /**
+ * Get effects by type
+ * @param {string} type - Effect type
+ * @returns {Array}
+ */
+ getEffectsByType(type) {
+ return this.effects[type] || [];
+ }
+
+ /**
+ * Initialize and preload fonts
+ * @returns {Promise}
+ */
+ async preloadFonts() {
+ const fontPromises = this.fonts.flatMap(font => [
+ document.fonts.load(`400 12px "${font}"`),
+ document.fonts.load(`700 12px "${font}"`),
+ document.fonts.load(`italic 400 12px "${font}"`)
+ ]);
+
+ await Promise.all(fontPromises);
+ console.log('All fonts loaded for canvas');
+ }
+
+ /**
+ * Get current control values from DOM
+ * @returns {Object} Map of control ID to value
+ */
+ getControlValues() {
+ const values = {};
+
+ // Get all registered control IDs from effects
+ const allControls = new Set();
+ this.getAllEffects().forEach(effect => {
+ effect.controls.forEach(control => {
+ allControls.add(control.id);
+ });
+ });
+
+ // Read values from DOM
+ allControls.forEach(id => {
+ const element = document.getElementById(id);
+ if (element) {
+ if (element.type === 'checkbox') {
+ values[id] = element.checked;
+ } else if (element.type === 'range' || element.type === 'number') {
+ values[id] = parseFloat(element.value);
+ } else if (element.type === 'file') {
+ // For file inputs, return an object with file metadata and blob URL
+ if (element.dataset.blobUrl) {
+ values[id] = {
+ fileName: element.dataset.fileName,
+ blobUrl: element.dataset.blobUrl,
+ fileSize: parseInt(element.dataset.fileSize),
+ fileType: element.dataset.fileType
+ };
+ } else {
+ values[id] = null;
+ }
+ } else {
+ values[id] = element.value;
+ }
+ }
+ });
+
+ return values;
+ }
+
+ /**
+ * Draw button with all effects applied
+ * @param {AnimationState} animState - Animation state (null for static)
+ * @param {Object} baseControls - Base button controls (text, colors, etc.)
+ */
+ draw(animState = null, baseControls = {}) {
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ const controlValues = { ...baseControls, ...this.getControlValues() };
+ const renderData = {
+ width: this.canvas.width,
+ height: this.canvas.height,
+ centerX: this.canvas.width / 2,
+ centerY: this.canvas.height / 2
+ };
+
+ // Apply effects in order: transform -> background -> background-animation -> text/text2 -> border -> general
+ const renderOrder = ['transform', 'background', 'background-animation', 'text', 'text2', 'border', 'general'];
+
+ // Save context once before transforms
+ this.ctx.save();
+
+ renderOrder.forEach(type => {
+ this.effects[type]?.forEach(effect => {
+ if (effect.canApply(controlValues)) {
+ // Transform effects should NOT be wrapped in save/restore
+ // They need to persist for all subsequent drawing operations
+ if (type !== 'transform') {
+ this.ctx.save();
+ }
+
+ try {
+ effect.apply(this.ctx, controlValues, animState, renderData);
+ } catch (error) {
+ console.error(`Error applying effect ${effect.id}:`, error);
+ }
+
+ if (type !== 'transform') {
+ this.ctx.restore();
+ }
+ }
+ });
+ });
+
+ // Restore context once after all drawing
+ this.ctx.restore();
+ }
+
+ /**
+ * Check if any animations are enabled
+ * @returns {boolean}
+ */
+ hasAnimationsEnabled() {
+ const controlValues = this.getControlValues();
+ return this.getAllEffects().some(effect =>
+ effect.type !== 'background' && // Background effects can be static
+ effect.isEnabled(controlValues)
+ );
+ }
+
+ /**
+ * Start animated preview loop
+ */
+ startAnimatedPreview() {
+ this.stopAnimatedPreview();
+
+ let frameNum = 0;
+ let lastFrameTime = performance.now();
+ const frameDelay = 1000 / this.animConfig.fps;
+
+ const animate = (currentTime) => {
+ const elapsed = currentTime - lastFrameTime;
+
+ if (elapsed >= frameDelay) {
+ const animState = new AnimationState(
+ frameNum,
+ this.animConfig.totalFrames,
+ this.animConfig.fps
+ );
+ this.draw(animState);
+ this.applyPreviewQuantization();
+
+ frameNum = (frameNum + 1) % this.animConfig.totalFrames;
+ lastFrameTime = currentTime - (elapsed % frameDelay);
+ }
+
+ this.previewAnimationId = requestAnimationFrame(animate);
+ };
+
+ this.previewAnimationId = requestAnimationFrame(animate);
+ }
+
+ /**
+ * Stop animated preview
+ */
+ stopAnimatedPreview() {
+ if (this.previewAnimationId) {
+ cancelAnimationFrame(this.previewAnimationId);
+ this.previewAnimationId = null;
+ }
+ }
+
+ /**
+ * Update preview (static or animated based on settings)
+ */
+ updatePreview() {
+ if (this.hasAnimationsEnabled()) {
+ this.startAnimatedPreview();
+ } else {
+ this.stopAnimatedPreview();
+ this.draw();
+ this.applyPreviewQuantization();
+ }
+ }
+
+ /**
+ * Apply color quantization to preview if enabled
+ */
+ applyPreviewQuantization() {
+ const colorCount = this.gifConfig.colorCount;
+ if (colorCount < 256) {
+ const quantizedData = ColorQuantizer.quantize(this.canvas, colorCount, 'floyd-steinberg');
+ this.ctx.putImageData(quantizedData, 0, 0);
+ }
+ }
+
+ /**
+ * Export as animated GIF
+ * @param {Function} progressCallback - Called with progress (0-1)
+ * @param {Object} options - Export options
+ * @param {number} options.quality - Quality (1-30, lower is better, default: 10)
+ * @param {boolean|string} options.dither - Dithering algorithm for gif.js
+ * @param {number} options.colorCount - Number of colors (2-256, default: 256) - uses custom quantization
+ * @returns {Promise}
+ */
+ async exportAsGif(progressCallback = null, options = {}) {
+ return new Promise((resolve, reject) => {
+ try {
+ // Create temporary canvas for frame generation
+ const frameCanvas = document.createElement('canvas');
+ frameCanvas.width = this.canvas.width;
+ frameCanvas.height = this.canvas.height;
+ const frameCtx = frameCanvas.getContext('2d');
+
+ // Merge options with defaults
+ const quality = options.quality !== undefined ? options.quality : this.gifConfig.quality;
+ const gifDither = options.dither !== undefined ? options.dither : this.gifConfig.dither;
+ const colorCount = options.colorCount !== undefined ? options.colorCount : this.gifConfig.colorCount;
+
+ // Determine if we need custom quantization
+ const useCustomQuantization = colorCount < 256;
+ const customDither = useCustomQuantization ? 'floyd-steinberg' : false;
+
+ // Initialize gif.js
+ const gifOptions = {
+ workers: 2,
+ quality: quality,
+ workerScript: '/js/gif.worker.js',
+ width: this.canvas.width,
+ height: this.canvas.height
+ };
+
+ // Add gif.js dither option if specified (only when not using custom quantization)
+ if (!useCustomQuantization && gifDither !== false) {
+ gifOptions.dither = gifDither;
+ }
+
+ const gif = new GIF(gifOptions);
+
+ // Generate frames
+ const totalFrames = this.animConfig.totalFrames;
+
+ const generateFrames = async () => {
+ for (let i = 0; i < totalFrames; i++) {
+ const animState = new AnimationState(i, totalFrames, this.animConfig.fps);
+
+ // Draw to temporary canvas
+ frameCtx.clearRect(0, 0, frameCanvas.width, frameCanvas.height);
+ const tempGenerator = new ButtonGenerator(frameCanvas, {
+ fps: this.animConfig.fps,
+ duration: this.animConfig.duration,
+ fonts: this.fonts
+ });
+
+ // Copy effects
+ this.getAllEffects().forEach(effect => {
+ tempGenerator.registerEffect(effect);
+ });
+
+ tempGenerator.draw(animState);
+
+ // Apply custom color quantization if needed
+ if (useCustomQuantization) {
+ const quantizedData = ColorQuantizer.quantize(frameCanvas, colorCount, customDither);
+ frameCtx.putImageData(quantizedData, 0, 0);
+ }
+
+ gif.addFrame(frameCtx, {
+ delay: 1000 / this.animConfig.fps,
+ copy: true
+ });
+
+ if (progressCallback) {
+ progressCallback(i / totalFrames, 'generating');
+ }
+
+ // Yield to browser every 5 frames
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+ };
+
+ generateFrames().then(() => {
+ gif.on('finished', (blob) => {
+ resolve(blob);
+ });
+
+ gif.on('progress', (progress) => {
+ if (progressCallback) {
+ progressCallback(progress, 'encoding');
+ }
+ });
+
+ gif.render();
+ });
+
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+
+ /**
+ * Bind UI controls to redraw on change
+ */
+ bindControls() {
+ const allControls = new Set();
+ this.getAllEffects().forEach(effect => {
+ effect.controls.forEach(control => {
+ allControls.add(control.id);
+ });
+ });
+
+ allControls.forEach(id => {
+ const element = document.getElementById(id);
+ if (element) {
+ element.addEventListener('input', () => this.updatePreview());
+ element.addEventListener('change', () => this.updatePreview());
+ }
+ });
+ }
+}
diff --git a/static/js/button-generator/color-quantizer.js b/static/js/button-generator/color-quantizer.js
new file mode 100644
index 0000000..f2343ba
--- /dev/null
+++ b/static/js/button-generator/color-quantizer.js
@@ -0,0 +1,263 @@
+/**
+ * Color Quantizer - Custom color reduction with median cut algorithm
+ * Reduces canvas colors with optional dithering for retro aesthetic
+ */
+
+export class ColorQuantizer {
+ /**
+ * Reduce colors in a canvas using median cut algorithm
+ * @param {HTMLCanvasElement} canvas - Canvas to quantize
+ * @param {number} colorCount - Target number of colors (2-256)
+ * @param {string|boolean} dither - Dithering algorithm ('floyd-steinberg', false)
+ * @returns {ImageData} Quantized image data
+ */
+ static quantize(canvas, colorCount = 256, dither = false) {
+ const ctx = canvas.getContext('2d');
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const pixels = imageData.data;
+
+ if (colorCount >= 256) {
+ return imageData; // No quantization needed
+ }
+
+ // Build palette using median cut
+ const palette = this.buildPalette(pixels, colorCount);
+
+ // Apply palette to image
+ if (dither === 'floyd-steinberg') {
+ this.applyPaletteWithDithering(pixels, palette, canvas.width, canvas.height);
+ } else {
+ this.applyPalette(pixels, palette);
+ }
+
+ return imageData;
+ }
+
+ /**
+ * Build color palette using median cut algorithm
+ */
+ static buildPalette(pixels, colorCount) {
+ // Collect unique colors
+ const colorMap = new Map();
+ for (let i = 0; i < pixels.length; i += 4) {
+ const r = pixels[i];
+ const g = pixels[i + 1];
+ const b = pixels[i + 2];
+ const a = pixels[i + 3];
+
+ if (a === 0) continue; // Skip transparent pixels
+
+ const key = (r << 16) | (g << 8) | b;
+ colorMap.set(key, (colorMap.get(key) || 0) + 1);
+ }
+
+ // Convert to array of color objects with counts
+ const colors = Array.from(colorMap.entries()).map(([key, count]) => ({
+ r: (key >> 16) & 0xFF,
+ g: (key >> 8) & 0xFF,
+ b: key & 0xFF,
+ count: count
+ }));
+
+ // If we have fewer colors than target, return as-is
+ if (colors.length <= colorCount) {
+ return colors.map(c => [c.r, c.g, c.b]);
+ }
+
+ // Start with all colors in one bucket
+ let buckets = [colors];
+
+ // Split buckets until we have desired number
+ while (buckets.length < colorCount) {
+ // Find bucket with largest range
+ let maxRange = -1;
+ let maxBucketIdx = 0;
+ let maxChannel = 'r';
+
+ buckets.forEach((bucket, idx) => {
+ if (bucket.length <= 1) return;
+
+ const ranges = this.getColorRanges(bucket);
+ const range = Math.max(ranges.r, ranges.g, ranges.b);
+
+ if (range > maxRange) {
+ maxRange = range;
+ maxBucketIdx = idx;
+ if (ranges.r >= ranges.g && ranges.r >= ranges.b) maxChannel = 'r';
+ else if (ranges.g >= ranges.b) maxChannel = 'g';
+ else maxChannel = 'b';
+ }
+ });
+
+ if (maxRange === -1) break; // Can't split further
+
+ // Split the bucket
+ const bucket = buckets[maxBucketIdx];
+ bucket.sort((a, b) => a[maxChannel] - b[maxChannel]);
+
+ const mid = Math.floor(bucket.length / 2);
+ const bucket1 = bucket.slice(0, mid);
+ const bucket2 = bucket.slice(mid);
+
+ buckets.splice(maxBucketIdx, 1, bucket1, bucket2);
+ }
+
+ // Average colors in each bucket to create palette
+ return buckets.map(bucket => {
+ let totalWeight = 0;
+ let sumR = 0, sumG = 0, sumB = 0;
+
+ bucket.forEach(color => {
+ const weight = color.count;
+ totalWeight += weight;
+ sumR += color.r * weight;
+ sumG += color.g * weight;
+ sumB += color.b * weight;
+ });
+
+ return [
+ Math.round(sumR / totalWeight),
+ Math.round(sumG / totalWeight),
+ Math.round(sumB / totalWeight)
+ ];
+ });
+ }
+
+ /**
+ * Get color ranges in a bucket
+ */
+ static getColorRanges(bucket) {
+ let minR = 255, maxR = 0;
+ let minG = 255, maxG = 0;
+ let minB = 255, maxB = 0;
+
+ bucket.forEach(color => {
+ minR = Math.min(minR, color.r);
+ maxR = Math.max(maxR, color.r);
+ minG = Math.min(minG, color.g);
+ maxG = Math.max(maxG, color.g);
+ minB = Math.min(minB, color.b);
+ maxB = Math.max(maxB, color.b);
+ });
+
+ return {
+ r: maxR - minR,
+ g: maxG - minG,
+ b: maxB - minB
+ };
+ }
+
+ /**
+ * Find nearest color in palette
+ */
+ static findNearestColor(r, g, b, palette) {
+ let minDist = Infinity;
+ let nearest = palette[0];
+
+ for (const color of palette) {
+ const dr = r - color[0];
+ const dg = g - color[1];
+ const db = b - color[2];
+ const dist = dr * dr + dg * dg + db * db;
+
+ if (dist < minDist) {
+ minDist = dist;
+ nearest = color;
+ }
+ }
+
+ return nearest;
+ }
+
+ /**
+ * Apply palette without dithering
+ */
+ static applyPalette(pixels, palette) {
+ for (let i = 0; i < pixels.length; i += 4) {
+ const r = pixels[i];
+ const g = pixels[i + 1];
+ const b = pixels[i + 2];
+ const a = pixels[i + 3];
+
+ if (a === 0) continue;
+
+ const nearest = this.findNearestColor(r, g, b, palette);
+ pixels[i] = nearest[0];
+ pixels[i + 1] = nearest[1];
+ pixels[i + 2] = nearest[2];
+ }
+ }
+
+ /**
+ * Apply palette with Floyd-Steinberg dithering
+ */
+ static applyPaletteWithDithering(pixels, palette, width, height) {
+ // Create error buffer
+ const errors = new Float32Array(width * height * 3);
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const idx = (y * width + x) * 4;
+ const errIdx = (y * width + x) * 3;
+
+ const r = pixels[idx];
+ const g = pixels[idx + 1];
+ const b = pixels[idx + 2];
+ const a = pixels[idx + 3];
+
+ if (a === 0) continue;
+
+ // Add accumulated error
+ const newR = Math.max(0, Math.min(255, r + errors[errIdx]));
+ const newG = Math.max(0, Math.min(255, g + errors[errIdx + 1]));
+ const newB = Math.max(0, Math.min(255, b + errors[errIdx + 2]));
+
+ // Find nearest palette color
+ const nearest = this.findNearestColor(newR, newG, newB, palette);
+
+ // Set pixel to nearest color
+ pixels[idx] = nearest[0];
+ pixels[idx + 1] = nearest[1];
+ pixels[idx + 2] = nearest[2];
+
+ // Calculate error
+ const errR = newR - nearest[0];
+ const errG = newG - nearest[1];
+ const errB = newB - nearest[2];
+
+ // Distribute error to neighboring pixels (Floyd-Steinberg)
+ // Right pixel (x+1, y): 7/16
+ if (x + 1 < width) {
+ const rightIdx = (y * width + (x + 1)) * 3;
+ errors[rightIdx] += errR * 7 / 16;
+ errors[rightIdx + 1] += errG * 7 / 16;
+ errors[rightIdx + 2] += errB * 7 / 16;
+ }
+
+ // Bottom-left pixel (x-1, y+1): 3/16
+ if (y + 1 < height && x > 0) {
+ const blIdx = ((y + 1) * width + (x - 1)) * 3;
+ errors[blIdx] += errR * 3 / 16;
+ errors[blIdx + 1] += errG * 3 / 16;
+ errors[blIdx + 2] += errB * 3 / 16;
+ }
+
+ // Bottom pixel (x, y+1): 5/16
+ if (y + 1 < height) {
+ const bottomIdx = ((y + 1) * width + x) * 3;
+ errors[bottomIdx] += errR * 5 / 16;
+ errors[bottomIdx + 1] += errG * 5 / 16;
+ errors[bottomIdx + 2] += errB * 5 / 16;
+ }
+
+ // Bottom-right pixel (x+1, y+1): 1/16
+ if (y + 1 < height && x + 1 < width) {
+ const brIdx = ((y + 1) * width + (x + 1)) * 3;
+ errors[brIdx] += errR * 1 / 16;
+ errors[brIdx + 1] += errG * 1 / 16;
+ errors[brIdx + 2] += errB * 1 / 16;
+ }
+ }
+ }
+ }
+}
diff --git a/static/js/button-generator/debug-helper.js b/static/js/button-generator/debug-helper.js
new file mode 100644
index 0000000..8368296
--- /dev/null
+++ b/static/js/button-generator/debug-helper.js
@@ -0,0 +1,25 @@
+// Debug helper - add this temporarily to main.js to see what's happening
+
+export function debugControlValues(generator) {
+ console.log('=== DEBUG: Control Values ===');
+ const values = generator.getControlValues();
+
+ // Text controls
+ console.log('Text controls:');
+ console.log(' button-text:', values['button-text']);
+ console.log(' font-size:', values['font-size']);
+ console.log(' text-x:', values['text-x']);
+ console.log(' text-y:', values['text-y']);
+
+ // Check which effects are enabled
+ console.log('\nEnabled effects:');
+ generator.getAllEffects().forEach(effect => {
+ const enabled = effect.isEnabled(values);
+ if (enabled) {
+ console.log(` ✓ ${effect.name} (${effect.id})`);
+ }
+ });
+
+ console.log('\nAll control values:', values);
+ console.log('=== END DEBUG ===');
+}
diff --git a/static/js/button-generator/effect-base.js b/static/js/button-generator/effect-base.js
new file mode 100644
index 0000000..87489cc
--- /dev/null
+++ b/static/js/button-generator/effect-base.js
@@ -0,0 +1,92 @@
+/**
+ * Base class for button generator effects
+ * All effects should extend this class and implement required methods
+ */
+export class ButtonEffect {
+ /**
+ * @param {Object} config - Effect configuration
+ * @param {string} config.id - Unique identifier for this effect
+ * @param {string} config.name - Display name for the effect
+ * @param {string} config.type - Effect type: 'text', 'text2', 'background', 'general', 'border'
+ * @param {string} config.category - UI category for grouping effects
+ * @param {number} config.renderOrder - Order in rendering pipeline (lower = earlier)
+ */
+ constructor(config) {
+ this.config = config; // Store full config for subclasses to access
+ this.id = config.id;
+ this.name = config.name;
+ this.type = config.type; // 'text', 'text2', 'background', 'general', 'border'
+ this.category = config.category;
+ this.renderOrder = config.renderOrder || 100;
+ this.controls = this.defineControls();
+ }
+
+ /**
+ * Define UI controls for this effect
+ * @returns {Array} Array of control definitions
+ *
+ * Control definition format:
+ * {
+ * id: string, // HTML element ID
+ * type: 'checkbox' | 'range' | 'color' | 'select' | 'text',
+ * label: string, // Display label
+ * defaultValue: any, // Default value
+ * min: number, // For range controls
+ * max: number, // For range controls
+ * step: number, // For range controls
+ * options: Array<{value, label}>, // For select controls
+ * showWhen: string, // ID of checkbox that controls visibility
+ * description: string // Optional tooltip/help text
+ * }
+ */
+ defineControls() {
+ return [];
+ }
+
+ /**
+ * Check if this effect is currently enabled
+ * @param {Object} controlValues - Current values of all controls
+ * @returns {boolean}
+ */
+ isEnabled(controlValues) {
+ // Default: check for a control with ID pattern 'animate-{effectId}' or '{effectId}-enabled'
+ const enableControl = controlValues[`animate-${this.id}`] ||
+ controlValues[`${this.id}-enabled`];
+ return enableControl === true || enableControl === 'true';
+ }
+
+ /**
+ * Apply the effect during rendering
+ * @param {CanvasRenderingContext2D} context - Canvas context to draw on
+ * @param {Object} controlValues - Current values of all controls
+ * @param {AnimationState} animState - Current animation state (null for static)
+ * @param {Object} renderData - Additional render data (text metrics, colors, etc.)
+ */
+ apply(context, controlValues, animState, renderData) {
+ throw new Error('Effect.apply() must be implemented by subclass');
+ }
+
+ /**
+ * Get control values specific to this effect
+ * @param {Object} allControlValues - All control values
+ * @returns {Object} Only the controls relevant to this effect
+ */
+ getEffectControls(allControlValues) {
+ const effectControls = {};
+ this.controls.forEach(control => {
+ if (control.id in allControlValues) {
+ effectControls[control.id] = allControlValues[control.id];
+ }
+ });
+ return effectControls;
+ }
+
+ /**
+ * Validate that this effect can be applied
+ * @param {Object} controlValues - Current values of all controls
+ * @returns {boolean}
+ */
+ canApply(controlValues) {
+ return this.isEnabled(controlValues);
+ }
+}
diff --git a/static/js/button-generator/effects/EXAMPLE.js b/static/js/button-generator/effects/EXAMPLE.js
new file mode 100644
index 0000000..c2126d0
--- /dev/null
+++ b/static/js/button-generator/effects/EXAMPLE.js
@@ -0,0 +1,205 @@
+/**
+ * EXAMPLE EFFECT
+ *
+ * This is a template for creating new effects.
+ * Copy this file and modify it to create your own custom effects.
+ *
+ * This example creates a "spotlight" effect that highlights a circular area
+ * and darkens the rest of the button.
+ */
+
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Spotlight Effect
+ * Creates a moving circular spotlight that highlights different areas
+ */
+export class SpotlightEffect extends ButtonEffect {
+ constructor() {
+ super({
+ // Unique ID for this effect (used in control IDs)
+ id: 'spotlight',
+
+ // Display name shown in UI
+ name: 'Spotlight',
+
+ // Effect type determines render order category
+ // Options: 'background', 'border', 'text', 'text2', 'general'
+ type: 'general',
+
+ // Category for organizing effects in UI
+ category: 'Visual Effects',
+
+ // Render order within type (lower = earlier)
+ // 1-9: backgrounds, 10-19: borders, 20-29: transforms,
+ // 30-49: text, 50-79: overlays, 80-99: post-processing
+ renderOrder: 60
+ });
+ }
+
+ /**
+ * Define UI controls for this effect
+ * These controls will be automatically bound to the generator
+ */
+ defineControls() {
+ return [
+ // Main enable/disable checkbox
+ {
+ id: 'animate-spotlight',
+ type: 'checkbox',
+ label: 'Spotlight Effect',
+ defaultValue: false,
+ description: 'Moving circular spotlight'
+ },
+
+ // Spotlight size control
+ {
+ id: 'spotlight-size',
+ type: 'range',
+ label: 'Spotlight Size',
+ defaultValue: 20,
+ min: 10,
+ max: 50,
+ step: 1,
+ showWhen: 'animate-spotlight', // Only show when checkbox is enabled
+ description: 'Radius of the spotlight'
+ },
+
+ // Darkness of the vignette
+ {
+ id: 'spotlight-darkness',
+ type: 'range',
+ label: 'Darkness',
+ defaultValue: 0.5,
+ min: 0,
+ max: 1,
+ step: 0.05,
+ showWhen: 'animate-spotlight',
+ description: 'How dark the non-spotlight area should be'
+ },
+
+ // Speed of movement
+ {
+ id: 'spotlight-speed',
+ type: 'range',
+ label: 'Movement Speed',
+ defaultValue: 1,
+ min: 0.1,
+ max: 3,
+ step: 0.1,
+ showWhen: 'animate-spotlight',
+ description: 'Speed of spotlight movement'
+ }
+ ];
+ }
+
+ /**
+ * Determine if this effect should be applied
+ * @param {Object} controlValues - Current values of all controls
+ * @returns {boolean}
+ */
+ isEnabled(controlValues) {
+ return controlValues['animate-spotlight'] === true;
+ }
+
+ /**
+ * Apply the effect to the canvas
+ *
+ * @param {CanvasRenderingContext2D} context - Canvas 2D rendering context
+ * @param {Object} controlValues - Current values of all controls
+ * @param {AnimationState|null} animState - Animation state (null for static render)
+ * @param {Object} renderData - Render information: { width, height, centerX, centerY }
+ */
+ apply(context, controlValues, animState, renderData) {
+ // Skip if no animation (spotlight needs movement)
+ if (!animState) return;
+
+ // Get control values
+ const size = controlValues['spotlight-size'] || 20;
+ const darkness = controlValues['spotlight-darkness'] || 0.5;
+ const speed = controlValues['spotlight-speed'] || 1;
+
+ // Calculate spotlight position
+ // Move in a circular pattern using animation phase
+ const phase = animState.getPhase(speed);
+ const spotX = renderData.centerX + Math.cos(phase) * 20;
+ const spotY = renderData.centerY + Math.sin(phase) * 10;
+
+ // Create radial gradient for spotlight effect
+ const gradient = context.createRadialGradient(
+ spotX, spotY, 0, // Inner circle (center of spotlight)
+ spotX, spotY, size // Outer circle (edge of spotlight)
+ );
+
+ // Center is transparent (spotlight is bright)
+ gradient.addColorStop(0, `rgba(0, 0, 0, 0)`);
+ // Edge fades to dark
+ gradient.addColorStop(0.5, `rgba(0, 0, 0, ${darkness * 0.3})`);
+ gradient.addColorStop(1, `rgba(0, 0, 0, ${darkness})`);
+
+ // Apply the gradient as an overlay
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
+ // Optional: Add a bright center dot
+ context.fillStyle = 'rgba(255, 255, 255, 0.3)';
+ context.beginPath();
+ context.arc(spotX, spotY, 2, 0, Math.PI * 2);
+ context.fill();
+ }
+
+ /**
+ * Optional: Override canApply for more complex logic
+ * By default, it just checks isEnabled()
+ */
+ canApply(controlValues) {
+ // Example: Only apply if text is also enabled
+ const textEnabled = controlValues['textEnabled'];
+ return this.isEnabled(controlValues) && textEnabled;
+ }
+
+ /**
+ * Optional: Add helper methods for your effect
+ */
+ calculateSpotlightPath(progress, width, height) {
+ // Example helper method
+ return {
+ x: width * progress,
+ y: height / 2
+ };
+ }
+}
+
+/**
+ * Registration function
+ * This is called to add the effect to the generator
+ *
+ * @param {ButtonGenerator} generator - The button generator instance
+ */
+export function register(generator) {
+ generator.registerEffect(new SpotlightEffect());
+}
+
+/**
+ * USAGE:
+ *
+ * 1. Copy this file to a new name (e.g., my-effect.js)
+ * 2. Modify the class name, id, and effect logic
+ * 3. Import in main.js:
+ * import * as myEffect from './effects/my-effect.js';
+ * 4. Register in setupApp():
+ * myEffect.register(generator);
+ * 5. Add HTML controls with matching IDs
+ */
+
+/**
+ * TIPS:
+ *
+ * - Use animState.progress for linear animations (0 to 1)
+ * - Use animState.getPhase(speed) for periodic animations (0 to 2π)
+ * - Use Math.sin/cos for smooth periodic motion
+ * - Check if (!animState) at the start if your effect requires animation
+ * - The context is automatically saved/restored, so feel free to transform
+ * - Use renderData for canvas dimensions and center point
+ * - Look at existing effects for more examples
+ */
diff --git a/static/js/button-generator/effects/background-aurora.js b/static/js/button-generator/effects/background-aurora.js
new file mode 100644
index 0000000..2afad33
--- /dev/null
+++ b/static/js/button-generator/effects/background-aurora.js
@@ -0,0 +1,191 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Aurora/Plasma background effect
+ * Flowing organic color patterns using layered gradients
+ */
+export class AuroraEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: "bg-aurora",
+ name: "Aurora",
+ type: "background-animation",
+ category: "Background Animations",
+ renderOrder: 10,
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: "animate-aurora",
+ type: "checkbox",
+ label: "Aurora Effect",
+ defaultValue: false,
+ },
+ {
+ id: "aurora-speed",
+ type: "range",
+ label: "Flow Speed",
+ defaultValue: 1,
+ min: 1,
+ max: 3,
+ step: 1,
+ showWhen: "animate-aurora",
+ description: "Speed of flowing colors",
+ },
+ {
+ id: "aurora-intensity",
+ type: "range",
+ label: "Intensity",
+ defaultValue: 0.6,
+ min: 0.2,
+ max: 1,
+ step: 0.1,
+ showWhen: "animate-aurora",
+ description: "Brightness and opacity",
+ },
+ {
+ id: "aurora-complexity",
+ type: "range",
+ label: "Complexity",
+ defaultValue: 3,
+ min: 2,
+ max: 6,
+ step: 1,
+ showWhen: "animate-aurora",
+ description: "Number of wave layers",
+ },
+ {
+ id: "aurora-color-scheme",
+ type: "select",
+ label: "Color Scheme",
+ defaultValue: "northern",
+ options: [
+ { value: "northern", label: "Northern Lights" },
+ { value: "purple", label: "Purple Dream" },
+ { value: "fire", label: "Fire" },
+ { value: "ocean", label: "Ocean" },
+ { value: "rainbow", label: "Rainbow" },
+ ],
+ showWhen: "animate-aurora",
+ description: "Color palette",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues["animate-aurora"] === true;
+ }
+
+ getColorScheme(scheme, hue) {
+ switch (scheme) {
+ case "northern":
+ return [
+ { h: 120, s: 70, l: 50 }, // Green
+ { h: 160, s: 70, l: 50 }, // Teal
+ { h: 200, s: 70, l: 50 }, // Blue
+ ];
+ case "purple":
+ return [
+ { h: 270, s: 70, l: 50 }, // Purple
+ { h: 300, s: 70, l: 50 }, // Magenta
+ { h: 330, s: 70, l: 50 }, // Pink
+ ];
+ case "fire":
+ return [
+ { h: 0, s: 80, l: 50 }, // Red
+ { h: 30, s: 80, l: 50 }, // Orange
+ { h: 50, s: 80, l: 50 }, // Yellow-Orange
+ ];
+ case "ocean":
+ return [
+ { h: 180, s: 70, l: 50 }, // Cyan
+ { h: 200, s: 70, l: 50 }, // Light Blue
+ { h: 220, s: 70, l: 50 }, // Blue
+ ];
+ case "rainbow":
+ return [
+ { h: (hue + 0) % 360, s: 70, l: 50 },
+ { h: (hue + 120) % 360, s: 70, l: 50 },
+ { h: (hue + 240) % 360, s: 70, l: 50 },
+ ];
+ default:
+ return [
+ { h: 120, s: 70, l: 50 },
+ { h: 180, s: 70, l: 50 },
+ { h: 240, s: 70, l: 50 },
+ ];
+ }
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const speed = controlValues["aurora-speed"] || 1;
+ const intensity = controlValues["aurora-intensity"] || 0.6;
+ const complexity = controlValues["aurora-complexity"] || 3;
+ const colorScheme = controlValues["aurora-color-scheme"] || "northern";
+
+ const time = animState.getPhase(speed);
+
+ // Create flowing hue shift that loops properly (only used for rainbow scheme)
+ // Convert phase (0 to 2π) to hue degrees (0 to 360)
+ const hueShift = (time / (Math.PI * 2)) * 360;
+ const colors = this.getColorScheme(colorScheme, hueShift);
+
+ // Draw multiple overlapping gradients to create aurora effect
+ context.globalCompositeOperation = "screen"; // Blend mode for aurora effect
+
+ for (let i = 0; i < complexity; i++) {
+ const phase = time + i * ((Math.PI * 2) / complexity);
+
+ // Calculate wave positions
+ const wave1X =
+ renderData.centerX + Math.sin(phase) * renderData.width * 0.5;
+ const wave1Y =
+ renderData.centerY + Math.cos(phase * 1.3) * renderData.height * 0.5;
+
+ // Create radial gradient
+ const gradient = context.createRadialGradient(
+ wave1X,
+ wave1Y,
+ 0,
+ wave1X,
+ wave1Y,
+ renderData.width * 0.8,
+ );
+
+ // Pick color based on wave index
+ const colorIdx = i % colors.length;
+ const color = colors[colorIdx];
+
+ const baseOpacity = intensity * 0.3;
+
+ // Rainbow scheme already has hueShift applied in getColorScheme
+ // Other schemes use their fixed colors
+ gradient.addColorStop(
+ 0,
+ `hsla(${color.h}, ${color.s}%, ${color.l}%, ${baseOpacity})`,
+ );
+ gradient.addColorStop(
+ 0.5,
+ `hsla(${color.h}, ${color.s}%, ${color.l}%, ${baseOpacity * 0.5})`,
+ );
+ gradient.addColorStop(
+ 1,
+ `hsla(${color.h}, ${color.s}%, ${color.l}%, 0)`,
+ );
+
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ }
+
+ // Reset composite operation
+ context.globalCompositeOperation = "source-over";
+ }
+}
+
+export function register(generator) {
+ generator.registerEffect(new AuroraEffect());
+}
diff --git a/static/js/button-generator/effects/background-bubbles.js b/static/js/button-generator/effects/background-bubbles.js
new file mode 100644
index 0000000..a72106c
--- /dev/null
+++ b/static/js/button-generator/effects/background-bubbles.js
@@ -0,0 +1,178 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Bubbles rising background effect
+ * Floating bubbles that rise with drift
+ */
+export class BubblesEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: "bg-bubbles",
+ name: "Bubbles",
+ type: "background-animation",
+ category: "Background Animations",
+ renderOrder: 10,
+ });
+
+ this.bubbles = [];
+ this.initialized = false;
+ }
+
+ defineControls() {
+ return [
+ {
+ id: "animate-bubbles",
+ type: "checkbox",
+ label: "Bubbles Effect",
+ defaultValue: false,
+ },
+ {
+ id: "bubble-count",
+ type: "range",
+ label: "Bubble Count",
+ defaultValue: 15,
+ min: 5,
+ max: 40,
+ step: 1,
+ showWhen: "animate-bubbles",
+ description: "Number of bubbles",
+ },
+ {
+ id: "bubble-speed",
+ type: "range",
+ label: "Rise Speed",
+ defaultValue: 1,
+ min: 0.3,
+ max: 3,
+ step: 0.1,
+ showWhen: "animate-bubbles",
+ description: "Speed of rising bubbles",
+ },
+ {
+ id: "bubble-drift",
+ type: "range",
+ label: "Drift Amount",
+ defaultValue: 1,
+ min: 0,
+ max: 3,
+ step: 0.1,
+ showWhen: "animate-bubbles",
+ description: "Side-to-side drift",
+ },
+ {
+ id: "bubble-color",
+ type: "color",
+ label: "Bubble Color",
+ defaultValue: "#4da6ff",
+ showWhen: "animate-bubbles",
+ description: "Color of bubbles",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues["animate-bubbles"] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const count = controlValues["bubble-count"] || 15;
+ const speed = controlValues["bubble-speed"] || 1;
+ const drift = controlValues["bubble-drift"] || 1;
+ const bubbleColor = controlValues["bubble-color"] || "#4da6ff";
+
+ // Initialize bubbles on first frame or count change
+ if (!this.initialized || this.bubbles.length !== count) {
+ this.bubbles = [];
+ for (let i = 0; i < count; i++) {
+ this.bubbles.push({
+ x: Math.random() * renderData.width,
+ startY: Math.random(), // Store as 0-1 ratio instead of pixel value
+ size: 3 + Math.random() * 8,
+ speedMultiplier: 0.5 + Math.random() * 1,
+ driftPhase: Math.random() * Math.PI * 2,
+ driftSpeed: 0.5 + Math.random() * 1,
+ });
+ }
+ this.initialized = true;
+ }
+
+ // Parse color for gradient
+ const hexToRgb = (hex) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result
+ ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16),
+ }
+ : { r: 77, g: 166, b: 255 };
+ };
+
+ const rgb = hexToRgb(bubbleColor);
+
+ // Draw bubbles
+ this.bubbles.forEach((bubble) => {
+ // Calculate Y position based on animation progress for perfect looping
+ // Each bubble has a different starting offset and speed
+ const bubbleProgress =
+ (animState.progress * speed * bubble.speedMultiplier + bubble.startY) %
+ 1;
+
+ // Convert to pixel position (bubbles rise from bottom to top)
+ const bubbleY =
+ renderData.height + bubble.size - bubbleProgress * (renderData.height + bubble.size * 2);
+
+ // Drift side to side
+ const driftOffset =
+ Math.sin(
+ animState.getPhase(bubble.driftSpeed * 0.5) + bubble.driftPhase
+ ) *
+ drift *
+ 2;
+ const currentX = bubble.x + driftOffset;
+
+ // Draw bubble with gradient for 3D effect
+ const gradient = context.createRadialGradient(
+ currentX - bubble.size * 0.3,
+ bubbleY - bubble.size * 0.3,
+ 0,
+ currentX,
+ bubbleY,
+ bubble.size
+ );
+
+ gradient.addColorStop(
+ 0,
+ `rgba(${rgb.r + 40}, ${rgb.g + 40}, ${rgb.b + 40}, 0.6)`
+ );
+ gradient.addColorStop(
+ 0.6,
+ `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)`
+ );
+ gradient.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.2)`);
+
+ context.fillStyle = gradient;
+ context.beginPath();
+ context.arc(currentX, bubbleY, bubble.size, 0, Math.PI * 2);
+ context.fill();
+
+ // Add highlight
+ context.fillStyle = "rgba(255, 255, 255, 0.4)";
+ context.beginPath();
+ context.arc(
+ currentX - bubble.size * 0.3,
+ bubbleY - bubble.size * 0.3,
+ bubble.size * 0.3,
+ 0,
+ Math.PI * 2
+ );
+ context.fill();
+ });
+ }
+}
+
+export function register(generator) {
+ generator.registerEffect(new BubblesEffect());
+}
diff --git a/static/js/button-generator/effects/background-emoji-wallpaper.js b/static/js/button-generator/effects/background-emoji-wallpaper.js
new file mode 100644
index 0000000..e6c38a4
--- /dev/null
+++ b/static/js/button-generator/effects/background-emoji-wallpaper.js
@@ -0,0 +1,109 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Emoji wallpaper background effect
+ * Tiles a user-specified emoji across the background
+ */
+export class EmojiWallpaperEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-emoji-wallpaper',
+ name: 'Emoji Wallpaper',
+ type: 'background',
+ category: 'Background',
+ renderOrder: 1
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'emoji-text',
+ type: 'text',
+ label: 'Emoji Character',
+ defaultValue: '✨',
+ showWhen: 'bg-type',
+ description: 'Emoji to tile (can be any text)'
+ },
+ {
+ id: 'emoji-size',
+ type: 'range',
+ label: 'Emoji Size',
+ defaultValue: 12,
+ min: 6,
+ max: 24,
+ step: 1,
+ showWhen: 'bg-type',
+ description: 'Size of each emoji'
+ },
+ {
+ id: 'emoji-spacing',
+ type: 'range',
+ label: 'Emoji Spacing',
+ defaultValue: 16,
+ min: 8,
+ max: 32,
+ step: 2,
+ showWhen: 'bg-type',
+ description: 'Space between emojis'
+ },
+ {
+ id: 'emoji-bg-color',
+ type: 'color',
+ label: 'Background Color',
+ defaultValue: '#1a1a2e',
+ showWhen: 'bg-type',
+ description: 'Background color behind emojis'
+ },
+ {
+ id: 'emoji-opacity',
+ type: 'range',
+ label: 'Emoji Opacity',
+ defaultValue: 30,
+ min: 10,
+ max: 100,
+ step: 5,
+ showWhen: 'bg-type',
+ description: 'Transparency of emojis (lower = more transparent)'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['bg-type'] === 'emoji-wallpaper';
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const emoji = controlValues['emoji-text'] || '✨';
+ const size = controlValues['emoji-size'] || 12;
+ const spacing = controlValues['emoji-spacing'] || 16;
+ const bgColor = controlValues['emoji-bg-color'] || '#1a1a2e';
+ const opacity = (controlValues['emoji-opacity'] || 30) / 100;
+
+ // Fill background color
+ context.fillStyle = bgColor;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
+ // Setup emoji font
+ context.font = `${size}px Arial`;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.globalAlpha = opacity;
+
+ // Tile emojis
+ for (let y = 0; y < renderData.height + spacing; y += spacing) {
+ for (let x = 0; x < renderData.width + spacing; x += spacing) {
+ // Offset every other row for a brick pattern
+ const offsetX = (Math.floor(y / spacing) % 2) * (spacing / 2);
+ context.fillText(emoji, x + offsetX, y);
+ }
+ }
+
+ context.globalAlpha = 1.0;
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new EmojiWallpaperEffect());
+}
diff --git a/static/js/button-generator/effects/background-external-image.js b/static/js/button-generator/effects/background-external-image.js
new file mode 100644
index 0000000..d3ffc9d
--- /dev/null
+++ b/static/js/button-generator/effects/background-external-image.js
@@ -0,0 +1,311 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * External Image Background Effect
+ * Loads an external image from a URL and displays it as the background
+ */
+export class ExternalImageBackgroundEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-external-image',
+ name: 'External Image Background',
+ type: 'background',
+ category: 'Background',
+ renderOrder: 1
+ });
+
+ // Cache for loaded images
+ this.imageCache = new Map();
+ this.loadingImages = new Map();
+ this.generator = null;
+
+ // Set up event listener for image loads
+ this.boundImageLoadHandler = this.handleImageLoad.bind(this);
+ window.addEventListener('imageLoaded', this.boundImageLoadHandler);
+ }
+
+ /**
+ * Handle image load event
+ */
+ handleImageLoad() {
+ // Trigger a redraw if we have a generator reference
+ if (this.generator) {
+ this.generator.updatePreview();
+ }
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'bg-image-file',
+ type: 'file',
+ label: 'Image File',
+ defaultValue: '',
+ accept: 'image/*',
+ showWhen: 'bg-type',
+ description: 'Select an image file from your computer'
+ },
+ {
+ id: 'bg-image-fit',
+ type: 'select',
+ label: 'Image Fit',
+ defaultValue: 'cover',
+ options: [
+ { value: 'cover', label: 'Cover (fill, crop if needed)' },
+ { value: 'contain', label: 'Contain (fit inside)' },
+ { value: 'stretch', label: 'Stretch (may distort)' },
+ { value: 'center', label: 'Center (actual size)' },
+ { value: 'manual', label: 'Manual (custom zoom & position)' }
+ ],
+ showWhen: 'bg-type',
+ description: 'How the image should fit in the canvas'
+ },
+ {
+ id: 'bg-image-zoom',
+ type: 'range',
+ label: 'Image Zoom',
+ defaultValue: 100,
+ min: 10,
+ max: 500,
+ step: 5,
+ showWhen: 'bg-image-fit',
+ description: 'Zoom level for manual positioning (percentage)'
+ },
+ {
+ id: 'bg-image-offset-x',
+ type: 'range',
+ label: 'Horizontal Position',
+ defaultValue: 50,
+ min: 0,
+ max: 100,
+ step: 1,
+ showWhen: 'bg-image-fit',
+ description: 'Horizontal position of the image (percentage)'
+ },
+ {
+ id: 'bg-image-offset-y',
+ type: 'range',
+ label: 'Vertical Position',
+ defaultValue: 50,
+ min: 0,
+ max: 100,
+ step: 1,
+ showWhen: 'bg-image-fit',
+ description: 'Vertical position of the image (percentage)'
+ },
+ {
+ id: 'bg-image-opacity',
+ type: 'range',
+ label: 'Image Opacity',
+ defaultValue: 1,
+ min: 0,
+ max: 1,
+ step: 0.05,
+ showWhen: 'bg-type',
+ description: 'Transparency of the background image'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['bg-type'] === 'external-image';
+ }
+
+ /**
+ * Start loading an image from a blob URL with caching
+ * Triggers async loading but returns immediately
+ * @param {string} blobUrl - Blob URL created from uploaded file
+ */
+ startLoadingImage(blobUrl) {
+ // Skip if already cached or loading
+ if (this.imageCache.has(blobUrl) || this.loadingImages.has(blobUrl)) {
+ return;
+ }
+
+ // Mark as loading to prevent duplicate requests
+ this.loadingImages.set(blobUrl, true);
+
+ const img = new Image();
+
+ img.onload = () => {
+ this.imageCache.set(blobUrl, img);
+ this.loadingImages.delete(blobUrl);
+ // Trigger a redraw when image loads
+ const event = new CustomEvent('imageLoaded');
+ window.dispatchEvent(event);
+ };
+
+ img.onerror = () => {
+ // Cache the error state to prevent retry spam
+ this.imageCache.set(blobUrl, 'ERROR');
+ this.loadingImages.delete(blobUrl);
+ // Trigger a redraw to show error state
+ const event = new CustomEvent('imageLoaded');
+ window.dispatchEvent(event);
+ };
+
+ img.src = blobUrl;
+ }
+
+ /**
+ * Get cached image if available
+ * @param {string} blobUrl - Blob URL
+ * @returns {HTMLImageElement|string|null} Image element, 'ERROR', or null
+ */
+ getCachedImage(blobUrl) {
+ return this.imageCache.get(blobUrl) || null;
+ }
+
+ /**
+ * Draw image with the specified fit mode
+ */
+ drawImage(context, img, fitMode, opacity, width, height, zoom, offsetX, offsetY) {
+ context.globalAlpha = opacity;
+
+ const imgRatio = img.width / img.height;
+ const canvasRatio = width / height;
+
+ let drawX = 0;
+ let drawY = 0;
+ let drawWidth = width;
+ let drawHeight = height;
+
+ switch (fitMode) {
+ case 'cover':
+ // Fill the canvas, cropping if necessary
+ if (imgRatio > canvasRatio) {
+ // Image is wider, fit height
+ drawHeight = height;
+ drawWidth = height * imgRatio;
+ drawX = (width - drawWidth) / 2;
+ } else {
+ // Image is taller, fit width
+ drawWidth = width;
+ drawHeight = width / imgRatio;
+ drawY = (height - drawHeight) / 2;
+ }
+ break;
+
+ case 'contain':
+ // Fit inside the canvas, showing all of the image
+ if (imgRatio > canvasRatio) {
+ // Image is wider, fit width
+ drawWidth = width;
+ drawHeight = width / imgRatio;
+ drawY = (height - drawHeight) / 2;
+ } else {
+ // Image is taller, fit height
+ drawHeight = height;
+ drawWidth = height * imgRatio;
+ drawX = (width - drawWidth) / 2;
+ }
+ break;
+
+ case 'center':
+ // Center the image at actual size
+ drawWidth = img.width;
+ drawHeight = img.height;
+ drawX = (width - drawWidth) / 2;
+ drawY = (height - drawHeight) / 2;
+ break;
+
+ case 'manual':
+ // Manual positioning with zoom and offset controls
+ const zoomFactor = zoom / 100;
+
+ // Start with the larger dimension to ensure coverage
+ if (imgRatio > canvasRatio) {
+ // Image is wider relative to canvas
+ drawHeight = height * zoomFactor;
+ drawWidth = drawHeight * imgRatio;
+ } else {
+ // Image is taller relative to canvas
+ drawWidth = width * zoomFactor;
+ drawHeight = drawWidth / imgRatio;
+ }
+
+ // Calculate the range of movement (how far can we move the image)
+ const maxOffsetX = drawWidth - width;
+ const maxOffsetY = drawHeight - height;
+
+ // Convert percentage (0-100) to actual position
+ // 0% = image fully left/top, 100% = image fully right/bottom
+ drawX = -(maxOffsetX * offsetX / 100);
+ drawY = -(maxOffsetY * offsetY / 100);
+ break;
+
+ case 'stretch':
+ // Stretch to fill (default values already set)
+ break;
+ }
+
+ context.drawImage(img, drawX, drawY, drawWidth, drawHeight);
+ context.globalAlpha = 1; // Reset alpha
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const file = controlValues['bg-image-file'];
+ const fitMode = controlValues['bg-image-fit'] || 'cover';
+ const opacity = controlValues['bg-image-opacity'] ?? 1;
+ const zoom = controlValues['bg-image-zoom'] ?? 100;
+ const offsetX = controlValues['bg-image-offset-x'] ?? 50;
+ const offsetY = controlValues['bg-image-offset-y'] ?? 50;
+
+ // If no file selected, fill with a placeholder color
+ if (!file || !file.blobUrl) {
+ context.fillStyle = '#cccccc';
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
+ // Draw placeholder text
+ context.fillStyle = '#666666';
+ context.font = '8px Arial';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText('Select an image', renderData.centerX, renderData.centerY);
+ return;
+ }
+
+ // Start loading if not already cached or loading
+ const cachedImage = this.getCachedImage(file.blobUrl);
+
+ if (!cachedImage) {
+ // Not cached yet - start loading
+ this.startLoadingImage(file.blobUrl);
+
+ // Draw loading state
+ context.fillStyle = '#3498db';
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
+ context.fillStyle = '#ffffff';
+ context.font = '6px Arial';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText('Loading...', renderData.centerX, renderData.centerY);
+ return;
+ }
+
+ if (cachedImage === 'ERROR') {
+ // Failed to load
+ context.fillStyle = '#ff6b6b';
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
+ context.fillStyle = '#ffffff';
+ context.font = '6px Arial';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText('Failed to load', renderData.centerX, renderData.centerY - 4);
+ context.fillText('image', renderData.centerX, renderData.centerY + 4);
+ return;
+ }
+
+ // Image is loaded - draw it
+ this.drawImage(context, cachedImage, fitMode, opacity, renderData.width, renderData.height, zoom, offsetX, offsetY);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ const effect = new ExternalImageBackgroundEffect();
+ effect.generator = generator; // Store reference for redraws
+ generator.registerEffect(effect);
+}
diff --git a/static/js/button-generator/effects/background-fire.js b/static/js/button-generator/effects/background-fire.js
new file mode 100644
index 0000000..0e3fb2c
--- /dev/null
+++ b/static/js/button-generator/effects/background-fire.js
@@ -0,0 +1,216 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Fire background effect
+ * Animated flames rising from bottom using particles
+ */
+export class FireEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: "bg-fire",
+ name: "Fire",
+ type: "background-animation",
+ category: "Background Animations",
+ renderOrder: 10,
+ });
+
+ this.particles = [];
+ this.initialized = false;
+ }
+
+ defineControls() {
+ return [
+ {
+ id: "animate-fire",
+ type: "checkbox",
+ label: "Fire Effect",
+ defaultValue: false,
+ },
+ {
+ id: "fire-intensity",
+ type: "range",
+ label: "Intensity",
+ defaultValue: 50,
+ min: 20,
+ max: 100,
+ step: 5,
+ showWhen: "animate-fire",
+ description: "Number of flame particles",
+ },
+ {
+ id: "fire-height",
+ type: "range",
+ label: "Flame Height",
+ defaultValue: 0.6,
+ min: 0.3,
+ max: 1,
+ step: 0.1,
+ showWhen: "animate-fire",
+ description: "How high flames reach",
+ },
+ {
+ id: "fire-speed",
+ type: "range",
+ label: "Speed",
+ defaultValue: 1,
+ min: 0.3,
+ max: 3,
+ step: 0.1,
+ showWhen: "animate-fire",
+ description: "Speed of rising flames",
+ },
+ {
+ id: "fire-color-scheme",
+ type: "select",
+ label: "Color Scheme",
+ defaultValue: "normal",
+ options: [
+ { value: "normal", label: "Normal Fire" },
+ { value: "blue", label: "Blue Flame" },
+ { value: "green", label: "Green Flame" },
+ { value: "purple", label: "Purple Flame" },
+ { value: "white", label: "White Hot" },
+ ],
+ showWhen: "animate-fire",
+ description: "Flame color",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues["animate-fire"] === true;
+ }
+
+ getFireColors(scheme) {
+ switch (scheme) {
+ case "normal":
+ return [
+ { r: 255, g: 60, b: 0 }, // Red-orange
+ { r: 255, g: 140, b: 0 }, // Orange
+ { r: 255, g: 200, b: 0 }, // Yellow
+ ];
+ case "blue":
+ return [
+ { r: 0, g: 100, b: 255 }, // Blue
+ { r: 100, g: 180, b: 255 }, // Light blue
+ { r: 200, g: 230, b: 255 }, // Very light blue
+ ];
+ case "green":
+ return [
+ { r: 0, g: 200, b: 50 }, // Green
+ { r: 100, g: 255, b: 100 }, // Light green
+ { r: 200, g: 255, b: 150 }, // Very light green
+ ];
+ case "purple":
+ return [
+ { r: 150, g: 0, b: 255 }, // Purple
+ { r: 200, g: 100, b: 255 }, // Light purple
+ { r: 230, g: 180, b: 255 }, // Very light purple
+ ];
+ case "white":
+ return [
+ { r: 255, g: 200, b: 150 }, // Warm white
+ { r: 255, g: 240, b: 200 }, // Light white
+ { r: 255, g: 255, b: 255 }, // Pure white
+ ];
+ default:
+ return [
+ { r: 255, g: 60, b: 0 },
+ { r: 255, g: 140, b: 0 },
+ { r: 255, g: 200, b: 0 },
+ ];
+ }
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const intensity = controlValues["fire-intensity"] || 50;
+ const height = controlValues["fire-height"] || 0.6;
+ const speed = controlValues["fire-speed"] || 1;
+ const colorScheme = controlValues["fire-color-scheme"] || "normal";
+
+ const colors = this.getFireColors(colorScheme);
+ const maxHeight = renderData.height * height;
+
+ // Spawn new particles at the bottom
+ for (let i = 0; i < intensity / 10; i++) {
+ this.particles.push({
+ x: Math.random() * renderData.width,
+ y: renderData.height,
+ vx: (Math.random() - 0.5) * 1.5,
+ vy: -(2 + Math.random() * 3) * speed,
+ size: 2 + Math.random() * 6,
+ life: 1.0,
+ colorIndex: Math.random(),
+ });
+ }
+
+ // Update and draw particles
+ this.particles = this.particles.filter((particle) => {
+ // Update position
+ particle.x += particle.vx;
+ particle.y += particle.vy;
+
+ // Add some turbulence
+ particle.vx += (Math.random() - 0.5) * 0.2;
+ particle.vy *= 0.98; // Slow down as they rise
+
+ // Fade out based on height and time
+ const heightRatio =
+ (renderData.height - particle.y) / renderData.height;
+ particle.life -= 0.015;
+
+ if (particle.life > 0 && particle.y > renderData.height - maxHeight) {
+ // Choose color based on life (hotter at bottom, cooler at top)
+ const colorProgress = 1 - particle.life;
+ const colorIdx = Math.floor(colorProgress * (colors.length - 1));
+ const colorBlend = (colorProgress * (colors.length - 1)) % 1;
+
+ const c1 = colors[Math.min(colorIdx, colors.length - 1)];
+ const c2 = colors[Math.min(colorIdx + 1, colors.length - 1)];
+
+ const r = Math.floor(c1.r + (c2.r - c1.r) * colorBlend);
+ const g = Math.floor(c1.g + (c2.g - c1.g) * colorBlend);
+ const b = Math.floor(c1.b + (c2.b - c1.b) * colorBlend);
+
+ // Draw particle with gradient
+ const gradient = context.createRadialGradient(
+ particle.x,
+ particle.y,
+ 0,
+ particle.x,
+ particle.y,
+ particle.size
+ );
+
+ gradient.addColorStop(
+ 0,
+ `rgba(${r}, ${g}, ${b}, ${particle.life * 0.8})`
+ );
+ gradient.addColorStop(
+ 0.5,
+ `rgba(${r}, ${g}, ${b}, ${particle.life * 0.5})`
+ );
+ gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
+
+ context.fillStyle = gradient;
+ context.beginPath();
+ context.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
+ context.fill();
+
+ return true;
+ }
+ return false;
+ });
+
+ // Limit particle count
+ if (this.particles.length > intensity * 5) {
+ this.particles = this.particles.slice(-intensity * 5);
+ }
+ }
+}
+
+export function register(generator) {
+ generator.registerEffect(new FireEffect());
+}
diff --git a/static/js/button-generator/effects/background-gradient.js b/static/js/button-generator/effects/background-gradient.js
new file mode 100644
index 0000000..4e8c8c8
--- /dev/null
+++ b/static/js/button-generator/effects/background-gradient.js
@@ -0,0 +1,76 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Gradient background effect
+ */
+export class GradientBackgroundEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-gradient',
+ name: 'Gradient Background',
+ type: 'background',
+ category: 'Background',
+ renderOrder: 1
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'gradient-color1',
+ type: 'color',
+ label: 'Gradient Color 1',
+ defaultValue: '#ff0000',
+ showWhen: 'bg-type',
+ description: 'Start color of gradient'
+ },
+ {
+ id: 'gradient-color2',
+ type: 'color',
+ label: 'Gradient Color 2',
+ defaultValue: '#0000ff',
+ showWhen: 'bg-type',
+ description: 'End color of gradient'
+ },
+ {
+ id: 'gradient-angle',
+ type: 'range',
+ label: 'Gradient Angle',
+ defaultValue: 90,
+ min: 0,
+ max: 360,
+ step: 1,
+ showWhen: 'bg-type',
+ description: 'Angle of gradient direction'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['bg-type'] === 'gradient';
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const color1 = controlValues['gradient-color1'] || '#ff0000';
+ const color2 = controlValues['gradient-color2'] || '#0000ff';
+ const angle = (controlValues['gradient-angle'] || 90) * (Math.PI / 180);
+
+ // Calculate gradient endpoints
+ const x1 = renderData.centerX + Math.cos(angle) * renderData.centerX;
+ const y1 = renderData.centerY + Math.sin(angle) * renderData.centerY;
+ const x2 = renderData.centerX - Math.cos(angle) * renderData.centerX;
+ const y2 = renderData.centerY - Math.sin(angle) * renderData.centerY;
+
+ const gradient = context.createLinearGradient(x1, y1, x2, y2);
+ gradient.addColorStop(0, color1);
+ gradient.addColorStop(1, color2);
+
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new GradientBackgroundEffect());
+}
diff --git a/static/js/button-generator/effects/background-rain.js b/static/js/button-generator/effects/background-rain.js
new file mode 100644
index 0000000..eb3b928
--- /dev/null
+++ b/static/js/button-generator/effects/background-rain.js
@@ -0,0 +1,139 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Raining background effect
+ * Animated raindrops falling down the button
+ */
+export class RainBackgroundEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-rain',
+ name: 'Rain Effect',
+ type: 'background-animation',
+ category: 'Background Animations',
+ renderOrder: 10
+ });
+
+ // Initialize raindrop positions (persistent across frames)
+ this.raindrops = [];
+ this.initialized = false;
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-rain',
+ type: 'checkbox',
+ label: 'Rain Effect',
+ defaultValue: false
+ },
+ {
+ id: 'rain-density',
+ type: 'range',
+ label: 'Rain Density',
+ defaultValue: 15,
+ min: 5,
+ max: 30,
+ step: 1,
+ showWhen: 'animate-rain',
+ description: 'Number of raindrops'
+ },
+ {
+ id: 'rain-speed',
+ type: 'range',
+ label: 'Rain Speed',
+ defaultValue: 2,
+ min: 1,
+ max: 3,
+ step: 1,
+ showWhen: 'animate-rain',
+ description: 'Speed of falling rain'
+ },
+ {
+ id: 'rain-color',
+ type: 'color',
+ label: 'Rain Color',
+ defaultValue: '#6ba3ff',
+ showWhen: 'animate-rain',
+ description: 'Color of raindrops'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-rain'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const density = controlValues['rain-density'] || 15;
+ const speed = controlValues['rain-speed'] || 1.5;
+ const color = controlValues['rain-color'] || '#6ba3ff';
+
+ // Initialize raindrop base properties (seed values for deterministic animation)
+ if (!this.initialized || this.raindrops.length !== density) {
+ this.raindrops = [];
+ for (let i = 0; i < density; i++) {
+ // Use multiple large prime seeds for better distribution
+ const seed1 = i * 2654435761; // Large prime multiplier
+ const seed2 = i * 2246822519 + 3141592653;
+ const seed3 = i * 3266489917 + 1618033988;
+ const seed4 = i * 374761393 + 2718281828;
+
+ // Hash-like function for better pseudo-random distribution
+ const hash = (s) => {
+ const x = Math.sin(s * 0.0001) * 10000;
+ return x - Math.floor(x);
+ };
+
+ this.raindrops.push({
+ xOffset: hash(seed1), // 0 to 1
+ startY: hash(seed2), // 0 to 1 (initial Y position within loop)
+ length: 2 + hash(seed3) * 4,
+ speedMultiplier: 0.8 + hash(seed4) * 0.4
+ });
+ }
+ this.initialized = true;
+ }
+
+ // Draw raindrops
+ context.strokeStyle = color;
+ context.lineWidth = 1;
+ context.lineCap = 'round';
+
+ this.raindrops.forEach(drop => {
+ // Calculate position based on current frame for perfect looping
+ const totalDistance = renderData.height + drop.length * 2;
+
+ // Progress through the animation (0 to 1)
+ const progress = animState.progress; // 0 at frame 0, ~0.975 at frame 39
+
+ // All drops complete the same number of cycles (based on speed)
+ // startY provides the offset for varied starting positions
+ // Round speed to nearest 0.5 to ensure clean cycles
+ const cycles = Math.round(speed * 2) / 2; // e.g., 1.5 -> 1.5, 1.7 -> 1.5, 2.3 -> 2.5
+ const cycleProgress = (progress * cycles + drop.startY) % 1.0;
+ const y = cycleProgress * totalDistance - drop.length;
+
+ // X position remains constant throughout the loop
+ const x = drop.xOffset * renderData.width;
+
+ // Vary opacity slightly for depth effect (using speedMultiplier for variation)
+ const opacity = 0.4 + drop.speedMultiplier * 0.3; // 0.64 to 0.76
+
+ // Draw raindrop
+ context.globalAlpha = opacity;
+ context.beginPath();
+ context.moveTo(x, y);
+ context.lineTo(x, y + drop.length);
+ context.stroke();
+ context.globalAlpha = 1.0;
+ });
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new RainBackgroundEffect());
+}
diff --git a/static/js/button-generator/effects/background-rainbow.js b/static/js/button-generator/effects/background-rainbow.js
new file mode 100644
index 0000000..07dae99
--- /dev/null
+++ b/static/js/button-generator/effects/background-rainbow.js
@@ -0,0 +1,137 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Rainbow flash background effect
+ * Animates background through rainbow colors
+ */
+export class RainbowBackgroundEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-rainbow',
+ name: 'Rainbow Background',
+ type: 'background',
+ category: 'Background Animations',
+ renderOrder: 2 // After base background
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-bg-rainbow',
+ type: 'checkbox',
+ label: 'Rainbow Flash',
+ defaultValue: false
+ },
+ {
+ id: 'rainbow-speed',
+ type: 'range',
+ label: 'Rainbow Speed',
+ defaultValue: 1,
+ min: 0.1,
+ max: 5,
+ step: 0.1,
+ showWhen: 'animate-bg-rainbow',
+ description: 'Speed of rainbow cycling'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-bg-rainbow'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const speed = controlValues['rainbow-speed'] || 1;
+ const hue = (animState.progress * speed * 360) % 360;
+
+ const bgType = controlValues['bg-type'];
+
+ if (bgType === 'solid') {
+ // Solid rainbow
+ context.fillStyle = `hsl(${hue}, 70%, 50%)`;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ } else if (bgType === 'gradient') {
+ // Rainbow gradient
+ const angle = (controlValues['gradient-angle'] || 90) * (Math.PI / 180);
+ const x1 = renderData.centerX + Math.cos(angle) * renderData.centerX;
+ const y1 = renderData.centerY + Math.sin(angle) * renderData.centerY;
+ const x2 = renderData.centerX - Math.cos(angle) * renderData.centerX;
+ const y2 = renderData.centerY - Math.sin(angle) * renderData.centerY;
+
+ const gradient = context.createLinearGradient(x1, y1, x2, y2);
+ gradient.addColorStop(0, `hsl(${hue}, 70%, 50%)`);
+ gradient.addColorStop(1, `hsl(${(hue + 60) % 360}, 70%, 60%)`);
+
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ }
+ }
+}
+
+/**
+ * Rainbow gradient sweep effect
+ * Creates a moving rainbow gradient that sweeps across the button
+ */
+export class RainbowGradientSweepEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-rainbow-gradient',
+ name: 'Rainbow Gradient Sweep',
+ type: 'general',
+ category: 'Background Animations',
+ renderOrder: 50 // After background and text
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-bg-rainbow-gradient',
+ type: 'checkbox',
+ label: 'Rainbow Sweep',
+ defaultValue: false,
+ description: 'Moving rainbow gradient overlay'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-bg-rainbow-gradient'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ // Map progress to position (-100 to 100)
+ const position = animState.progress * 200 - 100;
+
+ // Create a horizontal gradient that sweeps across
+ const gradient = context.createLinearGradient(
+ position - 50,
+ 0,
+ position + 50,
+ 0
+ );
+
+ // Create rainbow stops that also cycle through colors
+ const hueOffset = animState.progress * 360;
+ gradient.addColorStop(0, `hsla(${(hueOffset + 0) % 360}, 80%, 50%, 0)`);
+ gradient.addColorStop(0.2, `hsla(${(hueOffset + 60) % 360}, 80%, 50%, 0.6)`);
+ gradient.addColorStop(0.4, `hsla(${(hueOffset + 120) % 360}, 80%, 50%, 0.8)`);
+ gradient.addColorStop(0.6, `hsla(${(hueOffset + 180) % 360}, 80%, 50%, 0.8)`);
+ gradient.addColorStop(0.8, `hsla(${(hueOffset + 240) % 360}, 80%, 50%, 0.6)`);
+ gradient.addColorStop(1, `hsla(${(hueOffset + 300) % 360}, 80%, 50%, 0)`);
+
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ }
+}
+
+// Auto-register effects
+export function register(generator) {
+ generator.registerEffect(new RainbowBackgroundEffect());
+ generator.registerEffect(new RainbowGradientSweepEffect());
+}
diff --git a/static/js/button-generator/effects/background-solid.js b/static/js/button-generator/effects/background-solid.js
new file mode 100644
index 0000000..a835100
--- /dev/null
+++ b/static/js/button-generator/effects/background-solid.js
@@ -0,0 +1,57 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Solid color background effect
+ */
+export class SolidBackgroundEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: "bg-solid",
+ name: "Solid Background",
+ type: "background",
+ category: "Background",
+ renderOrder: 1,
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: "bg-type",
+ type: "select",
+ label: "Background Type",
+ defaultValue: "solid",
+ options: [
+ { value: "solid", label: "Solid Color" },
+ { value: "gradient", label: "Gradient" },
+ { value: "texture", label: "Texture" },
+ { value: "emoji-wallpaper", label: "Emoji Wallpaper" },
+ { value: 'external-image', label: 'Image Upload' }
+ ],
+ },
+ {
+ id: "bg-color",
+ type: "color",
+ label: "Background Color",
+ defaultValue: "#4a90e2",
+ showWhen: "bg-type",
+ description: "Solid background color",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues["bg-type"] === "solid";
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const color = controlValues["bg-color"] || "#4a90e2";
+ context.fillStyle = color;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new SolidBackgroundEffect());
+}
diff --git a/static/js/button-generator/effects/background-starfield.js b/static/js/button-generator/effects/background-starfield.js
new file mode 100644
index 0000000..61ed860
--- /dev/null
+++ b/static/js/button-generator/effects/background-starfield.js
@@ -0,0 +1,187 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Starfield background effect
+ * Twinkling stars with optional shooting stars
+ */
+export class StarfieldEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: "bg-starfield",
+ name: "Starfield",
+ type: "background-animation",
+ category: "Background Animations",
+ renderOrder: 10,
+ });
+
+ this.stars = [];
+ this.initialized = false;
+ }
+
+ defineControls() {
+ return [
+ {
+ id: "animate-starfield",
+ type: "checkbox",
+ label: "Starfield Effect",
+ defaultValue: false,
+ },
+ {
+ id: "star-density",
+ type: "range",
+ label: "Star Density",
+ defaultValue: 30,
+ min: 10,
+ max: 80,
+ step: 5,
+ showWhen: "animate-starfield",
+ description: "Number of stars",
+ },
+ {
+ id: "star-twinkle-speed",
+ type: "range",
+ label: "Twinkle Speed",
+ defaultValue: 1,
+ min: 1,
+ max: 3,
+ step: 0.1,
+ showWhen: "animate-starfield",
+ description: "Speed of twinkling",
+ },
+ {
+ id: "star-shooting-enabled",
+ type: "checkbox",
+ label: "Shooting Stars",
+ defaultValue: true,
+ showWhen: "animate-starfield",
+ description: "Enable shooting stars",
+ },
+ {
+ id: "star-color",
+ type: "color",
+ label: "Star Color",
+ defaultValue: "#ffffff",
+ showWhen: "animate-starfield",
+ description: "Color of stars",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues["animate-starfield"] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const density = controlValues["star-density"] || 30;
+ const twinkleSpeed = controlValues["star-twinkle-speed"] || 1;
+ const shootingEnabled = controlValues["star-shooting-enabled"] !== false;
+ const starColor = controlValues["star-color"] || "#ffffff";
+
+ // Initialize stars with deterministic positions (seed-based)
+ if (!this.initialized || this.stars.length !== density) {
+ this.stars = [];
+ for (let i = 0; i < density; i++) {
+ // Use density as part of the seed so pattern changes with density
+ // Use multiple large prime seeds for better distribution
+ const densitySeed = density * 97531; // Density affects all stars
+ const seed1 = (i * 2654435761) + densitySeed;
+ const seed2 = (i * 2246822519) + 3141592653 + densitySeed;
+ const seed3 = (i * 3266489917) + 1618033988 + densitySeed;
+ const seed4 = (i * 374761393) + 2718281828 + densitySeed;
+ const seed5 = (i * 1103515245) + 12345 + densitySeed;
+
+ // Hash-like function for better pseudo-random distribution
+ const hash = (s) => {
+ const x = Math.sin(s * 0.0001) * 10000;
+ return x - Math.floor(x);
+ };
+
+ this.stars.push({
+ x: hash(seed1) * renderData.width,
+ y: hash(seed2) * renderData.height,
+ size: 0.5 + hash(seed3) * 1.5,
+ twinkleOffset: hash(seed4) * Math.PI * 2,
+ twinkleSpeed: 0.5 + hash(seed5) * 1.5,
+ });
+ }
+ this.initialized = true;
+ }
+
+ // Draw twinkling stars
+ this.stars.forEach((star) => {
+ const twinkle =
+ Math.sin(
+ animState.getPhase(twinkleSpeed * star.twinkleSpeed) +
+ star.twinkleOffset,
+ ) *
+ 0.5 +
+ 0.5;
+ const opacity = 0.3 + twinkle * 0.7;
+
+ context.fillStyle = starColor;
+ context.globalAlpha = opacity;
+ context.beginPath();
+ context.arc(star.x, star.y, star.size, 0, Math.PI * 2);
+ context.fill();
+ context.globalAlpha = 1.0;
+ });
+
+ // Shooting stars - deterministic based on frame number
+ if (shootingEnabled) {
+ // Define shooting star spawn schedule (deterministic)
+ const shootingStarSpawns = [
+ { startFrame: 5, seed: 12345 },
+ { startFrame: 18, seed: 67890 },
+ { startFrame: 31, seed: 24680 },
+ ];
+
+ shootingStarSpawns.forEach((spawn) => {
+ const framesSinceSpawn = animState.frame - spawn.startFrame;
+ const duration = 25; // Frames the shooting star is visible
+
+ if (framesSinceSpawn >= 0 && framesSinceSpawn < duration) {
+ // Calculate deterministic properties from seed
+ const hash = (s) => {
+ const x = Math.sin(s * 0.0001) * 10000;
+ return x - Math.floor(x);
+ };
+
+ const startX = hash(spawn.seed * 1) * renderData.width;
+ const startY = -10;
+ const vx = (hash(spawn.seed * 2) - 0.5) * 2;
+ const vy = 3 + hash(spawn.seed * 3) * 2;
+
+ // Calculate position based on frames elapsed
+ const x = startX + vx * framesSinceSpawn;
+ const y = startY + vy * framesSinceSpawn;
+ const life = 1.0 - framesSinceSpawn / duration;
+
+ if (life > 0) {
+ // Draw shooting star trail
+ const gradient = context.createLinearGradient(
+ x,
+ y,
+ x - vx * 5,
+ y - vy * 5,
+ );
+ gradient.addColorStop(0, `rgba(255, 255, 255, ${life * 0.8})`);
+ gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
+
+ context.strokeStyle = gradient;
+ context.lineWidth = 2;
+ context.beginPath();
+ context.moveTo(x, y);
+ context.lineTo(x - vx * 5, y - vy * 5);
+ context.stroke();
+ }
+ }
+ });
+ }
+ }
+}
+
+export function register(generator) {
+ generator.registerEffect(new StarfieldEffect());
+}
diff --git a/static/js/button-generator/effects/background-texture.js b/static/js/button-generator/effects/background-texture.js
new file mode 100644
index 0000000..8afccbd
--- /dev/null
+++ b/static/js/button-generator/effects/background-texture.js
@@ -0,0 +1,217 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Texture background effect
+ * Provides various procedural texture patterns
+ */
+export class TextureBackgroundEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'bg-texture',
+ name: 'Texture Background',
+ type: 'background',
+ category: 'Background',
+ renderOrder: 1
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'texture-type',
+ type: 'select',
+ label: 'Texture Type',
+ defaultValue: 'dots',
+ showWhen: 'bg-type',
+ options: [
+ { value: 'dots', label: 'Dots' },
+ { value: 'grid', label: 'Grid' },
+ { value: 'diagonal', label: 'Diagonal Lines' },
+ { value: 'checkerboard', label: 'Checkerboard' },
+ { value: 'noise', label: 'Noise' },
+ { value: 'stars', label: 'Stars' }
+ ]
+ },
+ {
+ id: 'texture-color1',
+ type: 'color',
+ label: 'Texture Color 1',
+ defaultValue: '#000000',
+ showWhen: 'bg-type',
+ description: 'Base color'
+ },
+ {
+ id: 'texture-color2',
+ type: 'color',
+ label: 'Texture Color 2',
+ defaultValue: '#ffffff',
+ showWhen: 'bg-type',
+ description: 'Pattern color'
+ },
+ {
+ id: 'texture-scale',
+ type: 'range',
+ label: 'Texture Scale',
+ defaultValue: 50,
+ min: 10,
+ max: 100,
+ step: 5,
+ showWhen: 'bg-type',
+ description: 'Size/density of pattern'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['bg-type'] === 'texture';
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const type = controlValues['texture-type'] || 'dots';
+ const color1 = controlValues['texture-color1'] || '#000000';
+ const color2 = controlValues['texture-color2'] || '#ffffff';
+ const scale = controlValues['texture-scale'] || 50;
+
+ const texture = this.drawTexture(
+ type,
+ color1,
+ color2,
+ scale,
+ renderData.width,
+ renderData.height
+ );
+
+ context.drawImage(texture, 0, 0);
+ }
+
+ /**
+ * Draw texture pattern to a temporary canvas
+ */
+ drawTexture(type, color1, color2, scale, width, height) {
+ const tempCanvas = document.createElement('canvas');
+ tempCanvas.width = width;
+ tempCanvas.height = height;
+ const ctx = tempCanvas.getContext('2d');
+
+ // Fill base color
+ ctx.fillStyle = color1;
+ ctx.fillRect(0, 0, width, height);
+
+ // Draw pattern
+ ctx.fillStyle = color2;
+ const size = Math.max(2, Math.floor(scale / 10));
+
+ switch (type) {
+ case 'dots':
+ this.drawDots(ctx, width, height, size);
+ break;
+ case 'grid':
+ this.drawGrid(ctx, width, height, size);
+ break;
+ case 'diagonal':
+ this.drawDiagonal(ctx, width, height, size);
+ break;
+ case 'checkerboard':
+ this.drawCheckerboard(ctx, width, height, size);
+ break;
+ case 'noise':
+ this.drawNoise(ctx, width, height);
+ break;
+ case 'stars':
+ this.drawStars(ctx, width, height, scale);
+ break;
+ }
+
+ return tempCanvas;
+ }
+
+ /**
+ * Draw dots pattern
+ */
+ drawDots(ctx, width, height, size) {
+ for (let y = 0; y < height; y += size * 2) {
+ for (let x = 0; x < width; x += size * 2) {
+ ctx.beginPath();
+ ctx.arc(x, y, size / 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ }
+
+ /**
+ * Draw grid pattern
+ */
+ drawGrid(ctx, width, height, size) {
+ // Vertical lines
+ for (let x = 0; x < width; x += size) {
+ ctx.fillRect(x, 0, 1, height);
+ }
+ // Horizontal lines
+ for (let y = 0; y < height; y += size) {
+ ctx.fillRect(0, y, width, 1);
+ }
+ }
+
+ /**
+ * Draw diagonal lines pattern
+ */
+ drawDiagonal(ctx, width, height, size) {
+ for (let i = -height; i < width; i += size) {
+ ctx.fillRect(i, 0, 2, height);
+ ctx.save();
+ ctx.translate(i + 1, 0);
+ ctx.rotate(Math.PI / 4);
+ ctx.fillRect(0, 0, 2, Math.max(width, height));
+ ctx.restore();
+ }
+ }
+
+ /**
+ * Draw checkerboard pattern
+ */
+ drawCheckerboard(ctx, width, height, size) {
+ for (let y = 0; y < height; y += size) {
+ for (let x = 0; x < width; x += size) {
+ if ((x / size + y / size) % 2 === 0) {
+ ctx.fillRect(x, y, size, size);
+ }
+ }
+ }
+ }
+
+ /**
+ * Draw random noise pattern
+ */
+ drawNoise(ctx, width, height) {
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (Math.random() > 0.5) {
+ ctx.fillRect(x, y, 1, 1);
+ }
+ }
+ }
+ }
+
+ /**
+ * Draw stars pattern
+ */
+ drawStars(ctx, width, height, scale) {
+ const numStars = scale;
+ for (let i = 0; i < numStars; i++) {
+ const x = Math.floor(Math.random() * width);
+ const y = Math.floor(Math.random() * height);
+
+ // Draw plus-shape star
+ ctx.fillRect(x, y, 1, 1); // Center
+ ctx.fillRect(x - 1, y, 1, 1); // Left
+ ctx.fillRect(x + 1, y, 1, 1); // Right
+ ctx.fillRect(x, y - 1, 1, 1); // Top
+ ctx.fillRect(x, y + 1, 1, 1); // Bottom
+ }
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new TextureBackgroundEffect());
+}
diff --git a/static/js/button-generator/effects/border.js b/static/js/button-generator/effects/border.js
new file mode 100644
index 0000000..0ed6029
--- /dev/null
+++ b/static/js/button-generator/effects/border.js
@@ -0,0 +1,401 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Border effect
+ * Draws borders around the button with various styles
+ */
+export class BorderEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: "border",
+ name: "Border",
+ type: "border",
+ category: "Border",
+ renderOrder: 10,
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: "border-width",
+ type: "range",
+ label: "Border Width",
+ defaultValue: 2,
+ min: 0,
+ max: 5,
+ step: 1,
+ description: "Width of border in pixels",
+ },
+ {
+ id: "border-color",
+ type: "color",
+ label: "Border Color",
+ defaultValue: "#000000",
+ },
+ {
+ id: "border-style",
+ type: "select",
+ label: "Border Style",
+ defaultValue: "solid",
+ options: [
+ { value: "solid", label: "Solid" },
+ { value: "dashed", label: "Dashed" },
+ { value: "dotted", label: "Dotted" },
+ { value: "double", label: "Double" },
+ { value: "inset", label: "Inset (3D)" },
+ { value: "outset", label: "Outset (3D)" },
+ { value: "ridge", label: "Ridge" },
+ { value: "rainbow", label: "Rainbow (Animated)" },
+ { value: "marching-ants", label: "Marching Ants" },
+ { value: "checkerboard", label: "Checkerboard" },
+ ],
+ },
+ {
+ id: "border-rainbow-speed",
+ type: "range",
+ label: "Rainbow Speed",
+ defaultValue: 1,
+ min: 1,
+ max: 3,
+ step: 1,
+ showWhen: "border-style",
+ description: "Speed of rainbow animation",
+ },
+ {
+ id: "border-march-speed",
+ type: "range",
+ label: "March Speed",
+ defaultValue: 1,
+ min: 1,
+ max: 3,
+ step: 1,
+ showWhen: "border-style",
+ description: "Speed of marching animation",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const width = controlValues["border-width"] || 0;
+ return width > 0;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const width = controlValues["border-width"] || 0;
+ if (width === 0) return;
+
+ const color = controlValues["border-color"] || "#000000";
+ const style = controlValues["border-style"] || "solid";
+
+ if (style === "solid") {
+ this.drawSolidBorder(context, width, color, renderData);
+ } else if (style === "dashed") {
+ this.drawDashedBorder(context, width, color, renderData);
+ } else if (style === "dotted") {
+ this.drawDottedBorder(context, width, color, renderData);
+ } else if (style === "double") {
+ this.drawDoubleBorder(context, width, color, renderData);
+ } else if (style === "inset" || style === "outset") {
+ this.draw3DBorder(context, width, color, style === "outset", renderData);
+ } else if (style === "ridge") {
+ this.drawRidgeBorder(context, width, renderData);
+ } else if (style === "rainbow") {
+ const speed = controlValues["border-rainbow-speed"] || 1;
+ this.drawRainbowBorder(context, width, animState, speed, renderData);
+ } else if (style === "marching-ants") {
+ const speed = controlValues["border-march-speed"] || 1;
+ this.drawMarchingAntsBorder(context, width, animState, speed, renderData);
+ } else if (style === "checkerboard") {
+ this.drawCheckerboardBorder(context, width, color, renderData);
+ }
+ }
+
+ /**
+ * Draw solid border
+ */
+ drawSolidBorder(context, width, color, renderData) {
+ context.strokeStyle = color;
+ context.lineWidth = width;
+ context.strokeRect(
+ width / 2,
+ width / 2,
+ renderData.width - width,
+ renderData.height - width,
+ );
+ }
+
+ /**
+ * Draw 3D inset/outset border
+ */
+ draw3DBorder(context, width, color, isOutset, renderData) {
+ const w = renderData.width;
+ const h = renderData.height;
+ const t = width;
+
+ const normalized = color.toLowerCase();
+ const isPureBlack = normalized === "#000000";
+ const isPureWhite = normalized === "#ffffff";
+
+ let lightColor;
+ let darkColor;
+
+ if (isPureBlack || isPureWhite) {
+ lightColor = isOutset ? "#ffffff" : "#000000";
+ darkColor = isOutset ? "#000000" : "#ffffff";
+ } else {
+ const lighter = this.adjustColor(color, 0.25);
+ const darker = this.adjustColor(color, -0.25);
+
+ lightColor = isOutset ? lighter : darker;
+ darkColor = isOutset ? darker : lighter;
+ }
+
+ context.fillStyle = lightColor;
+ context.fillRect(0, 0, w - t, t);
+ context.fillRect(0, t, t, h - t);
+
+ context.fillStyle = darkColor;
+ context.fillRect(t, h - t, w - t, t);
+ context.fillRect(w - t, 0, t, h - t);
+
+ this.drawBevelCorners(context, t, w, h, lightColor, darkColor, isOutset);
+ }
+
+ drawBevelCorners(ctx, t, w, h, light, dark, isOutset) {
+ // Top-left corner
+ ctx.fillStyle = dark;
+ ctx.beginPath();
+ ctx.moveTo(0, h);
+ ctx.lineTo(t, h);
+ ctx.lineTo(t, h - t);
+ ctx.closePath();
+ ctx.fill();
+
+ // Bottom-right corner
+ ctx.fillStyle = light;
+ ctx.beginPath();
+ ctx.moveTo(w - t, 0);
+ ctx.lineTo(w - t, t);
+ ctx.lineTo(w, 0);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ /**
+ * Draw ridge border (double 3D effect)
+ */
+ drawRidgeBorder(context, width, renderData) {
+ // Outer ridge (light)
+ context.strokeStyle = "#ffffff";
+ context.lineWidth = width / 2;
+ context.strokeRect(
+ width / 4,
+ width / 4,
+ renderData.width - width / 2,
+ renderData.height - width / 2,
+ );
+
+ // Inner ridge (dark)
+ context.strokeStyle = "#000000";
+ context.strokeRect(
+ (width * 3) / 4,
+ (width * 3) / 4,
+ renderData.width - width * 1.5,
+ renderData.height - width * 1.5,
+ );
+ }
+
+ adjustColor(hex, amount) {
+ // hex: "#rrggbb", amount: -1.0 .. 1.0
+ let r = parseInt(hex.slice(1, 3), 16);
+ let g = parseInt(hex.slice(3, 5), 16);
+ let b = parseInt(hex.slice(5, 7), 16);
+
+ const adjust = (c) =>
+ Math.min(255, Math.max(0, Math.round(c + amount * 255)));
+
+ r = adjust(r);
+ g = adjust(g);
+ b = adjust(b);
+
+ return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
+ }
+
+ /**
+ * Draw dashed border
+ */
+ drawDashedBorder(context, width, color, renderData) {
+ context.strokeStyle = color;
+ context.lineWidth = width;
+ context.setLineDash([6, 3]); // 6px dash, 3px gap
+ context.strokeRect(
+ width / 2,
+ width / 2,
+ renderData.width - width,
+ renderData.height - width
+ );
+ context.setLineDash([]); // Reset to solid
+ }
+
+ /**
+ * Draw dotted border
+ */
+ drawDottedBorder(context, width, color, renderData) {
+ context.strokeStyle = color;
+ context.lineWidth = width;
+ context.setLineDash([2, 3]); // 2px dot, 3px gap
+ context.lineCap = "round";
+ context.strokeRect(
+ width / 2,
+ width / 2,
+ renderData.width - width,
+ renderData.height - width
+ );
+ context.setLineDash([]); // Reset to solid
+ context.lineCap = "butt";
+ }
+
+ /**
+ * Draw double border
+ */
+ drawDoubleBorder(context, width, color, renderData) {
+ const gap = Math.max(1, Math.floor(width / 3));
+ const lineWidth = Math.max(1, Math.floor((width - gap) / 2));
+
+ context.strokeStyle = color;
+ context.lineWidth = lineWidth;
+
+ // Outer border
+ context.strokeRect(
+ lineWidth / 2,
+ lineWidth / 2,
+ renderData.width - lineWidth,
+ renderData.height - lineWidth
+ );
+
+ // Inner border
+ const innerOffset = lineWidth + gap;
+ context.strokeRect(
+ innerOffset + lineWidth / 2,
+ innerOffset + lineWidth / 2,
+ renderData.width - innerOffset * 2 - lineWidth,
+ renderData.height - innerOffset * 2 - lineWidth
+ );
+ }
+
+ /**
+ * Draw rainbow animated border
+ */
+ drawRainbowBorder(context, width, animState, speed, renderData) {
+ if (!animState) {
+ // Fallback to solid if no animation
+ this.drawSolidBorder(context, width, "#ff0000", renderData);
+ return;
+ }
+
+ const hue = (animState.progress * speed * 360) % 360;
+ const color = `hsl(${hue}, 80%, 50%)`;
+
+ context.strokeStyle = color;
+ context.lineWidth = width;
+ context.strokeRect(
+ width / 2,
+ width / 2,
+ renderData.width - width,
+ renderData.height - width
+ );
+ }
+
+ /**
+ * Draw marching ants animated border
+ */
+ drawMarchingAntsBorder(context, width, animState, speed, renderData) {
+ if (!animState) {
+ // Fallback to dashed if no animation
+ this.drawDashedBorder(context, width, "#000000", renderData);
+ return;
+ }
+
+ // Animate the dash offset using phase for smooth looping
+ const phase = animState.getPhase(speed);
+ const dashLength = 9; // 4px dash + 5px gap = 9px total
+ const offset = (phase / (Math.PI * 2)) * dashLength;
+
+ context.strokeStyle = "#000000";
+ context.lineWidth = width;
+ context.setLineDash([4, 5]);
+ context.lineDashOffset = -offset;
+ context.strokeRect(
+ width / 2,
+ width / 2,
+ renderData.width - width,
+ renderData.height - width
+ );
+ context.setLineDash([]);
+ context.lineDashOffset = 0;
+ }
+
+ /**
+ * Draw checkerboard border
+ */
+ drawCheckerboardBorder(context, width, color, renderData) {
+ const squareSize = Math.max(2, width);
+ const w = renderData.width;
+ const h = renderData.height;
+
+ // Parse the color
+ const r = parseInt(color.slice(1, 3), 16);
+ const g = parseInt(color.slice(3, 5), 16);
+ const b = parseInt(color.slice(5, 7), 16);
+
+ // Create light and dark versions
+ const darkColor = color;
+ const lightColor = `rgb(${Math.min(255, r + 60)}, ${Math.min(
+ 255,
+ g + 60
+ )}, ${Math.min(255, b + 60)})`;
+
+ // Draw checkerboard on all four sides
+ // Top
+ for (let x = 0; x < w; x += squareSize) {
+ for (let y = 0; y < width; y += squareSize) {
+ const checker = Math.floor(x / squareSize + y / squareSize) % 2;
+ context.fillStyle = checker === 0 ? darkColor : lightColor;
+ context.fillRect(x, y, squareSize, squareSize);
+ }
+ }
+
+ // Bottom
+ for (let x = 0; x < w; x += squareSize) {
+ for (let y = h - width; y < h; y += squareSize) {
+ const checker = Math.floor(x / squareSize + y / squareSize) % 2;
+ context.fillStyle = checker === 0 ? darkColor : lightColor;
+ context.fillRect(x, y, squareSize, squareSize);
+ }
+ }
+
+ // Left
+ for (let x = 0; x < width; x += squareSize) {
+ for (let y = width; y < h - width; y += squareSize) {
+ const checker = Math.floor(x / squareSize + y / squareSize) % 2;
+ context.fillStyle = checker === 0 ? darkColor : lightColor;
+ context.fillRect(x, y, squareSize, squareSize);
+ }
+ }
+
+ // Right
+ for (let x = w - width; x < w; x += squareSize) {
+ for (let y = width; y < h - width; y += squareSize) {
+ const checker = Math.floor(x / squareSize + y / squareSize) % 2;
+ context.fillStyle = checker === 0 ? darkColor : lightColor;
+ context.fillRect(x, y, squareSize, squareSize);
+ }
+ }
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new BorderEffect());
+}
diff --git a/static/js/button-generator/effects/glitch.js b/static/js/button-generator/effects/glitch.js
new file mode 100644
index 0000000..ee78f34
--- /dev/null
+++ b/static/js/button-generator/effects/glitch.js
@@ -0,0 +1,93 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Glitch effect
+ * Creates horizontal scanline displacement for a glitchy look
+ */
+export class GlitchEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'glitch',
+ name: 'Glitch',
+ type: 'general',
+ category: 'Visual Effects',
+ renderOrder: 80
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-glitch',
+ type: 'checkbox',
+ label: 'Glitch Effect',
+ defaultValue: false
+ },
+ {
+ id: 'glitch-intensity',
+ type: 'range',
+ label: 'Glitch Intensity',
+ defaultValue: 5,
+ min: 1,
+ max: 20,
+ step: 1,
+ showWhen: 'animate-glitch',
+ description: 'Maximum pixel offset for glitch'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-glitch'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const intensity = controlValues['glitch-intensity'] || 5;
+ const imageData = context.getImageData(0, 0, renderData.width, renderData.height);
+
+ // Randomly glitch ~10% of scanlines per frame
+ const glitchProbability = 0.1;
+ const maxOffset = intensity;
+
+ for (let y = 0; y < renderData.height; y++) {
+ if (Math.random() < glitchProbability) {
+ const offset = Math.floor((Math.random() - 0.5) * maxOffset * 2);
+ this.shiftScanline(imageData, y, offset, renderData.width);
+ }
+ }
+
+ context.putImageData(imageData, 0, 0);
+ }
+
+ /**
+ * Shift a horizontal scanline by offset pixels (with wrapping)
+ */
+ shiftScanline(imageData, y, offset, 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];
+ }
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new GlitchEffect());
+}
diff --git a/static/js/button-generator/effects/hologram.js b/static/js/button-generator/effects/hologram.js
new file mode 100644
index 0000000..e226aa1
--- /dev/null
+++ b/static/js/button-generator/effects/hologram.js
@@ -0,0 +1,170 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Hologram effect
+ * Creates a futuristic holographic appearance with glitches and scan lines
+ */
+export class HologramEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'hologram',
+ name: 'Hologram',
+ type: 'general',
+ category: 'Visual Effects',
+ renderOrder: 88 // Near the end, after most other effects
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-hologram',
+ type: 'checkbox',
+ label: 'Hologram Effect',
+ defaultValue: false,
+ description: 'Futuristic holographic appearance'
+ },
+ {
+ id: 'hologram-intensity',
+ type: 'range',
+ label: 'Effect Intensity',
+ defaultValue: 50,
+ min: 10,
+ max: 100,
+ step: 5,
+ showWhen: 'animate-hologram',
+ description: 'Strength of hologram effect'
+ },
+ {
+ id: 'hologram-glitch-freq',
+ type: 'range',
+ label: 'Glitch Frequency',
+ defaultValue: 30,
+ min: 0,
+ max: 100,
+ step: 10,
+ showWhen: 'animate-hologram',
+ description: 'How often glitches occur'
+ },
+ {
+ id: 'hologram-color',
+ type: 'color',
+ label: 'Hologram Tint',
+ defaultValue: '#00ffff',
+ showWhen: 'animate-hologram',
+ description: 'Color tint for hologram effect'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-hologram'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const intensity = (controlValues['hologram-intensity'] || 50) / 100;
+ const glitchFreq = (controlValues['hologram-glitch-freq'] || 30) / 100;
+ const color = controlValues['hologram-color'] || '#00ffff';
+
+ // Get current canvas content
+ const imageData = context.getImageData(0, 0, renderData.width, renderData.height);
+ const data = imageData.data;
+
+ // Parse hologram color for tinting
+ const hexColor = color.replace('#', '');
+ const r = parseInt(hexColor.substr(0, 2), 16);
+ const g = parseInt(hexColor.substr(2, 2), 16);
+ const b = parseInt(hexColor.substr(4, 2), 16);
+
+ // Apply holographic tint
+ for (let i = 0; i < data.length; i += 4) {
+ // Mix with hologram color
+ data[i] = data[i] * (1 - intensity * 0.3) + r * intensity * 0.3; // Red
+ data[i + 1] = data[i + 1] * (1 - intensity * 0.5) + g * intensity * 0.5; // Green (more cyan)
+ data[i + 2] = data[i + 2] * (1 - intensity * 0.5) + b * intensity * 0.5; // Blue (more cyan)
+ }
+
+ context.putImageData(imageData, 0, 0);
+
+ // Add horizontal scan lines
+ context.globalAlpha = 0.05 * intensity;
+ context.fillStyle = '#000000';
+ for (let y = 0; y < renderData.height; y += 2) {
+ context.fillRect(0, y, renderData.width, 1);
+ }
+ context.globalAlpha = 1.0;
+
+ // Add moving highlight scan line
+ const scanY = (animState.progress * renderData.height) % renderData.height;
+ const gradient = context.createLinearGradient(0, scanY - 3, 0, scanY + 3);
+ gradient.addColorStop(0, 'rgba(0, 255, 255, 0)');
+ gradient.addColorStop(0.5, `rgba(${r}, ${g}, ${b}, ${0.3 * intensity})`);
+ gradient.addColorStop(1, 'rgba(0, 255, 255, 0)');
+ context.fillStyle = gradient;
+ context.fillRect(0, scanY - 3, renderData.width, 6);
+
+ // Random glitches
+ if (Math.random() < glitchFreq * 0.1) {
+ const glitchY = Math.floor(Math.random() * renderData.height);
+ const glitchHeight = Math.floor(2 + Math.random() * 4);
+ const offset = (Math.random() - 0.5) * 6 * intensity;
+
+ const sliceData = context.getImageData(0, glitchY, renderData.width, glitchHeight);
+ context.putImageData(sliceData, offset, glitchY);
+ }
+
+ // Add chromatic aberration on edges
+ if (intensity > 0.3) {
+ const originalImage = context.getImageData(0, 0, renderData.width, renderData.height);
+ const aberration = 2 * intensity;
+
+ // Slight red shift right
+ const redShift = context.getImageData(0, 0, renderData.width, renderData.height);
+ for (let i = 0; i < redShift.data.length; i += 4) {
+ const pixelIndex = i / 4;
+ const x = pixelIndex % renderData.width;
+ if (x < 3 || x > renderData.width - 3) {
+ const sourceIndex = ((pixelIndex + Math.floor(aberration)) * 4);
+ if (sourceIndex < originalImage.data.length) {
+ redShift.data[i] = originalImage.data[sourceIndex];
+ }
+ }
+ }
+
+ // Slight blue shift left
+ const blueShift = context.getImageData(0, 0, renderData.width, renderData.height);
+ for (let i = 0; i < blueShift.data.length; i += 4) {
+ const pixelIndex = i / 4;
+ const x = pixelIndex % renderData.width;
+ if (x < 3 || x > renderData.width - 3) {
+ const sourceIndex = ((pixelIndex - Math.floor(aberration)) * 4);
+ if (sourceIndex >= 0 && sourceIndex < originalImage.data.length) {
+ blueShift.data[i + 2] = originalImage.data[sourceIndex + 2];
+ }
+ }
+ }
+
+ context.putImageData(redShift, 0, 0);
+ context.globalCompositeOperation = 'screen';
+ context.globalAlpha = 0.3;
+ context.putImageData(blueShift, 0, 0);
+ context.globalCompositeOperation = 'source-over';
+ context.globalAlpha = 1.0;
+ }
+
+ // Add flickering effect
+ if (Math.random() < 0.05) {
+ context.globalAlpha = 0.9 + Math.random() * 0.1;
+ context.fillStyle = 'rgba(255, 255, 255, 0.05)';
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ context.globalAlpha = 1.0;
+ }
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new HologramEffect());
+}
diff --git a/static/js/button-generator/effects/noise.js b/static/js/button-generator/effects/noise.js
new file mode 100644
index 0000000..92db24d
--- /dev/null
+++ b/static/js/button-generator/effects/noise.js
@@ -0,0 +1,68 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Noise/Static effect
+ * Adds random pixel noise for a static/interference look
+ */
+export class NoiseEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'noise',
+ name: 'Noise',
+ type: 'general',
+ category: 'Visual Effects',
+ renderOrder: 90
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-noise',
+ type: 'checkbox',
+ label: 'Noise Effect',
+ defaultValue: false,
+ description: 'Random static/interference'
+ },
+ {
+ id: 'noise-intensity',
+ type: 'range',
+ label: 'Noise Intensity',
+ defaultValue: 0.1,
+ min: 0.01,
+ max: 0.5,
+ step: 0.01,
+ showWhen: 'animate-noise',
+ description: 'Amount of noise'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-noise'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const intensity = controlValues['noise-intensity'] || 0.1;
+ const imageData = context.getImageData(0, 0, renderData.width, renderData.height);
+
+ 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);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new NoiseEffect());
+}
diff --git a/static/js/button-generator/effects/pulse.js b/static/js/button-generator/effects/pulse.js
new file mode 100644
index 0000000..41a745a
--- /dev/null
+++ b/static/js/button-generator/effects/pulse.js
@@ -0,0 +1,62 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Pulse effect
+ * Scales the button content up and down
+ */
+export class PulseEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'pulse',
+ name: 'Pulse',
+ type: 'transform',
+ category: 'Visual Effects',
+ renderOrder: 1 // Must run before any drawing
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-pulse',
+ type: 'checkbox',
+ label: 'Pulse Effect',
+ defaultValue: false
+ },
+ {
+ id: 'pulse-scale',
+ type: 'range',
+ label: 'Pulse Scale',
+ defaultValue: 1.2,
+ min: 1.0,
+ max: 2.0,
+ step: 0.05,
+ showWhen: 'animate-pulse',
+ description: 'Maximum scale size'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-pulse'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const maxScale = controlValues['pulse-scale'] || 1.2;
+ const minScale = 1.0;
+ const scale = minScale + (maxScale - minScale) *
+ (Math.sin(animState.getPhase(1.0)) * 0.5 + 0.5);
+
+ // Apply transformation (context save/restore handled by caller)
+ context.translate(renderData.centerX, renderData.centerY);
+ context.scale(scale, scale);
+ context.translate(-renderData.centerX, -renderData.centerY);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new PulseEffect());
+}
diff --git a/static/js/button-generator/effects/rainbow-text.js b/static/js/button-generator/effects/rainbow-text.js
new file mode 100644
index 0000000..5431dae
--- /dev/null
+++ b/static/js/button-generator/effects/rainbow-text.js
@@ -0,0 +1,99 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Rainbow text animation effect
+ * Cycles text color through rainbow hues
+ */
+export class RainbowTextEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? '' : '2';
+ super({
+ id: `text-rainbow${suffix}`,
+ name: `Rainbow Text ${textLineNumber}`,
+ type: textLineNumber === 1 ? 'text' : 'text2',
+ category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
+ renderOrder: 5, // Apply before wave (lower order)
+ textLineNumber: textLineNumber // Pass through config
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? '' : '2';
+ return [
+ {
+ id: `animate-text-rainbow${suffix}`,
+ type: 'checkbox',
+ label: 'Rainbow Animation',
+ defaultValue: false
+ },
+ {
+ id: `text-rainbow-speed${suffix}`,
+ type: 'range',
+ label: 'Rainbow Speed',
+ defaultValue: 1,
+ min: 0.1,
+ max: 5,
+ step: 0.1,
+ showWhen: `animate-text-rainbow${suffix}`,
+ description: 'Speed of color cycling'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+ return controlValues[`animate-text-rainbow${suffix}`] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return; // Rainbow requires animation
+
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+
+ // Get text configuration
+ const text = controlValues[`button-text${suffix}`] || '';
+ if (!text || text.trim() === '') return;
+
+ // Check if wave is also enabled - if so, skip (wave will handle rainbow)
+ if (controlValues[`animate-text-wave${suffix}`]) return;
+
+ const fontSize = controlValues[`font-size${suffix}`] || 12;
+ const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal';
+ const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal';
+ const fontFamily = controlValues[`font-family${suffix}`] || 'Arial';
+
+ const x = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
+ const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
+
+ const speed = controlValues[`text-rainbow-speed${suffix}`] || 1;
+
+ // Calculate rainbow color
+ const hue = (animState.progress * speed * 360) % 360;
+ const fillStyle = `hsl(${hue}, 80%, 60%)`;
+ const strokeStyle = `hsl(${hue}, 80%, 30%)`;
+
+ // Set font
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+
+ // Draw outline if enabled
+ if (controlValues[`text${suffix}-outline`]) {
+ context.strokeStyle = strokeStyle;
+ context.lineWidth = 2;
+ context.strokeText(text, x, y);
+ }
+
+ // Draw text
+ context.fillStyle = fillStyle;
+ context.fillText(text, x, y);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new RainbowTextEffect(1));
+ generator.registerEffect(new RainbowTextEffect(2));
+}
diff --git a/static/js/button-generator/effects/rgb-split.js b/static/js/button-generator/effects/rgb-split.js
new file mode 100644
index 0000000..63013a7
--- /dev/null
+++ b/static/js/button-generator/effects/rgb-split.js
@@ -0,0 +1,85 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * RGB Split / Chromatic Aberration effect
+ * Separates color channels for a glitchy chromatic aberration look
+ */
+export class RgbSplitEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'rgb-split',
+ name: 'RGB Split',
+ type: 'general',
+ category: 'Visual Effects',
+ renderOrder: 85
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-rgb-split',
+ type: 'checkbox',
+ label: 'RGB Split',
+ defaultValue: false,
+ description: 'Chromatic aberration effect'
+ },
+ {
+ id: 'rgb-split-intensity',
+ type: 'range',
+ label: 'Split Intensity',
+ defaultValue: 2,
+ min: 1,
+ max: 10,
+ step: 0.5,
+ showWhen: 'animate-rgb-split',
+ description: 'Pixel offset for color channels'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-rgb-split'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const intensity = controlValues['rgb-split-intensity'] || 2;
+ const imageData = context.getImageData(0, 0, renderData.width, renderData.height);
+ const result = context.createImageData(renderData.width, renderData.height);
+
+ // Oscillating offset
+ const phase = Math.sin(animState.getPhase(1.0));
+ const offsetX = Math.round(phase * intensity);
+
+ for (let y = 0; y < renderData.height; y++) {
+ for (let x = 0; x < renderData.width; x++) {
+ const idx = (y * renderData.width + x) * 4;
+
+ // Red channel - shift left
+ const redX = Math.max(0, Math.min(renderData.width - 1, x - offsetX));
+ const redIdx = (y * renderData.width + 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(renderData.width - 1, x + offsetX));
+ const blueIdx = (y * renderData.width + 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);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new RgbSplitEffect());
+}
diff --git a/static/js/button-generator/effects/rotate.js b/static/js/button-generator/effects/rotate.js
new file mode 100644
index 0000000..88012cc
--- /dev/null
+++ b/static/js/button-generator/effects/rotate.js
@@ -0,0 +1,72 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Rotate effect
+ * Rotates the button back and forth
+ */
+export class RotateEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'rotate',
+ name: 'Rotate',
+ type: 'transform',
+ category: 'Visual Effects',
+ renderOrder: 2 // Must run before any drawing (after pulse)
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-rotate',
+ type: 'checkbox',
+ label: 'Rotate Effect',
+ defaultValue: false
+ },
+ {
+ id: 'rotate-angle',
+ type: 'range',
+ label: 'Max Angle',
+ defaultValue: 15,
+ min: 1,
+ max: 45,
+ step: 1,
+ showWhen: 'animate-rotate',
+ description: 'Maximum rotation angle in degrees'
+ },
+ {
+ id: 'rotate-speed',
+ type: 'range',
+ label: 'Rotation Speed',
+ defaultValue: 1,
+ min: 0.1,
+ max: 3,
+ step: 0.1,
+ showWhen: 'animate-rotate',
+ description: 'Speed of rotation'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-rotate'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const maxAngle = controlValues['rotate-angle'] || 15;
+ const speed = controlValues['rotate-speed'] || 1;
+ const angle = Math.sin(animState.getPhase(speed)) * maxAngle * (Math.PI / 180);
+
+ // Apply transformation (context save/restore handled by caller)
+ context.translate(renderData.centerX, renderData.centerY);
+ context.rotate(angle);
+ context.translate(-renderData.centerX, -renderData.centerY);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new RotateEffect());
+}
diff --git a/static/js/button-generator/effects/scanline.js b/static/js/button-generator/effects/scanline.js
new file mode 100644
index 0000000..5dab0b7
--- /dev/null
+++ b/static/js/button-generator/effects/scanline.js
@@ -0,0 +1,79 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Scanline effect
+ * Creates CRT-style horizontal lines
+ */
+export class ScanlineEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'scanline',
+ name: 'Scanline',
+ type: 'general',
+ category: 'Visual Effects',
+ renderOrder: 75
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-scanline',
+ type: 'checkbox',
+ label: 'Scanline Effect',
+ defaultValue: false
+ },
+ {
+ id: 'scanline-intensity',
+ type: 'range',
+ label: 'Scanline Intensity',
+ defaultValue: 0.3,
+ min: 0.1,
+ max: 0.8,
+ step: 0.05,
+ showWhen: 'animate-scanline',
+ description: 'Darkness of scanlines'
+ },
+ {
+ id: 'scanline-speed',
+ type: 'range',
+ label: 'Scanline Speed',
+ defaultValue: 1,
+ min: 0.1,
+ max: 3,
+ step: 0.1,
+ showWhen: 'animate-scanline',
+ description: 'Movement speed of scanlines'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-scanline'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const intensity = controlValues['scanline-intensity'] || 0.3;
+ const speed = controlValues['scanline-speed'] || 1;
+
+ // Create overlay with scanlines
+ context.globalCompositeOperation = 'multiply';
+ context.fillStyle = `rgba(0, 0, 0, ${intensity})`;
+
+ // Animate scanline position
+ const offset = (animState.progress * speed * renderData.height) % 2;
+
+ for (let y = offset; y < renderData.height; y += 2) {
+ context.fillRect(0, Math.floor(y), renderData.width, 1);
+ }
+
+ context.globalCompositeOperation = 'source-over';
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new ScanlineEffect());
+}
diff --git a/static/js/button-generator/effects/shimmer.js b/static/js/button-generator/effects/shimmer.js
new file mode 100644
index 0000000..327c124
--- /dev/null
+++ b/static/js/button-generator/effects/shimmer.js
@@ -0,0 +1,57 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Shimmer effect
+ * Creates a sweeping light/shine effect across the button
+ */
+export class ShimmerEffect extends ButtonEffect {
+ constructor() {
+ super({
+ id: 'shimmer',
+ name: 'Shimmer',
+ type: 'general',
+ category: 'Visual Effects',
+ renderOrder: 70
+ });
+ }
+
+ defineControls() {
+ return [
+ {
+ id: 'animate-shimmer',
+ type: 'checkbox',
+ label: 'Shimmer Effect',
+ defaultValue: false,
+ description: 'Sweeping light effect'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ return controlValues['animate-shimmer'] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return;
+
+ const shimmerX = animState.progress * (renderData.width + 40) - 20;
+
+ const gradient = context.createLinearGradient(
+ shimmerX - 15,
+ 0,
+ shimmerX + 15,
+ renderData.height
+ );
+ gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
+ gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.3)');
+ gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
+
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new ShimmerEffect());
+}
diff --git a/static/js/button-generator/effects/spin-text.js b/static/js/button-generator/effects/spin-text.js
new file mode 100644
index 0000000..7c4b3ef
--- /dev/null
+++ b/static/js/button-generator/effects/spin-text.js
@@ -0,0 +1,162 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Spinning text animation effect
+ * Makes each character rotate independently
+ */
+export class SpinTextEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? "" : "2";
+ super({
+ id: `text-spin${suffix}`,
+ name: `Spinning Text ${textLineNumber}`,
+ type: textLineNumber === 1 ? "text" : "text2",
+ category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2",
+ renderOrder: 8, // Before wave, after rainbow
+ textLineNumber: textLineNumber,
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ const textLineNumber =
+ this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? "" : "2";
+ return [
+ {
+ id: `animate-text-spin${suffix}`,
+ type: "checkbox",
+ label: "Spinning Animation",
+ defaultValue: false,
+ },
+ {
+ id: `spin-speed${suffix}`,
+ type: "range",
+ label: "Spin Speed",
+ defaultValue: 1,
+ min: 1,
+ max: 5,
+ step: 1,
+ showWhen: `animate-text-spin${suffix}`,
+ description: "Speed of character rotation",
+ },
+ {
+ id: `spin-stagger${suffix}`,
+ type: "range",
+ label: "Spin Stagger",
+ defaultValue: 0.3,
+ min: 0,
+ max: 1,
+ step: 0.1,
+ showWhen: `animate-text-spin${suffix}`,
+ description: "Delay between characters",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+ return controlValues[`animate-text-spin${suffix}`] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+ const text = controlValues[`button-text${suffix}`] || "";
+
+ if (!text || text.trim() === '') return;
+ if (!animState) return;
+
+ const speed = controlValues[`spin-speed${suffix}`] || 1;
+ const stagger = controlValues[`spin-stagger${suffix}`] || 0.3;
+ const fontSize = controlValues[`font-size${suffix}`] || 14;
+ const fontFamily = controlValues[`font-family${suffix}`] || "Arial";
+ const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal";
+ const fontStyle = controlValues[`text${suffix}-italic`]
+ ? "italic"
+ : "normal";
+
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+
+ // Get text color
+ let fillStyle;
+ const colorType = controlValues[`text${suffix}-color-type`] || "solid";
+ if (colorType === "gradient") {
+ const color1 =
+ controlValues[`text${suffix}-gradient-color1`] || "#ffffff";
+ const color2 =
+ controlValues[`text${suffix}-gradient-color2`] || "#00ffff";
+ const angle =
+ (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180);
+ const centerX = renderData.centerX;
+ const centerY = renderData.centerY;
+ const x1 = centerX + Math.cos(angle) * 20;
+ const y1 = centerY + Math.sin(angle) * 20;
+ const x2 = centerX - Math.cos(angle) * 20;
+ const y2 = centerY - Math.sin(angle) * 20;
+ const gradient = context.createLinearGradient(x1, y1, x2, y2);
+ gradient.addColorStop(0, color1);
+ gradient.addColorStop(1, color2);
+ fillStyle = gradient;
+ } else {
+ fillStyle = controlValues[`text${suffix}-color`] || "#ffffff";
+ }
+
+ // Calculate base position
+ const x = controlValues[`text${suffix}-x`] || 50;
+ const y = controlValues[`text${suffix}-y`] || 50;
+ const baseX = (x / 100) * renderData.width;
+ const baseY = (y / 100) * renderData.height;
+
+ // Split text into grapheme clusters (handles emojis properly)
+ // Use Intl.Segmenter if available, otherwise fall back to spread operator
+ let chars;
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
+ chars = Array.from(segmenter.segment(text), s => s.segment);
+ } else {
+ // Fallback: spread operator handles basic emoji
+ chars = [...text];
+ }
+
+ // Measure total text width for centering
+ const totalWidth = context.measureText(text).width;
+ let currentX = baseX - totalWidth / 2;
+
+ // Draw each character with rotation
+ for (let i = 0; i < chars.length; i++) {
+ const char = chars[i];
+ const charWidth = context.measureText(char).width;
+ const charCenterX = currentX + charWidth / 2;
+
+ // Calculate rotation for this character
+ const phase = animState.getPhase(speed) + i * stagger * Math.PI * 2;
+ const rotation = phase % (Math.PI * 2);
+
+ context.save();
+ context.translate(charCenterX, baseY);
+ context.rotate(rotation);
+
+ // Apply outline if enabled
+ if (controlValues[`text${suffix}-outline`]) {
+ context.strokeStyle =
+ controlValues[`text${suffix}-outline-color`] || "#000000";
+ context.lineWidth = 2;
+ context.strokeText(char, 0, 0);
+ }
+
+ context.fillStyle = fillStyle;
+ context.fillText(char, 0, 0);
+ context.restore();
+
+ currentX += charWidth;
+ }
+ }
+}
+
+// Auto-register effects for both text lines
+export function register(generator) {
+ generator.registerEffect(new SpinTextEffect(1));
+ generator.registerEffect(new SpinTextEffect(2));
+}
diff --git a/static/js/button-generator/effects/spotlight.js b/static/js/button-generator/effects/spotlight.js
new file mode 100644
index 0000000..cfd79fb
--- /dev/null
+++ b/static/js/button-generator/effects/spotlight.js
@@ -0,0 +1,199 @@
+/**
+ * EXAMPLE EFFECT
+ *
+ * This is a template for creating new effects.
+ * Copy this file and modify it to create your own custom effects.
+ *
+ * This example creates a "spotlight" effect that highlights a circular area
+ * and darkens the rest of the button.
+ */
+
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Spotlight Effect
+ * Creates a moving circular spotlight that highlights different areas
+ */
+export class SpotlightEffect extends ButtonEffect {
+ constructor() {
+ super({
+ // Unique ID for this effect (used in control IDs)
+ id: "spotlight",
+
+ // Display name shown in UI
+ name: "Spotlight",
+
+ // Effect type determines render order category
+ // Options: 'background', 'border', 'text', 'text2', 'general'
+ type: "general",
+
+ // Category for organizing effects in UI
+ category: "Visual Effects",
+
+ // Render order within type (lower = earlier)
+ // 1-9: backgrounds, 10-19: borders, 20-29: transforms,
+ // 30-49: text, 50-79: overlays, 80-99: post-processing
+ renderOrder: 60,
+ });
+ }
+
+ /**
+ * Define UI controls for this effect
+ * These controls will be automatically bound to the generator
+ */
+ defineControls() {
+ return [
+ // Main enable/disable checkbox
+ {
+ id: "animate-spotlight",
+ type: "checkbox",
+ label: "Spotlight Effect",
+ defaultValue: false,
+ description: "Moving circular spotlight",
+ },
+
+ // Spotlight size control
+ {
+ id: "spotlight-size",
+ type: "range",
+ label: "Spotlight Size",
+ defaultValue: 20,
+ min: 10,
+ max: 50,
+ step: 1,
+ showWhen: "animate-spotlight", // Only show when checkbox is enabled
+ description: "Radius of the spotlight",
+ },
+
+ // Darkness of the vignette
+ {
+ id: "spotlight-darkness",
+ type: "range",
+ label: "Darkness",
+ defaultValue: 1,
+ min: 0,
+ max: 1,
+ step: 0.1,
+ showWhen: "animate-spotlight",
+ description: "How dark the non-spotlight area should be",
+ },
+
+ // Speed of movement
+ {
+ id: "spotlight-speed",
+ type: "range",
+ label: "Movement Speed",
+ defaultValue: 1,
+ min: 1,
+ max: 3,
+ step: 1,
+ showWhen: "animate-spotlight",
+ description: "Speed of spotlight movement",
+ },
+ ];
+ }
+
+ /**
+ * Determine if this effect should be applied
+ * @param {Object} controlValues - Current values of all controls
+ * @returns {boolean}
+ */
+ isEnabled(controlValues) {
+ return controlValues["animate-spotlight"] === true;
+ }
+
+ /**
+ * Apply the effect to the canvas
+ *
+ * @param {CanvasRenderingContext2D} context - Canvas 2D rendering context
+ * @param {Object} controlValues - Current values of all controls
+ * @param {AnimationState|null} animState - Animation state (null for static render)
+ * @param {Object} renderData - Render information: { width, height, centerX, centerY }
+ */
+ apply(context, controlValues, animState, renderData) {
+ // Skip if no animation (spotlight needs movement)
+ if (!animState) return;
+
+ // Get control values
+ const size = controlValues["spotlight-size"] || 20;
+ const darkness = controlValues["spotlight-darkness"] || 0.5;
+ const speed = controlValues["spotlight-speed"] || 1;
+
+ // Calculate spotlight position
+ // Move in a circular pattern using animation phase
+ const phase = animState.getPhase(speed);
+ const spotX = renderData.centerX + Math.cos(phase) * 20;
+ const spotY = renderData.centerY + Math.sin(phase) * 10;
+
+ // Create radial gradient for spotlight effect
+ const gradient = context.createRadialGradient(
+ spotX,
+ spotY,
+ 0, // Inner circle (center of spotlight)
+ spotX,
+ spotY,
+ size, // Outer circle (edge of spotlight)
+ );
+
+ // Center is transparent (spotlight is bright)
+ gradient.addColorStop(0, `rgba(0, 0, 0, 0)`);
+ // Edge fades to dark
+ gradient.addColorStop(0.5, `rgba(0, 0, 0, ${darkness * 0.3})`);
+ gradient.addColorStop(1, `rgba(0, 0, 0, ${darkness})`);
+
+ // Apply the gradient as an overlay
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
+ // Optional: Add a bright center dot
+ context.fillStyle = "rgba(255, 255, 255, 0.3)";
+ context.beginPath();
+ context.arc(spotX, spotY, 2, 0, Math.PI * 2);
+ context.fill();
+ }
+
+ /**
+ * Optional: Add helper methods for your effect
+ */
+ calculateSpotlightPath(progress, width, height) {
+ // Example helper method
+ return {
+ x: width * progress,
+ y: height / 2,
+ };
+ }
+}
+
+/**
+ * Registration function
+ * This is called to add the effect to the generator
+ *
+ * @param {ButtonGenerator} generator - The button generator instance
+ */
+export function register(generator) {
+ generator.registerEffect(new SpotlightEffect());
+}
+
+/**
+ * USAGE:
+ *
+ * 1. Copy this file to a new name (e.g., my-effect.js)
+ * 2. Modify the class name, id, and effect logic
+ * 3. Import in main.js:
+ * import * as myEffect from './effects/my-effect.js';
+ * 4. Register in setupApp():
+ * myEffect.register(generator);
+ * 5. Add HTML controls with matching IDs
+ */
+
+/**
+ * TIPS:
+ *
+ * - Use animState.progress for linear animations (0 to 1)
+ * - Use animState.getPhase(speed) for periodic animations (0 to 2π)
+ * - Use Math.sin/cos for smooth periodic motion
+ * - Check if (!animState) at the start if your effect requires animation
+ * - The context is automatically saved/restored, so feel free to transform
+ * - Use renderData for canvas dimensions and center point
+ * - Look at existing effects for more examples
+ */
diff --git a/static/js/button-generator/effects/text-shadow.js b/static/js/button-generator/effects/text-shadow.js
new file mode 100644
index 0000000..1d7a882
--- /dev/null
+++ b/static/js/button-generator/effects/text-shadow.js
@@ -0,0 +1,167 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Text Drop Shadow Effect
+ * Renders text with a drop shadow underneath
+ * This draws the shadow first, then standard text rendering draws on top
+ */
+export class TextShadowEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? "" : "2";
+ super({
+ id: `text-shadow${suffix}`,
+ name: `Drop Shadow ${textLineNumber}`,
+ type: textLineNumber === 1 ? "text" : "text2",
+ category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2",
+ renderOrder: 19, // Before standard text (20), so shadow draws first
+ textLineNumber: textLineNumber,
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ const textLineNumber =
+ this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? "" : "2";
+
+ return [
+ {
+ id: `text${suffix}-shadow-enabled`,
+ type: "checkbox",
+ label: "Drop Shadow",
+ defaultValue: false,
+ description:
+ "Add drop shadow to text - Not compatible with other text effects!!",
+ },
+ {
+ id: `text${suffix}-shadow-color`,
+ type: "color",
+ label: "Shadow Color",
+ defaultValue: "#000000",
+ showWhen: `text${suffix}-shadow-enabled`,
+ description: "Color of the shadow",
+ },
+ {
+ id: `text${suffix}-shadow-blur`,
+ type: "range",
+ label: "Shadow Blur",
+ defaultValue: 4,
+ min: 0,
+ max: 10,
+ step: 1,
+ showWhen: `text${suffix}-shadow-enabled`,
+ description: "Blur radius of shadow",
+ },
+ {
+ id: `text${suffix}-shadow-offset-x`,
+ type: "range",
+ label: "Shadow X Offset",
+ defaultValue: 2,
+ min: -10,
+ max: 10,
+ step: 1,
+ showWhen: `text${suffix}-shadow-enabled`,
+ description: "Horizontal shadow offset",
+ },
+ {
+ id: `text${suffix}-shadow-offset-y`,
+ type: "range",
+ label: "Shadow Y Offset",
+ defaultValue: 2,
+ min: -10,
+ max: 10,
+ step: 1,
+ showWhen: `text${suffix}-shadow-enabled`,
+ description: "Vertical shadow offset",
+ },
+ {
+ id: `text${suffix}-shadow-opacity`,
+ type: "range",
+ label: "Shadow Opacity",
+ defaultValue: 0.8,
+ min: 0,
+ max: 1,
+ step: 0.1,
+ showWhen: `text${suffix}-shadow-enabled`,
+ description: "Opacity of the shadow",
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+ const text = controlValues[`button-text${suffix}`];
+ const shadowEnabled = controlValues[`text${suffix}-shadow-enabled`];
+ return text && text.trim() !== "" && shadowEnabled;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+
+ const text = controlValues[`button-text${suffix}`] || "";
+ if (!text) return;
+
+ // Get shadow settings
+ const shadowColor =
+ controlValues[`text${suffix}-shadow-color`] || "#000000";
+ const shadowBlur = controlValues[`text${suffix}-shadow-blur`] || 4;
+ const shadowOffsetX = controlValues[`text${suffix}-shadow-offset-x`] || 2;
+ const shadowOffsetY = controlValues[`text${suffix}-shadow-offset-y`] || 2;
+ const shadowOpacity = controlValues[`text${suffix}-shadow-opacity`] || 0.8;
+
+ // Get text rendering settings
+ const fontSize = controlValues[`font-size${suffix}`] || 14;
+ const fontFamily = controlValues[`font-family${suffix}`] || "Arial";
+ const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal";
+ const fontStyle = controlValues[`text${suffix}-italic`]
+ ? "italic"
+ : "normal";
+ const textX = (controlValues[`text${suffix}-x`] || 50) / 100;
+ const textY = (controlValues[`text${suffix}-y`] || 50) / 100;
+
+ // Convert hex to rgba
+ const hexToRgba = (hex, alpha) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ if (result) {
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+ }
+ return `rgba(0, 0, 0, ${alpha})`;
+ };
+
+ // Set up text rendering
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+
+ // Calculate text position
+ const x = renderData.width * textX;
+ const y = renderData.height * textY;
+
+ // Draw the shadow using the shadow API
+ // This will create a shadow underneath whatever we draw
+ context.shadowColor = hexToRgba(shadowColor, shadowOpacity);
+ context.shadowBlur = shadowBlur;
+ context.shadowOffsetX = shadowOffsetX;
+ context.shadowOffsetY = shadowOffsetY;
+
+ // Draw a solid shadow by filling with the shadow color
+ // The shadow API will create the blur effect
+ context.fillStyle = hexToRgba(shadowColor, shadowOpacity);
+ context.fillText(text, x, y);
+
+ // Reset shadow for subsequent renders
+ context.shadowColor = "transparent";
+ context.shadowBlur = 0;
+ context.shadowOffsetX = 0;
+ context.shadowOffsetY = 0;
+ }
+}
+
+// Export two instances for text line 1 and text line 2
+export function register(generator) {
+ generator.registerEffect(new TextShadowEffect(1));
+ generator.registerEffect(new TextShadowEffect(2));
+}
diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js
new file mode 100644
index 0000000..fb90f87
--- /dev/null
+++ b/static/js/button-generator/effects/text-standard.js
@@ -0,0 +1,243 @@
+import { ButtonEffect } from "../effect-base.js";
+
+/**
+ * Standard text rendering effect
+ * Renders static text (when no animations are active)
+ */
+export class StandardTextEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? "" : "2";
+ super({
+ id: `text-standard${suffix}`,
+ name: `Standard Text ${textLineNumber}`,
+ type: textLineNumber === 1 ? "text" : "text2",
+ category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2",
+ renderOrder: 20, // After animations
+ textLineNumber: textLineNumber, // Pass through config so defineControls can access it
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ // Access from config since this is called before constructor completes
+ const textLineNumber =
+ this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? "" : "2";
+ return [
+ {
+ id: `button-text${suffix}`,
+ type: "text",
+ label: `Text Line ${textLineNumber}`,
+ defaultValue: textLineNumber === 1 ? "RITUAL.SH" : "",
+ },
+ {
+ id: `font-size${suffix}`,
+ type: "range",
+ label: "Font Size",
+ min: 6,
+ max: 24,
+ defaultValue: textLineNumber === 1 ? 14 : 12,
+ },
+ {
+ id: `text${suffix}-x`,
+ type: "range",
+ label: "Horizontal Position",
+ min: 0,
+ max: 100,
+ defaultValue: 50,
+ description: "Percentage from left",
+ },
+ {
+ id: `text${suffix}-y`,
+ type: "range",
+ label: "Vertical Position",
+ min: 0,
+ max: 100,
+ defaultValue: textLineNumber === 1 ? 35 : 65,
+ description: "Percentage from top",
+ },
+ {
+ id: `text${suffix}-color-type`,
+ type: "select",
+ label: "Color Type",
+ defaultValue: "solid",
+ options: [
+ { value: "solid", label: "Solid Color" },
+ { value: "gradient", label: "Gradient" },
+ ],
+ },
+ {
+ id: `text${suffix}-color`,
+ type: "color",
+ label: "Text Color",
+ defaultValue: "#ffffff",
+ showWhen: `text${suffix}-color-type`,
+ },
+ {
+ id: `text${suffix}-gradient-color1`,
+ type: "color",
+ label: "Gradient Color 1",
+ defaultValue: "#ffffff",
+ showWhen: `text${suffix}-color-type`,
+ },
+ {
+ id: `text${suffix}-gradient-color2`,
+ type: "color",
+ label: "Gradient Color 2",
+ defaultValue: "#00ffff",
+ showWhen: `text${suffix}-color-type`,
+ },
+ {
+ id: `text${suffix}-gradient-angle`,
+ type: "range",
+ label: "Gradient Angle",
+ min: 0,
+ max: 360,
+ defaultValue: 0,
+ showWhen: `text${suffix}-color-type`,
+ },
+ {
+ id: `text${suffix}-outline`,
+ type: "checkbox",
+ label: "Outline",
+ defaultValue: false,
+ },
+ {
+ id: `outline${suffix}-color`,
+ type: "color",
+ label: "Outline Color",
+ defaultValue: "#000000",
+ showWhen: `text${suffix}-outline`,
+ },
+ {
+ id: `font-family${suffix}`,
+ type: "select",
+ label: "Font",
+ defaultValue: "Lato",
+ options: [
+ { value: "Lato", label: "Lato" },
+ { value: "Roboto", label: "Roboto" },
+ { value: "Open Sans", label: "Open Sans" },
+ { value: "Montserrat", label: "Montserrat" },
+ { value: "Oswald", label: "Oswald" },
+ { value: "Bebas Neue", label: "Bebas Neue" },
+ { value: "Roboto Mono", label: "Roboto Mono" },
+ { value: "VT323", label: "VT323" },
+ { value: "Press Start 2P", label: "Press Start 2P" },
+ { value: "DSEG7-Classic", label: "DSEG7" },
+ ],
+ },
+ {
+ id: `font-bold${suffix}`,
+ type: "checkbox",
+ label: "Bold",
+ defaultValue: false,
+ },
+ {
+ id: `font-italic${suffix}`,
+ type: "checkbox",
+ label: "Italic",
+ defaultValue: false,
+ },
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+ const text = controlValues[`button-text${suffix}`];
+
+ // Only render if text exists and no animations are active on this text
+ const waveActive = controlValues[`animate-text-wave${suffix}`];
+ const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
+ const spinActive = controlValues[`animate-text-spin${suffix}`];
+
+ return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+
+ const text = controlValues[`button-text${suffix}`];
+ if (!text) return;
+
+ const fontSize = controlValues[`font-size${suffix}`] || 12;
+ const fontWeight = controlValues[`font-bold${suffix}`] ? "bold" : "normal";
+ const fontStyle = controlValues[`font-italic${suffix}`]
+ ? "italic"
+ : "normal";
+ const fontFamily = controlValues[`font-family${suffix}`] || "Arial";
+
+ const x = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
+ const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
+
+ // Set font
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+
+ // Get colors
+ const colors = this.getTextColors(
+ context,
+ controlValues,
+ text,
+ x,
+ y,
+ fontSize,
+ );
+
+ // Draw outline if enabled
+ if (controlValues[`text${suffix}-outline`]) {
+ context.strokeStyle = colors.strokeStyle;
+ context.lineWidth = 2;
+ context.strokeText(text, x, y);
+ }
+
+ // Draw text
+ context.fillStyle = colors.fillStyle;
+ context.fillText(text, x, y);
+ }
+
+ /**
+ * Get text colors (solid or gradient)
+ */
+ getTextColors(context, controlValues, text, x, y, fontSize) {
+ const suffix = this.textLineNumber === 1 ? "" : "2";
+ const colorType = controlValues[`text${suffix}-color-type`] || "solid";
+
+ let fillStyle, strokeStyle;
+
+ if (colorType === "solid") {
+ fillStyle = controlValues[`text${suffix}-color`] || "#ffffff";
+ strokeStyle = controlValues[`outline${suffix}-color`] || "#000000";
+ } else {
+ // Gradient
+ const angle =
+ (controlValues[`text${suffix}-gradient-angle`] || 0) * (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 gradient = context.createLinearGradient(x1, y1, x2, y2);
+ gradient.addColorStop(
+ 0,
+ controlValues[`text${suffix}-gradient-color1`] || "#ffffff",
+ );
+ gradient.addColorStop(
+ 1,
+ controlValues[`text${suffix}-gradient-color2`] || "#00ffff",
+ );
+ fillStyle = gradient;
+ strokeStyle = controlValues[`outline${suffix}-color`] || "#000000";
+ }
+
+ return { fillStyle, strokeStyle };
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new StandardTextEffect(1));
+ generator.registerEffect(new StandardTextEffect(2));
+}
diff --git a/static/js/button-generator/effects/wave-text.js b/static/js/button-generator/effects/wave-text.js
new file mode 100644
index 0000000..61e097c
--- /dev/null
+++ b/static/js/button-generator/effects/wave-text.js
@@ -0,0 +1,177 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Wave text animation effect
+ * Makes text characters wave up and down in a sine wave pattern
+ */
+export class WaveTextEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? '' : '2';
+ super({
+ id: `text-wave${suffix}`,
+ name: `Text Wave ${textLineNumber}`,
+ type: textLineNumber === 1 ? 'text' : 'text2',
+ category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
+ renderOrder: 10,
+ textLineNumber: textLineNumber // Pass through config
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? '' : '2';
+ return [
+ {
+ id: `animate-text-wave${suffix}`,
+ type: 'checkbox',
+ label: 'Wave Animation',
+ defaultValue: false
+ },
+ {
+ id: `wave-amplitude${suffix}`,
+ type: 'range',
+ label: 'Wave Amplitude',
+ defaultValue: 3,
+ min: 1,
+ max: 10,
+ step: 0.5,
+ showWhen: `animate-text-wave${suffix}`,
+ description: 'Height of the wave motion'
+ },
+ {
+ id: `wave-speed${suffix}`,
+ type: 'range',
+ label: 'Wave Speed',
+ defaultValue: 1,
+ min: 0.1,
+ max: 3,
+ step: 0.1,
+ showWhen: `animate-text-wave${suffix}`,
+ description: 'Speed of the wave animation'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+ return controlValues[`animate-text-wave${suffix}`] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return; // Wave requires animation
+
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+
+ // Get text configuration
+ const text = controlValues[`button-text${suffix}`] || '';
+ if (!text || text.trim() === '') return;
+
+ const fontSize = controlValues[`font-size${suffix}`] || 12;
+ const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal';
+ const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal';
+ const fontFamily = controlValues[`font-family${suffix}`] || 'Arial';
+
+ const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
+ const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
+
+ const amplitude = controlValues[`wave-amplitude${suffix}`] || 3;
+ const speed = controlValues[`wave-speed${suffix}`] || 1;
+
+ // Set font
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+
+ // Get colors
+ const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState);
+
+ // Split text into grapheme clusters (handles emojis properly)
+ // Use Intl.Segmenter if available, otherwise fall back to spread operator
+ let chars;
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
+ chars = Array.from(segmenter.segment(text), s => s.segment);
+ } else {
+ // Fallback: spread operator handles basic emoji
+ chars = [...text];
+ }
+
+ // Measure total width for centering
+ const totalWidth = context.measureText(text).width;
+ let currentX = baseX - totalWidth / 2;
+
+ // Draw each character with wave offset
+ for (let i = 0; i < chars.length; i++) {
+ const char = chars[i];
+ const charWidth = context.measureText(char).width;
+
+ // Calculate wave offset for this character
+ const phase = animState.getPhase(speed);
+ const charOffset = i / chars.length;
+ const waveY = Math.sin(phase + charOffset * Math.PI * 2) * amplitude;
+
+ const charX = currentX + charWidth / 2;
+ const charY = baseY + waveY;
+
+ // Draw outline if enabled
+ if (controlValues[`text${suffix}-outline`]) {
+ 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)
+ */
+ getTextColors(context, controlValues, text, x, y, fontSize, animState) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+
+ let fillStyle, strokeStyle;
+
+ // Check if rainbow text is also enabled
+ if (animState && controlValues[`animate-text-rainbow${suffix}`]) {
+ const speed = controlValues[`text-rainbow-speed${suffix}`] || 1;
+ const hue = (animState.progress * speed * 360) % 360;
+ fillStyle = `hsl(${hue}, 80%, 60%)`;
+ strokeStyle = `hsl(${hue}, 80%, 30%)`;
+ } else {
+ const colorType = controlValues[`text${suffix}-color-type`] || 'solid';
+
+ if (colorType === 'solid') {
+ fillStyle = controlValues[`text${suffix}-color`] || '#ffffff';
+ strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
+ } else {
+ // Gradient
+ const angle = (controlValues[`text${suffix}-gradient-angle`] || 0) * (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 gradient = context.createLinearGradient(x1, y1, x2, y2);
+ gradient.addColorStop(0, controlValues[`text${suffix}-gradient-color1`] || '#ffffff');
+ gradient.addColorStop(1, controlValues[`text${suffix}-gradient-color2`] || '#00ffff');
+ fillStyle = gradient;
+ strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
+ }
+ }
+
+ return { fillStyle, strokeStyle };
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new WaveTextEffect(1));
+ generator.registerEffect(new WaveTextEffect(2));
+}
diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js
new file mode 100644
index 0000000..030236d
--- /dev/null
+++ b/static/js/button-generator/main.js
@@ -0,0 +1,472 @@
+/**
+ * Button Generator - Main Application File
+ *
+ * This demonstrates how to use the modular button generator system.
+ * Effects are imported and registered with the generator.
+ */
+
+import { ButtonGenerator } from "./button-generator-core.js";
+import { UIBuilder } from "./ui-builder.js";
+
+// Import effects (each effect file exports a register() function)
+import * as solidBg from "./effects/background-solid.js";
+import * as gradientBg from "./effects/background-gradient.js";
+import * as textureBg from "./effects/background-texture.js";
+import * as emojiWallpaper from "./effects/background-emoji-wallpaper.js";
+import * as externalImage from "./effects/background-external-image.js";
+import * as rainbowBg from "./effects/background-rainbow.js";
+import * as rain from "./effects/background-rain.js";
+import * as starfield from "./effects/background-starfield.js";
+//import * as bubbles from "./effects/background-bubbles.js";
+import * as aurora from "./effects/background-aurora.js";
+import * as fire from "./effects/background-fire.js";
+import * as border from "./effects/border.js";
+import * as standardText from "./effects/text-standard.js";
+import * as textShadow from "./effects/text-shadow.js";
+import * as waveText from "./effects/wave-text.js";
+import * as rainbowText from "./effects/rainbow-text.js";
+import * as spinText from "./effects/spin-text.js";
+import * as glitch from "./effects/glitch.js";
+import * as pulse from "./effects/pulse.js";
+import * as shimmer from "./effects/shimmer.js";
+import * as scanline from "./effects/scanline.js";
+import * as rgbSplit from "./effects/rgb-split.js";
+import * as noise from "./effects/noise.js";
+import * as rotate from "./effects/rotate.js";
+import * as hologram from "./effects/hologram.js";
+import * as spotlight from "./effects/spotlight.js";
+
+/**
+ * Initialize the button generator application
+ */
+export function init() {
+ // Wait for DOM to be ready
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", setupApp);
+ } else {
+ setupApp();
+ }
+}
+
+/**
+ * Setup the application
+ */
+async function setupApp() {
+ console.log("Initializing Button Generator...");
+
+ // Setup collapsible sections
+ setupCollapsible();
+
+ // Get canvas
+ const canvas = document.getElementById("button-canvas");
+ if (!canvas) {
+ console.error("Canvas element not found!");
+ return;
+ }
+
+ // Create button generator
+ const generator = new ButtonGenerator(canvas, {
+ fps: 20,
+ duration: 2,
+ fonts: [
+ "Arial",
+ "Lato",
+ "Roboto",
+ "Open Sans",
+ "Montserrat",
+ "Oswald",
+ "Bebas Neue",
+ "Roboto Mono",
+ "VT323",
+ "Press Start 2P",
+ "DSEG7-Classic",
+ ],
+ });
+
+ // Register all effects
+ console.log("Registering effects...");
+ solidBg.register(generator);
+ gradientBg.register(generator);
+ textureBg.register(generator);
+ emojiWallpaper.register(generator);
+ externalImage.register(generator);
+ rainbowBg.register(generator);
+ rain.register(generator);
+ starfield.register(generator);
+ //bubbles.register(generator);
+ aurora.register(generator);
+ fire.register(generator);
+ border.register(generator);
+ standardText.register(generator);
+ textShadow.register(generator);
+ waveText.register(generator);
+ rainbowText.register(generator);
+ spinText.register(generator);
+ glitch.register(generator);
+ pulse.register(generator);
+ shimmer.register(generator);
+ scanline.register(generator);
+ rgbSplit.register(generator);
+ noise.register(generator);
+ rotate.register(generator);
+ hologram.register(generator);
+ spotlight.register(generator);
+
+ console.log(`Registered ${generator.getAllEffects().length} effects`);
+
+ // Build UI from effects
+ console.log("Building UI...");
+ const controlsContainer = document.querySelector(".controls-section");
+ if (!controlsContainer) {
+ console.error("Controls container not found!");
+ return;
+ }
+
+ const uiBuilder = new UIBuilder(controlsContainer);
+ uiBuilder.buildUI(generator.getAllEffects());
+ uiBuilder.setupConditionalVisibility();
+
+ // Add GIF export settings at the bottom
+ addGifExportSettings(controlsContainer, generator);
+
+ // Preload fonts
+ console.log("Loading fonts...");
+ await generator.preloadFonts();
+
+ // Bind controls (after UI is built)
+ generator.bindControls();
+
+ // Setup additional UI handlers
+ setupUIHandlers(generator);
+
+ // Setup download button
+ setupDownloadButton(generator);
+
+ // Setup presets
+ setupPresets(generator);
+
+ // Initial draw
+ generator.updatePreview();
+
+ console.log("Button Generator ready!");
+}
+
+/**
+ * Add GIF export settings controls
+ */
+function addGifExportSettings(container, generator) {
+ const groupDiv = document.createElement('div');
+ groupDiv.className = 'control-group collapsed';
+
+ // Header
+ const header = document.createElement('h3');
+ header.className = 'control-group-header';
+ header.textContent = 'Advanced Settings';
+ groupDiv.appendChild(header);
+
+ const controlsDiv = document.createElement('div');
+ controlsDiv.className = 'control-group-controls';
+
+ // Color count control
+ const colorsWrapper = document.createElement('div');
+ colorsWrapper.className = 'control-wrapper';
+ colorsWrapper.innerHTML = `
+ Note: This only affects frame-by-frame settings, i.e. 8 colours would be 8 colours per frame. I am working on a solution for this.
+
+ Color Count
+ (16-32 for the 90s experience)
+
+
+
+ 256
+
+ `;
+ controlsDiv.appendChild(colorsWrapper);
+
+ // Quality control
+ const qualityWrapper = document.createElement('div');
+ qualityWrapper.className = 'control-wrapper';
+ qualityWrapper.innerHTML = `
+
+ Quantization Quality
+ (lower = better but slower export)
+
+
+
+ 1
+
+ `;
+ controlsDiv.appendChild(qualityWrapper);
+
+ // Dither control
+ const ditherWrapper = document.createElement('div');
+ ditherWrapper.className = 'control-wrapper';
+ ditherWrapper.innerHTML = `
+
+ Dithering Pattern
+
+
+
+ None (Smooth)
+ Floyd-Steinberg (Classic)
+ False Floyd-Steinberg (Fast)
+ Stucki (Distributed)
+ Atkinson (Retro Mac)
+
+ `;
+ controlsDiv.appendChild(ditherWrapper);
+
+ groupDiv.appendChild(controlsDiv);
+ container.appendChild(groupDiv);
+
+ // Setup event listeners for value display
+ const colorsInput = document.getElementById('gif-colors');
+ const colorsValue = document.getElementById('gif-colors-value');
+ colorsInput.addEventListener('input', () => {
+ colorsValue.textContent = colorsInput.value;
+ });
+
+ const qualityInput = document.getElementById('gif-quality');
+ const qualityValue = document.getElementById('gif-quality-value');
+ qualityInput.addEventListener('input', () => {
+ qualityValue.textContent = qualityInput.value;
+ });
+
+ // Update generator config when changed
+ colorsInput.addEventListener('change', () => {
+ generator.gifConfig.colorCount = parseInt(colorsInput.value);
+ generator.updatePreview(); // Update preview to show color quantization
+ });
+
+ // Also update preview on slider input for real-time feedback
+ colorsInput.addEventListener('input', () => {
+ generator.gifConfig.colorCount = parseInt(colorsInput.value);
+ generator.updatePreview(); // Real-time preview update
+ });
+
+ qualityInput.addEventListener('change', () => {
+ generator.gifConfig.quality = parseInt(qualityInput.value);
+ });
+
+ const ditherSelect = document.getElementById('gif-dither');
+ ditherSelect.addEventListener('change', () => {
+ const value = ditherSelect.value;
+ generator.gifConfig.dither = value === 'false' ? false : value;
+ });
+}
+
+/**
+ * Setup collapsible section 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");
+ }
+ });
+ });
+}
+
+/**
+ * Setup UI handlers for conditional visibility
+ */
+function setupUIHandlers(generator) {
+ // Note: Conditional visibility is now handled automatically by UIBuilder.setupConditionalVisibility()
+ // This function is kept for any additional custom handlers if needed in the future
+}
+
+/**
+ * Setup download button
+ */
+function setupDownloadButton(generator) {
+ const downloadBtn = document.getElementById("download-button");
+ if (!downloadBtn) return;
+
+ downloadBtn.addEventListener("click", async () => {
+ const originalText = downloadBtn.textContent;
+ downloadBtn.disabled = true;
+ downloadBtn.textContent = "Generating GIF...";
+
+ try {
+ const blob = await generator.exportAsGif((progress, stage) => {
+ if (stage === "generating") {
+ const percent = Math.round(progress * 100);
+ downloadBtn.textContent = `Generating: ${percent}%`;
+ } else if (stage === "encoding") {
+ const percent = Math.round(progress * 100);
+ downloadBtn.textContent = `Encoding: ${percent}%`;
+ }
+ });
+
+ // Download the blob
+ const link = document.createElement("a");
+ link.download = "button-88x31.gif";
+ link.href = URL.createObjectURL(blob);
+ link.click();
+ URL.revokeObjectURL(link.href);
+
+ downloadBtn.textContent = originalText;
+ } catch (error) {
+ console.error("Error generating GIF:", error);
+ alert("Error generating GIF. Please try again.");
+ downloadBtn.textContent = originalText;
+ } finally {
+ downloadBtn.disabled = false;
+ }
+ });
+}
+
+/**
+ * Setup preset buttons
+ */
+function setupPresets(generator) {
+ // Random preset
+ const randomBtn = document.getElementById("preset-random");
+ if (randomBtn) {
+ randomBtn.addEventListener("click", () => {
+ applyRandomPreset();
+ generator.updatePreview();
+ });
+ }
+
+ // Classic preset
+ const classicBtn = document.getElementById("preset-classic");
+ if (classicBtn) {
+ classicBtn.addEventListener("click", () => {
+ applyClassicPreset();
+ generator.updatePreview();
+ });
+ }
+
+ // Modern preset
+ const modernBtn = document.getElementById("preset-modern");
+ if (modernBtn) {
+ modernBtn.addEventListener("click", () => {
+ applyModernPreset();
+ generator.updatePreview();
+ });
+ }
+}
+
+/**
+ * Apply random preset
+ */
+function applyRandomPreset() {
+ const randomColor = () =>
+ "#" +
+ Math.floor(Math.random() * 16777215)
+ .toString(16)
+ .padStart(6, "0");
+
+ // Random background
+ const bgTypeEl = document.getElementById("bg-type");
+ if (bgTypeEl) {
+ bgTypeEl.value = ["solid", "gradient", "texture"][
+ Math.floor(Math.random() * 3)
+ ];
+ bgTypeEl.dispatchEvent(new Event("change"));
+ }
+
+ setControlValue("bg-color", randomColor());
+ setControlValue("gradient-color1", randomColor());
+ setControlValue("gradient-color2", randomColor());
+ setControlValue("gradient-angle", Math.floor(Math.random() * 360));
+
+ // Random text colors
+ setControlValue("text-color", randomColor());
+ setControlValue("text-gradient-color1", randomColor());
+ setControlValue("text-gradient-color2", randomColor());
+ setControlValue("text2-color", randomColor());
+ setControlValue("text2-gradient-color1", randomColor());
+ setControlValue("text2-gradient-color2", randomColor());
+
+ // Random border
+ setControlValue("border-color", randomColor());
+ setControlValue("border-width", Math.floor(Math.random() * 6));
+}
+
+/**
+ * Apply classic 90s preset
+ */
+function applyClassicPreset() {
+ setControlValue("bg-type", "gradient");
+ setControlValue("gradient-color1", "#6e6e6eff");
+ setControlValue("gradient-color2", "#979797");
+ setControlValue("gradient-angle", 90);
+
+ setControlValue("text-color-type", "solid");
+ setControlValue("text-color", "#000000");
+ setControlValue("text2-color-type", "solid");
+ setControlValue("text2-color", "#000000");
+
+ setControlValue("border-width", 2);
+ setControlValue("border-color", "#000000");
+ setControlValue("border-style", "outset");
+
+ setControlValue("font-family", "VT323");
+ setControlValue("font-family2", "VT323");
+
+ document.getElementById("bg-type")?.dispatchEvent(new Event("change"));
+}
+
+/**
+ * Apply modern preset
+ */
+function applyModernPreset() {
+ setControlValue("bg-type", "gradient");
+ setControlValue("gradient-color1", "#0a0a0a");
+ setControlValue("gradient-color2", "#1a0a2e");
+ setControlValue("gradient-angle", 135);
+
+ setControlValue("text-color-type", "gradient");
+ setControlValue("text-gradient-color1", "#00ffaa");
+ setControlValue("text-gradient-color2", "#00ffff");
+ setControlValue("text-gradient-angle", 90);
+
+ setControlValue("text2-color-type", "gradient");
+ setControlValue("text2-gradient-color1", "#ff00ff");
+ setControlValue("text2-gradient-color2", "#ff6600");
+
+ setControlValue("border-width", 1);
+ setControlValue("border-color", "#00ffaa");
+ setControlValue("border-style", "solid");
+
+ setControlValue("font-family", "Roboto Mono");
+ setControlValue("font-family2", "Roboto Mono");
+
+ document.getElementById("bg-type")?.dispatchEvent(new Event("change"));
+ document
+ .getElementById("text-color-type")
+ ?.dispatchEvent(new Event("change"));
+ document
+ .getElementById("text2-color-type")
+ ?.dispatchEvent(new Event("change"));
+}
+
+/**
+ * Helper to set control value
+ */
+function setControlValue(id, value) {
+ const el = document.getElementById(id);
+ if (el) {
+ if (el.type === "checkbox") {
+ el.checked = value;
+ } else {
+ el.value = value;
+ }
+
+ // Update value display if it exists
+ const valueDisplay = document.getElementById(id + "-value");
+ if (valueDisplay) {
+ valueDisplay.textContent = value;
+ }
+ }
+}
+
+// Auto-initialize when imported
+init();
diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js
new file mode 100644
index 0000000..2ab5e1d
--- /dev/null
+++ b/static/js/button-generator/ui-builder.js
@@ -0,0 +1,572 @@
+/**
+ * UI Builder - Dynamically generates control UI from effect definitions
+ */
+
+export class UIBuilder {
+ constructor(containerElement) {
+ this.container = containerElement;
+ this.controlGroups = new Map(); // category -> { element, controls }
+ this.tooltip = null;
+ this.tooltipTimeout = null;
+ this.setupTooltip();
+ }
+
+ /**
+ * Create and setup the tooltip element
+ */
+ setupTooltip() {
+ // Wait for DOM to be ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ this.createTooltipElement();
+ });
+ } else {
+ this.createTooltipElement();
+ }
+ }
+
+ createTooltipElement() {
+ this.tooltip = document.createElement('div');
+ this.tooltip.className = 'control-tooltip';
+ this.tooltip.style.cssText = `
+ position: fixed;
+ background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%);
+ color: #fff;
+ padding: 0.5rem 0.75rem;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ pointer-events: none;
+ z-index: 10000;
+ max-width: 250px;
+ box-shadow: 0 0 20px rgba(0, 150, 255, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(0, 150, 255, 0.6);
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ line-height: 1.4;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
+ `;
+ document.body.appendChild(this.tooltip);
+ }
+
+ /**
+ * Show tooltip for an element
+ */
+ showTooltip(element, text) {
+ if (!text || !this.tooltip) return;
+
+ clearTimeout(this.tooltipTimeout);
+
+ this.tooltip.textContent = text;
+ this.tooltip.style.opacity = '1';
+
+ // Position tooltip above the element
+ const rect = element.getBoundingClientRect();
+
+ // Set initial position to measure
+ this.tooltip.style.left = '0px';
+ this.tooltip.style.top = '0px';
+ this.tooltip.style.visibility = 'hidden';
+ this.tooltip.style.display = 'block';
+
+ const tooltipRect = this.tooltip.getBoundingClientRect();
+
+ this.tooltip.style.visibility = 'visible';
+
+ let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
+ let top = rect.top - tooltipRect.height - 10;
+
+ // Keep tooltip on screen
+ const padding = 10;
+ if (left < padding) left = padding;
+ if (left + tooltipRect.width > window.innerWidth - padding) {
+ left = window.innerWidth - tooltipRect.width - padding;
+ }
+ if (top < padding) {
+ top = rect.bottom + 10;
+ }
+
+ this.tooltip.style.left = `${left}px`;
+ this.tooltip.style.top = `${top}px`;
+ }
+
+ /**
+ * Hide tooltip
+ */
+ hideTooltip() {
+ if (!this.tooltip) return;
+ clearTimeout(this.tooltipTimeout);
+ this.tooltipTimeout = setTimeout(() => {
+ this.tooltip.style.opacity = '0';
+ }, 100);
+ }
+
+ /**
+ * Add tooltip handlers to an element
+ */
+ addTooltipHandlers(element, description) {
+ if (!description) return;
+
+ element.addEventListener('mouseenter', () => {
+ this.showTooltip(element, description);
+ });
+
+ element.addEventListener('mouseleave', () => {
+ this.hideTooltip();
+ });
+
+ element.addEventListener('mousemove', () => {
+ // Update position on mouse move for better following
+ if (this.tooltip && this.tooltip.style.opacity === '1') {
+ this.showTooltip(element, description);
+ }
+ });
+ }
+
+ /**
+ * Build the entire UI from registered effects
+ * @param {Array} effects - All registered effects
+ */
+ buildUI(effects) {
+ // Clear existing content
+ this.container.innerHTML = '';
+ this.controlGroups.clear();
+
+ // Group effects by category
+ const categorized = this.categorizeEffects(effects);
+
+ // Create control groups for each category
+ for (const [category, categoryEffects] of categorized) {
+ this.createControlGroup(category, categoryEffects);
+ }
+ }
+
+ /**
+ * Categorize effects by their category property
+ * @param {Array} effects
+ * @returns {Map>}
+ */
+ categorizeEffects(effects) {
+ const categories = new Map();
+
+ effects.forEach(effect => {
+ const category = effect.category || 'Other';
+ if (!categories.has(category)) {
+ categories.set(category, []);
+ }
+ categories.get(category).push(effect);
+ });
+
+ // Sort categories in a logical order
+ const orderedCategories = new Map();
+ const categoryOrder = [
+ 'Text Line 1',
+ 'Text Line 2',
+ 'Background',
+ 'Background Animations',
+ 'Border',
+ 'Visual Effects',
+ 'General Effects',
+ 'Special Effects'
+ ];
+
+ categoryOrder.forEach(cat => {
+ if (categories.has(cat)) {
+ orderedCategories.set(cat, categories.get(cat));
+ }
+ });
+
+ // Add any remaining categories
+ categories.forEach((effects, cat) => {
+ if (!orderedCategories.has(cat)) {
+ orderedCategories.set(cat, effects);
+ }
+ });
+
+ return orderedCategories;
+ }
+
+ /**
+ * Create a collapsible control group
+ * @param {string} category - Category name
+ * @param {Array} effects - Effects in this category
+ */
+ createControlGroup(category, effects) {
+ const groupDiv = document.createElement('div');
+ groupDiv.className = 'control-group';
+
+ // Create header
+ const header = document.createElement('h3');
+ header.className = 'control-group-header';
+ header.innerHTML = `
+ ${category}
+ −
+ `;
+
+ // Create content container
+ const content = document.createElement('div');
+ content.className = 'control-group-content';
+
+ // Add controls for each effect in this category
+ effects.forEach(effect => {
+ this.addEffectControls(content, effect);
+ });
+
+ // Add click handler for collapsing
+ header.addEventListener('click', () => {
+ groupDiv.classList.toggle('collapsed');
+ });
+
+ groupDiv.appendChild(header);
+ groupDiv.appendChild(content);
+ this.container.appendChild(groupDiv);
+
+ this.controlGroups.set(category, { element: groupDiv, effects });
+ }
+
+ /**
+ * Add controls for a single effect
+ * @param {HTMLElement} container - Container to add controls to
+ * @param {ButtonEffect} effect - Effect to create controls for
+ */
+ addEffectControls(container, effect) {
+ effect.controls.forEach(control => {
+ const controlEl = this.createControl(control);
+ if (controlEl) {
+ container.appendChild(controlEl);
+ }
+ });
+ }
+
+ /**
+ * Create a single control element
+ * @param {Object} controlDef - Control definition from effect
+ * @returns {HTMLElement}
+ */
+ createControl(controlDef) {
+ const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = controlDef;
+
+ switch (type) {
+ case 'checkbox':
+ return this.createCheckbox(id, label, defaultValue, showWhen, description);
+
+ case 'range':
+ return this.createRange(id, label, defaultValue, min, max, step, description, showWhen);
+
+ case 'color':
+ return this.createColor(id, label, defaultValue, showWhen, description);
+
+ case 'select':
+ return this.createSelect(id, label, defaultValue, options, showWhen, description);
+
+ case 'text':
+ return this.createTextInput(id, label, defaultValue, showWhen, description);
+
+ case 'file':
+ return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept);
+
+ default:
+ console.warn(`Unknown control type: ${type}`);
+ return null;
+ }
+ }
+
+ /**
+ * Create a checkbox control
+ */
+ createCheckbox(id, label, defaultValue, showWhen, description) {
+ const wrapper = document.createElement('label');
+ wrapper.className = 'checkbox-label';
+
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.id = id;
+ input.checked = defaultValue || false;
+
+ const span = document.createElement('span');
+ span.textContent = label;
+
+ wrapper.appendChild(input);
+ wrapper.appendChild(span);
+
+ if (showWhen) {
+ wrapper.style.display = 'none';
+ wrapper.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label wrapper
+ this.addTooltipHandlers(wrapper, description);
+
+ return wrapper;
+ }
+
+ /**
+ * Create a range slider control
+ */
+ createRange(id, label, defaultValue, min, max, step, description, showWhen) {
+ const container = document.createElement('div');
+
+ const labelEl = document.createElement('label');
+ labelEl.htmlFor = id;
+ labelEl.innerHTML = `${label}: ${defaultValue} `;
+
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.id = id;
+ input.min = min !== undefined ? min : 0;
+ input.max = max !== undefined ? max : 100;
+ input.value = defaultValue !== undefined ? defaultValue : 50;
+ if (step !== undefined) {
+ input.step = step;
+ }
+
+ // Update value display on input
+ input.addEventListener('input', () => {
+ const valueDisplay = document.getElementById(`${id}-value`);
+ if (valueDisplay) {
+ valueDisplay.textContent = input.value;
+ }
+ });
+
+ container.appendChild(labelEl);
+ container.appendChild(input);
+
+ if (showWhen) {
+ container.style.display = 'none';
+ container.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label
+ this.addTooltipHandlers(labelEl, description);
+
+ return container;
+ }
+
+ /**
+ * Create a color picker control
+ */
+ createColor(id, label, defaultValue, showWhen, description) {
+ const container = document.createElement('div');
+
+ const labelEl = document.createElement('label');
+ labelEl.htmlFor = id;
+ labelEl.textContent = label;
+
+ const input = document.createElement('input');
+ input.type = 'color';
+ input.id = id;
+ input.value = defaultValue || '#ffffff';
+
+ container.appendChild(labelEl);
+ container.appendChild(input);
+
+ if (showWhen) {
+ container.style.display = 'none';
+ container.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label
+ this.addTooltipHandlers(labelEl, description);
+
+ return container;
+ }
+
+ /**
+ * Create a select dropdown control
+ */
+ createSelect(id, label, defaultValue, options, showWhen, description) {
+ const container = document.createElement('div');
+
+ const labelEl = document.createElement('label');
+ labelEl.htmlFor = id;
+ labelEl.textContent = label;
+
+ const select = document.createElement('select');
+ select.id = id;
+
+ options.forEach(opt => {
+ const option = document.createElement('option');
+ option.value = opt.value;
+ option.textContent = opt.label;
+ if (opt.value === defaultValue) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ });
+
+ container.appendChild(labelEl);
+ container.appendChild(select);
+
+ if (showWhen) {
+ container.style.display = 'none';
+ container.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label
+ this.addTooltipHandlers(labelEl, description);
+
+ return container;
+ }
+
+ /**
+ * Create a text input control
+ */
+ createTextInput(id, label, defaultValue, showWhen, description) {
+ const container = document.createElement('div');
+
+ const labelEl = document.createElement('label');
+ labelEl.htmlFor = id;
+ labelEl.textContent = label;
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.id = id;
+ input.value = defaultValue || '';
+
+ // Only set maxLength for text inputs that aren't URLs
+ if (id !== 'bg-image-url') {
+ input.maxLength = 20;
+ }
+
+ container.appendChild(labelEl);
+ container.appendChild(input);
+
+ if (showWhen) {
+ container.style.display = 'none';
+ container.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label
+ this.addTooltipHandlers(labelEl, description);
+
+ return container;
+ }
+
+ /**
+ * Create a file input control
+ */
+ createFileInput(id, label, defaultValue, showWhen, description, accept) {
+ const container = document.createElement('div');
+
+ const labelEl = document.createElement('label');
+ labelEl.htmlFor = id;
+ labelEl.textContent = label;
+
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.id = id;
+ if (accept) {
+ input.accept = accept;
+ }
+
+ // Store the file data on the input element
+ input.addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ // Create a blob URL for the file
+ const blobUrl = URL.createObjectURL(file);
+ // Store file metadata on the input element
+ input.dataset.fileName = file.name;
+ input.dataset.blobUrl = blobUrl;
+ input.dataset.fileSize = file.size;
+ input.dataset.fileType = file.type;
+ } else {
+ // Clear the data if no file is selected
+ delete input.dataset.fileName;
+ delete input.dataset.blobUrl;
+ delete input.dataset.fileSize;
+ delete input.dataset.fileType;
+ }
+ });
+
+ container.appendChild(labelEl);
+ container.appendChild(input);
+
+ if (showWhen) {
+ container.style.display = 'none';
+ container.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label
+ this.addTooltipHandlers(labelEl, description);
+
+ return container;
+ }
+
+ /**
+ * Setup conditional visibility for controls
+ * Should be called after all controls are created
+ */
+ setupConditionalVisibility() {
+ // Find all controls with showWhen attribute
+ const conditionalControls = this.container.querySelectorAll('[data-show-when]');
+
+ conditionalControls.forEach(control => {
+ const triggerControlId = control.dataset.showWhen;
+ const triggerControl = document.getElementById(triggerControlId);
+
+ if (triggerControl) {
+ const updateVisibility = () => {
+ if (triggerControl.type === 'checkbox') {
+ control.style.display = triggerControl.checked ? 'block' : 'none';
+ } else if (triggerControl.tagName === 'SELECT') {
+ // Get the control ID to determine what value to check for
+ const controlId = control.querySelector('input, select')?.id;
+
+ // For background controls
+ if (triggerControlId === 'bg-type') {
+ if (controlId === 'bg-color') {
+ control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
+ } else if (controlId && (controlId.startsWith('gradient-') || controlId === 'gradient-angle')) {
+ control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
+ } else if (controlId && (controlId.startsWith('texture-') || controlId === 'texture-type' || controlId === 'texture-scale')) {
+ control.style.display = triggerControl.value === 'texture' ? 'block' : 'none';
+ } else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) {
+ control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none';
+ } else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-file' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) {
+ control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none';
+ }
+ }
+ // For image fit controls (zoom and position only show when manual mode)
+ else if (triggerControlId === 'bg-image-fit') {
+ if (controlId && (controlId === 'bg-image-zoom' || controlId === 'bg-image-offset-x' || controlId === 'bg-image-offset-y')) {
+ control.style.display = triggerControl.value === 'manual' ? 'block' : 'none';
+ }
+ }
+ // For text color controls
+ else if (triggerControlId === 'text-color-type') {
+ if (controlId === 'text-color') {
+ control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
+ } else if (controlId && controlId.startsWith('text-gradient-')) {
+ control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
+ }
+ } else if (triggerControlId === 'text2-color-type') {
+ if (controlId === 'text2-color') {
+ control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
+ } else if (controlId && controlId.startsWith('text2-gradient-')) {
+ control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
+ }
+ }
+ // For border style controls
+ else if (triggerControlId === 'border-style') {
+ if (controlId === 'border-rainbow-speed') {
+ control.style.display = triggerControl.value === 'rainbow' ? 'block' : 'none';
+ } else if (controlId === 'border-march-speed') {
+ control.style.display = triggerControl.value === 'marching-ants' ? 'block' : 'none';
+ }
+ } else {
+ // Default: show when any value is selected
+ control.style.display = triggerControl.value ? 'block' : 'none';
+ }
+ }
+ };
+
+ // Initial visibility
+ updateVisibility();
+
+ // Update on change
+ triggerControl.addEventListener('change', updateVisibility);
+ triggerControl.addEventListener('input', updateVisibility);
+ }
+ });
+ }
+}
diff --git a/static/js/footnote-scroll.js b/static/js/footnote-scroll.js
new file mode 100644
index 0000000..0c58b59
--- /dev/null
+++ b/static/js/footnote-scroll.js
@@ -0,0 +1,32 @@
+(function() {
+ // Handle smooth scrolling for all footnote links (both directions)
+ document.addEventListener('DOMContentLoaded', function() {
+ // Get all footnote links (both references and backlinks)
+ const footnoteLinks = document.querySelectorAll('a[href^="#fn:"], a[href^="#fnref:"]');
+
+ footnoteLinks.forEach(function(link) {
+ link.addEventListener('click', function(e) {
+ e.preventDefault();
+
+ const targetId = this.getAttribute('href').substring(1);
+ const targetElement = document.getElementById(targetId);
+
+ if (targetElement) {
+ // Calculate position with offset
+ const offset = 100; // pixels from top
+ const elementPosition = targetElement.getBoundingClientRect().top;
+ const offsetPosition = elementPosition + window.pageYOffset - offset;
+
+ // Smooth scroll to position
+ window.scrollTo({
+ top: offsetPosition,
+ behavior: 'smooth'
+ });
+
+ // Update URL hash without jumping
+ history.pushState(null, null, this.getAttribute('href'));
+ }
+ });
+ });
+ });
+})();
diff --git a/static/js/gif.js b/static/js/gif.js
new file mode 100644
index 0000000..2e4d204
--- /dev/null
+++ b/static/js/gif.js
@@ -0,0 +1,3 @@
+// gif.js 0.2.0 - https://github.com/jnordberg/gif.js
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GIF=f()}})(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o0&&this._events[type].length>m){this._events[type].warned=true;console.error("(node) warning: possible EventEmitter memory "+"leak detected. %d listeners added. "+"Use emitter.setMaxListeners() to increase limit.",this._events[type].length);if(typeof console.trace==="function"){console.trace()}}}return this};EventEmitter.prototype.on=EventEmitter.prototype.addListener;EventEmitter.prototype.once=function(type,listener){if(!isFunction(listener))throw TypeError("listener must be a function");var fired=false;function g(){this.removeListener(type,g);if(!fired){fired=true;listener.apply(this,arguments)}}g.listener=listener;this.on(type,g);return this};EventEmitter.prototype.removeListener=function(type,listener){var list,position,length,i;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events||!this._events[type])return this;list=this._events[type];length=list.length;position=-1;if(list===listener||isFunction(list.listener)&&list.listener===listener){delete this._events[type];if(this._events.removeListener)this.emit("removeListener",type,listener)}else if(isObject(list)){for(i=length;i-- >0;){if(list[i]===listener||list[i].listener&&list[i].listener===listener){position=i;break}}if(position<0)return this;if(list.length===1){list.length=0;delete this._events[type]}else{list.splice(position,1)}if(this._events.removeListener)this.emit("removeListener",type,listener)}return this};EventEmitter.prototype.removeAllListeners=function(type){var key,listeners;if(!this._events)return this;if(!this._events.removeListener){if(arguments.length===0)this._events={};else if(this._events[type])delete this._events[type];return this}if(arguments.length===0){for(key in this._events){if(key==="removeListener")continue;this.removeAllListeners(key)}this.removeAllListeners("removeListener");this._events={};return this}listeners=this._events[type];if(isFunction(listeners)){this.removeListener(type,listeners)}else if(listeners){while(listeners.length)this.removeListener(type,listeners[listeners.length-1])}delete this._events[type];return this};EventEmitter.prototype.listeners=function(type){var ret;if(!this._events||!this._events[type])ret=[];else if(isFunction(this._events[type]))ret=[this._events[type]];else ret=this._events[type].slice();return ret};EventEmitter.prototype.listenerCount=function(type){if(this._events){var evlistener=this._events[type];if(isFunction(evlistener))return 1;else if(evlistener)return evlistener.length}return 0};EventEmitter.listenerCount=function(emitter,type){return emitter.listenerCount(type)};function isFunction(arg){return typeof arg==="function"}function isNumber(arg){return typeof arg==="number"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isUndefined(arg){return arg===void 0}},{}],2:[function(require,module,exports){var UA,browser,mode,platform,ua;ua=navigator.userAgent.toLowerCase();platform=navigator.platform.toLowerCase();UA=ua.match(/(opera|ie|firefox|chrome|version)[\s\/:]([\w\d\.]+)?.*?(safari|version[\s\/:]([\w\d\.]+)|$)/)||[null,"unknown",0];mode=UA[1]==="ie"&&document.documentMode;browser={name:UA[1]==="version"?UA[3]:UA[1],version:mode||parseFloat(UA[1]==="opera"&&UA[4]?UA[4]:UA[2]),platform:{name:ua.match(/ip(?:ad|od|hone)/)?"ios":(ua.match(/(?:webos|android)/)||platform.match(/mac|win|linux/)||["other"])[0]}};browser[browser.name]=true;browser[browser.name+parseInt(browser.version,10)]=true;browser.platform[browser.platform.name]=true;module.exports=browser},{}],3:[function(require,module,exports){var EventEmitter,GIF,browser,extend=function(child,parent){for(var key in parent){if(hasProp.call(parent,key))child[key]=parent[key]}function ctor(){this.constructor=child}ctor.prototype=parent.prototype;child.prototype=new ctor;child.__super__=parent.prototype;return child},hasProp={}.hasOwnProperty,indexOf=[].indexOf||function(item){for(var i=0,l=this.length;iref;i=0<=ref?++j:--j){results.push(null)}return results}.call(this);numWorkers=this.spawnWorkers();if(this.options.globalPalette===true){this.renderNextFrame()}else{for(i=j=0,ref=numWorkers;0<=ref?jref;i=0<=ref?++j:--j){this.renderNextFrame()}}this.emit("start");return this.emit("progress",0)};GIF.prototype.abort=function(){var worker;while(true){worker=this.activeWorkers.shift();if(worker==null){break}this.log("killing active worker");worker.terminate()}this.running=false;return this.emit("abort")};GIF.prototype.spawnWorkers=function(){var j,numWorkers,ref,results;numWorkers=Math.min(this.options.workers,this.frames.length);(function(){results=[];for(var j=ref=this.freeWorkers.length;ref<=numWorkers?jnumWorkers;ref<=numWorkers?j++:j--){results.push(j)}return results}).apply(this).forEach(function(_this){return function(i){var worker;_this.log("spawning worker "+i);worker=new Worker(_this.options.workerScript);worker.onmessage=function(event){_this.activeWorkers.splice(_this.activeWorkers.indexOf(worker),1);_this.freeWorkers.push(worker);return _this.frameFinished(event.data)};return _this.freeWorkers.push(worker)}}(this));return numWorkers};GIF.prototype.frameFinished=function(frame){var i,j,ref;this.log("frame "+frame.index+" finished - "+this.activeWorkers.length+" active");this.finishedFrames++;this.emit("progress",this.finishedFrames/this.frames.length);this.imageParts[frame.index]=frame;if(this.options.globalPalette===true){this.options.globalPalette=frame.globalPalette;this.log("global palette analyzed");if(this.frames.length>2){for(i=j=1,ref=this.freeWorkers.length;1<=ref?jref;i=1<=ref?++j:--j){this.renderNextFrame()}}}if(indexOf.call(this.imageParts,null)>=0){return this.renderNextFrame()}else{return this.finishRendering()}};GIF.prototype.finishRendering=function(){var data,frame,i,image,j,k,l,len,len1,len2,len3,offset,page,ref,ref1,ref2;len=0;ref=this.imageParts;for(j=0,len1=ref.length;j=this.frames.length){return}frame=this.frames[this.nextFrame++];worker=this.freeWorkers.shift();task=this.getTask(frame);this.log("starting frame "+(task.index+1)+" of "+this.frames.length);this.activeWorkers.push(worker);return worker.postMessage(task)};GIF.prototype.getContextData=function(ctx){return ctx.getImageData(0,0,this.options.width,this.options.height).data};GIF.prototype.getImageData=function(image){var ctx;if(this._canvas==null){this._canvas=document.createElement("canvas");this._canvas.width=this.options.width;this._canvas.height=this.options.height}ctx=this._canvas.getContext("2d");ctx.setFill=this.options.background;ctx.fillRect(0,0,this.options.width,this.options.height);ctx.drawImage(image,0,0);return this.getContextData(ctx)};GIF.prototype.getTask=function(frame){var index,task;index=this.frames.indexOf(frame);task={index:index,last:index===this.frames.length-1,delay:frame.delay,transparent:frame.transparent,width:this.options.width,height:this.options.height,quality:this.options.quality,dither:this.options.dither,globalPalette:this.options.globalPalette,repeat:this.options.repeat,canTransfer:browser.name==="chrome"};if(frame.data!=null){task.data=frame.data}else if(frame.context!=null){task.data=this.getContextData(frame.context)}else if(frame.image!=null){task.data=this.getImageData(frame.image)}else{throw new Error("Invalid frame")}return task};GIF.prototype.log=function(){var args;args=1<=arguments.length?slice.call(arguments,0):[];if(!this.options.debug){return}return console.log.apply(console,args)};return GIF}(EventEmitter);module.exports=GIF},{"./browser.coffee":2,events:1}]},{},[3])(3)});
+//# sourceMappingURL=gif.js.map
diff --git a/static/js/gif.worker.js b/static/js/gif.worker.js
new file mode 100644
index 0000000..269624e
--- /dev/null
+++ b/static/js/gif.worker.js
@@ -0,0 +1,3 @@
+// gif.worker.js 0.2.0 - https://github.com/jnordberg/gif.js
+(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o=ByteArray.pageSize)this.newPage();this.pages[this.page][this.cursor++]=val};ByteArray.prototype.writeUTFBytes=function(string){for(var l=string.length,i=0;i=0)this.dispose=disposalCode};GIFEncoder.prototype.setRepeat=function(repeat){this.repeat=repeat};GIFEncoder.prototype.setTransparent=function(color){this.transparent=color};GIFEncoder.prototype.addFrame=function(imageData){this.image=imageData;this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null;this.getImagePixels();this.analyzePixels();if(this.globalPalette===true)this.globalPalette=this.colorTab;if(this.firstFrame){this.writeLSD();this.writePalette();if(this.repeat>=0){this.writeNetscapeExt()}}this.writeGraphicCtrlExt();this.writeImageDesc();if(!this.firstFrame&&!this.globalPalette)this.writePalette();this.writePixels();this.firstFrame=false};GIFEncoder.prototype.finish=function(){this.out.writeByte(59)};GIFEncoder.prototype.setQuality=function(quality){if(quality<1)quality=1;this.sample=quality};GIFEncoder.prototype.setDither=function(dither){if(dither===true)dither="FloydSteinberg";this.dither=dither};GIFEncoder.prototype.setGlobalPalette=function(palette){this.globalPalette=palette};GIFEncoder.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette};GIFEncoder.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")};GIFEncoder.prototype.analyzePixels=function(){if(!this.colorTab){this.neuQuant=new NeuQuant(this.pixels,this.sample);this.neuQuant.buildColormap();this.colorTab=this.neuQuant.getColormap()}if(this.dither){this.ditherPixels(this.dither.replace("-serpentine",""),this.dither.match(/-serpentine/)!==null)}else{this.indexPixels()}this.pixels=null;this.colorDepth=8;this.palSize=7;if(this.transparent!==null){this.transIndex=this.findClosest(this.transparent,true)}};GIFEncoder.prototype.indexPixels=function(imgq){var nPix=this.pixels.length/3;this.indexedPixels=new Uint8Array(nPix);var k=0;for(var j=0;j=0&&x1+x=0&&y1+y>16,(c&65280)>>8,c&255,used)};GIFEncoder.prototype.findClosestRGB=function(r,g,b,used){if(this.colorTab===null)return-1;if(this.neuQuant&&!used){return this.neuQuant.lookupRGB(r,g,b)}var c=b|g<<8|r<<16;var minpos=0;var dmin=256*256*256;var len=this.colorTab.length;for(var i=0,index=0;i=0){disp=dispose&7}disp<<=2;this.out.writeByte(0|disp|0|transp);this.writeShort(this.delay);this.out.writeByte(this.transIndex);this.out.writeByte(0)};GIFEncoder.prototype.writeImageDesc=function(){this.out.writeByte(44);this.writeShort(0);this.writeShort(0);this.writeShort(this.width);this.writeShort(this.height);if(this.firstFrame||this.globalPalette){this.out.writeByte(0)}else{this.out.writeByte(128|0|0|0|this.palSize)}};GIFEncoder.prototype.writeLSD=function(){this.writeShort(this.width);this.writeShort(this.height);this.out.writeByte(128|112|0|this.palSize);this.out.writeByte(0);this.out.writeByte(0)};GIFEncoder.prototype.writeNetscapeExt=function(){this.out.writeByte(33);this.out.writeByte(255);this.out.writeByte(11);this.out.writeUTFBytes("NETSCAPE2.0");this.out.writeByte(3);this.out.writeByte(1);this.writeShort(this.repeat);this.out.writeByte(0)};GIFEncoder.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);var n=3*256-this.colorTab.length;for(var i=0;i>8&255)};GIFEncoder.prototype.writePixels=function(){var enc=new LZWEncoder(this.width,this.height,this.indexedPixels,this.colorDepth);enc.encode(this.out)};GIFEncoder.prototype.stream=function(){return this.out};module.exports=GIFEncoder},{"./LZWEncoder.js":2,"./TypedNeuQuant.js":3}],2:[function(require,module,exports){var EOF=-1;var BITS=12;var HSIZE=5003;var masks=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function LZWEncoder(width,height,pixels,colorDepth){var initCodeSize=Math.max(2,colorDepth);var accum=new Uint8Array(256);var htab=new Int32Array(HSIZE);var codetab=new Int32Array(HSIZE);var cur_accum,cur_bits=0;var a_count;var free_ent=0;var maxcode;var clear_flg=false;var g_init_bits,ClearCode,EOFCode;function char_out(c,outs){accum[a_count++]=c;if(a_count>=254)flush_char(outs)}function cl_block(outs){cl_hash(HSIZE);free_ent=ClearCode+2;clear_flg=true;output(ClearCode,outs)}function cl_hash(hsize){for(var i=0;i=0){disp=hsize_reg-i;if(i===0)disp=1;do{if((i-=disp)<0)i+=hsize_reg;if(htab[i]===fcode){ent=codetab[i];continue outer_loop}}while(htab[i]>=0)}output(ent,outs);ent=c;if(free_ent<1<0){outs.writeByte(a_count);outs.writeBytes(accum,0,a_count);a_count=0}}function MAXCODE(n_bits){return(1<0)cur_accum|=code<=8){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}if(free_ent>maxcode||clear_flg){if(clear_flg){maxcode=MAXCODE(n_bits=g_init_bits);clear_flg=false}else{++n_bits;if(n_bits==BITS)maxcode=1<0){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}flush_char(outs)}}this.encode=encode}module.exports=LZWEncoder},{}],3:[function(require,module,exports){var ncycles=100;var netsize=256;var maxnetpos=netsize-1;var netbiasshift=4;var intbiasshift=16;var intbias=1<>betashift;var betagamma=intbias<>3;var radiusbiasshift=6;var radiusbias=1<>3);var i,v;for(i=0;i>=netbiasshift;network[i][1]>>=netbiasshift;network[i][2]>>=netbiasshift;network[i][3]=i}}function altersingle(alpha,i,b,g,r){network[i][0]-=alpha*(network[i][0]-b)/initalpha;network[i][1]-=alpha*(network[i][1]-g)/initalpha;network[i][2]-=alpha*(network[i][2]-r)/initalpha}function alterneigh(radius,i,b,g,r){var lo=Math.abs(i-radius);var hi=Math.min(i+radius,netsize);var j=i+1;var k=i-1;var m=1;var p,a;while(jlo){a=radpower[m++];if(jlo){p=network[k--];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}}}function contest(b,g,r){var bestd=~(1<<31);var bestbiasd=bestd;var bestpos=-1;var bestbiaspos=bestpos;var i,n,dist,biasdist,betafreq;for(i=0;i>intbiasshift-netbiasshift);if(biasdist>betashift;freq[i]-=betafreq;bias[i]+=betafreq<>1;for(j=previouscol+1;j>1;for(j=previouscol+1;j<256;j++)netindex[j]=maxnetpos}function inxsearch(b,g,r){var a,p,dist;var bestd=1e3;var best=-1;var i=netindex[g];var j=i-1;while(i=0){if(i=bestd)i=netsize;else{i++;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist=0){p=network[j];dist=g-p[1];if(dist>=bestd)j=-1;else{j--;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist>radiusbiasshift;if(rad<=1)rad=0;for(i=0;i=lengthcount)pix-=lengthcount;i++;if(delta===0)delta=1;if(i%delta===0){alpha-=alpha/alphadec;radius-=radius/radiusdec;rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(j=0;j
+
+
+
+
+ Lava Lamp Adoptable - Demo Examples
+
+
+
+
+
🌋 Lava Lamp Adoptable Demo
+
+ See the lava lamp in action at various sizes and color schemes!
+
+
+
+
+
+
Classic Orange
+
+
+
+
150px × 280px
+
+
+
+
+
Purple Dreams
+
+
+
+
120px × 250px
+
+
+
+
+
Ocean Blue
+
+
+
+
100px × 200px
+
+
+
+
+
Lava Red
+
+
+
+
180px × 320px
+
+
+
+
+
Mint Fresh
+
+
+
+
140px × 260px
+
+
+
+
+
Sunset Glow
+
+
+
+
110px × 220px
+
+
+
+
+
Cyber Green (Pixelated)
+
+
+
+
160px × 300px (6px pixels)
+
+
+
+
+
Pink Bubble (Pixelated)
+
+
+
+
130px × 240px (3px pixels)
+
+
+
+
+
Tiny Retro (Pixelated)
+
+
+
+
40px × 80px (3px pixels)
+
+
+
+
+
How to Use
+
+ Simply copy and paste this code into your website. The lava lamp will
+ scale to fit any container size you specify!
+
+
<div style="width: 200px; height: 350px;">
+ <script src="https://ritual.sh/js/adoptables/lavalamp.js"
+ data-bg-color-1="#F8E45C"
+ data-bg-color-2="#FF7800"
+ data-blob-color-1="#FF4500"
+ data-blob-color-2="#FF6347"
+ data-case-color="#333333"
+ data-blob-count="6"
+ data-speed="1.0"
+ data-blob-size="1.0"></script>
+</div>
+
+
Customization Options
+
+
+ data-bg-color-1 & data-bg-color-2:
+ Background gradient colors
+
+
+ data-blob-color-1 & data-blob-color-2:
+ Blob gradient colors
+
+
+ data-case-color: Color for the top cap and bottom base
+
+ data-blob-count: Number of blobs (3-12)
+
+ data-speed: Animation speed multiplier (0.5-1.5x)
+
+ data-blob-size: Blob size multiplier (0.5-2.0x)
+
+
+
+ 🌟 Create your own custom lava lamp at
+ ritual.sh/resources/adoptables
+
+
+
+
+
diff --git a/static/publickey.asc b/static/publickey.asc
new file mode 100644
index 0000000..2f54b8a
--- /dev/null
+++ b/static/publickey.asc
@@ -0,0 +1,17 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xjMEaVzgdxYJKwYBBAHaRw8BAQdADvLcBAhz3La0tovwPlJ2Z5uKHufb9MS9
+6CGKBIgssOfNHWRhbkByaXR1YWwuc2ggPGRhbkByaXR1YWwuc2g+wsARBBMW
+CgCDBYJpXOB3AwsJBwkQhk08tBmPAUJFFAAAAAAAHAAgc2FsdEBub3RhdGlv
+bnMub3BlbnBncGpzLm9yZ2lm/k+MdxoNH3+THDnkaLgtXLiV1AuTGyJ0ZlAe
+4ngSAxUKCAQWAAIBAhkBApsDAh4BFiEEFv8cu2aJ5iyvXhJ0hk08tBmPAUIA
+ABB0AP0ZvUva/yf6ZR8T7Zwp8+RtqyYfiDlJbpqt3hEGALw0EgD6AxO3O4tm
+f2IguB2kuUfrH223xGEzIOHYz1Ciwt6haAPOOARpXOB3EgorBgEEAZdVAQUB
+AQdAjllaNe/Z1No5rRxVz6SBsSN4o2xDHPWb0PnGxZAsT3EDAQgHwr4EGBYK
+AHAFgmlc4HcJEIZNPLQZjwFCRRQAAAAAABwAIHNhbHRAbm90YXRpb25zLm9w
+ZW5wZ3Bqcy5vcmfxjGz++fxlZxuZLgSTOTjWRrtwvPuLEFJSN1VxonqqcQKb
+DBYhBBb/HLtmieYsr14SdIZNPLQZjwFCAABdiAD8Cz5dxfHSm1mBwQ0jKjZd
+sktUeaa3Ksw2NsMFU3sbnoIBAMBoAaQVq+q5RoLn1ZOT/DIeDU+1o5HVAL0k
+sb/b4NYN
+=EIiw
+-----END PGP PUBLIC KEY BLOCK-----