Merge branch 'main' into develop

This commit is contained in:
Dan 2026-01-12 13:20:03 +00:00
commit af3335a837
113 changed files with 14843 additions and 602 deletions

1
.webmentions-sent Normal file
View file

@ -0,0 +1 @@
https://ritual.sh/blog/2026-01-06-week-3-bit-chilly-out/|https://enclose.horse/

262
GRAPHS.md Normal file
View file

@ -0,0 +1,262 @@
# Graph Shortcode Documentation
This Hugo site includes a custom shortcode for creating terminal-styled graphs that match the hacker green aesthetic.
## Features
- **Server-side processing**: Data is processed when Hugo builds the site
- **Terminal styling**: Automatic green color scheme matching the site theme
- **Multiple chart types**: Line, bar, pie, doughnut, radar, and more
- **Responsive**: Adapts to different screen sizes
- **Customizable**: Override colors and styling as needed
## Basic Usage
The shortcode supports two modes:
1. **Inline JSON data** - Embed data directly in your markdown
2. **CSV file data** - Load data from a CSV file in your page bundle
### CSV File Mode (Recommended)
Place your CSV file in the same directory as your blog post's `index.md`:
```markdown
{{< graph id="my-chart" type="line" title="My Data" height="500" csv="data.csv" labelColumn="Date" dataColumn="Value" dateFormat="2006-01" >}}
{{< /graph >}}
```
**CSV Parameters:**
- `csv` - Filename of the CSV in the page bundle (required for CSV mode)
- `labelColumn` - Column name for x-axis labels (default: "Month")
- `dataColumn` - Column name for y-axis data (default: "Questions")
- `dateFormat` - Go date format for parsing timestamps (default: "2006-01")
- `skipRows` - Number of rows to skip from the start (default: 0)
- `maxRows` - Maximum number of rows to display (default: 0 = all)
**Example CSV file** (`data.csv`):
```csv
Date,Value
2024-01,100
2024-02,150
2024-03,175
```
### Inline JSON Mode
### Line Chart
```markdown
{{< graph id="my-chart" type="line" title="My Data" height="400" >}}
{
"labels": ["Jan", "Feb", "Mar", "Apr", "May"],
"datasets": [{
"label": "Sales",
"data": [12, 19, 3, 5, 2]
}]
}
{{< /graph >}}
```
### Bar Chart
```markdown
{{< graph id="bar-example" type="bar" title="Monthly Revenue" height="350" >}}
{
"labels": ["Q1", "Q2", "Q3", "Q4"],
"datasets": [{
"label": "Revenue ($k)",
"data": [65, 59, 80, 81]
}]
}
{{< /graph >}}
```
### Multiple Datasets
```markdown
{{< graph id="multi-line" type="line" title="Comparison" height="400" >}}
{
"labels": ["2020", "2021", "2022", "2023", "2024"],
"datasets": [
{
"label": "Product A",
"data": [30, 45, 60, 70, 85]
},
{
"label": "Product B",
"data": [20, 35, 55, 65, 75]
}
]
}
{{< /graph >}}
```
### Pie Chart
```markdown
{{< graph id="pie-chart" type="pie" title="Market Share" height="400" >}}
{
"labels": ["Chrome", "Firefox", "Safari", "Edge"],
"datasets": [{
"data": [65, 15, 12, 8]
}]
}
{{< /graph >}}
```
## Parameters
### Common Parameters
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `id` | No | Auto-generated | Unique identifier for the chart |
| `type` | No | `line` | Chart type: `line`, `bar`, `pie`, `doughnut`, `radar`, `polarArea` |
| `title` | No | Empty | Chart title displayed above the graph |
| `height` | No | `400` | Height of the chart container in pixels |
### CSV Mode Parameters
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `csv` | Yes (for CSV mode) | - | Filename of CSV in page bundle |
| `labelColumn` | No | `"Month"` | CSV column name for labels (x-axis) |
| `dataColumn` | No | `"Questions"` | CSV column name for data (y-axis) |
| `dateFormat` | No | `"2006-01"` | Go date format string for parsing dates |
| `skipRows` | No | `0` | Number of data rows to skip |
| `maxRows` | No | `0` | Max rows to display (0 = all) |
## Data Format
The shortcode expects JSON data in Chart.js format. The basic structure is:
```json
{
"labels": ["Label 1", "Label 2", "Label 3"],
"datasets": [{
"label": "Dataset Name",
"data": [value1, value2, value3]
}]
}
```
### Advanced Data Options
You can override the automatic styling by providing Chart.js dataset properties:
```markdown
{{< graph id="custom-style" type="line" title="Custom Colors" >}}
{
"labels": ["A", "B", "C"],
"datasets": [{
"label": "Custom",
"data": [10, 20, 30],
"borderColor": "rgb(255, 99, 132)",
"backgroundColor": "rgba(255, 99, 132, 0.2)",
"borderWidth": 3
}]
}
{{< /graph >}}
```
## Color Scheme
The shortcode automatically applies terminal-style colors:
- **Primary**: `rgb(173, 255, 47)` (greenyellow)
- **Secondary**: `rgb(0, 255, 0)` (green)
- **Tertiary**: `rgb(0, 200, 0)` (darker green)
- **Grid**: `rgba(0, 255, 0, 0.2)`
- **Background**: `rgba(0, 255, 0, 0.1)`
Multiple datasets automatically cycle through these colors.
## CSS Classes
The graph container has the class `.graph-container` with the following variants:
- `.graph-container.full-width` - Full width graph (extends to edges)
- `.graph-container.compact` - Smaller padding and title
To use variants, wrap the shortcode in a div:
```html
<div class="graph-container compact">
{{< graph ... >}}
...
{{< /graph >}}
</div>
```
## Examples
### StackOverflow Decline
```markdown
{{< graph id="stackoverflow" type="line" title="Stack Overflow Activity" height="400" >}}
{
"labels": ["2018", "2019", "2020", "2021", "2022", "2023", "2024"],
"datasets": [{
"label": "Questions (thousands/month)",
"data": [170, 180, 195, 175, 150, 120, 85],
"fill": true
}]
}
{{< /graph >}}
```
### Technology Adoption
```markdown
{{< graph id="tech-adoption" type="bar" title="Framework Adoption (%)" height="350" >}}
{
"labels": ["React", "Vue", "Angular", "Svelte"],
"datasets": [{
"label": "2023",
"data": [67, 45, 42, 18]
}, {
"label": "2024",
"data": [71, 48, 38, 25]
}]
}
{{< /graph >}}
```
### Response Time Distribution
```markdown
{{< graph id="response-times" type="doughnut" title="API Response Times" height="400" >}}
{
"labels": ["< 100ms", "100-500ms", "500ms-1s", "> 1s"],
"datasets": [{
"data": [45, 35, 15, 5]
}]
}
{{< /graph >}}
```
## Tips
1. **Keep IDs unique**: Each chart on a page needs a unique ID
2. **Data validation**: The shortcode validates JSON at build time - invalid JSON will cause build errors
3. **Responsive**: Charts are responsive by default, but fixed heights work better for consistency
4. **Performance**: Charts are rendered client-side but data is embedded at build time
5. **Testing**: Set `draft: false` and run `hugo server -D` to preview graphs
## Troubleshooting
**Graph not showing?**
- Check browser console for JavaScript errors
- Verify Chart.js is loaded (should be in page `<head>`)
- Ensure JSON data is valid
- Check that the ID is unique on the page
**Styling issues?**
- Graphs inherit the terminal theme automatically
- Custom colors can be added per dataset
- Container styling can be modified in `assets/sass/partials/_graphs.scss`
**Build errors?**
- Invalid JSON will cause Hugo build failures
- Check for unclosed braces or missing commas
- Ensure the shortcode tags are properly formatted

View file

@ -0,0 +1,386 @@
(function () {
"use strict";
const previewContainers = {
flex: document.getElementById("lavalamp-preview-flex"),
small: document.getElementById("lavalamp-preview-100"),
large: document.getElementById("lavalamp-preview-200"),
};
const bgColor1Input = document.getElementById("bg-color-1");
const bgColor2Input = document.getElementById("bg-color-2");
const blobColor1Input = document.getElementById("blob-color-1");
const blobColor2Input = document.getElementById("blob-color-2");
const caseColorInput = document.getElementById("case-color");
const blobCountInput = document.getElementById("blob-count");
const speedInput = document.getElementById("speed");
const blobSizeInput = document.getElementById("blob-size");
const pixelateInput = document.getElementById("pixelate");
const pixelSizeInput = document.getElementById("pixel-size");
const pixelSizeGroup = document.getElementById("pixel-size-group");
const blobCountValue = document.getElementById("blob-count-value");
const speedValue = document.getElementById("speed-value");
const blobSizeValue = document.getElementById("blob-size-value");
const pixelSizeValue = document.getElementById("pixel-size-value");
const getCodeBtn = document.getElementById("get-code-btn");
const embedCodeSection = document.getElementById("embed-code-section");
const embedCodeDisplay = document.getElementById("embed-code-display");
const copyCodeBtn = document.getElementById("copy-code-btn");
const copyStatus = document.getElementById("copy-status");
let lampInstances = {
flex: { lampElements: {}, blobs: [] },
small: { lampElements: {}, blobs: [] },
large: { lampElements: {}, blobs: [] },
};
// Initialize the preview lamps
function initPreview() {
Object.keys(previewContainers).forEach((key, index) => {
const previewContainer = previewContainers[key];
const gooFilterId = `goo-preview-${key}`;
// Create SVG filters
const svg = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
svg.style.position = "absolute";
svg.style.width = "0";
svg.style.height = "0";
const pixelateFilterId = `pixelate-preview-${key}`;
svg.innerHTML = `
<defs>
<filter id="${gooFilterId}">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 18 -7"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
<filter id="${pixelateFilterId}" x="0%" y="0%" width="100%" height="100%">
<feFlood x="0" y="0" height="1" width="1"/>
<feComposite width="4" height="4" class="pixelate-composite"/>
<feTile result="a"/>
<feComposite in="SourceGraphic" in2="a" operator="in"/>
<feMorphology operator="dilate" radius="2" class="pixelate-morphology"/>
</filter>
</defs>
`;
const container = document.createElement("div");
container.className = "lavalamp-adoptable";
container.style.width = "100%";
container.style.height = "100%";
const lampCap = document.createElement("div");
lampCap.className = "lamp-cap";
const lampBody = document.createElement("div");
lampBody.className = "lamp-body";
const blobsContainer = document.createElement("div");
blobsContainer.className = "blobs-container";
// Initially set the goo filter, will be adjusted after layout
blobsContainer.style.filter = `url(#${gooFilterId})`;
const lampBase = document.createElement("div");
lampBase.className = "lamp-base";
lampBody.appendChild(blobsContainer);
container.appendChild(svg);
container.appendChild(lampCap);
container.appendChild(lampBody);
container.appendChild(lampBase);
previewContainer.appendChild(container);
lampInstances[key].lampElements = {
container,
lampCap,
lampBody,
lampBase,
blobsContainer,
svg,
pixelateFilterId,
};
});
// Apply initial styles
updatePreview();
// Adjust goo filter for small lamps after layout
adjustGooFilters();
}
// Adjust goo filters based on container width (matches embedded script logic)
function adjustGooFilters() {
Object.keys(lampInstances).forEach((key) => {
const instance = lampInstances[key];
const { lampElements } = instance;
if (!lampElements.lampBody) return;
const containerWidth = lampElements.lampBody.offsetWidth;
});
}
// Create a blob element for a specific instance
function createBlob(lampBody, lampHeight) {
const blob = document.createElement("div");
blob.className = "blob";
const containerWidth = lampBody.offsetWidth;
const containerHeight = lampHeight || lampBody.offsetHeight;
const blobSizeMultiplier = parseFloat(blobSizeInput.value);
const size =
(Math.random() * 0.15 + 0.25) * containerWidth * blobSizeMultiplier;
const duration = (Math.random() * 8 + 12) / parseFloat(speedInput.value);
const maxX = containerWidth - size;
const startX = Math.random() * maxX;
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.setProperty("--duration", `${duration}s`);
blob.style.setProperty("--start-x", "0px");
blob.style.setProperty("--start-y", "0px");
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`,
);
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),
);
blob.style.animationDelay = `${Math.random() * -20}s`;
return blob;
}
// Update blob count for all instances
function updateBlobCount() {
const count = parseInt(blobCountInput.value);
Object.keys(lampInstances).forEach((key) => {
const instance = lampInstances[key];
const { lampElements, blobs } = instance;
if (!lampElements.blobsContainer) return;
while (blobs.length > count) {
const blob = blobs.pop();
lampElements.blobsContainer.removeChild(blob);
}
while (blobs.length < count) {
const blob = createBlob(lampElements.lampBody);
updateBlobColors(blob);
blobs.push(blob);
lampElements.blobsContainer.appendChild(blob);
}
});
}
// Update blob colors
function updateBlobColors(blob) {
const color1 = blobColor1Input.value;
const color2 = blobColor2Input.value;
blob.style.background = `radial-gradient(circle at 30% 30%, ${color1}, ${color2})`;
}
// Adjust brightness helper
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)
);
}
// Update the preview for all instances
function updatePreview() {
Object.keys(lampInstances).forEach((key) => {
const instance = lampInstances[key];
const { lampElements, blobs } = instance;
if (!lampElements.lampBody) return;
// Update background gradient
lampElements.lampBody.style.background = `linear-gradient(180deg, ${bgColor1Input.value} 0%, ${bgColor2Input.value} 100%)`;
// Update cap and base color
const baseColor = caseColorInput.value;
lampElements.lampCap.style.background = `linear-gradient(180deg, ${adjustBrightness(baseColor, 40)} 0%, ${baseColor} 50%, ${adjustBrightness(baseColor, -20)} 100%)`;
lampElements.lampBase.style.background = `linear-gradient(180deg, ${baseColor} 0%, ${adjustBrightness(baseColor, -20)} 40%, ${adjustBrightness(baseColor, -40)} 100%)`;
lampElements.lampBase.style.borderTop = `1px solid ${adjustBrightness(bgColor2Input.value, -30)}`;
// Update all blob colors
blobs.forEach((blob) => updateBlobColors(blob));
});
// Update blob count
updateBlobCount();
}
// Update pixelation filters for all instances
function updatePixelation() {
const isPixelated = pixelateInput.checked;
const pixelSize = parseInt(pixelSizeInput.value);
Object.keys(lampInstances).forEach((key) => {
const instance = lampInstances[key];
const { lampElements } = instance;
if (isPixelated) {
// Update the filter in the SVG
const filter = lampElements.svg.querySelector(
`#${lampElements.pixelateFilterId}`,
);
const composite = filter.querySelector("feComposite[width]");
const morphology = filter.querySelector("feMorphology");
composite.setAttribute("width", pixelSize);
composite.setAttribute("height", pixelSize);
morphology.setAttribute("radius", Math.floor(pixelSize / 2));
// Apply filter to container
lampElements.container.style.filter = `url(#${lampElements.pixelateFilterId})`;
} else {
// Remove filter
lampElements.container.style.filter = "";
}
});
}
// Generate embed code
function generateEmbedCode() {
const siteUrl = window.location.origin;
let pixelateAttrs = "";
if (pixelateInput.checked) {
pixelateAttrs = '\n data-pixelate="true"';
if (parseInt(pixelSizeInput.value) !== 4) {
pixelateAttrs += `\n data-pixel-size="${pixelSizeInput.value}"`;
}
}
return `<script src="${siteUrl}/js/adoptables/lavalamp.js"
data-bg-color-1="${bgColor1Input.value}"
data-bg-color-2="${bgColor2Input.value}"
data-blob-color-1="${blobColor1Input.value}"
data-blob-color-2="${blobColor2Input.value}"
data-case-color="${caseColorInput.value}"
data-blob-count="${blobCountInput.value}"
data-speed="${speedInput.value}"
data-blob-size="${blobSizeInput.value}"${pixelateAttrs}><\/script>`;
}
// Event listeners
bgColor1Input.addEventListener("input", updatePreview);
bgColor2Input.addEventListener("input", updatePreview);
blobColor1Input.addEventListener("input", updatePreview);
blobColor2Input.addEventListener("input", updatePreview);
caseColorInput.addEventListener("input", updatePreview);
blobCountInput.addEventListener("input", function () {
blobCountValue.textContent = this.value;
updatePreview();
});
speedInput.addEventListener("input", function () {
speedValue.textContent = parseFloat(this.value).toFixed(1);
// Speed changes require recreating blobs for all instances
Object.keys(lampInstances).forEach((key) => {
const instance = lampInstances[key];
instance.blobs.forEach((blob) =>
instance.lampElements.blobsContainer.removeChild(blob),
);
instance.blobs = [];
});
updatePreview();
});
blobSizeInput.addEventListener("input", function () {
blobSizeValue.textContent = parseFloat(this.value).toFixed(1);
// Size changes require recreating blobs for all instances
Object.keys(lampInstances).forEach((key) => {
const instance = lampInstances[key];
instance.blobs.forEach((blob) =>
instance.lampElements.blobsContainer.removeChild(blob),
);
instance.blobs = [];
});
updatePreview();
});
pixelateInput.addEventListener("change", function () {
// Show/hide pixel size slider
if (this.checked) {
pixelSizeGroup.style.display = "block";
} else {
pixelSizeGroup.style.display = "none";
}
updatePixelation();
});
pixelSizeInput.addEventListener("input", function () {
pixelSizeValue.textContent = this.value;
updatePixelation();
});
getCodeBtn.addEventListener("click", function () {
embedCodeDisplay.textContent = generateEmbedCode();
embedCodeSection.style.display = "block";
embedCodeSection.scrollIntoView({ behavior: "smooth" });
});
// Initialize
initPreview();
})();

70
assets/js/code-copy.js Normal file
View file

@ -0,0 +1,70 @@
// Add copy buttons to all code blocks
document.addEventListener("DOMContentLoaded", function () {
// Find all <pre> elements that contain <code>
const codeBlocks = document.querySelectorAll("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
// Create wrapper for positioning
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
// Wrap the pre element
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
// Create copy button
const copyButton = document.createElement("button");
copyButton.className = "code-copy-btn";
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
copyButton.setAttribute("aria-label", "Copy code to clipboard");
// Add click handler
copyButton.addEventListener("click", async () => {
const code = codeBlock.textContent;
try {
await navigator.clipboard.writeText(code);
// Show success feedback
copyButton.classList.add("copied");
// Reset after 2 seconds
setTimeout(() => {
copyButton.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
copyButton.classList.add("copied");
setTimeout(() => {
copyButton.classList.remove("copied");
}, 2000);
} catch (err2) {
console.error("Fallback copy failed:", err2);
}
document.body.removeChild(textArea);
}
});
wrapper.appendChild(copyButton);
});
});

110
assets/js/crt-logs.js Normal file
View file

@ -0,0 +1,110 @@
// CRT Log Screen with Analytics
// This script updates the CRT screen with a mix of fake logs and real visitor stats
function initCRTLogs() {
const crtScreen = document.getElementById('crt-logs');
if (!crtScreen) {
return;
}
const fakeLogs = [
'[WARN] High load detected - time for coffee break',
'[ERROR] 404: Motivation not found',
'[WARN] Firewall detected actual fire.',
'[ERROR] Keyboard not found. Press F1 to continue.',
];
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function updateCRTScreen(stats) {
const logs = [];
// Add initial command
logs.push('> tail -f /var/log');
// Mix fake logs with real stats
const totalLogs = 10;
const statsPositions = [3, 6, 9]; // Insert stats at these positions
let fakeLogIndex = 0;
for (let i = 0; i < totalLogs; i++) {
if (statsPositions.includes(i) && stats) {
// Insert real stats
if (i === 3) {
logs.push(`[STATS] Total visitors: ${stats.totalHits.toLocaleString()}`);
} else if (i === 6) {
logs.push(`[STATS] Unique visitors: ${stats.uniqueVisitors.toLocaleString()}`);
} else if (i === 9 && stats.lastUpdated) {
logs.push(`[STATS] Last updated: ${formatDate(stats.lastUpdated)}`);
}
} else {
// Insert fake log
logs.push(fakeLogs[fakeLogIndex % fakeLogs.length]);
fakeLogIndex++;
}
}
// Animate logs appearing one by one
crtScreen.innerHTML = '> tail -f /var/log<br>\n<span class="cursor-blink">_</span>';
let currentIndex = 0;
const lineDelay = 150; // milliseconds between each line
function addNextLine() {
if (currentIndex < logs.length - 1) { // -1 to skip the initial command we already added
const displayedLogs = logs.slice(1, currentIndex + 2); // Skip initial command, add lines progressively
crtScreen.innerHTML = logs[0] + '<br>\n' + displayedLogs.join('<br>\n') + '<br>\n<span class="cursor-blink">_</span>';
currentIndex++;
setTimeout(addNextLine, lineDelay);
} else {
// Final update with cursor
crtScreen.innerHTML = logs.join('<br>\n') + '<br>\n<span class="cursor-blink">_</span>';
}
}
setTimeout(addNextLine, lineDelay);
}
function fetchAnalyticsStats() {
fetch('https://api.ritual.sh/analytics/stats')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch stats');
}
return response.json();
})
.then(stats => {
updateCRTScreen(stats);
})
.catch(err => {
// Silently fail and show fake logs only
console.debug('Failed to load analytics stats:', err);
updateCRTScreen(null);
});
}
// Initial load
fetchAnalyticsStats();
// Refresh stats every 30 seconds
setInterval(fetchAnalyticsStats, 30000);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCRTLogs);
} else {
// DOM already loaded
initCRTLogs();
}

371
assets/js/guestbook.js Normal file
View file

@ -0,0 +1,371 @@
/**
* Guestbook functionality for ritual.sh
* Custom implementation that calls the guestbook API directly
*/
class GuestbookManager {
constructor() {
// Configuration - Update this URL when the backend is deployed
this.apiUrl = "https://guestbook.ritual.sh";
this.perPage = 20;
this.currentPage = 1;
this.totalPages = 1;
// DOM elements
this.form = document.getElementById("guestbook-form");
this.entriesList = document.getElementById("entries-list");
this.entriesLoading = document.getElementById("entries-loading");
this.entriesError = document.getElementById("entries-error");
this.pagination = document.getElementById("pagination");
this.formFeedback = document.getElementById("form-feedback");
this.submitBtn = document.getElementById("submit-btn");
this.init();
}
init() {
if (!this.form) return;
// Attach event listeners
this.form.addEventListener("submit", (e) => this.handleSubmit(e));
// Load initial entries
this.loadEntries();
}
/**
* Load guestbook entries from the API
*/
async loadEntries(page = 1) {
this.currentPage = page;
// Show loading state
this.showLoading();
try {
const response = await fetch(
`${this.apiUrl}/entries?page=${page}&per_page=${this.perPage}`,
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Handle the actual API response structure
if (data.entries !== undefined) {
// API returns entries directly
this.renderEntries(data.entries || []);
this.totalPages = data.total_pages || 1;
// Create pagination object from the flat response
const pagination = {
current_page: data.page || 1,
total_pages: data.total_pages || 1,
total_entries: data.total || 0,
per_page: data.per_page || this.perPage
};
this.renderPagination(pagination);
} else if (data.success === false) {
// API returned an error
throw new Error(data.error || "Failed to load entries");
} else {
throw new Error("Unexpected API response format");
}
} catch (error) {
console.error("Error loading entries:", error);
this.showError();
}
}
/**
* Render entries to the DOM
*/
renderEntries(entries) {
// Hide loading and error states
this.entriesLoading.style.display = "none";
this.entriesError.style.display = "none";
this.entriesList.style.display = "block";
if (entries.length === 0) {
this.entriesList.innerHTML = `
<div class="no-entries">
<p>No entries yet. Be the first to sign the guestbook!</p>
<span class="cursor-blink">_</span>
</div>
`;
return;
}
// Build entries HTML
const entriesHTML = entries
.map((entry) => this.renderEntry(entry))
.join("");
this.entriesList.innerHTML = entriesHTML;
}
/**
* Render a single entry
*/
renderEntry(entry) {
const date = this.formatDate(entry.timestamp);
const nameHTML = entry.website
? `<a href="${this.escapeHtml(entry.website)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(entry.name)}</a>`
: this.escapeHtml(entry.name);
// Display website URL without https:// protocol
const websiteDisplay = entry.website
? `<span class="entry-separator">|</span><span class="entry-website">${this.formatWebsiteUrl(entry.website)}</span>`
: "";
return `
<div class="entry">
<div class="entry-header">
<span class="entry-name">${nameHTML}</span>
${websiteDisplay}
<span class="entry-date">${date}</span>
</div>
<div class="entry-message">${this.escapeHtml(entry.message)}</div>
</div>
`;
}
/**
* Render pagination controls
*/
renderPagination(pagination) {
if (pagination.total_pages <= 1) {
this.pagination.style.display = "none";
return;
}
this.pagination.style.display = "flex";
const prevDisabled = pagination.current_page === 1 ? "disabled" : "";
const nextDisabled =
pagination.current_page === pagination.total_pages ? "disabled" : "";
let pagesHTML = "";
// Show page numbers (max 5)
const startPage = Math.max(1, pagination.current_page - 2);
const endPage = Math.min(pagination.total_pages, startPage + 4);
for (let i = startPage; i <= endPage; i++) {
const active = i === pagination.current_page ? "active" : "";
pagesHTML += `
<button class="pagination-button ${active}" data-page="${i}" ${active ? "disabled" : ""}>
${i}
</button>
`;
}
this.pagination.innerHTML = `
<div class="pagination-info">
Page ${pagination.current_page} of ${pagination.total_pages}
(${pagination.total_entries} entries)
</div>
<div class="pagination-controls">
<button class="pagination-button" data-page="${pagination.current_page - 1}" ${prevDisabled}>
&lt; Prev
</button>
${pagesHTML}
<button class="pagination-button" data-page="${pagination.current_page + 1}" ${nextDisabled}>
Next &gt;
</button>
</div>
`;
// Attach click handlers to pagination buttons
this.pagination.querySelectorAll(".pagination-button").forEach((btn) => {
btn.addEventListener("click", (e) => {
const page = parseInt(e.target.dataset.page);
if (page && page !== pagination.current_page) {
this.loadEntries(page);
// Scroll to top of entries
document
.querySelector(".guestbook-entries-container")
.scrollIntoView({ behavior: "smooth" });
}
});
});
}
/**
* Handle form submission
*/
async handleSubmit(e) {
e.preventDefault();
// Disable submit button
this.submitBtn.disabled = true;
this.submitBtn.querySelector(".button-text").textContent = "[ SENDING... ]";
// Clear previous feedback
this.formFeedback.className = "form-feedback";
this.formFeedback.textContent = "";
// Get form data
const formData = new FormData(this.form);
const data = {
name: formData.get("name"),
email: formData.get("email"),
website: formData.get("website"),
message: formData.get("message"),
};
try {
const response = await fetch(`${this.apiUrl}/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
// Handle success response
if (result.success || response.ok) {
this.showSuccess(
result.message ||
"Entry submitted! It will appear after moderation. Thank you!",
);
this.form.reset();
// If entry was auto-approved, reload entries
if (result.status === "approved") {
setTimeout(() => this.loadEntries(1), 1000);
}
} else {
this.showFormError(result.error || result.message || "Failed to submit entry");
}
} catch (error) {
console.error("Error submitting entry:", error);
this.showFormError(
"Network error. Please check your connection and try again.",
);
} finally {
// Re-enable submit button
this.submitBtn.disabled = false;
this.submitBtn.querySelector(".button-text").textContent = "[ SUBMIT ]";
}
}
/**
* Show loading state
*/
showLoading() {
this.entriesLoading.style.display = "block";
this.entriesError.style.display = "none";
this.entriesList.style.display = "none";
this.pagination.style.display = "none";
}
/**
* Show error state
*/
showError() {
this.entriesLoading.style.display = "none";
this.entriesError.style.display = "block";
this.entriesList.style.display = "none";
this.pagination.style.display = "none";
}
/**
* Show form success message
*/
showSuccess(message) {
this.formFeedback.className = "form-feedback success";
this.formFeedback.textContent = `SUCCESS: ${message}`;
// Auto-hide after 10 seconds
setTimeout(() => {
this.formFeedback.className = "form-feedback";
}, 10000);
}
/**
* Show form error message
*/
showFormError(message) {
this.formFeedback.className = "form-feedback error";
this.formFeedback.textContent = `ERROR: ${message}`;
// Auto-hide after 8 seconds
setTimeout(() => {
this.formFeedback.className = "form-feedback";
}, 8000);
}
/**
* Format date to readable format
*/
formatDate(dateString) {
if (!dateString) return "Unknown date";
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) {
return "Unknown date";
}
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today";
} else if (diffDays === 1) {
return "Yesterday";
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
}
/**
* Format website URL for display (remove protocol)
*/
formatWebsiteUrl(url) {
if (!url) return "";
try {
const urlObj = new URL(url);
// Return hostname + pathname, removing trailing slash
let display = urlObj.hostname + urlObj.pathname;
return this.escapeHtml(display.replace(/\/$/, ""));
} catch (e) {
// If URL parsing fails, just remove common protocols
return this.escapeHtml(
url.replace(/^https?:\/\//, "").replace(/\/$/, "")
);
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
// Initialize guestbook when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
new GuestbookManager();
});
} else {
new GuestbookManager();
}

View file

@ -3,6 +3,19 @@
window.terminal = new TerminalShell(); window.terminal = new TerminalShell();
// Analytics tracking
function sendAnalyticsHit() {
fetch('https://api.ritual.sh/analytics/hit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).catch(err => {
// Silently fail - don't block page load on analytics errors
console.debug('Analytics tracking failed:', err);
});
}
// Function to initialize terminal // Function to initialize terminal
function initTerminal() { function initTerminal() {
// Check if terminal element exists // Check if terminal element exists
@ -14,8 +27,12 @@ function initTerminal() {
// Wait for DOM to be ready // Wait for DOM to be ready
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initTerminal); document.addEventListener("DOMContentLoaded", () => {
initTerminal();
sendAnalyticsHit();
});
} else { } else {
// DOM already loaded // DOM already loaded
initTerminal(); initTerminal();
sendAnalyticsHit();
} }

260
assets/js/lastfm-stats.js Normal file
View file

@ -0,0 +1,260 @@
// Last.fm Stats Interactive Module
(function () {
"use strict";
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
const LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
// Store current stats for export
let currentStats = {
artists: [],
totalTracks: 0,
period: "",
username: "",
};
// Calculate timestamps based on period
function getTimestamps(period) {
const now = Math.floor(Date.now() / 1000);
let from;
if (period === "7day") {
from = now - 7 * 24 * 60 * 60; // 7 days
} else if (period === "1month") {
from = now - 30 * 24 * 60 * 60; // 30 days
}
return { from, to: now };
}
// Fetch top artists for the specified period
async function fetchTopArtists(username, period) {
const url = `${LASTFM_API_URL}?method=user.gettopartists&user=${username}&api_key=${LASTFM_API_KEY}&format=json&period=${period}&limit=5`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch top artists: ${response.statusText}`);
}
const data = await response.json();
// Check for Last.fm API errors
if (data.error) {
throw new Error(data.message || "Last.fm API error");
}
return data.topartists?.artist || [];
}
// Fetch recent tracks to count total scrobbles in period
async function fetchTrackCount(username, period) {
const { from, to } = getTimestamps(period);
const url = `${LASTFM_API_URL}?method=user.getrecenttracks&user=${username}&api_key=${LASTFM_API_KEY}&format=json&from=${from}&to=${to}&limit=1`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch track count: ${response.statusText}`);
}
const data = await response.json();
// Check for Last.fm API errors
if (data.error) {
throw new Error(data.message || "Last.fm API error");
}
return data.recenttracks?.["@attr"]?.total || 0;
}
// Generate markdown format
function generateMarkdown() {
const periodText =
currentStats.period === "7day" ? "Past Week" : "Past Month";
let markdown = `## Last.fm Stats - ${periodText}\n\n`;
markdown += `**Total Tracks:** ${currentStats.totalTracks}\n\n`;
markdown += `**Top 5 Artists:**\n\n`;
currentStats.artists.forEach((artist) => {
markdown += `- [${artist.name}](${artist.url}) - ${artist.playcount} plays\n`;
});
return markdown;
}
// Generate plain text format
function generatePlainText() {
const periodText =
currentStats.period === "7day" ? "Past Week" : "Past Month";
let text = `Last.fm Stats - ${periodText}\n\n`;
text += `Total Tracks: ${currentStats.totalTracks}\n\n`;
text += `Top 5 Artists:\n\n`;
currentStats.artists.forEach((artist) => {
text += `- ${artist.name} - ${artist.playcount} plays\n`;
});
return text;
}
// Copy to clipboard
async function copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
const originalText = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
alert("Failed to copy to clipboard");
}
}
// Display the stats
function displayStats(artists, totalTracks, period, username) {
const artistsList = document.getElementById("top-artists");
const totalTracksEl = document.getElementById("total-tracks");
// Store current stats for export
currentStats = { artists, totalTracks, period, username };
// Update total tracks
totalTracksEl.textContent = totalTracks;
// Clear and populate artists list
artistsList.innerHTML = "";
if (artists.length === 0) {
artistsList.innerHTML = "<li>No artists found for this period</li>";
return;
}
artists.forEach((artist) => {
const li = document.createElement("li");
li.innerHTML = `<a href="${artist.url}" target="_blank">${artist.name}</a> - ${artist.playcount} plays`;
artistsList.appendChild(li);
});
// Show export buttons
const exportButtons = document.getElementById("export-buttons");
if (exportButtons) {
exportButtons.style.display = "flex";
}
}
// Show/hide UI elements
function setLoadingState(isLoading) {
const loading = document.getElementById("stats-loading");
const content = document.getElementById("stats-content");
const error = document.getElementById("stats-error");
const results = document.getElementById("stats-results");
results.style.display = "block";
if (isLoading) {
loading.style.display = "block";
content.style.display = "none";
error.style.display = "none";
} else {
loading.style.display = "none";
}
}
function showError(message) {
const error = document.getElementById("stats-error");
const errorMessage = document.getElementById("error-message");
const content = document.getElementById("stats-content");
error.style.display = "block";
content.style.display = "none";
errorMessage.textContent = message;
}
function showContent() {
const error = document.getElementById("stats-error");
const content = document.getElementById("stats-content");
error.style.display = "none";
content.style.display = "block";
}
// Main fetch function
async function fetchStats() {
const username = document.getElementById("lastfm-username").value.trim();
const period = document.getElementById("time-period").value;
if (!username) {
showError("Please enter a Last.fm username");
return;
}
setLoadingState(true);
try {
// Fetch both stats in parallel
const [artists, totalTracks] = await Promise.all([
fetchTopArtists(username, period),
fetchTrackCount(username, period),
]);
displayStats(artists, totalTracks, period, username);
showContent();
} catch (error) {
console.error("Error fetching Last.fm stats:", error);
showError(
error.message ||
"Failed to fetch stats. Please check the username and try again.",
);
} finally {
setLoadingState(false);
}
}
// Initialize when DOM is ready
function init() {
const fetchButton = document.getElementById("fetch-stats");
const usernameInput = document.getElementById("lastfm-username");
const copyMarkdownBtn = document.getElementById("copy-markdown");
const copyPlainTextBtn = document.getElementById("copy-plaintext");
if (!fetchButton || !usernameInput) {
return; // Not on the stats page
}
// Fetch stats on button click
fetchButton.addEventListener("click", fetchStats);
// Also fetch on Enter key in username input
usernameInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
fetchStats();
}
});
// Copy buttons
if (copyMarkdownBtn) {
copyMarkdownBtn.addEventListener("click", () => {
const markdown = generateMarkdown();
copyToClipboard(markdown, copyMarkdownBtn);
});
}
if (copyPlainTextBtn) {
copyPlainTextBtn.addEventListener("click", () => {
const plainText = generatePlainText();
copyToClipboard(plainText, copyPlainTextBtn);
});
}
}
// Run init when DOM is loaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

68
assets/js/lastfm-utils.js Normal file
View file

@ -0,0 +1,68 @@
// Shared Last.fm utilities - using global namespace pattern
(function(window) {
'use strict';
// Create global LastFmUtils namespace
window.LastFmUtils = {
// Last.fm API configuration
LASTFM_API_URL: "https://ws.audioscrobbler.com/2.0/",
LASTFM_USER: "ritualplays",
LASTFM_API_KEY: "3a4fef48fecc593d25e0f9a40df1fefe",
// Format time difference
getTimeAgo: function(timestamp) {
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 604800)}w ago`;
},
// Fetch recent tracks from Last.fm
fetchRecentTracks: async function(limit = 10) {
const url = `${this.LASTFM_API_URL}?method=user.getrecenttracks&user=${this.LASTFM_USER}&api_key=${this.LASTFM_API_KEY}&format=json&limit=${limit}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch tracks");
const data = await response.json();
return data.recenttracks.track;
} catch (error) {
console.error("Error fetching Last.fm data:", error);
return null;
}
},
// Filter tracks to remove duplicates of now playing track
// Returns a limited number of unique tracks
filterAndLimitTracks: function(tracks, maxTracks = 5) {
if (!tracks || tracks.length === 0) {
return [];
}
// Check if first track is now playing
const hasNowPlaying = tracks[0] && tracks[0]["@attr"] && tracks[0]["@attr"].nowplaying;
if (hasNowPlaying) {
// Show now playing + (maxTracks - 1) latest (excluding duplicates of now playing)
const nowPlayingTrack = tracks[0];
const nowPlayingId = `${nowPlayingTrack.name}-${nowPlayingTrack.artist["#text"]}`;
// Get remaining tracks, excluding duplicates of now playing
const remainingTracks = tracks.slice(1).filter(track => {
const trackId = `${track.name}-${track.artist["#text"]}`;
return trackId !== nowPlayingId;
});
return [nowPlayingTrack, ...remainingTracks.slice(0, maxTracks - 1)];
} else {
// No now playing, show maxTracks latest
return tracks.slice(0, maxTracks);
}
}
};
})(window);

View file

@ -83,27 +83,8 @@ if (document.getElementById("starfield")) {
const container = document.getElementById("recent-tracks"); const container = document.getElementById("recent-tracks");
if (!container) return; if (!container) return;
const AUDIO_LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
const AUDIO_LASTFM_USER = "ritualplays";
const AUDIO_LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
const AUDIO_TRACK_LIMIT = 6; // Fetch 6 to have enough after filtering const AUDIO_TRACK_LIMIT = 6; // Fetch 6 to have enough after filtering
// Fetch recent tracks from Last.fm for audio page
async function fetchAudioRecentTracks() {
const url = `${AUDIO_LASTFM_API_URL}?method=user.getrecenttracks&user=${AUDIO_LASTFM_USER}&api_key=${AUDIO_LASTFM_API_KEY}&format=json&limit=${AUDIO_TRACK_LIMIT}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch tracks");
const data = await response.json();
return data.recenttracks.track;
} catch (error) {
console.error("Error fetching Last.fm data:", error);
return null;
}
}
// Render recent tracks for audio page (horizontal layout) // Render recent tracks for audio page (horizontal layout)
function renderRecentTracks(tracks) { function renderRecentTracks(tracks) {
if (!tracks || tracks.length === 0) { if (!tracks || tracks.length === 0) {
@ -114,27 +95,8 @@ if (document.getElementById("starfield")) {
container.innerHTML = ""; container.innerHTML = "";
// Check if first track is now playing // Filter and limit tracks to 5 (excluding duplicates)
const hasNowPlaying = tracks[0] && tracks[0]["@attr"] && tracks[0]["@attr"].nowplaying; const tracksToShow = LastFmUtils.filterAndLimitTracks(tracks, 5);
// Filter and limit tracks
let tracksToShow;
if (hasNowPlaying) {
// Show now playing + 4 latest (excluding duplicates of now playing)
const nowPlayingTrack = tracks[0];
const nowPlayingId = `${nowPlayingTrack.name}-${nowPlayingTrack.artist["#text"]}`;
// Get remaining tracks, excluding duplicates of now playing
const remainingTracks = tracks.slice(1).filter(track => {
const trackId = `${track.name}-${track.artist["#text"]}`;
return trackId !== nowPlayingId;
});
tracksToShow = [nowPlayingTrack, ...remainingTracks.slice(0, 4)];
} else {
// No now playing, show 5 latest
tracksToShow = tracks.slice(0, 5);
}
tracksToShow.forEach((track) => { tracksToShow.forEach((track) => {
const isNowPlaying = track["@attr"] && track["@attr"].nowplaying; const isNowPlaying = track["@attr"] && track["@attr"].nowplaying;
@ -164,12 +126,12 @@ if (document.getElementById("starfield")) {
// Initialize Last.fm feed for audio page // Initialize Last.fm feed for audio page
async function initAudioRecentTracks() { async function initAudioRecentTracks() {
const tracks = await fetchAudioRecentTracks(); const tracks = await LastFmUtils.fetchRecentTracks(AUDIO_TRACK_LIMIT);
renderRecentTracks(tracks); renderRecentTracks(tracks);
// Update every 30 seconds // Update every 30 seconds
setInterval(async () => { setInterval(async () => {
const updatedTracks = await fetchAudioRecentTracks(); const updatedTracks = await LastFmUtils.fetchRecentTracks(AUDIO_TRACK_LIMIT);
renderRecentTracks(updatedTracks); renderRecentTracks(updatedTracks);
}, 30000); }, 30000);
} }

View file

@ -82,39 +82,8 @@ function initMatrixRain() {
}); });
} }
// Last.fm API configuration // Media page configuration
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/"; const TRACK_LIMIT = 12; // Fetch 12 to have enough after filtering
const LASTFM_USER = "ritualplays";
const LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
const TRACK_LIMIT = 10;
// Format time difference
function getTimeAgo(timestamp) {
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 604800)}w ago`;
}
// Fetch recent tracks from Last.fm
async function fetchRecentTracks() {
const url = `${LASTFM_API_URL}?method=user.getrecenttracks&user=${LASTFM_USER}&api_key=${LASTFM_API_KEY}&format=json&limit=${TRACK_LIMIT}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch tracks");
const data = await response.json();
return data.recenttracks.track;
} catch (error) {
console.error("Error fetching Last.fm data:", error);
return null;
}
}
// Render tracks to the DOM // Render tracks to the DOM
function renderTracks(tracks) { function renderTracks(tracks) {
@ -129,7 +98,10 @@ function renderTracks(tracks) {
container.innerHTML = ""; container.innerHTML = "";
tracks.forEach((track) => { // Filter and limit tracks to 10 (excluding duplicates of now playing)
const tracksToShow = LastFmUtils.filterAndLimitTracks(tracks, 10);
tracksToShow.forEach((track) => {
const isNowPlaying = track["@attr"] && track["@attr"].nowplaying; const isNowPlaying = track["@attr"] && track["@attr"].nowplaying;
const trackElement = document.createElement("a"); const trackElement = document.createElement("a");
trackElement.href = track.url; trackElement.href = track.url;
@ -143,7 +115,7 @@ function renderTracks(tracks) {
// Get timestamp // Get timestamp
const timestamp = track.date ? track.date.uts : null; const timestamp = track.date ? track.date.uts : null;
const timeAgo = timestamp ? getTimeAgo(timestamp) : ""; const timeAgo = timestamp ? LastFmUtils.getTimeAgo(timestamp) : "";
trackElement.innerHTML = ` trackElement.innerHTML = `
<div class="track-art ${!hasArt ? "no-art" : ""}"> <div class="track-art ${!hasArt ? "no-art" : ""}">
@ -162,12 +134,12 @@ function renderTracks(tracks) {
// Initialize Last.fm feed // Initialize Last.fm feed
async function initLastFmFeed() { async function initLastFmFeed() {
const tracks = await fetchRecentTracks(); const tracks = await LastFmUtils.fetchRecentTracks(TRACK_LIMIT);
renderTracks(tracks); renderTracks(tracks);
// Update every 30 seconds // Update every 30 seconds
setInterval(async () => { setInterval(async () => {
const updatedTracks = await fetchRecentTracks(); const updatedTracks = await LastFmUtils.fetchRecentTracks(TRACK_LIMIT);
renderTracks(updatedTracks); renderTracks(updatedTracks);
}, 30000); }, 30000);
} }

33
assets/js/pgp-copy.js Normal file
View file

@ -0,0 +1,33 @@
// PGP Key copy functionality
document.addEventListener('DOMContentLoaded', function() {
const copyButtons = document.querySelectorAll('.pgp-copy-trigger');
copyButtons.forEach(button => {
button.addEventListener('click', async function() {
const feedback = button.closest('.contact-pgp').querySelector('.pgp-copy-feedback');
try {
const response = await fetch('/publickey.asc');
const pgpKey = await response.text();
await navigator.clipboard.writeText(pgpKey);
feedback.textContent = 'PGP key copied to clipboard!';
feedback.classList.add('show', 'success');
setTimeout(() => {
feedback.classList.remove('show');
}, 3000);
} catch (err) {
console.error('Failed to copy PGP key:', err);
feedback.textContent = 'Failed to copy key';
feedback.classList.add('show', 'error');
setTimeout(() => {
feedback.classList.remove('show');
}, 3000);
}
});
});
});

View file

@ -2,6 +2,10 @@
color: white; color: white;
margin: auto; margin: auto;
.content-screen {
position: relative !important;
}
> .about-content { > .about-content {
width: 50%; width: 50%;
margin: auto; margin: auto;
@ -14,6 +18,125 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.about-contact-section {
margin-top: 1rem;
p {
margin-bottom: 1.5rem;
line-height: 1.6;
}
.contact-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.contact-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media (min-width: 600px) {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
}
.contact-label {
font-weight: bold;
white-space: nowrap;
}
.info {
color: #0f0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
text-decoration: none;
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
transition: all 0.3s ease;
&:hover {
border-bottom-style: solid;
border-bottom-color: rgba(0, 255, 0, 0.8);
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
}
.pgp-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-left: 0;
@media (min-width: 600px) {
margin-left: 0.5rem;
}
}
.pgp-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
color: #0f0;
text-decoration: none;
font-family: monospace;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
border-radius: 3px;
.button-icon {
font-size: 1em;
line-height: 1;
}
&:hover {
background: rgba(0, 255, 0, 0.2);
border-color: rgba(0, 255, 0, 0.6);
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
&:active {
transform: translateY(1px);
}
}
.copy-feedback {
margin-top: 0.5rem;
font-size: 0.85rem;
padding: 6px 10px;
border-radius: 3px;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
&.show {
opacity: 1;
transform: translateY(0);
}
&.success {
color: #0f0;
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
&.error {
color: #f00;
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
}
}
}
.about-header { .about-header {
position: absolute; position: absolute;
left: -80px; left: -80px;
@ -37,6 +160,10 @@
} }
} }
> .about-container {
position: relative;
}
> .info-badges { > .info-badges {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -55,6 +182,23 @@
transform: rotate(5deg); transform: rotate(5deg);
} }
} }
.about-music {
pointer-events: none;
> .music {
scale: 1.6;
.ipod-group {
top: 50%;
left: 25%;
}
.vu-meter {
position: relative;
}
}
}
} }
} }

View file

@ -21,7 +21,8 @@
padding-top: 330px; padding-top: 330px;
@include media-down(lg) { @include media-down(lg) {
padding-top: 400px; padding-top: 50px;
width: 95%;
} }
.audio-intro { .audio-intro {
@ -102,6 +103,13 @@
width: 100%; width: 100%;
pointer-events: none; pointer-events: none;
@include media-down(lg) {
left: auto;
top: auto;
position: relative;
margin-bottom: 50px;
}
.neon-text { .neon-text {
@include media-down(lg) { @include media-down(lg) {
font-size: 5rem; font-size: 5rem;
@ -159,12 +167,12 @@
gap: 20px; gap: 20px;
@include media-down(lg) { @include media-down(lg) {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 15px; gap: 15px;
} }
@include media-down(md) { @include media-down(md) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: 1fr;
} }
} }
@ -190,6 +198,27 @@
color: inherit; color: inherit;
position: relative; position: relative;
@include media-down(lg) {
display: grid;
grid-template-columns: auto 1fr;
}
.track-cover {
@include media-down(lg) {
max-width: 100px;
}
@include media-down(md) {
max-width: 60px;
}
}
.track-details {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
&:hover { &:hover {
border-color: #00ffff; border-color: #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3); box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);

View file

@ -4,7 +4,7 @@
position: relative; position: relative;
@include media-down(lg) { @include media-down(lg) {
padding: 1rem; padding: 0rem;
} }
// Main container with CRT monitor styling // Main container with CRT monitor styling
@ -21,13 +21,26 @@
position: relative; position: relative;
@include media-down(lg) { @include media-down(lg) {
padding: 15px 15px 35px 15px; padding: 0px;
border-radius: 8px; border-radius: 0px;
border: 0px;
margin: 0px;
width: 100%;
max-width: 100%;
&::before,
&::after {
display: none;
}
} }
.blog-header { .blog-header {
text-align: center; text-align: center;
padding-bottom: 20px; padding-bottom: 20px;
@include media-down(lg) {
font-size: 0.75rem;
}
} }
// Brand on bezel // Brand on bezel
@ -108,6 +121,7 @@
.blogs-screen { .blogs-screen {
width: 100%; width: 100%;
min-height: 500px; min-height: 500px;
height: auto; // Allow content to expand
background: #000; background: #000;
border-radius: 8px; border-radius: 8px;
position: relative; position: relative;
@ -119,7 +133,7 @@
inset -3px -3px 8px rgba(0, 0, 0, 0.5); inset -3px -3px 8px rgba(0, 0, 0, 0.5);
@include media-down(lg) { @include media-down(lg) {
border-radius: 6px; border-radius: 0px;
min-height: 400px; min-height: 400px;
} }
@ -271,14 +285,12 @@
} }
} }
.tag-filter-link { .tag-filter-link {
background: rgba(255, 153, 0, 0.2); background: rgba(255, 153, 0, 0.2);
border-color: rgba(255, 153, 0, 0.5); border-color: rgba(255, 153, 0, 0.5);
color: #ff9900; color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5); text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
} }
} }
// Post title // Post title
@ -458,6 +470,14 @@
} }
} }
// Horizontal rules
hr {
border: none;
border-top: 2px dashed #0f0;
margin: 2em 0;
opacity: 0.6;
}
.read-more { .read-more {
display: inline-block; display: inline-block;
margin-top: 10px; margin-top: 10px;
@ -538,6 +558,170 @@
} }
} }
// Contact section styling
.contact-section {
margin-top: 40px;
padding: 20px;
background: rgba(0, 255, 0, 0.05);
border: 1px solid rgba(0, 255, 0, 0.3);
border-radius: 4px;
&:before {
content: none;
}
@include media-down(lg) {
margin-top: 30px;
padding: 15px;
}
.contact-title {
font-size: 1.3rem;
margin-bottom: 15px;
color: greenyellow;
text-shadow: 0 0 10px rgba(173, 255, 47, 0.5);
@include media-down(lg) {
font-size: 1.1rem;
}
}
.contact-content {
display: flex;
flex-direction: column;
gap: 15px;
color: greenyellow;
a {
color: #0f0;
text-decoration: none;
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
transition: all 0.3s ease;
&:hover {
color: cyan;
border-bottom-style: solid;
border-bottom-color: rgba(0, 255, 0, 0.8);
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
background: rgba(0, 255, 0, 0.05);
}
}
}
.contact-email,
.contact-pgp {
font-size: 0.95rem;
line-height: 1.6;
a:hover {
color: cyan;
}
@include media-down(lg) {
font-size: 0.9rem;
}
}
.contact-label {
color: greenyellow;
font-weight: bold;
margin-right: 10px;
text-shadow: 0 0 5px rgba(173, 255, 47, 0.5);
}
.contact-email a {
color: #0f0;
text-decoration: none;
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
transition: all 0.3s ease;
&:hover {
border-bottom-style: solid;
border-bottom-color: rgba(0, 255, 0, 0.8);
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
background: rgba(0, 255, 0, 0.05);
}
}
.pgp-actions {
display: flex;
gap: 10px;
margin-top: 10px;
flex-wrap: wrap;
}
.pgp-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
color: #0f0;
text-decoration: none;
font-family: monospace;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.3s ease;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
border-radius: 3px;
&:hover {
color: cyan;
}
@include media-down(lg) {
font-size: 0.8rem;
padding: 6px 12px;
}
.button-icon {
font-size: 1.1em;
line-height: 1;
}
&:hover {
background: rgba(0, 255, 0, 0.2);
border-color: rgba(0, 255, 0, 0.6);
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
&:active {
transform: translateY(1px);
}
}
.copy-feedback {
margin-top: 10px;
font-size: 0.85rem;
padding: 8px 12px;
border-radius: 3px;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
&.show {
opacity: 1;
transform: translateY(0);
}
&.success {
color: #0f0;
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
&.error {
color: #f00;
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
}
}
}
// Blog image styling // Blog image styling
.blog-img-container { .blog-img-container {
position: relative; position: relative;
@ -555,7 +739,8 @@
background: background:
linear-gradient(#000, #000) padding-box, linear-gradient(#000, #000) padding-box,
linear-gradient(180deg, #0f0, #000) border-box; linear-gradient(180deg, #0f0, #000) border-box;
filter: grayscale(100%) contrast(1.2) brightness(0.9) sepia(100%) hue-rotate(60deg) saturate(300%); filter: grayscale(100%) contrast(1.2) brightness(0.9) sepia(100%)
hue-rotate(60deg) saturate(300%);
transition: all 0.3s ease; transition: all 0.3s ease;
&:hover { &:hover {
@ -590,6 +775,10 @@
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 15px; padding-bottom: 15px;
border-bottom: 1px solid rgba(0, 255, 0, 0.2); border-bottom: 1px solid rgba(0, 255, 0, 0.2);
@include media-down(lg) {
text-align: right;
}
} }
.blog-post-navigation { .blog-post-navigation {

View file

@ -0,0 +1,534 @@
@import url(https://fonts.bunny.net/css?family=bebas-neue:400|lato:400,400i,700,700i|montserrat:400,400i,700,700i|open-sans:400,400i,700,700i|oswald:400,700|press-start-2p:400|roboto:400,400i,700,700i|roboto-mono:400,400i,700,700i|vt323:400);
#button-generator-app {
margin: 2rem 0;
padding: 2rem;
background: linear-gradient(
145deg,
rgba(5, 15, 30, 0.9) 0%,
rgba(10, 20, 40, 0.95) 100%
);
border-radius: 6px;
border: 1px solid rgba(0, 150, 255, 0.4);
box-shadow:
0 0 30px rgba(0, 150, 255, 0.15),
0 0 50px rgba(255, 120, 0, 0.08),
inset 0 0 60px rgba(0, 100, 200, 0.05);
position: relative;
overflow: visible;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
transparent,
rgba(0, 150, 255, 0.6),
rgba(255, 120, 0, 0.6),
transparent
);
}
.generator-container {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
margin-top: 1rem;
position: relative;
z-index: 1;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.preview-section {
background: linear-gradient(
135deg,
rgba(0, 100, 180, 0.15) 0%,
rgba(0, 80, 150, 0.1) 100%
);
padding: 1.5rem;
border-radius: 6px;
border: 1px solid rgba(0, 150, 255, 0.3);
display: flex;
flex-direction: column;
gap: 1rem;
position: sticky;
top: 1rem;
align-self: flex-start;
max-height: calc(100vh - 2rem);
overflow-y: auto;
box-shadow:
0 0 20px rgba(0, 150, 255, 0.1),
inset 0 0 40px rgba(0, 100, 200, 0.05);
@include media-down(md) {
background: linear-gradient(
135deg,
rgba(0, 100, 180, 1) 0%,
rgb(0, 29, 55) 100%
);
z-index: 90;
}
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(
180deg,
rgba(0, 150, 255, 0.7),
rgba(255, 120, 0, 0.5)
);
border-radius: 6px 0 0 6px;
}
h3 {
margin-top: 0;
color: #0096ff;
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
text-shadow: 0 0 15px rgba(0, 150, 255, 0.5);
font-size: 1.2rem;
}
}
.presets-container {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 2px solid;
border-image: linear-gradient(
90deg,
transparent,
rgba(0, 150, 255, 0.5),
transparent
)
1;
h3 {
margin-top: 0;
margin-bottom: 1rem;
color: rgba(100, 180, 255, 0.9);
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
text-shadow: 0 0 10px rgba(0, 150, 255, 0.4);
font-size: 1rem;
}
}
.preview-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.preview-wrapper {
background: repeating-conic-gradient(
rgba(20, 30, 50, 0.8) 0% 25%,
rgba(10, 20, 40, 0.9) 0% 50%
)
50% / 20px 20px;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
border: 1px solid rgba(0, 150, 255, 0.2);
}
#button-canvas {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
//border: 1px solid rgba(0, 150, 255, 0.4);
box-shadow: 0 0 15px rgba(0, 150, 255, 0.2);
}
.controls-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.control-group {
background: linear-gradient(
135deg,
rgba(0, 100, 180, 0.15) 0%,
rgba(0, 80, 150, 0.1) 100%
);
padding: 1rem;
border-radius: 6px;
border: 1px solid rgba(0, 150, 255, 0.3);
box-shadow:
0 0 20px rgba(0, 150, 255, 0.1),
inset 0 0 40px rgba(0, 100, 200, 0.05);
position: relative;
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 3px;
height: 100%;
background: linear-gradient(
180deg,
rgba(0, 150, 255, 0.7),
rgba(255, 120, 0, 0.5)
);
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover::before {
opacity: 1;
}
h3 {
margin-top: 0;
margin-bottom: 1rem;
}
label {
//display: block;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
font-weight: 500;
font-size: 0.85rem;
color: rgba(100, 180, 255, 0.8);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input[type="text"],
select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 150, 255, 0.4);
border-radius: 4px;
font-size: 0.9rem;
background: rgba(5, 15, 30, 0.7);
color: rgba(200, 220, 255, 0.95);
transition: all 0.3s ease;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
&:focus {
outline: none;
border-color: rgba(0, 150, 255, 0.7);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.3),
inset 0 2px 8px rgba(0, 0, 0, 0.4);
background: rgba(5, 15, 30, 0.9);
}
}
select {
cursor: pointer;
option {
background: rgba(5, 15, 30, 0.95);
color: rgba(200, 220, 255, 0.95);
}
}
// Font preview in dropdowns
select#font-family,
select#font-family2 {
option[value="Lato"] {
font-family: "Lato", sans-serif;
}
option[value="Roboto"] {
font-family: "Roboto", sans-serif;
}
option[value="Open Sans"] {
font-family: "Open Sans", sans-serif;
}
option[value="Montserrat"] {
font-family: "Montserrat", sans-serif;
}
option[value="Oswald"] {
font-family: "Oswald", sans-serif;
}
option[value="Bebas Neue"] {
font-family: "Bebas Neue", display;
}
option[value="Roboto Mono"] {
font-family: "Roboto Mono", monospace;
}
option[value="VT323"] {
font-family: "VT323", monospace;
}
option[value="Press Start 2P"] {
font-family: "Press Start 2P", display;
}
}
input[type="range"] {
width: 100%;
margin-top: 0.25rem;
-webkit-appearance: none;
appearance: none;
background: rgba(0, 100, 180, 0.2);
border-radius: 4px;
height: 6px;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: linear-gradient(135deg, #0096ff, #66ccff);
cursor: pointer;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
border: 2px solid rgba(0, 150, 255, 0.8);
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
background: linear-gradient(135deg, #0096ff, #66ccff);
cursor: pointer;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
border: 2px solid rgba(0, 150, 255, 0.8);
}
}
input[type="color"] {
width: 100%;
height: 50px;
border: 1px solid rgba(0, 150, 255, 0.4);
border-radius: 4px;
cursor: pointer;
background: rgba(5, 15, 30, 0.7);
padding: 4px;
transition: all 0.3s ease;
&:hover {
border-color: rgba(0, 150, 255, 0.7);
box-shadow: 0 0 15px rgba(0, 150, 255, 0.3);
}
}
// Custom file input styling
input[type="file"] {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 150, 255, 0.4);
border-radius: 4px;
font-size: 0.9rem;
background: rgba(5, 15, 30, 0.7);
color: rgba(200, 220, 255, 0.95);
transition: all 0.3s ease;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
cursor: pointer;
&::file-selector-button {
padding: 0.5rem 1rem;
margin-right: 1rem;
border: 1px solid rgba(0, 150, 255, 0.5);
border-radius: 4px;
background: linear-gradient(
135deg,
rgba(0, 120, 200, 0.3) 0%,
rgba(0, 100, 180, 0.2) 100%
);
color: #66ccff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: all 0.3s ease;
&:hover {
background: linear-gradient(
135deg,
rgba(0, 150, 255, 0.4) 0%,
rgba(255, 100, 0, 0.3) 100%
);
border-color: rgba(0, 150, 255, 0.8);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.4),
0 0 20px rgba(255, 120, 0, 0.2);
color: #fff;
text-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
&:hover {
border-color: rgba(0, 150, 255, 0.7);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.3),
inset 0 2px 8px rgba(0, 0, 0, 0.4);
background: rgba(5, 15, 30, 0.9);
}
&:focus {
outline: none;
border-color: rgba(0, 150, 255, 0.7);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.3),
inset 0 2px 8px rgba(0, 0, 0, 0.4);
background: rgba(5, 15, 30, 0.9);
}
}
}
.control-group-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
margin: 0;
padding: 0.5rem 0;
border-bottom: 2px solid;
border-image: linear-gradient(
90deg,
transparent,
rgba(0, 150, 255, 0.5),
transparent
)
1;
margin-bottom: 1rem;
color: rgba(100, 180, 255, 0.9);
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: all 0.3s ease;
&:hover {
color: #66ccff;
text-shadow: 0 0 10px rgba(0, 150, 255, 0.5);
}
}
.toggle-icon {
font-size: 1.5rem;
font-weight: bold;
transition: transform 0.3s ease;
color: rgba(0, 150, 255, 0.8);
}
.control-group.collapsed .toggle-icon {
transform: rotate(180deg);
}
.control-group-content {
overflow: hidden;
max-height: 2000px;
transition:
max-height 0.3s ease,
opacity 0.3s ease;
}
.control-group.collapsed .control-group-content {
max-height: 0;
opacity: 0;
pointer-events: none;
}
.info-text {
font-size: 0.85rem;
color: rgba(150, 200, 255, 0.7);
font-style: italic;
margin-bottom: 1rem;
padding: 0.5rem;
background: rgba(0, 100, 180, 0.1);
border-left: 3px solid rgba(0, 150, 255, 0.5);
border-radius: 2px;
}
h4 {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: rgba(100, 180, 255, 0.85);
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.9rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(0, 150, 255, 0.3);
}
.checkbox-row {
display: flex;
gap: 1.5rem;
margin-top: 0.75rem;
}
// Button styles are now in partials/form-controls.scss
// Only page-specific overrides here
.btn-primary,
.btn-secondary {
width: 100%;
}
.btn-secondary {
margin-top: 0.5rem;
}
// Custom tooltip styles
.control-tooltip {
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),
inset 0 0 20px rgba(0, 150, 255, 0.1);
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);
&.visible {
opacity: 1;
}
&::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: rgba(0, 120, 200, 0.98);
filter: drop-shadow(0 -2px 4px rgba(0, 0, 0, 0.2));
}
}
}

View file

@ -0,0 +1,336 @@
@import "../mixins";
.guestbook-page {
color: white;
margin: auto;
.content-screen {
position: relative !important;
}
> .guestbook-content {
width: 50%;
margin: auto;
padding: 2rem;
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
@include media-down(lg) {
width: 100%;
grid-template-columns: 1fr;
}
> .wide-item {
grid-column: 1 / -1;
}
.guestbook-floppy {
margin: auto;
padding: 2rem;
width: 90%;
max-width: 300px;
transform: rotate(5deg);
}
}
}
// Terminal output styling
.terminal-output {
font-family: monospace;
line-height: 1.6;
.info {
color: #0f0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
.dim {
opacity: 0.7;
}
.warning {
color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
}
.error-text {
color: #ff0000;
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
}
}
// Guestbook Form
.guestbook-form {
margin-top: 0.5rem;
.form-row {
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
label {
font-family: monospace;
color: #0f0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
font-size: 0.85rem;
}
input,
textarea {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(0, 255, 0, 0.3);
color: #0f0;
padding: 0.4rem;
font-family: monospace;
font-size: 0.85rem;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.3);
border-radius: 2px;
outline: none;
transition: all 0.3s ease;
&::placeholder {
color: rgba(0, 255, 0, 0.4);
text-shadow: none;
}
&:focus {
border-color: rgba(0, 255, 0, 0.6);
box-shadow:
0 0 10px rgba(0, 255, 0, 0.3),
inset 0 0 10px rgba(0, 255, 0, 0.1);
}
}
textarea {
resize: vertical;
min-height: 60px;
}
}
.form-feedback {
margin: 0.5rem 0;
padding: 0.5rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.8rem;
display: none;
&.success {
display: block;
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
color: #0f0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
&.error {
display: block;
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
color: #ff0000;
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
}
&.pending {
display: block;
background: rgba(255, 153, 0, 0.1);
border: 1px solid rgba(255, 153, 0, 0.3);
color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
}
}
.terminal-button {
background: rgba(0, 255, 0, 0.1);
border: 2px solid rgba(0, 255, 0, 0.4);
color: #0f0;
padding: 0.5rem 1rem;
font-family: monospace;
font-size: 0.9rem;
font-weight: bold;
cursor: pointer;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
transition: all 0.3s ease;
border-radius: 3px;
letter-spacing: 2px;
&:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.2);
border-color: rgba(0, 255, 0, 0.7);
box-shadow:
0 0 15px rgba(0, 255, 0, 0.4),
inset 0 0 10px rgba(0, 255, 0, 0.2);
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
transform: translateY(-1px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: rgba(0, 255, 0, 0.2);
}
}
}
// Entries List
.entries-list {
margin-top: 1rem;
.entry {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
font-family: monospace;
&:last-child {
border-bottom: none;
}
.entry-header {
margin-bottom: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
.entry-name {
color: #0f0;
font-weight: bold;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
a {
color: #0f0;
text-decoration: none;
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
transition: all 0.3s ease;
&:hover {
border-bottom-style: solid;
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
}
}
.entry-website {
color: rgba(0, 255, 0, 0.6);
font-size: 0.85rem;
font-style: italic;
}
.entry-separator {
color: rgba(0, 255, 0, 0.4);
}
.entry-date {
color: rgba(0, 255, 0, 0.6);
font-size: 0.85rem;
margin-left: auto;
}
}
.entry-message {
color: #0f0;
line-height: 1.6;
opacity: 0.9;
white-space: pre-wrap;
word-wrap: break-word;
}
}
}
// Loading state
.loading-state {
padding: 2rem;
text-align: center;
font-family: monospace;
.loading-text {
color: #0f0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
.loading-dots {
color: #0f0;
animation: loading-blink 1.5s infinite;
}
}
@keyframes loading-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
// Error state
.error-state {
padding: 2rem;
text-align: center;
font-family: monospace;
}
// Pagination
.pagination {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(0, 255, 0, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
font-family: monospace;
gap: 1rem;
flex-wrap: wrap;
.pagination-info {
color: rgba(0, 255, 0, 0.7);
font-size: 0.85rem;
}
.pagination-controls {
display: flex;
gap: 0.5rem;
}
.pagination-button {
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
color: #0f0;
padding: 0.5rem 1rem;
font-family: monospace;
font-size: 0.85rem;
cursor: pointer;
text-shadow: 0 0 3px rgba(0, 255, 0, 0.5);
transition: all 0.3s ease;
border-radius: 2px;
&:hover:not(:disabled) {
background: rgba(0, 255, 0, 0.2);
border-color: rgba(0, 255, 0, 0.5);
box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
&.active {
background: rgba(0, 255, 0, 0.3);
border-color: rgba(0, 255, 0, 0.6);
font-weight: bold;
}
}
}
// Scrollbar customization for entries container
.guestbook-entries-container .screen-display {
@include scrollbar-custom(#0f0, 5px);
}

View file

@ -7,6 +7,12 @@
padding-top: 200px; padding-top: 200px;
@include media-up(lg) {
position: absolute;
bottom: 28%;
width: 100%;
}
> div { > div {
display: grid; display: grid;
align-items: center; align-items: center;
@ -28,7 +34,7 @@
} }
@include media-down(lg) { @include media-down(lg) {
padding-top: 50px; padding-top: 130px;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
@ -158,13 +164,17 @@
.navigation { .navigation {
position: relative; position: relative;
z-index: 999; z-index: 999;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@include media-up(lg) { @include media-up(lg) {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(6, 1fr);
position: absolute;
bottom: 5%;
width: 100%;
} }
> div { > div {
@ -176,6 +186,97 @@
justify-content: center; justify-content: center;
} }
.nav-floppy {
width: 44%;
transform: rotate(5deg);
position: relative;
&:hover {
.nav-floppy-text {
opacity: 1;
}
}
.nav-floppy-text {
position: absolute;
display: block;
bottom: 0;
right: 0;
color: white;
font-size: 20px;
font-weight: bold;
z-index: 8000;
transform: rotate(10deg);
border: 1px solid #0f0;
padding: 2px;
padding-left: 5px;
padding-right: 5px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.7);
opacity: 0;
transition: opacity 0.3s ease;
text-align: center;
@include media-down(lg) {
opacity: 1;
//transform: rotate(0deg);
bottom: 0;
font-size: 14px;
}
}
}
.nav-cube {
width: 106.5px;
position: relative;
&:hover {
.nav-cube-text {
opacity: 1;
}
}
.heart-icon {
font-size: 1.5em;
}
.portal {
border-width: 3px;
}
.nav-cube-text {
position: absolute;
display: block;
bottom: 0;
right: 0;
color: white;
font-size: 20px;
font-weight: bold;
z-index: 8000;
transform: rotate(-20deg);
border: 1px solid #0f0;
padding: 2px;
padding-left: 5px;
padding-right: 5px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.7);
opacity: 0;
transition: opacity 0.3s ease;
text-align: center;
&:hover::after {
opacity: 1;
}
@include media-down(lg) {
opacity: 1;
//transform: rotate(0deg);
bottom: 0;
font-size: 14px;
}
}
}
.time-display { .time-display {
width: 150px; width: 150px;
margin: auto; margin: auto;
@ -187,6 +288,7 @@
inset 0 1px 2px rgba(255, 255, 255, 0.1); inset 0 1px 2px rgba(255, 255, 255, 0.1);
border-radius: 1em; border-radius: 1em;
cursor: pointer; cursor: pointer;
position: relative;
&::after { &::after {
content: "Interests and Tools"; content: "Interests and Tools";
@ -210,7 +312,7 @@
@include media-down(lg) { @include media-down(lg) {
margin-left: -0.5em; margin-left: -0.5em;
opacity: 1; opacity: 1;
bottom: 0; bottom: -40px;
font-size: 20px; font-size: 20px;
} }
} }

View file

@ -0,0 +1,204 @@
// Lava Lamp Adoptable Generator Page Styles
// Namespaced to avoid conflicts with existing navigation lava lamp styles
.adoptable-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
@include media-down(md) {
grid-template-columns: 1fr;
}
}
.preview-section {
display: flex;
flex-direction: column;
position: sticky;
top: 2rem;
align-self: flex-start;
h3 {
margin-top: 0;
margin-bottom: 1rem;
}
}
.preview-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
@include media-down(lg) {
grid-template-columns: 1fr;
}
}
.preview-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.preview-wrapper {
border: 2px solid #333;
border-radius: 8px;
padding: 1rem;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-position:
0 0,
0 10px,
10px -10px,
-10px 0px;
background-color: #fff;
}
.preview-flexible {
width: 40px;
height: 75px;
padding: 3px;
}
.preview-100 {
width: 100px;
height: 200px;
@include media-down(md) {
position: absolute;
top: 10px;
right: 10px;
z-index: 900;
}
}
.preview-200 {
width: 200px;
height: 350px;
}
.preview-label {
font-size: 0.875rem;
color: #666;
text-align: center;
}
.lavalamp-preview-container {
width: 100%;
height: 100%;
}
.controls-section {
h3 {
margin-top: 0;
}
}
// Page-specific overrides - make buttons full width in this layout
#lavalamp-adoptable-app .control-group button {
width: 100%;
}
// Adoptable Lava Lamp Component Styles
// Namespaced under .lavalamp-adoptable to avoid conflicts with navigation lava lamp
.lavalamp-adoptable {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
.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;
&::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;
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 5% rgba(255, 255, 255, 0.2),
inset 0 -2px 5% rgba(0, 0, 0, 0.5);
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
}
// Animation for adoptable lava lamp blobs (different from navigation lava lamp)
@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));
}
}

View file

@ -54,11 +54,19 @@
margin-bottom: 50px; margin-bottom: 50px;
padding-bottom: 20px; padding-bottom: 20px;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
border-image: linear-gradient(90deg, transparent, #ff00ff 20%, #00ffff 50%, #ff00ff 80%, transparent) 1; border-image: linear-gradient(
90deg,
transparent,
#ff00ff 20%,
#00ffff 50%,
#ff00ff 80%,
transparent
)
1;
display: flex; display: flex;
justify-content: center; justify-content: center;
pre { > pre {
background: linear-gradient(90deg, #ff00ff, #00ffff, #ff00ff); background: linear-gradient(90deg, #ff00ff, #00ffff, #ff00ff);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
@ -70,7 +78,7 @@
margin: 0; margin: 0;
@include media-down(lg) { @include media-down(lg) {
font-size: 12px; font-size: 0.5rem;
} }
} }
} }
@ -98,6 +106,16 @@
gap: 25px; gap: 25px;
} }
> h3 {
display: none;
padding-bottom: 15px;
border-bottom: 2px solid transparent;
border-image: linear-gradient(90deg, #ff00ff, #00ffff) 1;
@include media-down(lg) {
display: block;
}
}
// Subtle divider between items // Subtle divider between items
.media-item + .media-item { .media-item + .media-item {
position: relative; position: relative;
@ -109,7 +127,13 @@
left: 0; left: 0;
right: 0; right: 0;
height: 1px; height: 1px;
background: linear-gradient(90deg, transparent, #333 20%, #333 80%, transparent); background: linear-gradient(
90deg,
transparent,
#333 20%,
#333 80%,
transparent
);
} }
} }
} }
@ -120,13 +144,16 @@
gap: 25px; gap: 25px;
padding: 20px; padding: 20px;
border: 1px solid #333; border: 1px solid #333;
background: rgba(0,0,0,0.6); background: rgba(0, 0, 0, 0.6);
border-radius: 8px; border-radius: 8px;
transition: all 0.3s ease; transition: all 0.3s ease;
@include media-down(lg) { @include media-down(lg) {
gap: 20px; gap: 20px;
flex-direction: column; flex-direction: column;
display: grid;
grid-template-columns: auto 1fr;
} }
&:hover { &:hover {
@ -154,9 +181,9 @@
transition: all 0.3s ease; transition: all 0.3s ease;
@include media-down(lg) { @include media-down(lg) {
width: 100%; max-width: 100px;
max-width: 300px; max-height: 100px;
height: 450px; height: auto;
margin: 0 auto; margin: 0 auto;
} }
@ -394,6 +421,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 15px;
@include media-down(lg) {
.lastfm-track:nth-child(n + 4) {
display: none;
}
}
} }
.lastfm-loading { .lastfm-loading {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,320 @@
.companion-cube-container {
width: 100%;
aspect-ratio: 1/1;
display: flex;
align-items: center;
justify-content: center;
margin: 2rem auto;
position: relative;
// Ground shadow beneath the cube
&::after {
content: "";
position: absolute;
bottom: -30%;
left: 50%;
transform: translateX(-50%);
width: 250%;
height: 50%;
background: radial-gradient(
ellipse,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.5) 40%,
transparent 70%
);
border-radius: 50%;
filter: blur(8px);
z-index: -1;
@include media-down(md) {
bottom: -4%;
filter: blur(6px);
}
@include media-down(sm) {
bottom: -3%;
filter: blur(4px);
}
}
}
.cube-one,
.cube-two {
position: absolute;
top: 0;
left: 0;
width: 100%;
aspect-ratio: 1/1;
overflow: hidden;
z-index: 30;
.companion-cube-face {
animation: dropCycle 24s cubic-bezier(0.68, -0.1, 0.265, 1.1) infinite;
}
}
.cube-two .companion-cube-face {
animation-delay: -12s;
}
@keyframes dropCycle {
0% {
transform: translateY(-150%);
}
12.5% {
transform: translateY(0);
}
50% {
transform: translateY(0);
}
62.5% {
transform: translateY(150%);
}
62.51%,
100% {
transform: translateY(-150%);
}
}
.companion-cube-face {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8e0d5 0%, #d0c8bd 50%, #beb6ab 100%);
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.3);
clip-path: polygon(
/* Top left corner */ 0% 0%,
0% 35%,
/* Left indent */ 6% 37.5%,
6% 62.5%,
0% 65%,
/* Bottom left corner */ 0% 100%,
35% 100%,
/* Bottom indent */ 37.5% 94%,
62.5% 94%,
65% 100%,
/* Bottom right corner */ 100% 100%,
100% 65%,
/* Right indent */ 94% 62.5%,
94% 37.5%,
100% 35%,
/* Top right corner */ 100% 0%,
65% 0%,
/* Top indent */ 62.5% 6%,
37.5% 6%,
35% 0%
);
@include media-down(md) {
border-radius: 6px;
}
}
.cube-center-circle {
position: absolute;
width: 70%;
height: 70%;
background: linear-gradient(135deg, #6a6a6a 0%, #4a4a4a 50%, #3a3a3a 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.center-inner-ring {
position: absolute;
width: 60%;
height: 60%;
border: 2px solid #5a5a5a;
border-radius: 50%;
background: linear-gradient(135deg, #e8e0d5 0%, #d0c8bd 50%, #beb6ab 100%);
border-radius: 50%;
@include media-down(md) {
border-width: 1.5px;
}
}
.grey-bar {
position: absolute;
background-color: #5a5a5a;
&.horizontal-top,
&.horizontal-bottom {
height: 2.5%;
}
&.vertical-left,
&.vertical-right {
width: 2.5%;
}
&.horizontal-top {
left: 0;
right: 0;
top: 37.5%;
}
&.horizontal-bottom {
left: 0;
right: 0;
bottom: 37.5%;
}
&.vertical-left {
left: 37.5%;
top: 0;
bottom: 0;
}
&.vertical-right {
right: 37.5%;
top: 0;
bottom: 0;
}
}
// Pink connector bars extending from center
.pink-bar {
position: absolute;
background: #ff80bf;
&.vertical {
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 5%;
}
&.horizontal {
top: 50%;
right: 0;
left: 0;
transform: translateY(-50%);
height: 5%;
}
}
.heart-icon {
font-size: 5rem;
z-index: 3;
color: #ffc0e5;
pointer-events: none;
-webkit-text-stroke: 2px #8b5a6f;
animation: heartPulse 3s ease-in-out infinite;
@include media-down(lg) {
font-size: 4rem;
}
@include media-down(md) {
font-size: 3rem;
-webkit-text-stroke: 1.5px #8b5a6f;
}
@include media-down(sm) {
font-size: 2rem;
-webkit-text-stroke: 1px #8b5a6f;
}
}
.portal-icon {
width: 100%;
aspect-ratio: 1/1;
position: relative;
overflow: visible;
z-index: 1;
.portal {
content: "";
position: absolute;
transform: translateX(-10%) scaleY(0);
width: 120%;
height: 20%;
border-radius: 50%;
border: 8px solid;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.85) 60%,
rgba(0, 0, 0, 0.6) 100%
);
@include media-down(lg) {
border-width: 6px;
}
@include media-down(md) {
border-width: 4px;
}
@include media-down(sm) {
border-width: 2px;
}
}
.blue-portal {
bottom: -10%;
z-index: 20;
border-color: #0096ff;
box-shadow:
inset 0 0 40px rgba(0, 150, 255, 0.3),
inset 0 0 20px rgba(0, 150, 255, 0.5);
animation: portalCycle 24s cubic-bezier(0.68, -0.1, 0.265, 1.1) infinite;
}
.orange-portal {
top: -10%;
z-index: 40;
border-color: #ff7800;
box-shadow:
0 0 30px 8px rgba(255, 120, 0, 0.8),
0 0 60px 12px rgba(255, 120, 0, 0.5),
inset 0 0 30px rgba(255, 120, 0, 0.6);
animation: portalCycle 24s cubic-bezier(0.68, -0.1, 0.265, 1.1) infinite;
animation-delay: 0.2s;
}
}
@keyframes portalCycle {
0%,
12% {
transform: translateX(-10%) scaleY(1);
}
12.5%,
48% {
transform: translateX(-10%) scaleY(0);
}
49%,
60.5% {
transform: translateX(-10%) scaleY(1);
}
61%,
100% {
transform: translateX(-10%) scaleY(0);
}
}
// Pulsing heart animation
@keyframes heartPulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.3);
opacity: 0.85;
}
}

View file

@ -6,7 +6,7 @@
aspect-ratio: 300 / 245; aspect-ratio: 300 / 245;
background: linear-gradient(145deg, #b8b8b0, #989888); background: linear-gradient(145deg, #b8b8b0, #989888);
box-shadow: box-shadow:
0 8px 20px rgba(0, 0, 0, 0.7), 0 8px 20px #000000b3,
inset 0 2px 4px rgba(255, 255, 255, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.3); inset 0 -2px 4px rgba(0, 0, 0, 0.3);
padding: 6px 8px 18px 8px; padding: 6px 8px 18px 8px;
@ -20,7 +20,7 @@
&::before { &::before {
content: " "; content: " ";
display: block; display: block;
position: fixed; position: absolute;
top: 6px; top: 6px;
left: 8px; left: 8px;
bottom: 18px; bottom: 18px;
@ -44,7 +44,7 @@
&::after { &::after {
content: " "; content: " ";
display: block; display: block;
position: fixed; position: absolute;
top: 6px; top: 6px;
left: 8px; left: 8px;
bottom: 18px; bottom: 18px;

View file

@ -0,0 +1,332 @@
// Reusable Form Controls for Resources Pages
// Aperture Science / Portal-themed form inputs and controls
// Form Groups
.form-group,
.control-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
label {
font-weight: 600;
color: #66ccff;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
text-shadow: 0 0 8px rgba(0, 150, 255, 0.4);
display: block;
margin-bottom: 0.5rem;
// Override for checkbox labels (when checkbox is inside label)
&:has(input[type="checkbox"]) {
display: flex;
align-items: center;
text-transform: none;
font-size: 1rem;
text-shadow: none;
letter-spacing: normal;
}
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="color"],
select {
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 150, 255, 0.4);
border-radius: 4px;
font-size: 1rem;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background: rgba(5, 15, 30, 0.7);
color: rgba(200, 220, 255, 0.95);
transition: all 0.3s ease;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
&:focus {
outline: none;
border-color: rgba(0, 150, 255, 0.7);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.3),
inset 0 2px 8px rgba(0, 0, 0, 0.4);
background: rgba(5, 15, 30, 0.9);
}
&::placeholder {
color: rgba(100, 150, 200, 0.5);
}
}
// Color inputs
input[type="color"] {
width: 100%;
height: 40px;
cursor: pointer;
}
// Range inputs (sliders)
input[type="range"] {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: linear-gradient(
90deg,
rgba(0, 100, 180, 0.3) 0%,
rgba(0, 150, 255, 0.5) 100%
);
border-radius: 3px;
outline: none;
border: 1px solid rgba(0, 150, 255, 0.4);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
// Webkit browsers (Chrome, Safari, Edge)
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: linear-gradient(
135deg,
rgba(0, 150, 255, 0.9) 0%,
rgba(0, 120, 200, 0.8) 100%
);
border: 2px solid rgba(0, 150, 255, 0.6);
border-radius: 50%;
cursor: pointer;
box-shadow:
0 0 10px rgba(0, 150, 255, 0.5),
0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(
135deg,
rgba(0, 180, 255, 1) 0%,
rgba(255, 120, 0, 0.6) 100%
);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.8),
0 0 20px rgba(255, 120, 0, 0.3);
transform: scale(1.1);
}
}
// Firefox
&::-moz-range-thumb {
width: 18px;
height: 18px;
background: linear-gradient(
135deg,
rgba(0, 150, 255, 0.9) 0%,
rgba(0, 120, 200, 0.8) 100%
);
border: 2px solid rgba(0, 150, 255, 0.6);
border-radius: 50%;
cursor: pointer;
box-shadow:
0 0 10px rgba(0, 150, 255, 0.5),
0 2px 4px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(
135deg,
rgba(0, 180, 255, 1) 0%,
rgba(255, 120, 0, 0.6) 100%
);
box-shadow:
0 0 15px rgba(0, 150, 255, 0.8),
0 0 20px rgba(255, 120, 0, 0.3);
transform: scale(1.1);
}
}
&::-moz-range-track {
background: linear-gradient(
90deg,
rgba(0, 100, 180, 0.3) 0%,
rgba(0, 150, 255, 0.5) 100%
);
border-radius: 3px;
border: 1px solid rgba(0, 150, 255, 0.4);
}
}
// Checkboxes
input[type="checkbox"] {
width: 18px;
height: 18px;
margin-right: 0.5rem;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background: rgba(5, 15, 30, 0.7);
border: 2px solid rgba(0, 150, 255, 0.4);
border-radius: 3px;
position: relative;
transition: all 0.3s ease;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
&:checked {
background: linear-gradient(
135deg,
rgba(0, 150, 255, 0.5) 0%,
rgba(0, 120, 200, 0.4) 100%
);
border-color: rgba(0, 150, 255, 0.7);
box-shadow:
0 0 10px rgba(0, 150, 255, 0.4),
inset 0 1px 3px rgba(0, 0, 0, 0.3);
}
&:checked::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #66ccff;
font-size: 14px;
font-weight: bold;
text-shadow: 0 0 5px rgba(0, 150, 255, 0.8);
}
&:hover {
border-color: rgba(0, 150, 255, 0.6);
box-shadow:
0 0 8px rgba(0, 150, 255, 0.3),
inset 0 1px 3px rgba(0, 0, 0, 0.3);
}
&:focus {
outline: none;
border-color: rgba(0, 150, 255, 0.8);
box-shadow:
0 0 12px rgba(0, 150, 255, 0.5),
inset 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
// Select dropdowns
select {
cursor: pointer;
option {
background: rgba(5, 15, 30, 0.95);
color: rgba(200, 220, 255, 0.95);
}
}
}
// Shared button base styles
.btn-primary,
.btn-secondary {
padding: 0.75rem 1.5rem;
font-size: 0.95rem;
font-weight: 600;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
border: 1px solid rgba(0, 150, 255, 0.5);
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.08em;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(0, 150, 255, 0.3),
transparent
);
transition: left 0.5s ease;
}
&:hover::before {
left: 100%;
}
}
// Primary Button
.btn-primary {
background: linear-gradient(
135deg,
rgba(0, 120, 200, 0.3) 0%,
rgba(0, 100, 180, 0.2) 100%
);
color: #66ccff;
&:hover {
background: linear-gradient(
135deg,
rgba(0, 150, 255, 0.4) 0%,
rgba(255, 100, 0, 0.3) 100%
);
border-color: rgba(0, 150, 255, 0.8);
box-shadow:
0 0 20px rgba(0, 150, 255, 0.4),
0 0 30px rgba(255, 120, 0, 0.2);
color: #fff;
text-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
// Secondary Button
.btn-secondary {
background: linear-gradient(
135deg,
rgba(0, 100, 180, 0.2) 0%,
rgba(0, 80, 150, 0.15) 100%
);
color: #66ccff;
&:hover {
background: linear-gradient(
135deg,
rgba(0, 120, 200, 0.3) 0%,
rgba(255, 100, 0, 0.2) 100%
);
border-color: rgba(0, 150, 255, 0.6);
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
color: #fff;
text-shadow: 0 0 8px rgba(0, 150, 255, 0.5);
}
&:active {
transform: translateY(0);
}
}
// Stats Form (specific layout for forms in stats apps)
.stats-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 2rem;
position: relative;
z-index: 1;
@include media-up(md) {
flex-direction: row;
align-items: flex-end;
}
}

View file

@ -12,6 +12,10 @@ footer[role="contentinfo"] {
border-left: 1px solid #0f0; border-left: 1px solid #0f0;
border-top-left-radius: 5px; border-top-left-radius: 5px;
> .crt {
position: relative;
}
@include media-down(lg) { @include media-down(lg) {
display: none; display: none;
} }

View file

@ -0,0 +1,174 @@
// Graph container styling to match terminal aesthetic
.graph-container {
margin: 2rem 0;
padding: 20px 20px 100px 20px; // Extra bottom padding for rotated x-axis labels
background: rgba(0, 255, 0, 0.05);
border: 2px solid rgba(0, 255, 0, 0.3);
border-radius: 8px;
position: relative;
box-shadow:
0 0 20px rgba(0, 255, 0, 0.1),
inset 0 0 40px rgba(0, 255, 0, 0.05);
overflow: visible; // Prevent clipping of labels
@include media-down(lg) {
padding: 15px 15px 120px 15px;
margin: 1.5rem 0;
}
// Terminal-style border glow on hover
&:hover {
border-color: rgba(0, 255, 0, 0.5);
box-shadow:
0 0 30px rgba(0, 255, 0, 0.2),
inset 0 0 40px rgba(0, 255, 0, 0.08);
}
// Scanline effect overlay
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.1) 0px,
rgba(0, 0, 0, 0.1) 1px,
transparent 1px,
transparent 3px
);
pointer-events: none;
z-index: 1;
border-radius: 6px;
}
// Graph title
.graph-title {
color: greenyellow;
font-family: monospace;
font-size: 1.3rem;
margin: 0 0 15px 0;
text-align: center;
text-shadow: 0 0 10px rgba(173, 255, 47, 0.5);
text-transform: uppercase;
letter-spacing: 2px;
position: relative;
z-index: 2;
@include media-down(lg) {
font-size: 1.1rem;
margin-bottom: 10px;
}
&::after {
content: "";
display: block;
width: 60%;
height: 2px;
background: linear-gradient(
90deg,
transparent,
rgba(0, 255, 0, 0.6),
transparent
);
margin: 8px auto 0;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
}
}
// Canvas element
canvas {
position: relative;
z-index: 2;
filter: drop-shadow(0 0 8px rgba(0, 255, 0, 0.3));
}
// CRT flicker effect (subtle)
@keyframes graph-flicker {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.98;
}
}
animation: graph-flicker 3s ease-in-out infinite;
}
// Special styling for graphs in blog posts
.blog-summary,
.blogs-content {
.graph-container {
// Ensure proper spacing in blog context
margin: 2.5rem 0;
@include media-down(lg) {
margin: 1.5rem 0;
}
}
}
// Full-width graph variant
.graph-container.full-width {
margin-left: -20px;
margin-right: -20px;
border-radius: 0;
@include media-down(lg) {
margin-left: -15px;
margin-right: -15px;
}
}
// Compact graph variant
.graph-container.compact {
padding: 15px;
.graph-title {
font-size: 1.1rem;
margin-bottom: 10px;
}
@include media-down(lg) {
padding: 10px;
.graph-title {
font-size: 1rem;
}
}
}
// Loading state
.graph-container.loading {
&::after {
content: "LOADING DATA...";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #0f0;
font-family: monospace;
font-size: 1.2rem;
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
animation: pulse 1.5s ease-in-out infinite;
z-index: 3;
}
canvas {
opacity: 0.3;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View file

@ -1,8 +1,35 @@
// Function for standard neon glow effect
@function neon-glow($color) {
@return 0 0 5px #fff, 0 0 5px #fff, 0 0 21px $color, 0 0 42px $color,
0 0 82px $color, 0 0 92px $color, 0 0 142px $color, 0 0 181px $color;
}
/* Neon sign styling */ /* Neon sign styling */
.neon-sign { .neon-sign {
text-align: center; text-align: center;
line-height: 5rem; line-height: 5rem;
transform: rotate(-10deg);
.homepage-neon & {
transform: rotate(-5deg);
margin-left: 50%;
transform: translateX(-50%);
overflow: hidden;
width: 100%;
height: 200px;
position: absolute;
pointer-events: none;
@include media-up(lg) {
position: absolute;
top: 5%;
left: 60%;
transform: translateX(-50%) rotate(-10deg);
z-index: 1;
margin-left: auto;
overflow: visible;
width: 50%;
}
}
@include media-up(lg) { @include media-up(lg) {
position: absolute; position: absolute;
@ -62,12 +89,6 @@
} }
} }
// Function for standard neon glow effect
@function neon-glow($color) {
@return 0 0 5px #fff, 0 0 5px #fff, 0 0 21px $color, 0 0 42px $color,
0 0 82px $color, 0 0 92px $color, 0 0 142px $color, 0 0 181px $color;
}
// Mixin for pulse animation - generates keyframes for any color // Mixin for pulse animation - generates keyframes for any color
@mixin neon-pulse-animation($name, $color) { @mixin neon-pulse-animation($name, $color) {
@keyframes #{$name} { @keyframes #{$name} {

View file

@ -1,11 +1,14 @@
/* VU Meter on desk */ /* VU Meter on desk */
.vu-meter { .vu-meter {
position: absolute; position: absolute;
left: 50%;
top: 10px; top: 10px;
width: 120px; width: 120px;
height: 60px; height: 60px;
z-index: 8; z-index: 8;
@include media-up(md) {
left: 50%;
}
} }
.vu-meter-body { .vu-meter-body {
@ -40,6 +43,10 @@
justify-content: space-around; justify-content: space-around;
height: 100%; height: 100%;
gap: 2px; gap: 2px;
&.crt {
position: relative;
}
} }
.vu-bar { .vu-bar {

View file

@ -3,6 +3,7 @@
@import "animations"; @import "animations";
@import "partials/global-styles"; @import "partials/global-styles";
@import "partials/form-controls";
@import "partials/neon-sign"; @import "partials/neon-sign";
@import "partials/music"; @import "partials/music";
@ -14,6 +15,8 @@
@import "partials/lcd-display"; @import "partials/lcd-display";
@import "partials/window"; @import "partials/window";
@import "partials/crt-tv"; @import "partials/crt-tv";
@import "partials/graphs";
@import "partials/companion-cube";
@import "partials/content-screens"; @import "partials/content-screens";
@ -24,8 +27,11 @@
@import "pages/blog"; @import "pages/blog";
@import "pages/media"; @import "pages/media";
@import "pages/resources"; @import "pages/resources";
@import "pages/button-generator";
@import "pages/lavalamp-adoptable";
@import "pages/guestbook";
@import url("https://fonts.bunny.net/css2?family=Caveat:wght@400..700&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Neonderthaw&display=swap"); @import url(https://fonts.bunny.net/css?family=abel:400|barlow-condensed:400,500|caveat:400|lato:300,300i,400,400i|neonderthaw:400);
@font-face { @font-face {
font-family: "DSEG7-Classic"; font-family: "DSEG7-Classic";
@ -102,6 +108,7 @@ body {
pointer-events: none; pointer-events: none;
} }
.crt { .crt {
position: absolute;
animation: textShadow 1.6s infinite; animation: textShadow 1.6s infinite;
background: black; background: black;
color: greenyellow; color: greenyellow;
@ -812,7 +819,6 @@ body {
text-decoration: none !important; text-decoration: none !important;
} }
/* Desk-mounted CRT - on desk left side */ /* Desk-mounted CRT - on desk left side */
.desk-monitor { .desk-monitor {
bottom: 10%; bottom: 10%;
@ -1035,8 +1041,6 @@ body {
} }
} }
@keyframes pulse-slow { @keyframes pulse-slow {
0%, 0%,
100% { 100% {

View file

@ -6,6 +6,15 @@ buildFuture: false
buildExpired: false buildExpired: false
enableEmoji: true enableEmoji: true
outputs:
home: ["HTML", "RSS", "webmentions"]
outputFormats:
webmentions:
mediaType: "application/json"
baseName: "webmentions"
isPlainText: true
pagination: pagination:
pagerSize: 5 pagerSize: 5
@ -42,29 +51,6 @@ params:
limit: 10 limit: 10
keys: ["title", "permalink", "summary", "content"] keys: ["title", "permalink", "summary", "content"]
menu:
main:
- identifier: about
name: about
url: /about/
weight: 5
- identifier: gear
name: gear & edc
url: /gear/
weight: 10
- identifier: resources
name: resources
url: /resources/
weight: 12
- identifier: archives
name: archives
url: /archives/
weight: 15
- identifier: tags
name: tags
url: /tags/
weight: 20
markup: markup:
highlight: highlight:
noClasses: false noClasses: false

View file

@ -0,0 +1,33 @@
---
title: "Week 3 - Bit Chilly Out"
date: 2026-01-11
tags:
- weeknote
- weekly update
draft: false
---
- ❄️ It's been pretty cold and we've had the lightest sprinkling of snow.
- 🏠️ We've started getting the house ready to go on the market ahead of a move this year.
- 📱 I did a mobile pass on some of the pages on this website, it works a bit better on phones now.
- 🟠 Added the [resources](/resources) section with a script to pull weekly last.fm stats (see example output below!)
- 🆒 Started working on an [88x31 button creator](/resources/button-generator/), it's got a decent amount of functionality so far.
- 🎁 Added an adoptables section to the resources page, at the moment there's just a [lavalamp generator](/resources/adoptables/) there but I have plans for more!
## Links I Found Interesting
- [enclose.horse](https://enclose.horse/) - A daily game where you have to enclose a horse in the largest possible field.
## Weekly Listening Stats
**Total Tracks:** 378
**Top 5 Artists:**
- [Lazerhawk](https://www.last.fm/music/Lazerhawk) - 37 plays
- [Kavinsky](https://www.last.fm/music/Kavinsky) - 27 plays
- [Alien Ant Farm](https://www.last.fm/music/Alien+Ant+Farm) - 26 plays
- [Linkin Park](https://www.last.fm/music/Linkin+Park) - 15 plays
- [Perturbator](https://www.last.fm/music/Perturbator) - 15 plays
Until next week!

View file

@ -0,0 +1,24 @@
---
title: "Week 4 - Got Webmentions"
date: 2026-01-18
tags:
- weeknote
- weekly update
draft: false
---
- 🗨️ Added the ability to send and receive [webmentions](https://indieweb.org/Webmention) to the blog. Haven't automated displaying of received ones yet, but I'll get there.
- 🧰 Setup a little personal API to handle the guestbook, visitor counter, and aforementioned webmentions.
- 📺 Setup a little dashboard on my homelab for monitoring all of the above!
## Links I Found Interesting
- [iCloud Photos Downloader](https://github.com/icloud-photos-downloader/icloud_photos_downloader) and [Photos Backup Anywhere](https://photosbackup.app/) - Two useful looking things for backing up my Photos library
## Music
## Next Week
Not necessary
Until next week!

View file

@ -0,0 +1,85 @@
Month,ai coding: (Worldwide)
2019-01,1
2019-02,1
2019-03,1
2019-04,1
2019-05,1
2019-06,1
2019-07,1
2019-08,1
2019-09,1
2019-10,1
2019-11,1
2019-12,1
2020-01,1
2020-02,1
2020-03,1
2020-04,1
2020-05,1
2020-06,1
2020-07,1
2020-08,1
2020-09,1
2020-10,1
2020-11,1
2020-12,1
2021-01,1
2021-02,1
2021-03,1
2021-04,1
2021-05,1
2021-06,1
2021-07,1
2021-08,1
2021-09,1
2021-10,1
2021-11,1
2021-12,1
2022-01,1
2022-02,1
2022-03,1
2022-04,1
2022-05,1
2022-06,1
2022-07,1
2022-08,1
2022-09,1
2022-10,1
2022-11,1
2022-12,3
2023-01,4
2023-02,5
2023-03,5
2023-04,6
2023-05,7
2023-06,7
2023-07,7
2023-08,7
2023-09,7
2023-10,7
2023-11,8
2023-12,8
2024-01,9
2024-02,10
2024-03,14
2024-04,12
2024-05,11
2024-06,11
2024-07,10
2024-08,11
2024-09,14
2024-10,16
2024-11,18
2024-12,19
2025-01,21
2025-02,27
2025-03,31
2025-04,32
2025-05,36
2025-06,53
2025-07,58
2025-08,100
2025-09,89
2025-10,82
2025-11,92
2025-12,82
1 Month ai coding: (Worldwide)
2 2019-01 1
3 2019-02 1
4 2019-03 1
5 2019-04 1
6 2019-05 1
7 2019-06 1
8 2019-07 1
9 2019-08 1
10 2019-09 1
11 2019-10 1
12 2019-11 1
13 2019-12 1
14 2020-01 1
15 2020-02 1
16 2020-03 1
17 2020-04 1
18 2020-05 1
19 2020-06 1
20 2020-07 1
21 2020-08 1
22 2020-09 1
23 2020-10 1
24 2020-11 1
25 2020-12 1
26 2021-01 1
27 2021-02 1
28 2021-03 1
29 2021-04 1
30 2021-05 1
31 2021-06 1
32 2021-07 1
33 2021-08 1
34 2021-09 1
35 2021-10 1
36 2021-11 1
37 2021-12 1
38 2022-01 1
39 2022-02 1
40 2022-03 1
41 2022-04 1
42 2022-05 1
43 2022-06 1
44 2022-07 1
45 2022-08 1
46 2022-09 1
47 2022-10 1
48 2022-11 1
49 2022-12 3
50 2023-01 4
51 2023-02 5
52 2023-03 5
53 2023-04 6
54 2023-05 7
55 2023-06 7
56 2023-07 7
57 2023-08 7
58 2023-09 7
59 2023-10 7
60 2023-11 8
61 2023-12 8
62 2024-01 9
63 2024-02 10
64 2024-03 14
65 2024-04 12
66 2024-05 11
67 2024-06 11
68 2024-07 10
69 2024-08 11
70 2024-09 14
71 2024-10 16
72 2024-11 18
73 2024-12 19
74 2025-01 21
75 2025-02 27
76 2025-03 31
77 2025-04 32
78 2025-05 36
79 2025-06 53
80 2025-07 58
81 2025-08 100
82 2025-09 89
83 2025-10 82
84 2025-11 92
85 2025-12 82

View file

@ -0,0 +1,85 @@
Month,ai: (Worldwide)
2019-01,5
2019-02,5
2019-03,5
2019-04,5
2019-05,5
2019-06,5
2019-07,5
2019-08,5
2019-09,5
2019-10,5
2019-11,5
2019-12,5
2020-01,5
2020-02,5
2020-03,5
2020-04,6
2020-05,6
2020-06,5
2020-07,5
2020-08,5
2020-09,5
2020-10,5
2020-11,5
2020-12,5
2021-01,5
2021-02,5
2021-03,6
2021-04,6
2021-05,6
2021-06,6
2021-07,5
2021-08,6
2021-09,6
2021-10,6
2021-11,6
2021-12,6
2022-01,7
2022-02,7
2022-03,7
2022-04,7
2022-05,7
2022-06,7
2022-07,7
2022-08,7
2022-09,8
2022-10,8
2022-11,8
2022-12,12
2023-01,12
2023-02,15
2023-03,16
2023-04,21
2023-05,28
2023-06,27
2023-07,26
2023-08,26
2023-09,25
2023-10,29
2023-11,30
2023-12,30
2024-01,32
2024-02,33
2024-03,36
2024-04,34
2024-05,36
2024-06,34
2024-07,34
2024-08,39
2024-09,39
2024-10,42
2024-11,43
2024-12,43
2025-01,47
2025-02,51
2025-03,54
2025-04,55
2025-05,52
2025-06,57
2025-07,62
2025-08,73
2025-09,100
2025-10,92
2025-11,90
2025-12,80
1 Month ai: (Worldwide)
2 2019-01 5
3 2019-02 5
4 2019-03 5
5 2019-04 5
6 2019-05 5
7 2019-06 5
8 2019-07 5
9 2019-08 5
10 2019-09 5
11 2019-10 5
12 2019-11 5
13 2019-12 5
14 2020-01 5
15 2020-02 5
16 2020-03 5
17 2020-04 6
18 2020-05 6
19 2020-06 5
20 2020-07 5
21 2020-08 5
22 2020-09 5
23 2020-10 5
24 2020-11 5
25 2020-12 5
26 2021-01 5
27 2021-02 5
28 2021-03 6
29 2021-04 6
30 2021-05 6
31 2021-06 6
32 2021-07 5
33 2021-08 6
34 2021-09 6
35 2021-10 6
36 2021-11 6
37 2021-12 6
38 2022-01 7
39 2022-02 7
40 2022-03 7
41 2022-04 7
42 2022-05 7
43 2022-06 7
44 2022-07 7
45 2022-08 7
46 2022-09 8
47 2022-10 8
48 2022-11 8
49 2022-12 12
50 2023-01 12
51 2023-02 15
52 2023-03 16
53 2023-04 21
54 2023-05 28
55 2023-06 27
56 2023-07 26
57 2023-08 26
58 2023-09 25
59 2023-10 29
60 2023-11 30
61 2023-12 30
62 2024-01 32
63 2024-02 33
64 2024-03 36
65 2024-04 34
66 2024-05 36
67 2024-06 34
68 2024-07 34
69 2024-08 39
70 2024-09 39
71 2024-10 42
72 2024-11 43
73 2024-12 43
74 2025-01 47
75 2025-02 51
76 2025-03 54
77 2025-04 55
78 2025-05 52
79 2025-06 57
80 2025-07 62
81 2025-08 73
82 2025-09 100
83 2025-10 92
84 2025-11 90
85 2025-12 80

View file

@ -0,0 +1,137 @@
---
title: "The Downfall of StackOverflow"
date: 2026-01-04T13:58:02Z
tags:
- analysis
- ai
draft: false
---
_Quick note: This isn't an "AI bad" rant. AI tools are a useful thing to have in your kit. This is just an observational look at what the data shows about StackOverflow's decline. That being said, the sooner the AI bubble bursts the better._
This post was inspired by a post on [Hacker News](https://news.ycombinator.com/item?id=46482345) that linked to this [StackOverflow data](https://data.stackexchange.com/stackoverflow/query/1926661#graph).
My kneejerk reaction to the data was that the rise in AI and its code analysis capabilities have caused the downfall of StackOverflow, but I needed some data to back it up.
We can see a peak after a gradual decline in early 2020 (COVID bedroom coders?) which then returns to a roughly normal level by 2021, before starting a stark decline into obscurity, very much accelerating at the end of 2022.
{{< graph id="stackoverflow-trend" type="line" title="Stack Overflow Questions Over Time" height="500" csv="stackoverflow_questions_over_time.csv" labelColumn="Month" dataColumn="" dateFormat="2006-01" >}}
{{< /graph >}}
## The rise and fall
StackOverflow had a hell of a run. From just 4 questions monthly in July 2008 to over 207,000 in March 2014: six years of basically uninterrupted growth. It became _the_ place every developer went when they were stuck.
Then around 2014-2015, it plateaued. About 170,000-190,000 questions per month, which held steady for a few years before starting to slip. By 2019 we're down to around 150,000 per month. Still solid, but the writing was on the wall.
Then it properly falls off a cliff. January 2023: 97,209 questions. December 2023: 42,601. December 2025: 3,862. That's a 98.1% drop from the 2020 peak.
## Is it AI?
Looking at Google Trends[^1] for AI-related searches, there's a bit of a gap between when StackOverflow started dying and when AI actually took off.
{{< graph id="ai-trends" type="line" title="AI Search Trends" height="500"
csv="ai-coding-trend.csv,ai-trend.csv"
labelColumn="Month"
dataColumns="ai coding: (Worldwide),ai: (Worldwide)"
datasetLabels="AI Coding,AI General"
dateFormat="2006-01" >}}
{{< /graph >}}
Both general AI interest and AI coding searches were basically flat from 2019 through most of 2022. Then in December 2022 it spikes. That's ChatGPT launching[^2]. This is seen conversely in the data on StackOverflow Questions declining at the same point.
Zooming in on 2019 onwards makes it clearer:
{{< graph id="ai-trends-vs-questions" type="line" title="AI Search Trends vs StackOverflow Questions" height="500"
csv="stackoverflow_questions_after_2019.csv,ai-coding-trend.csv,ai-trend.csv"
labelColumn="Month"
dataColumns="Questions,ai coding: (Worldwide),ai: (Worldwide)"
datasetLabels="StackOverflow Questions,AI Coding,AI General"
yAxisIDs="y,y1,y1"
dateFormat="2006-01" >}}
{{< /graph >}}
## The timeline doesn't quite add up
StackOverflow's real decline starts in 2021, well before anyone gave a shit about AI coding. From January 2021 (140,009 questions) to December 2022 (96,767 questions), it lost 31% of its traffic while "AI coding" searches sat at baseline.
The AI coding surge doesn't really kick off until early 2023, then explodes through 2025, peaking in August. By then StackOverflow was already down to 5,885 questions per month.
## Did the rise of GitHub contribute?
A few comments[^3] on Hacker News suggested that the rise of GitHub could have been a contributor to the decline of StackOverflow. On the surface that sounds reasonable, a lot of support happens directly on GitHub nowadays, which wasn't really a thing during StackOverflow's peak.
To check this I grabbed data from the top 100 starred GitHub repositories and tracked issues opened per month. This isn't an ideal metric[^4], but it gives us something to work with.
{{< graph id="stackoverflow-vs-github-issues" type="line" title="StackOverflow Questions vs GitHub Issues" height="500"
csv="stackoverflow_questions_over_time.csv,total_issues_by_date.csv"
labelColumn="Month"
dataColumns="Questions,Total Issues"
datasetLabels="StackOverflow Questions,GitHub Issues"
yAxisIDs="y,y1"
dateFormat="2006-01" >}}
{{< /graph >}}
GitHub issues start slow in these specific repositories, but by 2019 we're seeing around 9,000-10,000 issues per month, which plateaus and holds relatively steady through 2025 (hovering between 10,000-14,000).
The timeline doesn't really match StackOverflow's decline either. GitHub issues hit a high in 2018-2019 (when they jumped to 8,000-10,000 per month), but StackOverflow was still holding steady at 150,000+ questions. The platform only started its real nosedive in 2021-2023, while GitHub issues stayed flat.
If GitHub issues were the culprit, you'd expect to see an inverse relationship: as issues go up, questions go down. Instead, both coexisted just fine from 2015-2020. GitHub issues plateaued in 2019 and have stayed relatively constant since, even slightly increasing in 2025. Meanwhile, StackOverflow collapsed by 98.1%.
So no, I don't believe GitHub issues killed StackOverflow. It may have been a contributing factor, with people going elsewhere to find answers. Developers were using both for years without real conflict though.
## What about toxicity?
Several comments[^5] brought up StackOverflow's harsh moderation and toxic environment as a major factor. The "marked as duplicate," "this question doesn't belong here," or outright hostile responses to newbies are easier to find than actual answers most of the time.
Wasn't StackOverflow _always_ like that? The platform had a reputation for being unwelcoming since at least 2012-2013[^6], right during its peak growth years. If toxicity was the primary killer, you'd expect to see the decline start much earlier, not suddenly accelerate in 2021-2023.
Could it be a contributing factor? Absolutely. If you had a choice between asking a question on StackOverflow (and risking getting dunked on by some pedantic asshole) versus just asking ChatGPT (which never judges you 😘), the choice is obvious. But toxicity alone doesn't explain the timing or the scale of the collapse.
To actually test this properly you'd need to pull years of StackOverflow comments and run sentiment analysis to see if moderation got worse over time. That's way more effort than I'm putting into this blog post. Maybe one for another day.
## So what's was the actual cause?
The data suggests AI accelerated something that was already happening. A few things probably contributed:
**The saturation effect**: By 2021, StackOverflow had 16+ years of answered questions. How many times can you ask "how do I parse JSON in Python" before every variation is covered? The "just Google it" response became the correct answer because everything _had_ been Googled already.
**The pre-AI decline (2021-2022)**: 31% drop over 18 months while AI coding searches were dead flat. This points to other shifts: better documentation, clearer error messages, frameworks maturing and becoming less footgun-y. Developers were finding answers without needing to ask.
**The AI acceleration (2023-2025)**: ChatGPT launches November 30, 2022. By March 2023, StackOverflow drops from 123,614 questions to 87,543. AI tools give you instant answers without needing to wade through ten variations of your question that are marked as duplicates and locked.
**The collapse (2025-2026)**: By mid-2025, AI coding tools are just... everywhere. GitHub Copilot, ChatGPT, Claude, all of them baked into every IDE and workflow. The August 2025 peak in "AI coding" searches lines up with StackOverflow hitting 5,885 questions. That's a 96% decline from five years earlier.
## Summary
AI didn't kill StackOverflow, but it's definitely finishing the job. The platform was already bleeding out when ChatGPT showed up: content saturation, better tooling, the ecosystem maturing. But AI coding tools changed the game completely. Why search through old forum posts when you can just ask?
The inverse relationship is stark: as AI coding interest hits its peak in 2025, StackOverflow craters. By December 2025 we're at 3,862 questions. That's roughly the same as September 2008, just a few months after the platform launched.
Twelve years to build it. Five years to watch it end. The last three were almost certainly AI finishing what was already started.
### Footnote
This is the hardest and longest I have thought about anything in a long time. I was an avid StackOverflow user 10~ years ago, and the graph showing up on Hacker News showing the current state absolutely blew my mind and inspired me to dig a bit deeper.
The CSV data for this post can be found on my [GitHub](https://github.com/unbolt/ritual.sh/tree/main/content/blog/the-downfall-of-stackoverflow).
I hope you found this interesting. Please get in touch if you have any further insights.
---
### Corrections and Updates
- 04/01/2025 - Removed the January 2026 data from all graphs as we're only 4 days in and it made it look weird. Also added some 0 value months to the GitHub data at the start so the graphs line up properly visually.
[^1]: Google Trends data provides relative search interest rather than absolute numbers, which may not capture the full picture.
[^2]: [ChatGPT Launch Announcement](https://openai.com/index/chatgpt/)
[^3]: [Comment](https://news.ycombinator.com/item?id=46486171), [Comment](https://news.ycombinator.com/item?id=46487601), [Comment](https://news.ycombinator.com/item?id=46482758) + More.
[^4]: Read: It's bad. There's a couple of massive outlier projects with a LOT more issues raised than others, but that's a topic for another blog.
[^5]: [Comment](https://news.ycombinator.com/item?id=46482624)
[^6]: In 2013, Tim Schreiber wrote about ["StackOverlords"](https://timschreiber.com/2013/10/30/beware-the-stackoverlords/) who "ruthlessly wield" their privileges against newcomers. By 2018, StackOverflow officially acknowledged the problem in their blog post ["Stack Overflow Isn't Very Welcoming. It's Time for That to Change."](https://stackoverflow.blog/2018/04/26/stack-overflow-isnt-very-welcoming-its-time-for-that-to-change/)

View file

@ -0,0 +1,85 @@
Month,Questions
"2019-01-01 00:00:00","149628"
"2019-02-01 00:00:00","146421"
"2019-03-01 00:00:00","161159"
"2019-04-01 00:00:00","153558"
"2019-05-01 00:00:00","151393"
"2019-06-01 00:00:00","135935"
"2019-07-01 00:00:00","151081"
"2019-08-01 00:00:00","137181"
"2019-09-01 00:00:00","136935"
"2019-10-01 00:00:00","152721"
"2019-11-01 00:00:00","148338"
"2019-12-01 00:00:00","132623"
"2020-01-01 00:00:00","146735"
"2020-02-01 00:00:00","145191"
"2020-03-01 00:00:00","156037"
"2020-04-01 00:00:00","183016"
"2020-05-01 00:00:00","186573"
"2020-06-01 00:00:00","172002"
"2020-07-01 00:00:00","166135"
"2020-08-01 00:00:00","148452"
"2020-09-01 00:00:00","141887"
"2020-10-01 00:00:00","141948"
"2020-11-01 00:00:00","135088"
"2020-12-01 00:00:00","134035"
"2021-01-01 00:00:00","140009"
"2021-02-01 00:00:00","131810"
"2021-03-01 00:00:00","148900"
"2021-04-01 00:00:00","136022"
"2021-05-01 00:00:00","133911"
"2021-06-01 00:00:00","129106"
"2021-07-01 00:00:00","124130"
"2021-08-01 00:00:00","122273"
"2021-09-01 00:00:00","119882"
"2021-10-01 00:00:00","119008"
"2021-11-01 00:00:00","119260"
"2021-12-01 00:00:00","112278"
"2022-01-01 00:00:00","119459"
"2022-02-01 00:00:00","114114"
"2022-03-01 00:00:00","123614"
"2022-04-01 00:00:00","114422"
"2022-05-01 00:00:00","116346"
"2022-06-01 00:00:00","111741"
"2022-07-01 00:00:00","111059"
"2022-08-01 00:00:00","113048"
"2022-09-01 00:00:00","103965"
"2022-10-01 00:00:00","106366"
"2022-11-01 00:00:00","109719"
"2022-12-01 00:00:00","96767"
"2023-01-01 00:00:00","97209"
"2023-02-01 00:00:00","85973"
"2023-03-01 00:00:00","87543"
"2023-04-01 00:00:00","68746"
"2023-05-01 00:00:00","66749"
"2023-06-01 00:00:00","63858"
"2023-07-01 00:00:00","62938"
"2023-08-01 00:00:00","60319"
"2023-09-01 00:00:00","53046"
"2023-10-01 00:00:00","52743"
"2023-11-01 00:00:00","50646"
"2023-12-01 00:00:00","42601"
"2024-01-01 00:00:00","47854"
"2024-02-01 00:00:00","46292"
"2024-03-01 00:00:00","45070"
"2024-04-01 00:00:00","42776"
"2024-05-01 00:00:00","40485"
"2024-06-01 00:00:00","32243"
"2024-07-01 00:00:00","31740"
"2024-08-01 00:00:00","28059"
"2024-09-01 00:00:00","24947"
"2024-10-01 00:00:00","23319"
"2024-11-01 00:00:00","20891"
"2024-12-01 00:00:00","18029"
"2025-01-01 00:00:00","22394"
"2025-02-01 00:00:00","19340"
"2025-03-01 00:00:00","18965"
"2025-04-01 00:00:00","14138"
"2025-05-01 00:00:00","11824"
"2025-06-01 00:00:00","9392"
"2025-07-01 00:00:00","7841"
"2025-08-01 00:00:00","5885"
"2025-09-01 00:00:00","6132"
"2025-10-01 00:00:00","5415"
"2025-11-01 00:00:00","4366"
"2025-12-01 00:00:00","3862"
1 Month Questions
2 2019-01-01 00:00:00 149628
3 2019-02-01 00:00:00 146421
4 2019-03-01 00:00:00 161159
5 2019-04-01 00:00:00 153558
6 2019-05-01 00:00:00 151393
7 2019-06-01 00:00:00 135935
8 2019-07-01 00:00:00 151081
9 2019-08-01 00:00:00 137181
10 2019-09-01 00:00:00 136935
11 2019-10-01 00:00:00 152721
12 2019-11-01 00:00:00 148338
13 2019-12-01 00:00:00 132623
14 2020-01-01 00:00:00 146735
15 2020-02-01 00:00:00 145191
16 2020-03-01 00:00:00 156037
17 2020-04-01 00:00:00 183016
18 2020-05-01 00:00:00 186573
19 2020-06-01 00:00:00 172002
20 2020-07-01 00:00:00 166135
21 2020-08-01 00:00:00 148452
22 2020-09-01 00:00:00 141887
23 2020-10-01 00:00:00 141948
24 2020-11-01 00:00:00 135088
25 2020-12-01 00:00:00 134035
26 2021-01-01 00:00:00 140009
27 2021-02-01 00:00:00 131810
28 2021-03-01 00:00:00 148900
29 2021-04-01 00:00:00 136022
30 2021-05-01 00:00:00 133911
31 2021-06-01 00:00:00 129106
32 2021-07-01 00:00:00 124130
33 2021-08-01 00:00:00 122273
34 2021-09-01 00:00:00 119882
35 2021-10-01 00:00:00 119008
36 2021-11-01 00:00:00 119260
37 2021-12-01 00:00:00 112278
38 2022-01-01 00:00:00 119459
39 2022-02-01 00:00:00 114114
40 2022-03-01 00:00:00 123614
41 2022-04-01 00:00:00 114422
42 2022-05-01 00:00:00 116346
43 2022-06-01 00:00:00 111741
44 2022-07-01 00:00:00 111059
45 2022-08-01 00:00:00 113048
46 2022-09-01 00:00:00 103965
47 2022-10-01 00:00:00 106366
48 2022-11-01 00:00:00 109719
49 2022-12-01 00:00:00 96767
50 2023-01-01 00:00:00 97209
51 2023-02-01 00:00:00 85973
52 2023-03-01 00:00:00 87543
53 2023-04-01 00:00:00 68746
54 2023-05-01 00:00:00 66749
55 2023-06-01 00:00:00 63858
56 2023-07-01 00:00:00 62938
57 2023-08-01 00:00:00 60319
58 2023-09-01 00:00:00 53046
59 2023-10-01 00:00:00 52743
60 2023-11-01 00:00:00 50646
61 2023-12-01 00:00:00 42601
62 2024-01-01 00:00:00 47854
63 2024-02-01 00:00:00 46292
64 2024-03-01 00:00:00 45070
65 2024-04-01 00:00:00 42776
66 2024-05-01 00:00:00 40485
67 2024-06-01 00:00:00 32243
68 2024-07-01 00:00:00 31740
69 2024-08-01 00:00:00 28059
70 2024-09-01 00:00:00 24947
71 2024-10-01 00:00:00 23319
72 2024-11-01 00:00:00 20891
73 2024-12-01 00:00:00 18029
74 2025-01-01 00:00:00 22394
75 2025-02-01 00:00:00 19340
76 2025-03-01 00:00:00 18965
77 2025-04-01 00:00:00 14138
78 2025-05-01 00:00:00 11824
79 2025-06-01 00:00:00 9392
80 2025-07-01 00:00:00 7841
81 2025-08-01 00:00:00 5885
82 2025-09-01 00:00:00 6132
83 2025-10-01 00:00:00 5415
84 2025-11-01 00:00:00 4366
85 2025-12-01 00:00:00 3862

View file

@ -0,0 +1,211 @@
Month,Questions
"2008-07-01 00:00:00","4"
"2008-08-01 00:00:00","3749"
"2008-09-01 00:00:00","14040"
"2008-10-01 00:00:00","14578"
"2008-11-01 00:00:00","12717"
"2008-12-01 00:00:00","12081"
"2009-01-01 00:00:00","15833"
"2009-02-01 00:00:00","17581"
"2009-03-01 00:00:00","20482"
"2009-04-01 00:00:00","21336"
"2009-05-01 00:00:00","25814"
"2009-06-01 00:00:00","28326"
"2009-07-01 00:00:00","32483"
"2009-08-01 00:00:00","32775"
"2009-09-01 00:00:00","33054"
"2009-10-01 00:00:00","36331"
"2009-11-01 00:00:00","38518"
"2009-12-01 00:00:00","37795"
"2010-01-01 00:00:00","44938"
"2010-02-01 00:00:00","44811"
"2010-03-01 00:00:00","52236"
"2010-04-01 00:00:00","50084"
"2010-05-01 00:00:00","51996"
"2010-06-01 00:00:00","55750"
"2010-07-01 00:00:00","60848"
"2010-08-01 00:00:00","63660"
"2010-09-01 00:00:00","61230"
"2010-10-01 00:00:00","63777"
"2010-11-01 00:00:00","69693"
"2010-12-01 00:00:00","69573"
"2011-01-01 00:00:00","79911"
"2011-02-01 00:00:00","82942"
"2011-03-01 00:00:00","100970"
"2011-04-01 00:00:00","95562"
"2011-05-01 00:00:00","100229"
"2011-06-01 00:00:00","99027"
"2011-07-01 00:00:00","100445"
"2011-08-01 00:00:00","106917"
"2011-09-01 00:00:00","101722"
"2011-10-01 00:00:00","101032"
"2011-11-01 00:00:00","108450"
"2011-12-01 00:00:00","103172"
"2012-01-01 00:00:00","115434"
"2012-02-01 00:00:00","123299"
"2012-03-01 00:00:00","133000"
"2012-04-01 00:00:00","128064"
"2012-05-01 00:00:00","133515"
"2012-06-01 00:00:00","130323"
"2012-07-01 00:00:00","141906"
"2012-08-01 00:00:00","141528"
"2012-09-01 00:00:00","132241"
"2012-10-01 00:00:00","150083"
"2012-11-01 00:00:00","148310"
"2012-12-01 00:00:00","135471"
"2013-01-01 00:00:00","157850"
"2013-02-01 00:00:00","153402"
"2013-03-01 00:00:00","173814"
"2013-04-01 00:00:00","171546"
"2013-05-01 00:00:00","166468"
"2013-06-01 00:00:00","158334"
"2013-07-01 00:00:00","176636"
"2013-08-01 00:00:00","170215"
"2013-09-01 00:00:00","165374"
"2013-10-01 00:00:00","184133"
"2013-11-01 00:00:00","176033"
"2013-12-01 00:00:00","165192"
"2014-01-01 00:00:00","188797"
"2014-02-01 00:00:00","187751"
"2014-03-01 00:00:00","207493"
"2014-04-01 00:00:00","194022"
"2014-05-01 00:00:00","176017"
"2014-06-01 00:00:00","162354"
"2014-07-01 00:00:00","177066"
"2014-08-01 00:00:00","163406"
"2014-09-01 00:00:00","165866"
"2014-10-01 00:00:00","172887"
"2014-11-01 00:00:00","166598"
"2014-12-01 00:00:00","155299"
"2015-01-01 00:00:00","165978"
"2015-02-01 00:00:00","168513"
"2015-03-01 00:00:00","189769"
"2015-04-01 00:00:00","189967"
"2015-05-01 00:00:00","185878"
"2015-06-01 00:00:00","187915"
"2015-07-01 00:00:00","195439"
"2015-08-01 00:00:00","181413"
"2015-09-01 00:00:00","177859"
"2015-10-01 00:00:00","186418"
"2015-11-01 00:00:00","178205"
"2015-12-01 00:00:00","172586"
"2016-01-01 00:00:00","181380"
"2016-02-01 00:00:00","188742"
"2016-03-01 00:00:00","201941"
"2016-04-01 00:00:00","197067"
"2016-05-01 00:00:00","189772"
"2016-06-01 00:00:00","184660"
"2016-07-01 00:00:00","176974"
"2016-08-01 00:00:00","181960"
"2016-09-01 00:00:00","171676"
"2016-10-01 00:00:00","175399"
"2016-11-01 00:00:00","175326"
"2016-12-01 00:00:00","162421"
"2017-01-01 00:00:00","176969"
"2017-02-01 00:00:00","175280"
"2017-03-01 00:00:00","201543"
"2017-04-01 00:00:00","178508"
"2017-05-01 00:00:00","186511"
"2017-06-01 00:00:00","178175"
"2017-07-01 00:00:00","179856"
"2017-08-01 00:00:00","177742"
"2017-09-01 00:00:00","162369"
"2017-10-01 00:00:00","170083"
"2017-11-01 00:00:00","169012"
"2017-12-01 00:00:00","145330"
"2018-01-01 00:00:00","160544"
"2018-02-01 00:00:00","153155"
"2018-03-01 00:00:00","173466"
"2018-04-01 00:00:00","162981"
"2018-05-01 00:00:00","168104"
"2018-06-01 00:00:00","154885"
"2018-07-01 00:00:00","160095"
"2018-08-01 00:00:00","158544"
"2018-09-01 00:00:00","144581"
"2018-10-01 00:00:00","160501"
"2018-11-01 00:00:00","149820"
"2018-12-01 00:00:00","132269"
"2019-01-01 00:00:00","149628"
"2019-02-01 00:00:00","146421"
"2019-03-01 00:00:00","161159"
"2019-04-01 00:00:00","153558"
"2019-05-01 00:00:00","151393"
"2019-06-01 00:00:00","135935"
"2019-07-01 00:00:00","151081"
"2019-08-01 00:00:00","137181"
"2019-09-01 00:00:00","136935"
"2019-10-01 00:00:00","152721"
"2019-11-01 00:00:00","148338"
"2019-12-01 00:00:00","132623"
"2020-01-01 00:00:00","146735"
"2020-02-01 00:00:00","145191"
"2020-03-01 00:00:00","156037"
"2020-04-01 00:00:00","183016"
"2020-05-01 00:00:00","186573"
"2020-06-01 00:00:00","172002"
"2020-07-01 00:00:00","166135"
"2020-08-01 00:00:00","148452"
"2020-09-01 00:00:00","141887"
"2020-10-01 00:00:00","141948"
"2020-11-01 00:00:00","135088"
"2020-12-01 00:00:00","134035"
"2021-01-01 00:00:00","140009"
"2021-02-01 00:00:00","131810"
"2021-03-01 00:00:00","148900"
"2021-04-01 00:00:00","136022"
"2021-05-01 00:00:00","133911"
"2021-06-01 00:00:00","129106"
"2021-07-01 00:00:00","124130"
"2021-08-01 00:00:00","122273"
"2021-09-01 00:00:00","119882"
"2021-10-01 00:00:00","119008"
"2021-11-01 00:00:00","119260"
"2021-12-01 00:00:00","112278"
"2022-01-01 00:00:00","119459"
"2022-02-01 00:00:00","114114"
"2022-03-01 00:00:00","123614"
"2022-04-01 00:00:00","114422"
"2022-05-01 00:00:00","116346"
"2022-06-01 00:00:00","111741"
"2022-07-01 00:00:00","111059"
"2022-08-01 00:00:00","113048"
"2022-09-01 00:00:00","103965"
"2022-10-01 00:00:00","106366"
"2022-11-01 00:00:00","109719"
"2022-12-01 00:00:00","96767"
"2023-01-01 00:00:00","97209"
"2023-02-01 00:00:00","85973"
"2023-03-01 00:00:00","87543"
"2023-04-01 00:00:00","68746"
"2023-05-01 00:00:00","66749"
"2023-06-01 00:00:00","63858"
"2023-07-01 00:00:00","62938"
"2023-08-01 00:00:00","60319"
"2023-09-01 00:00:00","53046"
"2023-10-01 00:00:00","52743"
"2023-11-01 00:00:00","50646"
"2023-12-01 00:00:00","42601"
"2024-01-01 00:00:00","47854"
"2024-02-01 00:00:00","46292"
"2024-03-01 00:00:00","45070"
"2024-04-01 00:00:00","42776"
"2024-05-01 00:00:00","40485"
"2024-06-01 00:00:00","32243"
"2024-07-01 00:00:00","31740"
"2024-08-01 00:00:00","28059"
"2024-09-01 00:00:00","24947"
"2024-10-01 00:00:00","23319"
"2024-11-01 00:00:00","20891"
"2024-12-01 00:00:00","18029"
"2025-01-01 00:00:00","22394"
"2025-02-01 00:00:00","19340"
"2025-03-01 00:00:00","18965"
"2025-04-01 00:00:00","14138"
"2025-05-01 00:00:00","11824"
"2025-06-01 00:00:00","9392"
"2025-07-01 00:00:00","7841"
"2025-08-01 00:00:00","5885"
"2025-09-01 00:00:00","6132"
"2025-10-01 00:00:00","5415"
"2025-11-01 00:00:00","4366"
"2025-12-01 00:00:00","3862"
1 Month Questions
2 2008-07-01 00:00:00 4
3 2008-08-01 00:00:00 3749
4 2008-09-01 00:00:00 14040
5 2008-10-01 00:00:00 14578
6 2008-11-01 00:00:00 12717
7 2008-12-01 00:00:00 12081
8 2009-01-01 00:00:00 15833
9 2009-02-01 00:00:00 17581
10 2009-03-01 00:00:00 20482
11 2009-04-01 00:00:00 21336
12 2009-05-01 00:00:00 25814
13 2009-06-01 00:00:00 28326
14 2009-07-01 00:00:00 32483
15 2009-08-01 00:00:00 32775
16 2009-09-01 00:00:00 33054
17 2009-10-01 00:00:00 36331
18 2009-11-01 00:00:00 38518
19 2009-12-01 00:00:00 37795
20 2010-01-01 00:00:00 44938
21 2010-02-01 00:00:00 44811
22 2010-03-01 00:00:00 52236
23 2010-04-01 00:00:00 50084
24 2010-05-01 00:00:00 51996
25 2010-06-01 00:00:00 55750
26 2010-07-01 00:00:00 60848
27 2010-08-01 00:00:00 63660
28 2010-09-01 00:00:00 61230
29 2010-10-01 00:00:00 63777
30 2010-11-01 00:00:00 69693
31 2010-12-01 00:00:00 69573
32 2011-01-01 00:00:00 79911
33 2011-02-01 00:00:00 82942
34 2011-03-01 00:00:00 100970
35 2011-04-01 00:00:00 95562
36 2011-05-01 00:00:00 100229
37 2011-06-01 00:00:00 99027
38 2011-07-01 00:00:00 100445
39 2011-08-01 00:00:00 106917
40 2011-09-01 00:00:00 101722
41 2011-10-01 00:00:00 101032
42 2011-11-01 00:00:00 108450
43 2011-12-01 00:00:00 103172
44 2012-01-01 00:00:00 115434
45 2012-02-01 00:00:00 123299
46 2012-03-01 00:00:00 133000
47 2012-04-01 00:00:00 128064
48 2012-05-01 00:00:00 133515
49 2012-06-01 00:00:00 130323
50 2012-07-01 00:00:00 141906
51 2012-08-01 00:00:00 141528
52 2012-09-01 00:00:00 132241
53 2012-10-01 00:00:00 150083
54 2012-11-01 00:00:00 148310
55 2012-12-01 00:00:00 135471
56 2013-01-01 00:00:00 157850
57 2013-02-01 00:00:00 153402
58 2013-03-01 00:00:00 173814
59 2013-04-01 00:00:00 171546
60 2013-05-01 00:00:00 166468
61 2013-06-01 00:00:00 158334
62 2013-07-01 00:00:00 176636
63 2013-08-01 00:00:00 170215
64 2013-09-01 00:00:00 165374
65 2013-10-01 00:00:00 184133
66 2013-11-01 00:00:00 176033
67 2013-12-01 00:00:00 165192
68 2014-01-01 00:00:00 188797
69 2014-02-01 00:00:00 187751
70 2014-03-01 00:00:00 207493
71 2014-04-01 00:00:00 194022
72 2014-05-01 00:00:00 176017
73 2014-06-01 00:00:00 162354
74 2014-07-01 00:00:00 177066
75 2014-08-01 00:00:00 163406
76 2014-09-01 00:00:00 165866
77 2014-10-01 00:00:00 172887
78 2014-11-01 00:00:00 166598
79 2014-12-01 00:00:00 155299
80 2015-01-01 00:00:00 165978
81 2015-02-01 00:00:00 168513
82 2015-03-01 00:00:00 189769
83 2015-04-01 00:00:00 189967
84 2015-05-01 00:00:00 185878
85 2015-06-01 00:00:00 187915
86 2015-07-01 00:00:00 195439
87 2015-08-01 00:00:00 181413
88 2015-09-01 00:00:00 177859
89 2015-10-01 00:00:00 186418
90 2015-11-01 00:00:00 178205
91 2015-12-01 00:00:00 172586
92 2016-01-01 00:00:00 181380
93 2016-02-01 00:00:00 188742
94 2016-03-01 00:00:00 201941
95 2016-04-01 00:00:00 197067
96 2016-05-01 00:00:00 189772
97 2016-06-01 00:00:00 184660
98 2016-07-01 00:00:00 176974
99 2016-08-01 00:00:00 181960
100 2016-09-01 00:00:00 171676
101 2016-10-01 00:00:00 175399
102 2016-11-01 00:00:00 175326
103 2016-12-01 00:00:00 162421
104 2017-01-01 00:00:00 176969
105 2017-02-01 00:00:00 175280
106 2017-03-01 00:00:00 201543
107 2017-04-01 00:00:00 178508
108 2017-05-01 00:00:00 186511
109 2017-06-01 00:00:00 178175
110 2017-07-01 00:00:00 179856
111 2017-08-01 00:00:00 177742
112 2017-09-01 00:00:00 162369
113 2017-10-01 00:00:00 170083
114 2017-11-01 00:00:00 169012
115 2017-12-01 00:00:00 145330
116 2018-01-01 00:00:00 160544
117 2018-02-01 00:00:00 153155
118 2018-03-01 00:00:00 173466
119 2018-04-01 00:00:00 162981
120 2018-05-01 00:00:00 168104
121 2018-06-01 00:00:00 154885
122 2018-07-01 00:00:00 160095
123 2018-08-01 00:00:00 158544
124 2018-09-01 00:00:00 144581
125 2018-10-01 00:00:00 160501
126 2018-11-01 00:00:00 149820
127 2018-12-01 00:00:00 132269
128 2019-01-01 00:00:00 149628
129 2019-02-01 00:00:00 146421
130 2019-03-01 00:00:00 161159
131 2019-04-01 00:00:00 153558
132 2019-05-01 00:00:00 151393
133 2019-06-01 00:00:00 135935
134 2019-07-01 00:00:00 151081
135 2019-08-01 00:00:00 137181
136 2019-09-01 00:00:00 136935
137 2019-10-01 00:00:00 152721
138 2019-11-01 00:00:00 148338
139 2019-12-01 00:00:00 132623
140 2020-01-01 00:00:00 146735
141 2020-02-01 00:00:00 145191
142 2020-03-01 00:00:00 156037
143 2020-04-01 00:00:00 183016
144 2020-05-01 00:00:00 186573
145 2020-06-01 00:00:00 172002
146 2020-07-01 00:00:00 166135
147 2020-08-01 00:00:00 148452
148 2020-09-01 00:00:00 141887
149 2020-10-01 00:00:00 141948
150 2020-11-01 00:00:00 135088
151 2020-12-01 00:00:00 134035
152 2021-01-01 00:00:00 140009
153 2021-02-01 00:00:00 131810
154 2021-03-01 00:00:00 148900
155 2021-04-01 00:00:00 136022
156 2021-05-01 00:00:00 133911
157 2021-06-01 00:00:00 129106
158 2021-07-01 00:00:00 124130
159 2021-08-01 00:00:00 122273
160 2021-09-01 00:00:00 119882
161 2021-10-01 00:00:00 119008
162 2021-11-01 00:00:00 119260
163 2021-12-01 00:00:00 112278
164 2022-01-01 00:00:00 119459
165 2022-02-01 00:00:00 114114
166 2022-03-01 00:00:00 123614
167 2022-04-01 00:00:00 114422
168 2022-05-01 00:00:00 116346
169 2022-06-01 00:00:00 111741
170 2022-07-01 00:00:00 111059
171 2022-08-01 00:00:00 113048
172 2022-09-01 00:00:00 103965
173 2022-10-01 00:00:00 106366
174 2022-11-01 00:00:00 109719
175 2022-12-01 00:00:00 96767
176 2023-01-01 00:00:00 97209
177 2023-02-01 00:00:00 85973
178 2023-03-01 00:00:00 87543
179 2023-04-01 00:00:00 68746
180 2023-05-01 00:00:00 66749
181 2023-06-01 00:00:00 63858
182 2023-07-01 00:00:00 62938
183 2023-08-01 00:00:00 60319
184 2023-09-01 00:00:00 53046
185 2023-10-01 00:00:00 52743
186 2023-11-01 00:00:00 50646
187 2023-12-01 00:00:00 42601
188 2024-01-01 00:00:00 47854
189 2024-02-01 00:00:00 46292
190 2024-03-01 00:00:00 45070
191 2024-04-01 00:00:00 42776
192 2024-05-01 00:00:00 40485
193 2024-06-01 00:00:00 32243
194 2024-07-01 00:00:00 31740
195 2024-08-01 00:00:00 28059
196 2024-09-01 00:00:00 24947
197 2024-10-01 00:00:00 23319
198 2024-11-01 00:00:00 20891
199 2024-12-01 00:00:00 18029
200 2025-01-01 00:00:00 22394
201 2025-02-01 00:00:00 19340
202 2025-03-01 00:00:00 18965
203 2025-04-01 00:00:00 14138
204 2025-05-01 00:00:00 11824
205 2025-06-01 00:00:00 9392
206 2025-07-01 00:00:00 7841
207 2025-08-01 00:00:00 5885
208 2025-09-01 00:00:00 6132
209 2025-10-01 00:00:00 5415
210 2025-11-01 00:00:00 4366
211 2025-12-01 00:00:00 3862

View file

@ -0,0 +1,211 @@
Date,Total Issues
2008-07,0
2008-08,0
2008-09,0
2008-10,0
2008-11,0
2008-12,0
2009-01,0
2009-02,0
2009-03,0
2009-04,0
2009-05,0
2009-06,0
2009-07,0
2009-08,0
2009-09,0
2009-10,1
2009-11,199
2009-12,88
2010-01,57
2010-02,36
2010-03,42
2010-04,34
2010-05,45
2010-06,41
2010-07,54
2010-08,46
2010-09,41
2010-10,51
2010-11,35
2010-12,35
2011-01,50
2011-02,71
2011-03,42
2011-04,59
2011-05,73
2011-06,70
2011-07,50
2011-08,49
2011-09,46
2011-10,48
2011-11,52
2011-12,60
2012-01,81
2012-02,148
2012-03,112
2012-04,54
2012-05,46
2012-06,38
2012-07,47
2012-08,40
2012-09,63
2012-10,56
2012-11,72
2012-12,62
2013-01,47
2013-02,70
2013-03,70
2013-04,76
2013-05,75
2013-06,93
2013-07,75
2013-08,97
2013-09,90
2013-10,78
2013-11,85
2013-12,98
2014-01,112
2014-02,105
2014-03,152
2014-04,135
2014-05,123
2014-06,141
2014-07,138
2014-08,157
2014-09,211
2014-10,269
2014-11,272
2014-12,348
2015-01,391
2015-02,452
2015-03,495
2015-04,666
2015-05,739
2015-06,875
2015-07,1024
2015-08,838
2015-09,710
2015-10,839
2015-11,1287
2015-12,1222
2016-01,1326
2016-02,1576
2016-03,1490
2016-04,1404
2016-05,1456
2016-06,1502
2016-07,1647
2016-08,1765
2016-09,1796
2016-10,1542
2016-11,1473
2016-12,1465
2017-01,1551
2017-02,1725
2017-03,2055
2017-04,1847
2017-05,2063
2017-06,2057
2017-07,1805
2017-08,1973
2017-09,2051
2017-10,2115
2017-11,2510
2017-12,2353
2018-01,3100
2018-02,3097
2018-03,3790
2018-04,3662
2018-05,3875
2018-06,4247
2018-07,4377
2018-08,4741
2018-09,4459
2018-10,5292
2018-11,5458
2018-12,6048
2019-01,8605
2019-02,8408
2019-03,9698
2019-04,8991
2019-05,9687
2019-06,9013
2019-07,9963
2019-08,10381
2019-09,9459
2019-10,10923
2019-11,10365
2019-12,9743
2020-01,10240
2020-02,9782
2020-03,11336
2020-04,12978
2020-05,12955
2020-06,11921
2020-07,11547
2020-08,11006
2020-09,10252
2020-10,10560
2020-11,9827
2020-12,9509
2021-01,9788
2021-02,10228
2021-03,11503
2021-04,10537
2021-05,10048
2021-06,10273
2021-07,9494
2021-08,9496
2021-09,9584
2021-10,9652
2021-11,9500
2021-12,8836
2022-01,9202
2022-02,9612
2022-03,10661
2022-04,9016
2022-05,9552
2022-06,9207
2022-07,9633
2022-08,10136
2022-09,11260
2022-10,11668
2022-11,10974
2022-12,9605
2023-01,11124
2023-02,10993
2023-03,12906
2023-04,12436
2023-05,12798
2023-06,11041
2023-07,11009
2023-08,11467
2023-09,10751
2023-10,11316
2023-11,11278
2023-12,9598
2024-01,10656
2024-02,10658
2024-03,10949
2024-04,10735
2024-05,10996
2024-06,10195
2024-07,11183
2024-08,11632
2024-09,11180
2024-10,11319
2024-11,10850
2024-12,10091
2025-01,11259
2025-02,12612
2025-03,13429
2025-04,11680
2025-05,11367
2025-06,10888
2025-07,14019
2025-08,13127
2025-09,12489
2025-10,13904
2025-11,13730
2025-12,12652
1 Date Total Issues
2 2008-07 0
3 2008-08 0
4 2008-09 0
5 2008-10 0
6 2008-11 0
7 2008-12 0
8 2009-01 0
9 2009-02 0
10 2009-03 0
11 2009-04 0
12 2009-05 0
13 2009-06 0
14 2009-07 0
15 2009-08 0
16 2009-09 0
17 2009-10 1
18 2009-11 199
19 2009-12 88
20 2010-01 57
21 2010-02 36
22 2010-03 42
23 2010-04 34
24 2010-05 45
25 2010-06 41
26 2010-07 54
27 2010-08 46
28 2010-09 41
29 2010-10 51
30 2010-11 35
31 2010-12 35
32 2011-01 50
33 2011-02 71
34 2011-03 42
35 2011-04 59
36 2011-05 73
37 2011-06 70
38 2011-07 50
39 2011-08 49
40 2011-09 46
41 2011-10 48
42 2011-11 52
43 2011-12 60
44 2012-01 81
45 2012-02 148
46 2012-03 112
47 2012-04 54
48 2012-05 46
49 2012-06 38
50 2012-07 47
51 2012-08 40
52 2012-09 63
53 2012-10 56
54 2012-11 72
55 2012-12 62
56 2013-01 47
57 2013-02 70
58 2013-03 70
59 2013-04 76
60 2013-05 75
61 2013-06 93
62 2013-07 75
63 2013-08 97
64 2013-09 90
65 2013-10 78
66 2013-11 85
67 2013-12 98
68 2014-01 112
69 2014-02 105
70 2014-03 152
71 2014-04 135
72 2014-05 123
73 2014-06 141
74 2014-07 138
75 2014-08 157
76 2014-09 211
77 2014-10 269
78 2014-11 272
79 2014-12 348
80 2015-01 391
81 2015-02 452
82 2015-03 495
83 2015-04 666
84 2015-05 739
85 2015-06 875
86 2015-07 1024
87 2015-08 838
88 2015-09 710
89 2015-10 839
90 2015-11 1287
91 2015-12 1222
92 2016-01 1326
93 2016-02 1576
94 2016-03 1490
95 2016-04 1404
96 2016-05 1456
97 2016-06 1502
98 2016-07 1647
99 2016-08 1765
100 2016-09 1796
101 2016-10 1542
102 2016-11 1473
103 2016-12 1465
104 2017-01 1551
105 2017-02 1725
106 2017-03 2055
107 2017-04 1847
108 2017-05 2063
109 2017-06 2057
110 2017-07 1805
111 2017-08 1973
112 2017-09 2051
113 2017-10 2115
114 2017-11 2510
115 2017-12 2353
116 2018-01 3100
117 2018-02 3097
118 2018-03 3790
119 2018-04 3662
120 2018-05 3875
121 2018-06 4247
122 2018-07 4377
123 2018-08 4741
124 2018-09 4459
125 2018-10 5292
126 2018-11 5458
127 2018-12 6048
128 2019-01 8605
129 2019-02 8408
130 2019-03 9698
131 2019-04 8991
132 2019-05 9687
133 2019-06 9013
134 2019-07 9963
135 2019-08 10381
136 2019-09 9459
137 2019-10 10923
138 2019-11 10365
139 2019-12 9743
140 2020-01 10240
141 2020-02 9782
142 2020-03 11336
143 2020-04 12978
144 2020-05 12955
145 2020-06 11921
146 2020-07 11547
147 2020-08 11006
148 2020-09 10252
149 2020-10 10560
150 2020-11 9827
151 2020-12 9509
152 2021-01 9788
153 2021-02 10228
154 2021-03 11503
155 2021-04 10537
156 2021-05 10048
157 2021-06 10273
158 2021-07 9494
159 2021-08 9496
160 2021-09 9584
161 2021-10 9652
162 2021-11 9500
163 2021-12 8836
164 2022-01 9202
165 2022-02 9612
166 2022-03 10661
167 2022-04 9016
168 2022-05 9552
169 2022-06 9207
170 2022-07 9633
171 2022-08 10136
172 2022-09 11260
173 2022-10 11668
174 2022-11 10974
175 2022-12 9605
176 2023-01 11124
177 2023-02 10993
178 2023-03 12906
179 2023-04 12436
180 2023-05 12798
181 2023-06 11041
182 2023-07 11009
183 2023-08 11467
184 2023-09 10751
185 2023-10 11316
186 2023-11 11278
187 2023-12 9598
188 2024-01 10656
189 2024-02 10658
190 2024-03 10949
191 2024-04 10735
192 2024-05 10996
193 2024-06 10195
194 2024-07 11183
195 2024-08 11632
196 2024-09 11180
197 2024-10 11319
198 2024-11 10850
199 2024-12 10091
200 2025-01 11259
201 2025-02 12612
202 2025-03 13429
203 2025-04 11680
204 2025-05 11367
205 2025-06 10888
206 2025-07 14019
207 2025-08 13127
208 2025-09 12489
209 2025-10 13904
210 2025-11 13730
211 2025-12 12652

11
content/guestbook.md Normal file
View file

@ -0,0 +1,11 @@
---
title: "Guestbook"
author: Dan
type: guestbook
date: 2026-01-11
comments: false
---
I'd love to hear from you if you've passed by my corner of the internet.
If you want to say hi privately you can [email me](mailto:dan@ritual.sh) instead.

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View file

@ -0,0 +1,13 @@
---
title: "Fackham Hall"
date: 2025-12-24
tags: ["film"] # album, film, show, book, game
format: "stream" # stream, cd, vinyl, cassette, dvd, bluray, digital
description: ""
cover: "cover.jpg"
rating: 8 # out of 10
build:
render: never
---
Review here :)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

View file

@ -0,0 +1,13 @@
---
title: "Taskmaster - Champion of Champions 4"
date: 2025-12-26
tags: ["show"] # album, film, show, book, game
format: "stream" # stream, cd, vinyl, cassette, dvd, bluray, digital
description: ""
cover: "cover.jpg"
rating: 9 # out of 10
build:
render: never
---
Review here :)

View file

@ -10,7 +10,7 @@ order: 4
- **Browser** - [Zen](https://zen-browser.app/) - **Browser** - [Zen](https://zen-browser.app/)
- **Code Editor** - VS Code - **Code Editor** - VS Code
- **Audio** - High Tide & Turntable - **Audio** - High Tide & Turntable
- **Password Manager** - 1password - **Password Manager** - [Proton Pass](https://pr.tn/ref/MNB13JYX)
**Hardware:** **Hardware:**

View file

@ -1,7 +1,8 @@
--- ---
title: "Resources" title: "Resources"
description: "A collection of useful tools, scripts, and experiments"
draft: false draft: false
--- ---
Welcome to my whiteboard of resources. Here you'll find various tools, scripts, and experiments I've built and wanted to share. Welcome to my test chamber, here you'll find various experiments, tests and resources. Take a look around.
The cake isn't a lie.

View file

@ -0,0 +1,74 @@
---
title: "Adoptables"
date: 2026-01-10
description: "Customisable widgets and animations for your website"
icon: "adoptables"
draft: false
---
Welcome to my adoptables collection! These are customisable widgets you can embed on your own website. Each adoptable comes with a live preview and customisation options. Just configure it how you like, grab the code, and paste it into your site.
All adoptables are:
- **Free to use** - No attribution required (but appreciated!)
- **Probably customisable** - Tweak your own colors and settings if applicable
- **Self-contained** - Single script tag, no dependencies
- **Responsive** - Scales to fit your layout
---
## Lava Lamp
My brother had a blue and yellow lava lamp when I was younger and I was obsessed with it, so making one for my website was an obvious choice... and now I'd like to share it with everyone!
Using the pixelation effect with very high contrast colours is going to result in an aura on the blobs due to the way the SVG filter works, I am trying to find a solution for this.
{{< lavalamp-adoptable >}}
---
## Usage Instructions
1. **Customise** - Use the controls to adjust colors, animation speed, and other settings
2. **Get Code** - Click "Get Embed Code" to generate your custom script tag
3. **Embed** - Copy the code and paste it anywhere in your HTML
4. **Style** - Wrap it in a div and set width/height to control the lamp size
### Example Container
The lava lamp will scale to fit its container. You can control the size like this:
```html
<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>
```
### Customisation 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)
- `data-pixelate`: Set to "true" for a retro pixelated effect
- `data-pixel-size`: Pixel size for pixelation effect (2-10, default 4)
---
## More Coming Soon
The companion cube is coming soon!
Got ideas for adoptables you'd like to see? Let me know!

View file

@ -0,0 +1,40 @@
---
title: "88x31 Button Creator"
date: 2026-01-08
description: "Make custom 88x31 pixel buttons with text, colors, gradients, and textures"
icon: "button-generator"
demo_url: ""
source_url: ""
draft: false
---
Welcome to my 88x31 button creator, this is a pretty rough and ready implementation so it could be buggy, please let me know if you find any issues.
This supports gif despite the basic `canvas` tag limitation courtesy of [gif.js](https://github.com/jnordberg/gif.js) - none of this would be possible without that project.
Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x31) and everyone who made the buttons that appear there. You should check it out if you need inspiration for your button!
**Important note:** Some effects and animations stack, some don't. Some work better with certain lengths of text or variables depending on text length. Experiment, see what happens.
{{< button-generator >}}
---
### Changelog
- 07/01/2025 - Initial release.
- 08/01/2025 - Total refactor to be modular, added many more effects.
- 09/01/2025 - Rewrote the bevel inset and outset border code so they look a lot nicer at the corners. Updates throughout so that multibyte (emoji!) characters should work.
---
### Cool People Who Have Linked To This Project
[tdh.se](https://tdh.se/2026/01/88x31-pixlar-retroknapp/),
[rknight.me](https://rknight.me/blog/88x31-button-curios/),
[fyr.io](https://fyr.io/scrap/2026-01-09),
[brennan.day](https://brennan.day/resources-for-the-personal-web-a-follow-up-guide/),
[kuemmerle.name](https://kuemmerle.name/bastelglueck/),
[craney.uk](https://craney.uk/posts/stuff-this-week-74)
Am I missing you? Email me or send a webmention!

View file

@ -1,7 +1,6 @@
--- ---
title: "Last.fm Weekly Stats Script" title: "Last.fm Weekly Stats"
date: 2026-01-04 date: 2026-01-04
tags: ["javascript", "api", "last.fm"]
description: "Fetch and display your weekly listening stats from Last.fm" description: "Fetch and display your weekly listening stats from Last.fm"
icon: "lastfm-stats" icon: "lastfm-stats"
demo_url: "" demo_url: ""
@ -9,26 +8,115 @@ source_url: ""
draft: false draft: false
--- ---
A handy script for pulling your weekly listening statistics from Last.fm's API. Perfect for tracking your music habits and creating weekly listening reports. Get your weekly listening statistics from Last.fm's API. Enter your username below to see your top artists and track counts for different time periods.
## Features I made this so I could easily add in listening stats to my [weekly posts](/tags/weekly-update/), if you find it useful please let me know.
- Fetches weekly top tracks from Last.fm {{< lastfm-stats-form >}}
- Displays track count and listening time
- Formats data in a clean, readable format
- Easy to integrate into blogs or dashboards
## Setup ## Download the Shell Script
1. Get your Last.fm API key from [Last.fm API](https://www.last.fm/api) Want to run this locally or integrate it into your own workflows? Here is a bash script I was using to generate this before I decided to make a web version.
2. Configure your username in the script
3. Run the script to fetch your weekly stats
## Output ```
#!/bin/bash
The script returns your top tracks for the week, including: # Last.fm Weekly Stats Script
- Track name and artist # Fetches your Last.fm listening statistics for the past week
- Play count #
- Listening duration # Requirements:
# - curl (for API requests)
# - jq (for JSON parsing)
#
# Usage: ./lastfm-week.sh
#
# Setup:
# Create a .env file with:
# LASTFM_API_KEY=your_api_key_here
# LASTFM_USERNAME=your_username_here
#
# Output: Markdown-formatted stats with top artists and track counts
#
# Download from: https://ritual.sh/resources/lastfm-stats/
Great for weekly blog posts or personal music tracking! # Load environment variables from .env file
if [ -f .env ]; then
export $(cat .env | grep -v '^#' | xargs)
else
echo "Error: .env file not found"
exit 1
fi
# Check required variables
if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then
echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file"
exit 1
fi
API_BASE="http://ws.audioscrobbler.com/2.0/"
# Get current timestamp
NOW=$(date +%s)
# Get timestamp from 7 days ago
WEEK_AGO=$((NOW - 604800))
# Fetch top artists for the week
TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5")
# Fetch recent tracks to count this week's scrobbles
RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1")
# Get total track count
TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total')
# Output in markdown format
echo "## Last.fm Weekly Stats"
echo ""
echo "**Total Tracks:** ${TOTAL_TRACKS}"
echo ""
echo "**Top 5 Artists:**"
echo ""
# Parse and display top 5 artists as markdown links
echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"'
```
### Shell Script Usage
The script fetches your Last.fm stats and outputs them in markdown format.
**Requirements:**
- `curl` for API requests
- `jq` for JSON parsing
**Setup:**
1. Create a `.env` file with your credentials:
```bash
LASTFM_API_KEY=your_api_key_here
LASTFM_USERNAME=your_username_here
```
2. Make the script executable:
```bash
chmod +x lastfm-week.sh
```
3. Run it:
```bash
./lastfm-week.sh
```
**Output:**
The script prints markdown-formatted stats including:
- Total tracks scrobbled this week
- Top 5 artists with play counts
- Direct links to artist pages
Enjoy!

View file

@ -1,27 +0,0 @@
---
title: "HTML/CSS Lavalamp"
date: 2026-01-04
tags: ["css", "html", "animation"]
description: "A pure CSS lavalamp animation with floating blobs"
icon: "lavalamp"
demo_url: ""
source_url: ""
draft: false
---
A mesmerizing lavalamp effect created entirely with HTML and CSS. Features smooth floating animations and gradient blobs that rise and fall like a classic lava lamp.
## Features
- Pure CSS animation (no JavaScript required)
- Customizable colors and blob shapes
- Smooth, organic floating motion
- Responsive design
## How It Works
The animation uses CSS keyframes to create the illusion of blobs floating up and down. Multiple blob elements with different animation delays create a realistic lava lamp effect.
## Usage
Simply copy the HTML and CSS into your project. You can customize the colors by changing the gradient values and adjust the animation speed by modifying the animation duration.

View file

@ -0,0 +1,10 @@
---
title: "2026 01 08 Resources Section"
date: 2026-01-08T09:24:45Z
tags: []
description: ""
build:
render: never
---
Added the resources section with a tool to pull weekly listening stats from last.fm

View file

@ -0,0 +1,10 @@
---
title: "2026 01 11 Button Generator"
date: 2026-01-11T08:32:16Z
tags: []
description: ""
build:
render: never
---
Added an 88x31 button generator to the resources section - it can do animated gifs too!

View file

@ -1,7 +1,22 @@
#!/bin/bash #!/bin/bash
# Last.fm Weekly Stats Script # Last.fm Weekly Stats Script
# Usage: ./lastfm-weekly.sh # Fetches your Last.fm listening statistics for the past week
#
# Requirements:
# - curl (for API requests)
# - jq (for JSON parsing)
#
# Usage: ./lastfm-week.sh
#
# Setup:
# Create a .env file with:
# LASTFM_API_KEY=your_api_key_here
# LASTFM_USERNAME=your_username_here
#
# Output: Markdown-formatted stats with top artists and track counts
#
# Download from: https://ritual.sh/resources/lastfm-stats/
# Load environment variables from .env file # Load environment variables from .env file
if [ -f .env ]; then if [ -f .env ]; then

View file

@ -2,8 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="theme-color" content="#00ff00" /> <meta name="theme-color" content="#00ff00" />
<link rel="webmention" href="https://api.ritual.sh/webmention" />
<title> <title>
{{ block "title" . }}{{ .Site.Title }} {{ with .Params.Title }} | {{ . {{ block "title" . }}{{ .Site.Title }} {{ with .Params.Title }} | {{ .
}}{{ end }}{{ end }} }}{{ end }}{{ end }}
@ -17,6 +17,16 @@
<link rel="stylesheet" href="{{ relURL ($.Site.BaseURL) }}{{ . }}" /> <link rel="stylesheet" href="{{ relURL ($.Site.BaseURL) }}{{ . }}" />
{{ end }} {{ block "favicon" . }} {{ partial "site-favicon.html" . }} {{ end {{ end }} {{ block "favicon" . }} {{ partial "site-favicon.html" . }} {{ end
}} }}
<!-- Chart.js for graphs - only load if page content contains graph shortcode -->
{{ if or (findRE "{{<\\s*graph" .RawContent) (findRE "{{%\\s*graph"
.RawContent) }}
<script
src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
integrity="sha384-9nhczxUqK87bcKHh20fSQcTGD4qq5GhayNYSYWqwBkINBhOfQLg/P5HG5lF1urn4"
crossorigin="anonymous"
></script>
{{ end }}
</head> </head>
<body> <body>
@ -24,5 +34,23 @@
<main role="main">{{ block "main" . }}{{ end }}</main> <main role="main">{{ block "main" . }}{{ end }}</main>
{{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }} {{ block {{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }} {{ block
"scripts" . }}{{ partial "site-scripts.html" . }}{{ end }} "scripts" . }}{{ partial "site-scripts.html" . }}{{ end }}
<!-- Button Generator - only load if page content contains button-generator shortcode -->
{{ if or (findRE "{{<\\s*button-generator" .RawContent) (findRE "{{%\\s*button-generator" .RawContent) }}
<script src="{{ "js/gif.js" | relURL }}"></script>
<script type="module" src="{{ "js/button-generator/main.js" | relURL }}"></script>
{{ end }}
<!-- Lava Lamp Adoptable - only load if page content contains lavalamp-adoptable shortcode -->
{{ if or (findRE "{{<\\s*lavalamp-adoptable" .RawContent) (findRE "{{%\\s*lavalamp-adoptable" .RawContent) }}
{{ $lavalampAdoptable := resources.Get "js/adoptables/lavalamp-adoptable.js" | resources.Minify | resources.Fingerprint }}
<script src="{{ $lavalampAdoptable.RelPermalink }}" integrity="{{ $lavalampAdoptable.Data.Integrity }}"></script>
{{ end }}
<!-- Guestbook - only load on guestbook page -->
{{ if eq .Type "guestbook" }}
{{ $guestbook := resources.Get "js/guestbook.js" | resources.Minify | resources.Fingerprint }}
<script src="{{ $guestbook.RelPermalink }}" integrity="{{ $guestbook.Data.Integrity }}"></script>
{{ end }}
</body> </body>
</html> </html>

View file

@ -0,0 +1,36 @@
{{- $links := slice -}}
{{- $excludeDomains := slice "ritual.sh" "last.fm" "www.last.fm" "amazon.co.uk" "www.amazon.co.uk" "amazon.com" "www.amazon.com" -}}
{{- range .Site.RegularPages -}}
{{- $postUrl := .Permalink -}}
{{- $content := .Content -}}
{{- /* Extract all hrefs from content */ -}}
{{- $hrefs := findRE `href="([^"]+)"` $content -}}
{{- range $hrefs -}}
{{- $href := . | replaceRE `href="([^"]+)"` "$1" -}}
{{- /* Only external links */ -}}
{{- if hasPrefix $href "http" -}}
{{- $shouldExclude := false -}}
{{- /* Check if href contains any excluded domain */ -}}
{{- range $excludeDomains -}}
{{- if in $href . -}}
{{- $shouldExclude = true -}}
{{- end -}}
{{- end -}}
{{- if not $shouldExclude -}}
{{- $links = $links | append (dict "source" $postUrl "target" $href) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
[
{{ range $i, $link := $links -}}
{{ if $i }},
{{ end }} {"source":"{{ $link.source }}","target":"{{ $link.target }}"}
{{ end -}}
]

View file

@ -1,17 +1,15 @@
{{ define "header" }}{{ partial "page-header.html" . }}{{ end }} {{ define {{ define "main" }}
"main" }}
<article class="about-page"> <article class="about-page">
<div class="about-content"> <div class="about-content">
<div class="content-screen"> <div class="content-screen">
<div class="about-header">{{ partial "elements/lavalamp.html" . }}</div> <div class="about-header">{{ partial "elements/lavalamp.html" . }}</div>
<div class="screen-display crt no-scroll"> <div class="screen-display crt no-scroll">
> about -v <br /> > about -s <br />
<span>Name</span> / <span class="info">Dan (He/Him)</span><br /> <span>Name</span> / <span class="info">Dan (He/Him)</span><br />
<span>Age</span> / <span class="info">40-something</span><br /> <span>Age</span> / <span class="info">40-something</span><br />
<span>Location</span> / <span class="info">UK 🇬🇧</span><br /><br /> <span>Location</span> / <span class="info">UK 🇬🇧</span><br /><br />
<span>Interests</span> / <span>Interests</span> /
<span class="info"> <span class="info"> Programming. Music. Movies. Tech. Photography.</span
Programming. Music. Movies. Tech. Photography. </span
><br /> ><br />
<span class="cursor-blink">_</span> <span class="cursor-blink">_</span>
</div> </div>
@ -23,6 +21,53 @@
partial "elements/lcd-screen.html" (dict "text" .) }} {{ end }} partial "elements/lcd-screen.html" (dict "text" .) }} {{ end }}
</div> </div>
<div class="wide-item">
<div class="content-screen">
<div class="screen-display crt">
> about -v
<br /><br />
<p>
<span>Intro</span> /
<span class="info">
How do you do? Welcome to my little corner of the internet. I've
been building websites since the original IndieWeb, so am happy to
see the movement taking off and people embracing building and
curating their own websites. This is my virtual garden, you can
read more about why of it all in my "manifesto" below.</span
><br />
</p>
<p>
<span>What's here</span> /
<span class="info">
I'm working on creating pages on my varied interests, so far you
can find a section on the things I have been
<a href="/audio"> listening to and enjoying</a>, and what I have
been listening to them on. There's also a
<a href="/blog">blog(gish)</a> where I am going to write whatever
comes to mind, and theoretically a brief weekly update*.<br /><br />
I am working on a resources section to share some of my CSS
artwork and various random scripts with the world. Please look
foward to it.
<br /><br />
<em
>* I have never kept up with a blog before, don't expect
much.</em
></span
><br />
</p>
<p>
<span>Click around</span> /
<span class="info">
It's probably best if you just have a little click around for
yourself. Good luck.</span
><br />
</p>
<span class="cursor-blink">_</span>
</div>
</div>
</div>
<div class="wide-item manifesto-container"> <div class="wide-item manifesto-container">
<div class="content-screen"> <div class="content-screen">
<div class="screen-display crt"> <div class="screen-display crt">
@ -105,10 +150,96 @@
><br /> ><br />
Camera / <span class="info">Fuji X-T5</span><br /> Camera / <span class="info">Fuji X-T5</span><br />
Audio / <span class="info">Hiby R4 EVA, Fiio FT-1</span> <br /><br /> Audio / <span class="info">Hiby R4 EVA, Fiio FT-1</span> <br /><br />
More info coming soon... Check out my <a href="/now">/now</a> page to see more hardware and
<br /><span class="cursor-blink">_</span> software information. <br /><span class="cursor-blink">_</span>
</div>
</div>
<div class="about-music hidden-xxl-down">
<div class="music">
{{ partial "elements/ipod.html" . }}
<div class="">{{ partial "elements/vu-meter.html" . }}</div>
</div>
</div>
<div class="content-screen wide-item">
<div class="screen-display crt">
> cat contact_info<br />
<div class="about-contact-section">
<p>
If you have any comments, questions, corrections, or just fancy
saying "hello" please feel free to get in touch.
</p>
<p>
I am still debating with myself if I want to put more effort into a
mastodon/bluesky type thing, so for now email is the best way to
talk to me. PGP is available if that's your kind of thing.
</p>
<div class="contact-info">
<div class="contact-item">
<span class="contact-label">Email</span> /
<a href="mailto:dan@ritual.sh" class="info">dan@ritual.sh</a>
</div>
<div class="contact-item">
<span class="contact-label">PGP Public Key</span> /
<div class="pgp-actions">
<a href="/publickey.asc" download class="pgp-button">
<span class="button-icon"></span> Download
</a>
<button id="copy-pgp-key-about" class="pgp-button">
<span class="button-icon"></span> Copy to Clipboard
</button>
</div>
</div>
<div id="copy-feedback-about" class="copy-feedback"></div>
</div>
</div>
<pre>
| .-.
| / \ .-.
| / \ / \ .-. .-. _ _
+--/-------\-----/-----\-----/---\---/---\---/-\-/-\/\/---
| / \ / \ / '-' '-'
|/ '-' '-'
</pre>
<span class="cursor-blink">_</span>
</div> </div>
</div> </div>
</div> </div>
</article> </article>
<script>
document.addEventListener("DOMContentLoaded", function () {
const copyButton = document.getElementById("copy-pgp-key-about");
const feedback = document.getElementById("copy-feedback-about");
if (copyButton) {
copyButton.addEventListener("click", async function () {
try {
const response = await fetch("/publickey.asc");
const pgpKey = await response.text();
await navigator.clipboard.writeText(pgpKey);
feedback.textContent = "PGP key copied to clipboard!";
feedback.classList.add("show", "success");
setTimeout(() => {
feedback.classList.remove("show");
}, 3000);
} catch (err) {
feedback.textContent = "Failed to copy key";
feedback.classList.add("show", "error");
setTimeout(() => {
feedback.classList.remove("show");
}, 3000);
}
});
}
});
</script>
{{ end }} {{ end }}

View file

@ -23,6 +23,8 @@
<div class="blog-summary">{{ .Content }}</div> <div class="blog-summary">{{ .Content }}</div>
</article> </article>
{{ partial "contact-section.html" . }}
<nav class="blog-post-navigation"> <nav class="blog-post-navigation">
<div class="post-nav-links"> <div class="post-nav-links">
{{ with .PrevInSection }} {{ with .PrevInSection }}
@ -46,4 +48,7 @@
<div class="blogs-lavalamp">{{ partial "elements/lavalamp.html" . }}</div> <div class="blogs-lavalamp">{{ partial "elements/lavalamp.html" . }}</div>
</div> </div>
</div> </div>
<script src="/js/footnote-scroll.js"></script>
{{ end }} {{ end }}

View file

@ -0,0 +1,112 @@
{{ define "main" }}
<article class="guestbook-page">
<div class="guestbook-content">
<!-- Header Section -->
<div>
<div class="guestbook-header content-screen">
<div class="screen-display crt">
<div class="terminal-output">
> guestbook --info<br />
<span class="info">{{ .Content }}</span><br />
<br />
<span class="cursor-blink">_</span>
</div>
</div>
</div>
<div class="guestbook-floppy">
{{ partial "elements/floppy.html" (dict "title" "Guestbook Entries"
"lines" (slice "" "" "DO NOT DELETE" "" "") "bgColor" "#1a4d8f"
"bgColorDark" "#0d2747") }}
</div>
</div>
<!-- Guestbook Form -->
<div class="guestbook-form-container content-screen">
<div class="screen-display crt">
<div class="terminal-output">> new-entry --interactive<br /></div>
<form id="guestbook-form" class="guestbook-form">
<div class="form-row">
<label for="gb-name">> Name:</label>
<input
type="text"
id="gb-name"
name="name"
required
maxlength="50"
placeholder="Your name"
/>
</div>
<div class="form-row">
<label for="gb-email">> Email:</label>
<input
type="email"
id="gb-email"
name="email"
placeholder="your@email.com (optional)"
/>
</div>
<div class="form-row">
<label for="gb-website">> Website:</label>
<input
type="url"
id="gb-website"
name="website"
placeholder="https://yoursite.com (optional)"
/>
</div>
<div class="form-row">
<label for="gb-message">> Message:</label>
<textarea
id="gb-message"
name="message"
required
maxlength="500"
rows="3"
placeholder="Your message..."
></textarea>
</div>
<div id="form-feedback" class="form-feedback"></div>
<button type="submit" id="submit-btn" class="terminal-button">
<span class="button-text">[ SUBMIT ]</span>
</button>
</form>
</div>
</div>
<!-- Guestbook Entries -->
<div class="guestbook-entries-container content-screen wide-item">
<div class="screen-display crt">
<div class="terminal-output">
> list-entries --all<br />
<br />
</div>
<div id="entries-loading" class="loading-state">
<span class="loading-text">Loading entries</span
><span class="loading-dots">...</span>
<span class="cursor-blink">_</span>
</div>
<div id="entries-error" class="error-state" style="display: none">
<span class="error-text">ERROR: Failed to load entries</span><br />
<span class="dim">Please try again later.</span><br />
<span class="cursor-blink">_</span>
</div>
<div id="entries-list" class="entries-list" style="display: none">
<!-- Entries will be dynamically inserted here -->
</div>
<div id="pagination" class="pagination" style="display: none">
<!-- Pagination controls will be inserted here -->
</div>
</div>
</div>
</div>
</article>
{{ end }}

View file

@ -1,6 +1,6 @@
/* TEAM */ /* TEAM */
Creator: Dan Creator: Dan Baker
Contact: dan [at] unbo.lt Contact: dan [at] ritual.sh
From: ritual.sh From: ritual.sh
/* THANKS */ /* THANKS */
@ -16,7 +16,6 @@ Software: Built with care
/* PHILOSOPHY */ /* PHILOSOPHY */
No tracking No tracking
No analytics
No AI training data No AI training data
No engagement metrics No engagement metrics
No corporate platforms No corporate platforms

View file

@ -6,7 +6,7 @@
<div class="window hidden-lg-down">{{ partial "elements/window.html" . }}</div> <div class="window hidden-lg-down">{{ partial "elements/window.html" . }}</div>
<!-- Neon sign above monitor --> <!-- Neon sign above monitor -->
{{ partial "elements/neon-sign.html" . }} <div class="homepage-neon">{{ partial "elements/neon-sign.html" . }}</div>
<!-- Sticky notes --> <!-- Sticky notes -->
<div class="sticky-note note1 hidden-xl-down">fix bugs</div> <div class="sticky-note note1 hidden-xl-down">fix bugs</div>
@ -89,18 +89,8 @@
</div> </div>
<div class="secondary-screen wall-monitor-4 hidden-xl-down"> <div class="secondary-screen wall-monitor-4 hidden-xl-down">
<div class="screen-display tiny amber crt"> <div class="screen-display amber crt" id="crt-logs">
> tail -f /var/log<br /> > tail -f /var/log<br />
[INFO] Process OK<br />
[WARN] High load detected - time for coffee break<br />
[INFO] Connected to database (it's in a relationship now)<br />
[ERROR] 404: Motivation not found<br />
[WARN] Firewall detected actual fire, calling emergency services<br />
[INFO] Successfully hacked into mainframe (jk it's just localhost)<br />
[ERROR] Keyboard not found. Press F1 to continue.<br />
[WARN] Too many open tabs. Browser having existential crisis.<br />
[INFO] Ping 127.0.0.1 - there's no place like home<br />
[ERROR] SQL injection attempt detected. Nice try, Bobby Tables.<br />
<span class="cursor-blink">_</span> <span class="cursor-blink">_</span>
</div> </div>
</div> </div>
@ -239,7 +229,7 @@
<div> <div>
<a href="/audio/"> <a href="/audio/">
<div class="music"> <div class="music">
<div class="music-text">MUSIC &amp; AUDIO GEAR</div> <div class="music-text">Music &amp; Audio Gear</div>
{{ partial "elements/ipod.html" . }} {{ partial "elements/ipod.html" . }}
@ -265,6 +255,26 @@
</a> </a>
</div> </div>
<div>
<a href="/resources/">
<div class="nav-cube">
{{ partial "elements/companion-cube.html" . }}
<div class="nav-cube-text">Experiments &amp; Resources</div>
</div>
</a>
</div>
<div>
<div class="nav-floppy">
<a href="/guestbook/">
{{ partial "elements/floppy.html" (dict "title" "Guestbook" "lines"
(slice "" "" "DO NOT DELETE" "" "") "bgColor" "#1a4d8f" "bgColorDark"
"#0d2747") }}
<div class="nav-floppy-text">Guestbook</div>
</a>
</div>
</div>
<div>{{ partial "elements/crt-tv.html" . }}</div> <div>{{ partial "elements/crt-tv.html" . }}</div>
</div> </div>

View file

@ -14,17 +14,25 @@
▀██▀ ▀██▄▄▀█▄▄▄▄█▀███▄██▄▀█▄██ ████████▄▀███▀ ▀████ ▀██▀ ▀██▄▄▀█▄▄▄▄█▀███▄██▄▀█▄██ ████████▄▀███▀ ▀████
██ ██
▀▀▀ ▀▀▀
</pre> </pre
>
</div> </div>
<div class="media-layout"> <div class="media-layout">
<div class="media-list"> <div class="media-list">
<h3>Recent Media</h3>
{{ range .Paginator.Pages }} {{ range .Paginator.Pages }}
<div class="media-item" data-type="{{ index .Params.tags 0 }}"> <div class="media-item" data-type="{{ index .Params.tags 0 }}">
<div class="media-cover"> <div class="media-cover">
{{ with .Resources.GetMatch "cover.*" }} {{ with .Resources.GetMatch "cover.*" }} {{ $image := .Resize
{{ $image := .Resize "320x webp q85" }} "320x webp q85" }}
<img src="{{ $image.RelPermalink }}" alt="{{ $.Title }}" loading="lazy" width="{{ $image.Width }}" height="{{ $image.Height }}"> <img
src="{{ $image.RelPermalink }}"
alt="{{ $.Title }}"
loading="lazy"
width="{{ $image.Width }}"
height="{{ $image.Height }}"
/>
{{ else }} {{ else }}
<div class="no-cover"> <div class="no-cover">
<span class="cover-placeholder">NO COVER</span> <span class="cover-placeholder">NO COVER</span>
@ -33,7 +41,9 @@
</div> </div>
<div class="media-info"> <div class="media-info">
<div class="media-meta"> <div class="media-meta">
<div class="media-type">{{ index .Params.tags 0 | upper }}</div> <div class="media-type">
{{ index .Params.tags 0 | upper }}
</div>
{{ if .Params.format }} {{ if .Params.format }}
<div class="media-format">{{ .Params.format | upper }}</div> <div class="media-format">{{ .Params.format | upper }}</div>
{{ end }} {{ end }}
@ -48,15 +58,19 @@
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ end }} {{ end }} {{ partial "pagination.html" .Paginator }}
{{ partial "pagination.html" .Paginator }}
</div> </div>
<div class="lastfm-sidebar"> <div class="lastfm-sidebar">
<div class="lastfm-header"> <div class="lastfm-header">
<h3>Recently Listened</h3> <h3>Recently Listened</h3>
<a href="https://www.last.fm/user/ritualplays" target="_blank" rel="noopener noreferrer" class="lastfm-profile-link">last.fm →</a> <a
href="https://www.last.fm/user/ritualplays"
target="_blank"
rel="noopener noreferrer"
class="lastfm-profile-link"
>last.fm →</a
>
</div> </div>
<div class="lastfm-tracks" id="lastfm-tracks"> <div class="lastfm-tracks" id="lastfm-tracks">
<div class="lastfm-loading">Loading tracks...</div> <div class="lastfm-loading">Loading tracks...</div>
@ -67,6 +81,4 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/js/pages/media.js"></script>
{{ end }} {{ end }}

View file

@ -0,0 +1,27 @@
<section class="contact-section">
<h2 class="contact-title">Contact</h2>
<div class="contact-content">
<p>
If you found this interesting, have any comments, questions, corrections,
or just fancy saying "hello" please feel free to get in touch. If you
really enjoyed it you could consider
<a href="https://ko-fi.com/ritual" target="_blank">buying me a coffee</a>.
</p>
<div class="contact-email">
<span class="contact-label">Email:</span>
<a href="mailto:dan@ritual.sh">dan@ritual.sh</a>
</div>
<div class="contact-pgp">
<span class="contact-label">PGP Public Key:</span>
<div class="pgp-actions">
<a href="/publickey.asc" download class="pgp-button download-key">
<span class="button-icon"></span> Download
</a>
<button class="pgp-button copy-key pgp-copy-trigger">
<span class="button-icon"></span> Copy to Clipboard
</button>
</div>
<div class="copy-feedback pgp-copy-feedback"></div>
</div>
</div>
</section>

View file

@ -0,0 +1,41 @@
<div class="companion-cube-container">
<div class="portal-icon">
<div class="portal blue-portal"></div>
<div class="portal orange-portal"></div>
</div>
<div class="cube-one">
<div class="companion-cube-face">
<div class="grey-bar horizontal-top"></div>
<div class="grey-bar horizontal-bottom"></div>
<div class="grey-bar vertical-left"></div>
<div class="grey-bar vertical-right"></div>
<div class="cube-center-circle">
<div class="pink-bar vertical"></div>
<div class="pink-bar horizontal"></div>
<div class="center-inner-ring"></div>
<div class="heart-icon">♥︎</div>
</div>
</div>
</div>
<div class="cube-two">
<div class="companion-cube-face">
<div class="grey-bar horizontal-top"></div>
<div class="grey-bar horizontal-bottom"></div>
<div class="grey-bar vertical-left"></div>
<div class="grey-bar vertical-right"></div>
<div class="cube-center-circle">
<div class="pink-bar vertical"></div>
<div class="pink-bar horizontal"></div>
<div class="center-inner-ring"></div>
<div class="heart-icon">♥︎</div>
</div>
</div>
</div>
</div>

View file

@ -1,15 +1,3 @@
<header> <header>
<div> <div>{{ partial "site-navigation.html" . }}</div>
{{ partial "site-navigation.html" . }}
<div>
<!-- <h1>
{{ .Title | default .Site.Title }}
</h1> -->
{{ with .Params.description }}
<h2>
{{ . }}
</h2>
{{ end }}
</div>
</div>
</header> </header>

View file

@ -7,11 +7,18 @@
{{ $filtered := slice }} {{ $filtered := slice }}
{{ range $remaining }} {{ range $remaining }}
{{ $path := .RelPermalink }} {{ $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 . }} {{ $filtered = $filtered | append . }}
{{ end }} {{ end }}
{{ 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 }} {{ $terminalBundle := $allFiles | resources.Concat "js/terminal-bundle.js" | resources.Minify | resources.Fingerprint }}
<!-- prettier-ignore-end --> <!-- prettier-ignore-end -->
<script <script

View file

@ -1,19 +1,38 @@
{{ define "main" }} {{ define "main" }}
<div class="resources-page"> <div class="resources-page portal-theme">
<div class="portal-bg-elements">
<div class="aperture-logo"></div>
<div class="test-chamber-grid"></div>
</div>
<div class="resources-container"> <div class="resources-container">
<div class="whiteboard"> <div class="portal-header">
<div class="whiteboard-header"> <div class="portal-icon blue-portal hidden-lg-down"></div>
<h1>{{ .Title }}</h1> <h1 class="portal-title">Resources</h1>
<div class="whiteboard-description"> <div class="portal-icon orange-portal hidden-md-down"></div>
{{ .Content }} </div>
<div class="portal-sign">
<div class="portal-sign-decor">
<div class="portal-sign-text">{{ .Content }}</div>
<div class="portal-sign-number">06</div>
<div class="portal-sign-sub">06/19</div>
</div>
<div class="portal-sign-lines"></div>
<div class="portal-sign-content">
<div class="portal-sign-header">
<div class="portal-description"></div>
</div>
<div class="portal-sign-items">
{{ range .Pages }} {{ .Render "summary" }} {{ end }}
</div>
</div> </div>
</div> </div>
<div class="whiteboard-pins"> <div class="background-cube">
{{ range .Pages }} {{ partial "elements/companion-cube.html" . }}
{{ .Render "summary" }}
{{ end }}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,8 +1,16 @@
{{ define "main" }} {{ define "main" }}
<div class="resource-single"> <div class="resource-single">
<div class="test-chamber-grid">
</div>
<article class="resource-content"> <article class="resource-content">
<!-- Aperture Science hazard stripes in corners -->
<!-- Animated energy particles -->
<div class="energy-particles"></div>
<header class="resource-header"> <header class="resource-header">
<div class="resource-icon-large {{ .Params.icon }}"></div>
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
{{ if .Params.description }} {{ if .Params.description }}
<p class="lead">{{ .Params.description }}</p> <p class="lead">{{ .Params.description }}</p>
@ -28,6 +36,8 @@
{{ .Content }} {{ .Content }}
</div> </div>
{{ partial "contact-section.html" . }}
<nav class="resource-navigation"> <nav class="resource-navigation">
{{ with .PrevInSection }} {{ with .PrevInSection }}
<a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a> <a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a>
@ -38,5 +48,8 @@
{{ end }} {{ end }}
</nav> </nav>
</article> </article>
<div class="background-cube">
{{ partial "elements/companion-cube.html" . }}
</div>
</div> </div>
{{ end }} {{ end }}

View file

@ -1,25 +1,8 @@
<div class="resource-pin" data-icon="{{ .Params.icon }}"> <div class="resource-pin" data-icon="{{ .Params.icon }}">
<div class="pin-tack"></div> <a href="{{ .Permalink }}">
<div class="resource-card"> <div class="resource-card">
<div class="resource-icon {{ .Params.icon }}"></div> <div class="resource-icon {{ .Params.icon }}"></div>
<div class="resource-info"> <div class="resource-info">{{ .Title }}</div>
<h2><a href="{{ .Permalink }}">{{ .Title }}</a></h2>
<p class="resource-description">{{ .Params.description }}</p>
{{ if .Params.tags }}
<div class="resource-tags">
{{ range .Params.tags }}
<span class="tag">{{ . }}</span>
{{ end }}
</div>
{{ end }}
<div class="resource-links">
{{ if .Params.demo_url }}
<a href="{{ .Params.demo_url }}" class="resource-link demo" target="_blank">Demo</a>
{{ end }}
{{ if .Params.source_url }}
<a href="{{ .Params.source_url }}" class="resource-link source" target="_blank">Source</a>
{{ end }}
</div>
</div>
</div> </div>
</a>
</div> </div>

View file

@ -0,0 +1,22 @@
<div id="button-generator-app">
<div class="generator-container">
<div class="preview-section">
<h3 class="hidden-md-down">Preview</h3>
<div class="preview-container">
<div class="preview-wrapper">
<canvas id="button-canvas" width="88" height="31"></canvas>
</div>
</div>
<button id="download-button" class="btn-primary">Download Button</button>
<div class="presets-container hidden-md-down">
<h3>Presets</h3>
<button id="preset-random" class="btn-secondary">Random Button</button>
<button id="preset-classic" class="btn-secondary">Classic Style</button>
<button id="preset-modern" class="btn-secondary">Modern Style</button>
</div>
</div>
<div class="controls-section"></div>
</div>
</div>

View file

@ -0,0 +1,350 @@
{{- $id := .Get "id" | default (printf "chart-%d" now.UnixNano) -}}
{{- $type := .Get "type" | default "line" -}}
{{- $title := .Get "title" | default "" -}}
{{- $height := .Get "height" | default "400" -}}
{{- $csvFile := .Get "csv" -}}
{{- $labelCol := .Get "labelColumn" | default "Month" -}}
{{- $dataCol := .Get "dataColumn" | default "Questions" -}}
{{- $dataColumns := .Get "dataColumns" | default "" -}}
{{- $datasetLabels := .Get "datasetLabels" | default "" -}}
{{- $yAxisIDs := .Get "yAxisIDs" | default "" -}}
{{- $dateFormat := .Get "dateFormat" | default "2006-01" -}}
{{- $skipRows := .Get "skipRows" | default 0 | int -}}
{{- $maxRows := .Get "maxRows" | default 0 | int -}}
{{- $chartData := dict -}}
{{- if $csvFile -}}
{{/* CSV file mode - supports multiple CSV files */}}
{{- $csvFiles := strings.Split $csvFile "," -}}
{{- $dataColsList := slice -}}
{{- if ne $dataColumns "" -}}
{{- $dataColsList = strings.Split $dataColumns "," -}}
{{- end -}}
{{- $labelsList := slice -}}
{{- if ne $datasetLabels "" -}}
{{- $labelsList = strings.Split $datasetLabels "," -}}
{{- end -}}
{{- $yAxisList := slice -}}
{{- if ne $yAxisIDs "" -}}
{{- $yAxisList = strings.Split $yAxisIDs "," -}}
{{- end -}}
{{- $labels := slice -}}
{{- $datasets := slice -}}
{{/* Process each CSV file */}}
{{- range $fileIdx, $csvFileName := $csvFiles -}}
{{- $csvFileName = strings.TrimSpace $csvFileName -}}
{{- $csvResource := $.Page.Resources.GetMatch $csvFileName -}}
{{- if not $csvResource -}}
{{- errorf "CSV file '%s' not found in page bundle for %s. Make sure the file exists in the same directory as index.md" $csvFileName $.Page.File.Path -}}
{{- end -}}
{{- $csvData := $csvResource | transform.Unmarshal -}}
{{- $data := slice -}}
{{/* Determine which data column to use */}}
{{- $currentDataCol := $dataCol -}}
{{- if ge $fileIdx (len $dataColsList) -}}
{{- $currentDataCol = $dataCol -}}
{{- else -}}
{{- $currentDataCol = strings.TrimSpace (index $dataColsList $fileIdx) -}}
{{- end -}}
{{/* Determine dataset label */}}
{{- $datasetLabel := $currentDataCol -}}
{{- if lt $fileIdx (len $labelsList) -}}
{{- $datasetLabel = strings.TrimSpace (index $labelsList $fileIdx) -}}
{{- end -}}
{{/* Determine Y-axis ID */}}
{{- $yAxisID := "y" -}}
{{- if lt $fileIdx (len $yAxisList) -}}
{{- $yAxisID = strings.TrimSpace (index $yAxisList $fileIdx) -}}
{{- end -}}
{{/* Process CSV rows */}}
{{- range $idx, $row := $csvData -}}
{{- if gt $idx $skipRows -}}
{{- if or (eq $maxRows 0) (le (len $data) $maxRows) -}}
{{/* Only set labels once from the first CSV file */}}
{{- if eq $fileIdx 0 -}}
{{/* Get label value */}}
{{- $labelRaw := "" -}}
{{- if reflect.IsMap $row -}}
{{- $labelRaw = index $row $labelCol -}}
{{- else -}}
{{/* Handle as slice/array - first column is label */}}
{{- $labelRaw = index $row 0 -}}
{{- end -}}
{{- $label := $labelRaw -}}
{{/* Try to parse and format date if it looks like a timestamp */}}
{{- $labelStr := printf "%v" $labelRaw -}}
{{- if strings.Contains $labelStr " 00:00:00" -}}
{{- $parsedTime := time.AsTime $labelStr -}}
{{- $label = $parsedTime.Format $dateFormat -}}
{{- end -}}
{{- $labels = $labels | append $label -}}
{{- end -}}
{{/* Get data value */}}
{{- $dataValue := 0 -}}
{{- if reflect.IsMap $row -}}
{{- $dataValue = index $row $currentDataCol | int -}}
{{- else -}}
{{/* Handle as slice/array - second column is data */}}
{{- $dataValue = index $row 1 | int -}}
{{- end -}}
{{- $data = $data | append $dataValue -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Add this dataset to the datasets array */}}
{{- $datasets = $datasets | append (dict "label" $datasetLabel "data" $data "yAxisID" $yAxisID) -}}
{{- end -}}
{{- $chartData = dict "labels" $labels "datasets" $datasets -}}
{{- else -}}
{{/* JSON inline mode */}}
{{- if .Inner -}}
{{- $chartData = .Inner | transform.Unmarshal -}}
{{- else -}}
{{- errorf "Graph shortcode requires either CSV file or inline JSON data" -}}
{{- end -}}
{{- end -}}
<div class="graph-container" style="height: {{ $height }}px;">
{{- if $title -}}
<h3 class="graph-title">{{ $title }}</h3>
{{- end -}}
<canvas id="{{ $id }}"></canvas>
</div>
<script>
(function() {
// Wait for Chart.js to load
function initChart() {
if (typeof Chart === 'undefined') {
setTimeout(initChart, 100);
return;
}
const ctx = document.getElementById('{{ $id }}').getContext('2d');
// Site color scheme - green, blue, amber
const colors = {
primary: 'rgb(0, 255, 0)', // green
secondary: 'rgb(0, 191, 255)', // blue/cyan
tertiary: 'rgb(255, 153, 0)', // amber/orange
quaternary: 'rgb(173, 255, 47)', // greenyellow
background: 'rgba(0, 255, 0, 0.1)',
grid: 'rgba(0, 255, 0, 0.2)',
text: 'rgb(0, 255, 0)'
};
const chartData = {{ $chartData | jsonify | safeJS }};
console.log('Chart ID: {{ $id }}');
console.log('Chart data:', chartData);
console.log('Labels count:', chartData.labels ? chartData.labels.length : 0);
console.log('Data count:', chartData.datasets && chartData.datasets[0] ? chartData.datasets[0].data.length : 0);
// Validate data
if (!chartData || !chartData.datasets || chartData.datasets.length === 0) {
console.error('Invalid chart data - no datasets found');
return;
}
// Check if any dataset uses the y1 axis
const usesY1Axis = chartData.datasets.some(dataset => dataset.yAxisID === 'y1');
// Apply terminal styling to datasets
if (chartData.datasets) {
chartData.datasets.forEach((dataset, idx) => {
const colorOptions = [colors.primary, colors.secondary, colors.tertiary, colors.quaternary];
const baseColor = colorOptions[idx % colorOptions.length];
console.log('Dataset', idx, 'using color:', baseColor);
if (!dataset.borderColor) {
dataset.borderColor = baseColor;
}
if (!dataset.backgroundColor && '{{ $type }}' === 'line') {
dataset.backgroundColor = baseColor.replace('rgb', 'rgba').replace(')', ', 0.1)');
} else if (!dataset.backgroundColor) {
dataset.backgroundColor = baseColor.replace('rgb', 'rgba').replace(')', ', 0.6)');
}
if (dataset.borderWidth === undefined) {
dataset.borderWidth = 2;
}
if (dataset.tension === undefined && '{{ $type }}' === 'line') {
dataset.tension = 0.1;
}
if (dataset.pointBackgroundColor === undefined && '{{ $type }}' === 'line') {
dataset.pointBackgroundColor = baseColor;
}
if (dataset.pointBorderColor === undefined && '{{ $type }}' === 'line') {
dataset.pointBorderColor = '#000';
}
if (dataset.pointRadius === undefined && '{{ $type }}' === 'line') {
dataset.pointRadius = 2;
}
if (dataset.pointHoverRadius === undefined && '{{ $type }}' === 'line') {
dataset.pointHoverRadius = 4;
}
});
}
// Build scales configuration
const scales = {
x: {
offset: true,
grid: {
color: colors.grid,
drawBorder: true,
borderColor: colors.secondary,
borderWidth: 2
},
ticks: {
color: colors.text,
font: {
family: 'monospace',
size: 10
},
maxRotation: 45,
minRotation: 45,
autoSkip: true,
maxTicksLimit: 30,
padding: 8
},
afterFit: function(scale) {
scale.paddingBottom = 20; // Extra space for rotated labels
}
},
y: {
type: 'linear',
position: 'left',
grid: {
color: colors.grid,
drawBorder: true,
borderColor: colors.secondary,
borderWidth: 2
},
ticks: {
color: colors.text,
font: {
family: 'monospace',
size: 11
},
padding: 5
}
}
};
// Only add y1 axis if it's actually used by any dataset
if (usesY1Axis) {
scales.y1 = {
type: 'linear',
position: 'right',
grid: {
drawOnChartArea: false, // Don't draw grid lines for secondary axis
drawBorder: true,
borderColor: colors.tertiary,
borderWidth: 2
},
ticks: {
color: colors.text,
font: {
family: 'monospace',
size: 11
},
padding: 5
}
};
}
const config = {
type: '{{ $type }}',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
bottom: 30,
left: 10,
right: 10
}
},
plugins: {
legend: {
display: chartData.datasets && chartData.datasets.length > 1,
labels: {
color: colors.text,
font: {
family: 'monospace',
size: 12
},
boxWidth: 15,
padding: 10,
generateLabels: function(chart) {
const labels = Chart.defaults.plugins.legend.labels.generateLabels(chart);
labels.forEach(label => {
label.fillStyle = label.strokeStyle;
});
return labels;
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: colors.primary,
bodyColor: colors.text,
borderColor: colors.secondary,
borderWidth: 1,
titleFont: {
family: 'monospace',
size: 14,
weight: 'bold'
},
bodyFont: {
family: 'monospace',
size: 12
},
padding: 12,
displayColors: true,
callbacks: {
labelColor: function(context) {
return {
borderColor: context.dataset.borderColor,
backgroundColor: context.dataset.borderColor
};
}
}
}
},
scales: scales,
animation: {
duration: 1000,
easing: 'easeOutQuart'
}
}
};
new Chart(ctx, config);
console.log('Chart initialized successfully');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChart);
} else {
initChart();
}
})();
</script>

View file

@ -0,0 +1,53 @@
<div id="lastfm-stats-app">
<div class="stats-form">
<div class="form-group">
<label for="lastfm-username">Last.fm Username</label>
<input
type="text"
id="lastfm-username"
placeholder="Enter your username"
/>
</div>
<div class="form-group">
<label for="time-period">Time Period</label>
<select id="time-period">
<option value="7day">Past Week (7 days)</option>
<option value="1month">Past Month (30 days)</option>
</select>
</div>
<div class="form-group">
<button id="fetch-stats" class="btn-primary">Get Stats</button>
</div>
</div>
<div id="stats-results" class="stats-results" style="display: none">
<div id="stats-loading" class="loading" style="display: none">
<p>Loading your stats...</p>
</div>
<div id="stats-content" style="display: none">
<h2>Your Last.fm Stats</h2>
<div class="stat-box">
<h3>Total Tracks</h3>
<p id="total-tracks" class="stat-number">-</p>
</div>
<div class="stat-box">
<h3>Top 5 Artists</h3>
<ul id="top-artists" class="artist-list"></ul>
</div>
</div>
<div id="export-buttons" class="export-buttons" style="display: none">
<button id="copy-markdown" class="btn-export">Copy as Markdown</button>
<button id="copy-plaintext" class="btn-export">Copy as Plain Text</button>
</div>
<div id="stats-error" class="error" style="display: none">
<p id="error-message"></p>
</div>
</div>
</div>

View file

@ -0,0 +1,124 @@
<div id="lavalamp-adoptable-app">
<div class="adoptable-container">
<div class="preview-section">
<h3 class="hidden-md-down">Preview</h3>
<div class="preview-grid">
<div class="preview-item hidden-md-down">
<div class="preview-wrapper preview-flexible">
<div
id="lavalamp-preview-flex"
class="lavalamp-preview-container"
></div>
</div>
<span class="preview-label">40px × 60px</span>
</div>
<div class="preview-item">
<div class="preview-wrapper preview-100">
<div
id="lavalamp-preview-100"
class="lavalamp-preview-container"
></div>
</div>
<span class="preview-label hidden-md-down">100px × 200px</span>
</div>
<div class="preview-item hidden-md-down">
<div class="preview-wrapper preview-200">
<div
id="lavalamp-preview-200"
class="lavalamp-preview-container"
></div>
</div>
<span class="preview-label">200px × 350px</span>
</div>
</div>
</div>
<div class="controls-section">
<h3>Customize Your Lava Lamp</h3>
<div class="control-group">
<label for="bg-color-1">Background Color 1</label>
<input type="color" id="bg-color-1" value="#F8E45C" />
</div>
<div class="control-group">
<label for="bg-color-2">Background Color 2</label>
<input type="color" id="bg-color-2" value="#FF7800" />
</div>
<div class="control-group">
<label for="blob-color-1">Blob Color 1</label>
<input type="color" id="blob-color-1" value="#FF4500" />
</div>
<div class="control-group">
<label for="blob-color-2">Blob Color 2</label>
<input type="color" id="blob-color-2" value="#FF6347" />
</div>
<div class="control-group">
<label for="case-color">Case Color</label>
<input type="color" id="case-color" value="#333333" />
</div>
<div class="control-group">
<label for="blob-count"
>Number of Blobs: <span id="blob-count-value">6</span></label
>
<input type="range" id="blob-count" min="3" max="12" value="6" />
</div>
<div class="control-group">
<label for="blob-size"
>Blob Size: <span id="blob-size-value">1.0</span>x</label
>
<input
type="range"
id="blob-size"
min="0.5"
max="2.0"
step="0.1"
value="1.0"
/>
</div>
<div class="control-group">
<label for="speed">Speed: <span id="speed-value">1.0</span>x</label>
<input
type="range"
id="speed"
min="0.5"
max="1.5"
step="0.1"
value="1.0"
/>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="pixelate" />
Pixelate
</label>
</div>
<div class="control-group" id="pixel-size-group" style="display: none">
<label for="pixel-size"
>Pixel Size: <span id="pixel-size-value">4</span>px</label
>
<input type="range" id="pixel-size" min="2" max="10" value="4" />
</div>
<div class="control-group">
<button id="get-code-btn" class="btn-primary">Get Embed Code</button>
</div>
</div>
</div>
<div id="embed-code-section" style="display: none; margin-top: 2rem">
<h3>Embed Code</h3>
<p>Copy this code and paste it anywhere on your website:</p>
<div class="highlight">
<pre><code id="embed-code-display" class="language-html" data-lang="html"></code></pre>
</div>
</div>
</div>

96
send-webmentions.sh Executable file
View file

@ -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"

View file

@ -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 = `
<defs>
<filter id="${gooFilterId}">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 18 -7"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
${
config.pixelate
? `
<filter id="pixelate-filter" x="0%" y="0%" width="100%" height="100%">
<feFlood x="0" y="0" height="1" width="1"/>
<feComposite width="${config.pixelSize}" height="${config.pixelSize}"/>
<feTile result="a"/>
<feComposite in="SourceGraphic" in2="a" operator="in"/>
<feMorphology operator="dilate" radius="${Math.floor(config.pixelSize / 2)}"/>
</filter>
`
: ""
}
</defs>
`;
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();
}
})();

View file

@ -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<ButtonEffect>}
*/
getAllEffects() {
return Array.from(this.effectsById.values());
}
/**
* Get effects by type
* @param {string} type - Effect type
* @returns {Array<ButtonEffect>}
*/
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<Blob>}
*/
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());
}
});
}
}

View file

@ -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;
}
}
}
}
}

View file

@ -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 ===');
}

View file

@ -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<Object>} 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);
}
}

View file

@ -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
*/

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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);
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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));
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

Some files were not shown because too many files have changed in this diff Show more