diff --git a/assets/sass/pages/blog.scss b/assets/sass/pages/blog.scss
index f3dface..4e08641 100644
--- a/assets/sass/pages/blog.scss
+++ b/assets/sass/pages/blog.scss
@@ -216,6 +216,23 @@
line-height: 1.6;
margin-bottom: 1rem;
}
+
+ a {
+ color: greenyellow;
+ text-decoration: none;
+ position: relative;
+ padding-bottom: 2px;
+ border-bottom: 1px dotted rgba(173, 255, 47, 0.5);
+ transition: all 0.3s ease;
+ text-shadow: 0 0 5px rgba(173, 255, 47, 0.3);
+
+ &:hover {
+ border-bottom-style: solid;
+ border-bottom-color: rgba(173, 255, 47, 0.8);
+ text-shadow: 0 0 10px rgba(173, 255, 47, 0.8);
+ background: rgba(173, 255, 47, 0.05);
+ }
+ }
}
// Blog posts listing
@@ -277,6 +294,7 @@
a {
color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
+ border-bottom: 1px dotted rgba(255, 200, 47, 0.5);
&::after {
background: #ff9900;
@@ -285,11 +303,50 @@
}
}
+ .blog-summary,
+ .blogs-content {
+ color: #ff9900;
+ text-shadow: 0 0 10px rgba(255, 153, 0, 0.5);
+
+ a {
+ color: #ffb13d;
+ text-shadow: 0 0 5px rgba(255, 162, 23, 0.5);
+ border-bottom: 1px dotted rgba(255, 200, 47, 0.5);
+
+ &:hover {
+ border-bottom-color: orange;
+ text-shadow: 0 0 10px rgba(255, 217, 47, 0.8);
+ }
+
+ &::after {
+ background: #ff9900;
+ box-shadow: 0 0 10px #ff9900;
+ }
+ }
+
+ > .read-more {
+ color: #ff9900;
+ text-shadow: 0 0 10px rgba(255, 153, 0, 0.5);
+
+ &:after {
+ background: #ff9900;
+ box-shadow: 0 0 10px rgb(255, 213, 47);
+ }
+ }
+ }
+
.tag-filter-link {
background: rgba(255, 153, 0, 0.2);
border-color: rgba(255, 153, 0, 0.5);
color: #ff9900;
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
+
+ &:hover {
+ background: rgba(255, 153, 0, 0.2);
+ border-color: rgba(255, 177, 60, 0.828);
+ box-shadow: 0 0 10px rgba(255, 164, 29, 0.4);
+ text-shadow: 0 0 10px rgba(255, 169, 39, 0.8);
+ }
}
}
@@ -371,6 +428,7 @@
line-height: 1.6;
opacity: 0.9;
color: #0f0;
+ margin-top: 1rem;
@include media-down(lg) {
font-size: 0.9rem;
@@ -504,6 +562,13 @@
}
}
}
+
+ .blog-footer {
+ text-align: center;
+ padding: 1rem;
+ margin: auto;
+ font-size: 1rem;
+ }
}
// Tag filter navigation
@@ -811,6 +876,7 @@
transition: all 0.3s ease;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
border-radius: 3px;
+ line-height: 2.2rem;
&:hover:not(.disabled) {
background: rgba(0, 255, 0, 0.2);
diff --git a/config.yml b/config.yml
index 6b6a541..1873c7e 100644
--- a/config.yml
+++ b/config.yml
@@ -14,6 +14,11 @@ outputFormats:
mediaType: "application/json"
baseName: "webmentions"
isPlainText: true
+ RSS:
+ mediaType: application/rss+xml
+ baseName: feed
+ isPlainText: false
+ rel: alternate
pagination:
pagerSize: 5
@@ -41,16 +46,6 @@ params:
iconImageHeight: 35
iconHeight: 70
- fuseOpts:
- isCaseSensitive: false
- shouldSort: true
- location: 0
- distance: 1000
- threshold: 0.4
- minMatchCharLength: 0
- limit: 10
- keys: ["title", "permalink", "summary", "content"]
-
markup:
highlight:
noClasses: false
diff --git a/content/blog/2026-01-12-week-4-got-webmentions/index.md b/content/blog/2026-01-12-week-4-got-webmentions/index.md
index 8194ce9..a4c0718 100644
--- a/content/blog/2026-01-12-week-4-got-webmentions/index.md
+++ b/content/blog/2026-01-12-week-4-got-webmentions/index.md
@@ -9,14 +9,19 @@ 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!
+- 📺 Setup a dashboard on my homelab for monitoring all of the above.
+- 🛜 Added an RSS feed for the blog. I've made my full posts available via RSS so you can consume them however you please.
## 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
+- [Zootopia 2](https://blog.yiningkarlli.com/2025/12/zootopia-2.html) - CG Engineer from Walt Disney Studios talks about some of the technical stuff from making Zootopia 2.
+
## Music
+- 📺 [Mortal Kombat x Rhythm is a Dancer](https://www.youtube.com/watch?v=vKxn6P947PE)
+
## Next Week
Not necessary
diff --git a/content/blog/_index.md b/content/blog/_index.md
index 2124d6f..710bc1b 100644
--- a/content/blog/_index.md
+++ b/content/blog/_index.md
@@ -3,3 +3,5 @@ title: "Blog"
---
The home of my weekly updates and occasional other thoughts.
+
+🛜 [Available via RSS](/blog/feed.xml)
diff --git a/content/blog/app-defaults-2026/index.md b/content/blog/app-defaults-2026/index.md
new file mode 100644
index 0000000..a9ed154
--- /dev/null
+++ b/content/blog/app-defaults-2026/index.md
@@ -0,0 +1,34 @@
+---
+title: "App Defaults - 2026"
+date: 2026-01-13T18:27:20Z
+tags:
+ - meta
+draft: false
+---
+
+I've seen a lot of these popping up around the tech space and find them very interesting to see what tools and apps are peoples go-to for various every day things. Apparently there are [over 500 posts](https://defaults.rknight.me/) and counting! Here's mine right now:
+
+- 📨 Mail Client - Proton Mail
+- 📮 Mail Server - [Proton Mail](https://pr.tn/ref/MNB13JYX)
+- 📝 Notes - Notes.app
+- ✅ To-Do - Notes.app
+- 📷 Phone Photo Shooting - Camera.app
+- 🟦 Photo Management - Photos.app
+- 📆 Calendar - Calendar.app
+- 📁 Cloud File Storage - None
+- 📖 RSS - [FreshRSS](https://github.com/FreshRSS/FreshRSS)
+- 🙍🏻♂️ Contacts - Contacts.app
+- 🌐 Browser - [Zen](https://zen-browser.app/)
+- 💬 Chat - Discord
+- 🔖 Bookmarks - Firefox Bookmarks Sync
+- 📑 Read It Later - Notes.app
+- 🧑💻 Code Editor - [VSCodium](https://vscodium.com/)
+- 🛒 Shopping Lists - Notes.app
+- 📰 News - Avoid at all costs
+- 🎵 Music - Tidal, [High Tide](https://github.com/Nokse22/high-tide), Turntable
+- 🎤 Podcasts - Apple Podcasts
+- 🔐 Password Management - Proton Pass
+
+Apparently I dump a lot of stuff in the Notes app on my phone. It's the closest thing to hand and has almost zero barrier to entry. Also I know where all my stuff is then.
+
+I should probably find better ways of organising things, but you know what they say - the best system is the one that works for you.
diff --git a/content/resources/button-generator/index.md b/content/resources/button-generator/index.md
index 5d074e1..f17c143 100644
--- a/content/resources/button-generator/index.md
+++ b/content/resources/button-generator/index.md
@@ -25,6 +25,7 @@ Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x3
- 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.
+- 13/01/2025 - Added ticker scrolling and flashing text options. Added a background image selector.
---
diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html
index 9e4c950..428b850 100755
--- a/layouts/_default/baseof.html
+++ b/layouts/_default/baseof.html
@@ -9,7 +9,13 @@
}}{{ end }}{{ end }}
- {{ hugo.Generator }} {{ $sass := resources.Get "sass/style.scss" }} {{
+
+
+ {{ range .AlternativeOutputFormats }}
+
+ {{ end }}
+
+ {{ $sass := resources.Get "sass/style.scss" }} {{
$style := $sass | css.Sass | resources.Minify | resources.Fingerprint }}
diff --git a/layouts/blog/single.html b/layouts/blog/single.html
index b8ea142..0aa3eaa 100644
--- a/layouts/blog/single.html
+++ b/layouts/blog/single.html
@@ -21,6 +21,9 @@
{{ end }}
{{ .Content }}
+
{{ partial "contact-section.html" . }}
diff --git a/layouts/blog/summary.html b/layouts/blog/summary.html
index 50c1709..5ed05cb 100644
--- a/layouts/blog/summary.html
+++ b/layouts/blog/summary.html
@@ -23,5 +23,14 @@
{{ .Content }}
+ {{ else }}
+
{{ end }}
diff --git a/layouts/rss.xml b/layouts/rss.xml
new file mode 100644
index 0000000..2dfb093
--- /dev/null
+++ b/layouts/rss.xml
@@ -0,0 +1,56 @@
+{{- $authorEmail := "" }}
+{{- with site.Params.author }}
+ {{- if reflect.IsMap . }}
+ {{- with .email }}
+ {{- $authorEmail = . }}
+ {{- end }}
+ {{- end }}
+{{- end }}
+{{- $authorName := "" }}
+{{- with site.Params.author }}
+ {{- if reflect.IsMap . }}
+ {{- with .name }}
+ {{- $authorName = . }}
+ {{- end }}
+ {{- else }}
+ {{- $authorName = . }}
+ {{- end }}
+{{- end }}
+{{- $pctx := . }}
+{{- if .IsHome }}{{ $pctx = .Site }}{{ end }}
+{{- $pages := slice }}
+{{- if or $.IsHome $.IsSection }}
+{{- $pages = $pctx.RegularPages }}
+{{- else }}
+{{- $pages = $pctx.Pages }}
+{{- end }}
+{{- $limit := .Site.Config.Services.RSS.Limit }}
+{{- if ge $limit 1 }}
+{{- $pages = $pages | first $limit }}
+{{- end }}
+{{- printf "" | safeHTML }}
+
+
+Blog(gish) by Dan @ ritual.sh
+{{ .Permalink }}
+Dan's thoughts on web and game development, tech experiments, and whatever else catches my attention in the Golden Valley.
+{{ site.Language.LanguageCode }}{{ if not .Date.IsZero }}
+{{ (index $pages.ByLastmod.Reverse 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}{{ end }}
+ {{- with .OutputFormats.Get "RSS" }}
+ {{ printf "" .Permalink .MediaType | safeHTML }}
+ {{- end }}
+ {{- range $pages }}
+ {{- $content := .Content }}
+ {{- /* Remove graph containers with all their content including canvas and script tags */ -}}
+ {{- $content = replaceRE `(?s)]*>.*?
\s*` "[Interactive graph not available in RSS - please visit the full post to view the charts]
" $content }}
+-
+{{ .Title }}
+{{ .Permalink }}
+{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}
+ {{- with $authorEmail }}{{ . }}{{ with $authorName }} ({{ . }}){{ end }}{{ end }}
+{{ .Permalink }}
+{{ $content | transform.XMLEscape | safeHTML }}
+
+ {{- end }}
+
+
\ No newline at end of file
diff --git a/static/js/button-generator/button-generator-core.js b/static/js/button-generator/button-generator-core.js
index 292bc4f..2c5ba45 100644
--- a/static/js/button-generator/button-generator-core.js
+++ b/static/js/button-generator/button-generator-core.js
@@ -174,6 +174,14 @@ export class ButtonGenerator {
values[id] = element.value;
}
}
+
+ // For range-dual controls, also check for -start and -end suffixed elements
+ const startElement = document.getElementById(`${id}-start`);
+ const endElement = document.getElementById(`${id}-end`);
+ if (startElement && endElement) {
+ values[`${id}-start`] = parseFloat(startElement.value);
+ values[`${id}-end`] = parseFloat(endElement.value);
+ }
});
return values;
diff --git a/static/js/button-generator/effects/background-external-image.js b/static/js/button-generator/effects/background-external-image.js
index d3ffc9d..28af29d 100644
--- a/static/js/button-generator/effects/background-external-image.js
+++ b/static/js/button-generator/effects/background-external-image.js
@@ -245,12 +245,17 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) {
const file = controlValues['bg-image-file'];
+ const bgColor = controlValues['bg-color'] || '#FFFFFF';
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;
+ // Draw background color first (always, for all states)
+ context.fillStyle = bgColor;
+ context.fillRect(0, 0, renderData.width, renderData.height);
+
// If no file selected, fill with a placeholder color
if (!file || !file.blobUrl) {
context.fillStyle = '#cccccc';
diff --git a/static/js/button-generator/effects/background-solid.js b/static/js/button-generator/effects/background-solid.js
index a835100..3c3b965 100644
--- a/static/js/button-generator/effects/background-solid.js
+++ b/static/js/button-generator/effects/background-solid.js
@@ -35,7 +35,7 @@ export class SolidBackgroundEffect extends ButtonEffect {
label: "Background Color",
defaultValue: "#4a90e2",
showWhen: "bg-type",
- description: "Solid background color",
+ description: "Background color (also used behind images)",
},
];
}
diff --git a/static/js/button-generator/effects/flash-text.js b/static/js/button-generator/effects/flash-text.js
new file mode 100644
index 0000000..bfd7a3d
--- /dev/null
+++ b/static/js/button-generator/effects/flash-text.js
@@ -0,0 +1,88 @@
+import { ButtonEffect } from '../effect-base.js';
+
+export class FlashTextEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? '' : '2';
+ super({
+ id: `text-flash${suffix}`,
+ name: `Flash Text ${textLineNumber}`,
+ type: textLineNumber === 1 ? 'text' : 'text2',
+ category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
+ renderOrder: 1, // Execute very early, before all other text effects
+ textLineNumber: textLineNumber
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? '' : '2';
+
+ return [
+ {
+ id: `animate-text-flash${suffix}`,
+ type: 'checkbox',
+ label: 'Flash Visibility',
+ defaultValue: false
+ },
+ {
+ id: `flash-range${suffix}`,
+ type: 'range-dual',
+ label: 'Visible Frame Range',
+ defaultValueStart: textLineNumber === 1 ? 0 : 20,
+ defaultValueEnd: textLineNumber === 1 ? 19 : 39,
+ min: 0,
+ max: 39,
+ step: 1,
+ showWhen: `animate-text-flash${suffix}`,
+ description: 'Frame range where text is visible (0-39 frames total)'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+ return controlValues[`animate-text-flash${suffix}`] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return; // Flash requires animation
+
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+ const startFrame = controlValues[`flash-range${suffix}-start`] || 0;
+ const endFrame = controlValues[`flash-range${suffix}-end`] || 39;
+
+ // Check if current frame is within visible range
+ const isVisible = this.isFrameVisible(animState.frame, startFrame, endFrame);
+
+ // Store visibility state in renderData so other text effects can check it
+ const visibilityKey = `textFlashVisible${suffix}`;
+ renderData[visibilityKey] = isVisible;
+
+ // Also set globalAlpha (even though it gets restored, it helps during this effect's lifecycle)
+ if (!isVisible) {
+ context.globalAlpha = 0;
+ }
+ }
+
+ /**
+ * Check if frame is within visible range
+ * @param {number} frame - Current frame number (0-39)
+ * @param {number} startFrame - Start of visible range
+ * @param {number} endFrame - End of visible range
+ * @returns {boolean} - True if frame is in visible range
+ */
+ isFrameVisible(frame, startFrame, endFrame) {
+ // Ensure start is less than or equal to end
+ const start = Math.min(startFrame, endFrame);
+ const end = Math.max(startFrame, endFrame);
+
+ return frame >= start && frame <= end;
+ }
+}
+
+// Auto-register effects for both text lines
+export function register(generator) {
+ generator.registerEffect(new FlashTextEffect(1));
+ generator.registerEffect(new FlashTextEffect(2));
+}
diff --git a/static/js/button-generator/effects/rainbow-text.js b/static/js/button-generator/effects/rainbow-text.js
index 5431dae..213f931 100644
--- a/static/js/button-generator/effects/rainbow-text.js
+++ b/static/js/button-generator/effects/rainbow-text.js
@@ -52,6 +52,18 @@ export class RainbowTextEffect extends ButtonEffect {
const suffix = this.textLineNumber === 1 ? '' : '2';
+ // Check if ticker is active - if so, ticker handles rendering
+ const tickerActive = controlValues[`animate-text-ticker${suffix}`];
+ if (tickerActive) {
+ return;
+ }
+
+ // Check flash visibility - if flash is active and text is invisible, don't render
+ const flashActive = controlValues[`animate-text-flash${suffix}`];
+ if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
+ return;
+ }
+
// Get text configuration
const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return;
diff --git a/static/js/button-generator/effects/spin-text.js b/static/js/button-generator/effects/spin-text.js
index 7c4b3ef..b50bada 100644
--- a/static/js/button-generator/effects/spin-text.js
+++ b/static/js/button-generator/effects/spin-text.js
@@ -61,6 +61,19 @@ export class SpinTextEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2";
+
+ // Check if ticker is active - if so, ticker handles rendering
+ const tickerActive = controlValues[`animate-text-ticker${suffix}`];
+ if (tickerActive) {
+ return;
+ }
+
+ // Check flash visibility - if flash is active and text is invisible, don't render
+ const flashActive = controlValues[`animate-text-flash${suffix}`];
+ if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
+ return;
+ }
+
const text = controlValues[`button-text${suffix}`] || "";
if (!text || text.trim() === '') return;
diff --git a/static/js/button-generator/effects/text-shadow.js b/static/js/button-generator/effects/text-shadow.js
index 1d7a882..09b141d 100644
--- a/static/js/button-generator/effects/text-shadow.js
+++ b/static/js/button-generator/effects/text-shadow.js
@@ -98,6 +98,12 @@ export class TextShadowEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2";
+ // Check flash visibility - if flash is active and text is invisible, don't render
+ const flashActive = controlValues[`animate-text-flash${suffix}`];
+ if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
+ return;
+ }
+
const text = controlValues[`button-text${suffix}`] || "";
if (!text) return;
diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js
index fb90f87..c1ebf71 100644
--- a/static/js/button-generator/effects/text-standard.js
+++ b/static/js/button-generator/effects/text-standard.js
@@ -150,13 +150,20 @@ export class StandardTextEffect extends ButtonEffect {
const waveActive = controlValues[`animate-text-wave${suffix}`];
const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
const spinActive = controlValues[`animate-text-spin${suffix}`];
+ const tickerActive = controlValues[`animate-text-ticker${suffix}`];
- return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive;
+ return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive && !tickerActive;
}
apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2";
+ // Check flash visibility - if flash is active and text is invisible, don't render
+ const flashActive = controlValues[`animate-text-flash${suffix}`];
+ if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
+ return;
+ }
+
const text = controlValues[`button-text${suffix}`];
if (!text) return;
diff --git a/static/js/button-generator/effects/ticker-text.js b/static/js/button-generator/effects/ticker-text.js
new file mode 100644
index 0000000..360f130
--- /dev/null
+++ b/static/js/button-generator/effects/ticker-text.js
@@ -0,0 +1,312 @@
+import { ButtonEffect } from '../effect-base.js';
+
+/**
+ * Ticker text animation effect
+ * Makes text scroll across the button in various directions with seamless looping
+ */
+export class TickerTextEffect extends ButtonEffect {
+ constructor(textLineNumber = 1) {
+ const suffix = textLineNumber === 1 ? '' : '2';
+ super({
+ id: `text-ticker${suffix}`,
+ name: `Ticker Text ${textLineNumber}`,
+ type: textLineNumber === 1 ? 'text' : 'text2',
+ category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
+ renderOrder: 12, // After wave(10) and spin(8), before shadow(19) and standard(20)
+ textLineNumber: textLineNumber
+ });
+ this.textLineNumber = textLineNumber;
+ }
+
+ defineControls() {
+ const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
+ const suffix = textLineNumber === 1 ? '' : '2';
+ return [
+ {
+ id: `animate-text-ticker${suffix}`,
+ type: 'checkbox',
+ label: 'Ticker Scroll',
+ defaultValue: false
+ },
+ {
+ id: `ticker-direction${suffix}`,
+ type: 'select',
+ label: 'Scroll Direction',
+ defaultValue: 'left',
+ options: [
+ { value: 'left', label: 'Right to Left' },
+ { value: 'right', label: 'Left to Right' },
+ { value: 'up', label: 'Down to Up' },
+ { value: 'down', label: 'Up to Down' }
+ ],
+ showWhen: `animate-text-ticker${suffix}`,
+ description: 'Direction of text scrolling'
+ }
+ ];
+ }
+
+ isEnabled(controlValues) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+ return controlValues[`animate-text-ticker${suffix}`] === true;
+ }
+
+ apply(context, controlValues, animState, renderData) {
+ if (!animState) return; // Ticker requires animation
+
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+
+ // Check flash visibility - if flash is active and text is invisible, don't render
+ const flashActive = controlValues[`animate-text-flash${suffix}`];
+ if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
+ return;
+ }
+
+ // Get text configuration
+ const text = controlValues[`button-text${suffix}`] || '';
+ if (!text || text.trim() === '') return;
+
+ const fontSize = controlValues[`font-size${suffix}`] || 12;
+ const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal';
+ const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal';
+ const fontFamily = controlValues[`font-family${suffix}`] || 'Arial';
+
+ const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
+ const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
+
+ const direction = controlValues[`ticker-direction${suffix}`] || 'left';
+
+ // Set font
+ context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+
+ // Check if other effects are active
+ const waveActive = controlValues[`animate-text-wave${suffix}`];
+ const spinActive = controlValues[`animate-text-spin${suffix}`];
+ const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
+
+ // Split text into grapheme clusters (handles emojis properly)
+ const chars = this.splitGraphemes(text);
+
+ // Measure total width
+ const totalWidth = context.measureText(text).width;
+
+ // Calculate scroll parameters - SIMPLIFIED
+ const horizontal = direction === 'left' || direction === 'right';
+ const gapSize = 50; // Gap between text repetitions
+
+ // For ticker to work: text must scroll across full screen width PLUS its own width PLUS gap
+ // This ensures text fully enters, crosses, and exits with proper spacing
+ const copySpacing = horizontal
+ ? (renderData.width + totalWidth + gapSize)
+ : (renderData.height + fontSize * 2 + gapSize);
+
+ // For a seamless loop, offset scrolls through one full copy spacing in 40 frames
+ // At frame 0: offset = 0
+ // At frame 39: offset approaches copySpacing (ready to wrap to next copy)
+ const offset = animState.progress * copySpacing;
+
+ // Apply direction
+ const scrollOffset = {
+ x: direction === 'left' ? -offset : (direction === 'right' ? offset : 0),
+ y: direction === 'up' ? -offset : (direction === 'down' ? offset : 0)
+ };
+
+ // Calculate how many copies we need to fill the screen
+ const numCopies = horizontal
+ ? Math.ceil(renderData.width / copySpacing) + 3
+ : Math.ceil(renderData.height / copySpacing) + 3;
+
+ // Get colors
+ const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState, rainbowActive);
+
+ // Set ticker active flag so other effects can skip rendering if needed
+ renderData[`tickerActive${suffix}`] = true;
+
+ // Render scrolling text
+ this.renderScrollingText(
+ context, chars, scrollOffset, numCopies,
+ totalWidth, fontSize, copySpacing, horizontal, direction,
+ { wave: waveActive, spin: spinActive, rainbow: rainbowActive },
+ controlValues, animState, renderData, colors
+ );
+ }
+
+ /**
+ * Split text into grapheme clusters (emoji-safe)
+ */
+ splitGraphemes(text) {
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
+ return Array.from(segmenter.segment(text), s => s.segment);
+ } else {
+ // Fallback: spread operator handles basic emoji
+ return [...text];
+ }
+ }
+
+ /**
+ * Render scrolling text with multiple copies for seamless looping
+ */
+ renderScrollingText(
+ context, chars, scrollOffset, numCopies,
+ totalWidth, fontSize, copySpacing, horizontal, direction,
+ effects, controlValues, animState, renderData, colors
+ ) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+
+ // Get wave parameters if active
+ let waveAmplitude, waveSpeed;
+ if (effects.wave) {
+ waveAmplitude = controlValues[`wave-amplitude${suffix}`] || 3;
+ waveSpeed = controlValues[`wave-speed${suffix}`] || 1;
+ }
+
+ // Get spin parameters if active
+ let spinSpeed, spinStagger;
+ if (effects.spin) {
+ spinSpeed = controlValues[`spin-speed${suffix}`] || 1;
+ spinStagger = controlValues[`spin-stagger${suffix}`] || 0.3;
+ }
+
+ // Get base positioning from controls
+ const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
+ const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
+
+ // Loop through copies - render multiple instances for seamless wrap
+ for (let copy = 0; copy < numCopies; copy++) {
+ // Each copy is spaced by copySpacing (which includes text width + gap)
+ const copyOffsetX = horizontal ? copy * copySpacing : 0;
+ const copyOffsetY = !horizontal ? copy * copySpacing : 0;
+
+ // Calculate starting position based on direction
+ // The key: text should be fully OFF screen before appearing on the other side
+ let startX, startY;
+
+ if (direction === 'left') {
+ // Right to left: Position so after scrolling copySpacing left, text fully exits
+ // copySpacing = totalWidth + gap
+ // Start with left edge at: copySpacing (so right edge is at copySpacing + totalWidth)
+ // After scrolling copySpacing left: right edge is at totalWidth (still need to exit more!)
+ // Actually: start at renderData.width so left edge begins at right screen edge
+ startX = renderData.width;
+ } else if (direction === 'right') {
+ // Left to right: Start with RIGHT edge of text at left edge of screen
+ // Text scrolls right, exits when LEFT edge reaches right edge of screen
+ startX = -totalWidth;
+ } else if (direction === 'up') {
+ // Down to up: Start off-screen below
+ startX = baseX - totalWidth / 2; // Center the text horizontally
+ startY = renderData.height;
+ } else { // down
+ // Up to down: Start off-screen above
+ startX = baseX - totalWidth / 2; // Center the text horizontally
+ startY = -fontSize * 2;
+ }
+
+ // Calculate current position with scroll offset and copy offset
+ let currentX = startX + scrollOffset.x + copyOffsetX;
+ let currentY = horizontal ? baseY : (startY + scrollOffset.y + copyOffsetY);
+
+ // Render each character
+ for (let i = 0; i < chars.length; i++) {
+ const char = chars[i];
+ const charWidth = context.measureText(char).width;
+ const charCenterX = currentX + charWidth / 2;
+ let charY = currentY;
+
+ // Apply wave effect if active
+ let waveY = 0;
+ if (effects.wave) {
+ const phase = animState.getPhase(waveSpeed);
+ const charOffset = i / chars.length;
+ waveY = Math.sin(phase + charOffset * Math.PI * 2) * waveAmplitude;
+ }
+
+ // Apply spin effect if active
+ if (effects.spin) {
+ const phase = animState.getPhase(spinSpeed);
+ const rotation = (phase + i * spinStagger * Math.PI * 2) % (Math.PI * 2);
+
+ context.save();
+ context.translate(charCenterX, charY + waveY);
+ context.rotate(rotation);
+
+ // Draw outline if enabled
+ if (controlValues[`text${suffix}-outline`]) {
+ context.strokeStyle = colors.strokeStyle;
+ context.lineWidth = 2;
+ context.strokeText(char, 0, 0);
+ }
+
+ // Draw character
+ context.fillStyle = colors.fillStyle;
+ context.fillText(char, 0, 0);
+ context.restore();
+ } else {
+ // No spin - draw normally with wave offset
+ const finalY = charY + waveY;
+
+ // Draw outline if enabled
+ if (controlValues[`text${suffix}-outline`]) {
+ context.strokeStyle = colors.strokeStyle;
+ context.lineWidth = 2;
+ context.strokeText(char, charCenterX, finalY);
+ }
+
+ // Draw character
+ context.fillStyle = colors.fillStyle;
+ context.fillText(char, charCenterX, finalY);
+ }
+
+ currentX += charWidth;
+ }
+ }
+ }
+
+ /**
+ * Get text colors (solid, gradient, or rainbow)
+ */
+ getTextColors(context, controlValues, text, x, y, fontSize, animState, rainbowActive) {
+ const suffix = this.textLineNumber === 1 ? '' : '2';
+
+ let fillStyle, strokeStyle;
+
+ // Check if rainbow text is also enabled
+ if (animState && rainbowActive) {
+ const speed = controlValues[`text-rainbow-speed${suffix}`] || 1;
+ const hue = (animState.progress * speed * 360) % 360;
+ fillStyle = `hsl(${hue}, 80%, 60%)`;
+ strokeStyle = `hsl(${hue}, 80%, 30%)`;
+ } else {
+ const colorType = controlValues[`text${suffix}-color-type`] || 'solid';
+
+ if (colorType === 'solid') {
+ fillStyle = controlValues[`text${suffix}-color`] || '#ffffff';
+ strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
+ } else {
+ // Gradient
+ const angle = (controlValues[`text${suffix}-gradient-angle`] || 0) * (Math.PI / 180);
+ const textWidth = context.measureText(text).width;
+ const x1 = x - textWidth / 2 + (Math.cos(angle) * textWidth) / 2;
+ const y1 = y - fontSize / 2 + (Math.sin(angle) * fontSize) / 2;
+ const x2 = x + textWidth / 2 - (Math.cos(angle) * textWidth) / 2;
+ const y2 = y + fontSize / 2 - (Math.sin(angle) * fontSize) / 2;
+
+ const gradient = context.createLinearGradient(x1, y1, x2, y2);
+ gradient.addColorStop(0, controlValues[`text${suffix}-gradient-color1`] || '#ffffff');
+ gradient.addColorStop(1, controlValues[`text${suffix}-gradient-color2`] || '#00ffff');
+ fillStyle = gradient;
+ strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
+ }
+ }
+
+ return { fillStyle, strokeStyle };
+ }
+}
+
+// Auto-register effect
+export function register(generator) {
+ generator.registerEffect(new TickerTextEffect(1));
+ generator.registerEffect(new TickerTextEffect(2));
+}
diff --git a/static/js/button-generator/effects/wave-text.js b/static/js/button-generator/effects/wave-text.js
index 61e097c..f764d25 100644
--- a/static/js/button-generator/effects/wave-text.js
+++ b/static/js/button-generator/effects/wave-text.js
@@ -63,6 +63,18 @@ export class WaveTextEffect extends ButtonEffect {
const suffix = this.textLineNumber === 1 ? '' : '2';
+ // Check if ticker is active - if so, ticker handles rendering
+ const tickerActive = controlValues[`animate-text-ticker${suffix}`];
+ if (tickerActive) {
+ return;
+ }
+
+ // Check flash visibility - if flash is active and text is invisible, don't render
+ const flashActive = controlValues[`animate-text-flash${suffix}`];
+ if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
+ return;
+ }
+
// Get text configuration
const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return;
diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js
index 030236d..695507d 100644
--- a/static/js/button-generator/main.js
+++ b/static/js/button-generator/main.js
@@ -25,7 +25,9 @@ import * as standardText from "./effects/text-standard.js";
import * as textShadow from "./effects/text-shadow.js";
import * as waveText from "./effects/wave-text.js";
import * as rainbowText from "./effects/rainbow-text.js";
+import * as flashText from "./effects/flash-text.js";
import * as spinText from "./effects/spin-text.js";
+import * as tickerText from "./effects/ticker-text.js";
import * as glitch from "./effects/glitch.js";
import * as pulse from "./effects/pulse.js";
import * as shimmer from "./effects/shimmer.js";
@@ -101,7 +103,9 @@ async function setupApp() {
textShadow.register(generator);
waveText.register(generator);
rainbowText.register(generator);
+ flashText.register(generator);
spinText.register(generator);
+ tickerText.register(generator);
glitch.register(generator);
pulse.register(generator);
shimmer.register(generator);
diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js
index 2ab5e1d..ed79f49 100644
--- a/static/js/button-generator/ui-builder.js
+++ b/static/js/button-generator/ui-builder.js
@@ -16,8 +16,8 @@ export class UIBuilder {
*/
setupTooltip() {
// Wait for DOM to be ready
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => {
this.createTooltipElement();
});
} else {
@@ -26,8 +26,8 @@ export class UIBuilder {
}
createTooltipElement() {
- this.tooltip = document.createElement('div');
- this.tooltip.className = 'control-tooltip';
+ this.tooltip = document.createElement("div");
+ this.tooltip.className = "control-tooltip";
this.tooltip.style.cssText = `
position: fixed;
background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%);
@@ -57,20 +57,20 @@ export class UIBuilder {
clearTimeout(this.tooltipTimeout);
this.tooltip.textContent = text;
- this.tooltip.style.opacity = '1';
+ this.tooltip.style.opacity = "1";
// Position tooltip above the element
const rect = element.getBoundingClientRect();
// Set initial position to measure
- this.tooltip.style.left = '0px';
- this.tooltip.style.top = '0px';
- this.tooltip.style.visibility = 'hidden';
- this.tooltip.style.display = 'block';
+ this.tooltip.style.left = "0px";
+ this.tooltip.style.top = "0px";
+ this.tooltip.style.visibility = "hidden";
+ this.tooltip.style.display = "block";
const tooltipRect = this.tooltip.getBoundingClientRect();
- this.tooltip.style.visibility = 'visible';
+ this.tooltip.style.visibility = "visible";
let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
let top = rect.top - tooltipRect.height - 10;
@@ -96,7 +96,7 @@ export class UIBuilder {
if (!this.tooltip) return;
clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = setTimeout(() => {
- this.tooltip.style.opacity = '0';
+ this.tooltip.style.opacity = "0";
}, 100);
}
@@ -106,17 +106,17 @@ export class UIBuilder {
addTooltipHandlers(element, description) {
if (!description) return;
- element.addEventListener('mouseenter', () => {
+ element.addEventListener("mouseenter", () => {
this.showTooltip(element, description);
});
- element.addEventListener('mouseleave', () => {
+ element.addEventListener("mouseleave", () => {
this.hideTooltip();
});
- element.addEventListener('mousemove', () => {
+ element.addEventListener("mousemove", () => {
// Update position on mouse move for better following
- if (this.tooltip && this.tooltip.style.opacity === '1') {
+ if (this.tooltip && this.tooltip.style.opacity === "1") {
this.showTooltip(element, description);
}
});
@@ -128,7 +128,7 @@ export class UIBuilder {
*/
buildUI(effects) {
// Clear existing content
- this.container.innerHTML = '';
+ this.container.innerHTML = "";
this.controlGroups.clear();
// Group effects by category
@@ -148,8 +148,8 @@ export class UIBuilder {
categorizeEffects(effects) {
const categories = new Map();
- effects.forEach(effect => {
- const category = effect.category || 'Other';
+ effects.forEach((effect) => {
+ const category = effect.category || "Other";
if (!categories.has(category)) {
categories.set(category, []);
}
@@ -159,17 +159,17 @@ export class UIBuilder {
// Sort categories in a logical order
const orderedCategories = new Map();
const categoryOrder = [
- 'Text Line 1',
- 'Text Line 2',
- 'Background',
- 'Background Animations',
- 'Border',
- 'Visual Effects',
- 'General Effects',
- 'Special Effects'
+ "Text Line 1",
+ "Text Line 2",
+ "Background",
+ "Background Animations",
+ "Border",
+ "Visual Effects",
+ "General Effects",
+ "Special Effects",
];
- categoryOrder.forEach(cat => {
+ categoryOrder.forEach((cat) => {
if (categories.has(cat)) {
orderedCategories.set(cat, categories.get(cat));
}
@@ -191,29 +191,29 @@ export class UIBuilder {
* @param {Array} effects - Effects in this category
*/
createControlGroup(category, effects) {
- const groupDiv = document.createElement('div');
- groupDiv.className = 'control-group';
+ const groupDiv = document.createElement("div");
+ groupDiv.className = "control-group";
// Create header
- const header = document.createElement('h3');
- header.className = 'control-group-header';
+ const header = document.createElement("h3");
+ header.className = "control-group-header";
header.innerHTML = `
${category}
−
`;
// Create content container
- const content = document.createElement('div');
- content.className = 'control-group-content';
+ const content = document.createElement("div");
+ content.className = "control-group-content";
// Add controls for each effect in this category
- effects.forEach(effect => {
+ effects.forEach((effect) => {
this.addEffectControls(content, effect);
});
// Add click handler for collapsing
- header.addEventListener('click', () => {
- groupDiv.classList.toggle('collapsed');
+ header.addEventListener("click", () => {
+ groupDiv.classList.toggle("collapsed");
});
groupDiv.appendChild(header);
@@ -229,7 +229,7 @@ export class UIBuilder {
* @param {ButtonEffect} effect - Effect to create controls for
*/
addEffectControls(container, effect) {
- effect.controls.forEach(control => {
+ effect.controls.forEach((control) => {
const controlEl = this.createControl(control);
if (controlEl) {
container.appendChild(controlEl);
@@ -243,26 +243,85 @@ export class UIBuilder {
* @returns {HTMLElement}
*/
createControl(controlDef) {
- const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = controlDef;
+ const {
+ id,
+ type,
+ label,
+ defaultValue,
+ min,
+ max,
+ step,
+ options,
+ showWhen,
+ description,
+ } = controlDef;
switch (type) {
- case 'checkbox':
- return this.createCheckbox(id, label, defaultValue, showWhen, description);
+ case "checkbox":
+ return this.createCheckbox(
+ id,
+ label,
+ defaultValue,
+ showWhen,
+ description,
+ );
- case 'range':
- return this.createRange(id, label, defaultValue, min, max, step, description, showWhen);
+ case "range":
+ return this.createRange(
+ id,
+ label,
+ defaultValue,
+ min,
+ max,
+ step,
+ description,
+ showWhen,
+ );
- case 'color':
+ case "range-dual":
+ return this.createRangeDual(
+ id,
+ label,
+ controlDef.defaultValueStart,
+ controlDef.defaultValueEnd,
+ min,
+ max,
+ step,
+ description,
+ showWhen,
+ );
+
+ case "color":
return this.createColor(id, label, defaultValue, showWhen, description);
- case 'select':
- return this.createSelect(id, label, defaultValue, options, showWhen, description);
+ case "select":
+ return this.createSelect(
+ id,
+ label,
+ defaultValue,
+ options,
+ showWhen,
+ description,
+ );
- case 'text':
- return this.createTextInput(id, label, defaultValue, showWhen, description);
+ case "text":
+ return this.createTextInput(
+ id,
+ label,
+ defaultValue,
+ showWhen,
+ description,
+ );
- case 'file':
- return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept);
+ case "file":
+ return this.createFileInput(
+ id,
+ label,
+ defaultValue,
+ showWhen,
+ description,
+ controlDef.accept,
+ );
default:
console.warn(`Unknown control type: ${type}`);
@@ -274,22 +333,22 @@ export class UIBuilder {
* Create a checkbox control
*/
createCheckbox(id, label, defaultValue, showWhen, description) {
- const wrapper = document.createElement('label');
- wrapper.className = 'checkbox-label';
+ const wrapper = document.createElement("label");
+ wrapper.className = "checkbox-label";
- const input = document.createElement('input');
- input.type = 'checkbox';
+ const input = document.createElement("input");
+ input.type = "checkbox";
input.id = id;
input.checked = defaultValue || false;
- const span = document.createElement('span');
+ const span = document.createElement("span");
span.textContent = label;
wrapper.appendChild(input);
wrapper.appendChild(span);
if (showWhen) {
- wrapper.style.display = 'none';
+ wrapper.style.display = "none";
wrapper.dataset.showWhen = showWhen;
}
@@ -303,14 +362,14 @@ export class UIBuilder {
* Create a range slider control
*/
createRange(id, label, defaultValue, min, max, step, description, showWhen) {
- const container = document.createElement('div');
+ const container = document.createElement("div");
- const labelEl = document.createElement('label');
+ const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.innerHTML = `${label}: ${defaultValue}`;
- const input = document.createElement('input');
- input.type = 'range';
+ const input = document.createElement("input");
+ input.type = "range";
input.id = id;
input.min = min !== undefined ? min : 0;
input.max = max !== undefined ? max : 100;
@@ -320,7 +379,7 @@ export class UIBuilder {
}
// Update value display on input
- input.addEventListener('input', () => {
+ input.addEventListener("input", () => {
const valueDisplay = document.getElementById(`${id}-value`);
if (valueDisplay) {
valueDisplay.textContent = input.value;
@@ -331,7 +390,7 @@ export class UIBuilder {
container.appendChild(input);
if (showWhen) {
- container.style.display = 'none';
+ container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@@ -341,26 +400,239 @@ export class UIBuilder {
return container;
}
+ /**
+ * Create a dual-range slider control (two handles on one track)
+ */
+ createRangeDual(
+ id,
+ label,
+ defaultValueStart,
+ defaultValueEnd,
+ min,
+ max,
+ step,
+ description,
+ showWhen,
+ ) {
+ const container = document.createElement("div");
+ container.className = "range-dual-container";
+
+ const labelEl = document.createElement("label");
+ labelEl.innerHTML = `${label}: ${defaultValueStart}-${defaultValueEnd}`;
+
+ // Create wrapper for the dual range slider
+ const sliderWrapper = document.createElement("div");
+ sliderWrapper.className = "range-dual-wrapper";
+ sliderWrapper.style.cssText = `
+ position: relative;
+ height: 30px;
+ margin: 10px 0;
+ `;
+
+ // Create the track
+ const track = document.createElement("div");
+ track.className = "range-dual-track";
+ track.style.cssText = `
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 100%;
+ height: 4px;
+ background: #444;
+ border-radius: 2px;
+ z-index: 1;
+ `;
+
+ // Create the filled range indicator
+ const range = document.createElement("div");
+ range.className = "range-dual-range";
+ range.id = `${id}-range`;
+ range.style.cssText = `
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 4px;
+ background: linear-gradient(90deg, #ff9966, #00bcf2); // Change this to orange
+ border-radius: 2px;
+ pointer-events: none;
+ z-index: 10;
+ `;
+
+ // Create start handle input
+ const inputStart = document.createElement("input");
+ inputStart.type = "range";
+ inputStart.id = `${id}-start`;
+ inputStart.className = "range-dual-input";
+ inputStart.min = min !== undefined ? min : 0;
+ inputStart.max = max !== undefined ? max : 100;
+ inputStart.value =
+ defaultValueStart !== undefined ? defaultValueStart : min || 0;
+ if (step !== undefined) {
+ inputStart.step = step;
+ }
+ inputStart.style.cssText = `
+ position: absolute;
+ width: 100%;
+ top: 50%;
+ transform: translateY(-50%);
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ pointer-events: none;
+ margin: 0;
+ z-index: 3;
+ `;
+
+ // Create end handle input
+ const inputEnd = document.createElement("input");
+ inputEnd.type = "range";
+ inputEnd.id = `${id}-end`;
+ inputEnd.className = "range-dual-input";
+ inputEnd.min = min !== undefined ? min : 0;
+ inputEnd.max = max !== undefined ? max : 100;
+ inputEnd.value =
+ defaultValueEnd !== undefined ? defaultValueEnd : max || 100;
+ if (step !== undefined) {
+ inputEnd.step = step;
+ }
+ inputEnd.style.cssText = `
+ position: absolute;
+ width: 100%;
+ top: 50%;
+ transform: translateY(-50%);
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ pointer-events: none;
+ margin: 0;
+ z-index: 4;
+ `;
+
+ // Add CSS for the range inputs
+ const style = document.createElement("style");
+ style.textContent = `
+ .range-dual-input::-webkit-slider-runnable-track {
+ background: transparent;
+ border: none;
+ height: 4px;
+ }
+ .range-dual-input::-moz-range-track {
+ background: transparent;
+ border: none;
+ height: 4px;
+ }
+ .range-dual-input::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #0078d4;
+ cursor: pointer;
+ pointer-events: all;
+ border: 2px solid #fff;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
+ position: relative;
+ z-index: 100;
+ }
+ .range-dual-input::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #0078d4;
+ cursor: pointer;
+ pointer-events: all;
+ border: 2px solid #fff;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
+ position: relative;
+ z-index: 100;
+ }
+ .range-dual-input::-webkit-slider-thumb:hover {
+ background: #00bcf2;
+ }
+ .range-dual-input::-moz-range-thumb:hover {
+ background: #00bcf2;
+ }
+ `;
+ if (!document.getElementById("range-dual-styles")) {
+ style.id = "range-dual-styles";
+ document.head.appendChild(style);
+ }
+
+ // Update function
+ const updateRange = () => {
+ let startVal = parseInt(inputStart.value);
+ let endVal = parseInt(inputEnd.value);
+
+ // Ensure start is never greater than end
+ if (startVal > endVal) {
+ const temp = startVal;
+ startVal = endVal;
+ endVal = temp;
+ inputStart.value = startVal;
+ inputEnd.value = endVal;
+ }
+
+ const minVal = parseInt(inputStart.min);
+ const maxVal = parseInt(inputStart.max);
+ const startPercent = ((startVal - minVal) / (maxVal - minVal)) * 100;
+ const endPercent = ((endVal - minVal) / (maxVal - minVal)) * 100;
+
+ range.style.left = `${startPercent}%`;
+ range.style.width = `${endPercent - startPercent}%`;
+
+ const valueDisplay = document.getElementById(`${id}-value`);
+ if (valueDisplay) {
+ valueDisplay.textContent = `${startVal}-${endVal}`;
+ }
+ };
+
+ inputStart.addEventListener("input", updateRange);
+ inputEnd.addEventListener("input", updateRange);
+
+ // Assemble the dual range slider
+ sliderWrapper.appendChild(track);
+ sliderWrapper.appendChild(range);
+ sliderWrapper.appendChild(inputStart);
+ sliderWrapper.appendChild(inputEnd);
+
+ container.appendChild(labelEl);
+ container.appendChild(sliderWrapper);
+
+ if (showWhen) {
+ container.style.display = "none";
+ container.dataset.showWhen = showWhen;
+ }
+
+ // Add tooltip handlers to the label
+ this.addTooltipHandlers(labelEl, description);
+
+ // Initialize range display
+ updateRange();
+
+ return container;
+ }
+
/**
* Create a color picker control
*/
createColor(id, label, defaultValue, showWhen, description) {
- const container = document.createElement('div');
+ const container = document.createElement("div");
- const labelEl = document.createElement('label');
+ const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
- const input = document.createElement('input');
- input.type = 'color';
+ const input = document.createElement("input");
+ input.type = "color";
input.id = id;
- input.value = defaultValue || '#ffffff';
+ input.value = defaultValue || "#ffffff";
container.appendChild(labelEl);
container.appendChild(input);
if (showWhen) {
- container.style.display = 'none';
+ container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@@ -374,17 +646,17 @@ export class UIBuilder {
* Create a select dropdown control
*/
createSelect(id, label, defaultValue, options, showWhen, description) {
- const container = document.createElement('div');
+ const container = document.createElement("div");
- const labelEl = document.createElement('label');
+ const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
- const select = document.createElement('select');
+ const select = document.createElement("select");
select.id = id;
- options.forEach(opt => {
- const option = document.createElement('option');
+ options.forEach((opt) => {
+ const option = document.createElement("option");
option.value = opt.value;
option.textContent = opt.label;
if (opt.value === defaultValue) {
@@ -397,7 +669,7 @@ export class UIBuilder {
container.appendChild(select);
if (showWhen) {
- container.style.display = 'none';
+ container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@@ -411,19 +683,19 @@ export class UIBuilder {
* Create a text input control
*/
createTextInput(id, label, defaultValue, showWhen, description) {
- const container = document.createElement('div');
+ const container = document.createElement("div");
- const labelEl = document.createElement('label');
+ const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
- const input = document.createElement('input');
- input.type = 'text';
+ const input = document.createElement("input");
+ input.type = "text";
input.id = id;
- input.value = defaultValue || '';
+ input.value = defaultValue || "";
// Only set maxLength for text inputs that aren't URLs
- if (id !== 'bg-image-url') {
+ if (id !== "bg-image-url") {
input.maxLength = 20;
}
@@ -431,7 +703,7 @@ export class UIBuilder {
container.appendChild(input);
if (showWhen) {
- container.style.display = 'none';
+ container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@@ -445,21 +717,21 @@ export class UIBuilder {
* Create a file input control
*/
createFileInput(id, label, defaultValue, showWhen, description, accept) {
- const container = document.createElement('div');
+ const container = document.createElement("div");
- const labelEl = document.createElement('label');
+ const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
- const input = document.createElement('input');
- input.type = 'file';
+ const input = document.createElement("input");
+ input.type = "file";
input.id = id;
if (accept) {
input.accept = accept;
}
// Store the file data on the input element
- input.addEventListener('change', (e) => {
+ input.addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) {
// Create a blob URL for the file
@@ -482,7 +754,7 @@ export class UIBuilder {
container.appendChild(input);
if (showWhen) {
- container.style.display = 'none';
+ container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@@ -498,64 +770,103 @@ export class UIBuilder {
*/
setupConditionalVisibility() {
// Find all controls with showWhen attribute
- const conditionalControls = this.container.querySelectorAll('[data-show-when]');
+ const conditionalControls =
+ this.container.querySelectorAll("[data-show-when]");
- conditionalControls.forEach(control => {
+ conditionalControls.forEach((control) => {
const triggerControlId = control.dataset.showWhen;
const triggerControl = document.getElementById(triggerControlId);
if (triggerControl) {
const updateVisibility = () => {
- if (triggerControl.type === 'checkbox') {
- control.style.display = triggerControl.checked ? 'block' : 'none';
- } else if (triggerControl.tagName === 'SELECT') {
+ if (triggerControl.type === "checkbox") {
+ control.style.display = triggerControl.checked ? "block" : "none";
+ } else if (triggerControl.tagName === "SELECT") {
// Get the control ID to determine what value to check for
- const controlId = control.querySelector('input, select')?.id;
+ const controlId = control.querySelector("input, select")?.id;
// For background controls
- if (triggerControlId === 'bg-type') {
- if (controlId === 'bg-color') {
- control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
- } else if (controlId && (controlId.startsWith('gradient-') || controlId === 'gradient-angle')) {
- control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
- } else if (controlId && (controlId.startsWith('texture-') || controlId === 'texture-type' || controlId === 'texture-scale')) {
- control.style.display = triggerControl.value === 'texture' ? 'block' : 'none';
- } else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) {
- control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none';
- } else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-file' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) {
- control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none';
+ if (triggerControlId === "bg-type") {
+ if (controlId === "bg-color") {
+ control.style.display =
+ triggerControl.value === "solid" ||
+ triggerControl.value === "external-image"
+ ? "block"
+ : "none";
+ } else if (
+ controlId &&
+ (controlId.startsWith("gradient-") ||
+ controlId === "gradient-angle")
+ ) {
+ control.style.display =
+ triggerControl.value === "gradient" ? "block" : "none";
+ } else if (
+ controlId &&
+ (controlId.startsWith("texture-") ||
+ controlId === "texture-type" ||
+ controlId === "texture-scale")
+ ) {
+ control.style.display =
+ triggerControl.value === "texture" ? "block" : "none";
+ } else if (
+ controlId &&
+ (controlId.startsWith("emoji-") || controlId === "emoji-text")
+ ) {
+ control.style.display =
+ triggerControl.value === "emoji-wallpaper" ? "block" : "none";
+ } else if (
+ controlId &&
+ (controlId.startsWith("bg-image-") ||
+ controlId === "bg-image-file" ||
+ controlId === "bg-image-fit" ||
+ controlId === "bg-image-opacity")
+ ) {
+ control.style.display =
+ triggerControl.value === "external-image" ? "block" : "none";
}
}
// For image fit controls (zoom and position only show when manual mode)
- else if (triggerControlId === 'bg-image-fit') {
- if (controlId && (controlId === 'bg-image-zoom' || controlId === 'bg-image-offset-x' || controlId === 'bg-image-offset-y')) {
- control.style.display = triggerControl.value === 'manual' ? 'block' : 'none';
+ else if (triggerControlId === "bg-image-fit") {
+ if (
+ controlId &&
+ (controlId === "bg-image-zoom" ||
+ controlId === "bg-image-offset-x" ||
+ controlId === "bg-image-offset-y")
+ ) {
+ control.style.display =
+ triggerControl.value === "manual" ? "block" : "none";
}
}
// For text color controls
- else if (triggerControlId === 'text-color-type') {
- if (controlId === 'text-color') {
- control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
- } else if (controlId && controlId.startsWith('text-gradient-')) {
- control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
+ else if (triggerControlId === "text-color-type") {
+ if (controlId === "text-color") {
+ control.style.display =
+ triggerControl.value === "solid" ? "block" : "none";
+ } else if (controlId && controlId.startsWith("text-gradient-")) {
+ control.style.display =
+ triggerControl.value === "gradient" ? "block" : "none";
}
- } else if (triggerControlId === 'text2-color-type') {
- if (controlId === 'text2-color') {
- control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
- } else if (controlId && controlId.startsWith('text2-gradient-')) {
- control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
+ } else if (triggerControlId === "text2-color-type") {
+ if (controlId === "text2-color") {
+ control.style.display =
+ triggerControl.value === "solid" ? "block" : "none";
+ } else if (controlId && controlId.startsWith("text2-gradient-")) {
+ control.style.display =
+ triggerControl.value === "gradient" ? "block" : "none";
}
}
// For border style controls
- else if (triggerControlId === 'border-style') {
- if (controlId === 'border-rainbow-speed') {
- control.style.display = triggerControl.value === 'rainbow' ? 'block' : 'none';
- } else if (controlId === 'border-march-speed') {
- control.style.display = triggerControl.value === 'marching-ants' ? 'block' : 'none';
+ else if (triggerControlId === "border-style") {
+ if (controlId === "border-rainbow-speed") {
+ control.style.display =
+ triggerControl.value === "rainbow" ? "block" : "none";
+ } else if (controlId === "border-march-speed") {
+ control.style.display =
+ triggerControl.value === "marching-ants" ? "block" : "none";
}
} else {
// Default: show when any value is selected
- control.style.display = triggerControl.value ? 'block' : 'none';
+ control.style.display = triggerControl.value ? "block" : "none";
}
}
};
@@ -564,8 +875,8 @@ export class UIBuilder {
updateVisibility();
// Update on change
- triggerControl.addEventListener('change', updateVisibility);
- triggerControl.addEventListener('input', updateVisibility);
+ triggerControl.addEventListener("change", updateVisibility);
+ triggerControl.addEventListener("input", updateVisibility);
}
});
}
diff --git a/static/test/moleskine.html b/static/test/moleskine.html
new file mode 100644
index 0000000..fca01a2
--- /dev/null
+++ b/static/test/moleskine.html
@@ -0,0 +1,331 @@
+
+
+
+
+
+ Moleskine Notebook
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This is where the left page content goes
+
+
+
+
+
+
+
+
+
+
+
+
+
This is where the right page content goes
+
+
+
+
+
+
+
+
+
+
+
+