feat(badges): slow pulse mode for rainbow punch hole markers

Adds punch_holes.slow_pulse cfg_json flag. When enabled, replaces the fast
2.5s linear hue-rotate with a 6s ease-in-out breathing animation that dims
(0.55 brightness) to bright (1.30) while shifting 180° of hue and back.
Same 120° phase offsets apply (2s apart). Form shows a Slow Pulse checkbox
below the slot cards whenever at least one slot has rainbow enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-04 20:42:23 -04:00
parent 8f815b7033
commit 70fda25c95
3 changed files with 38 additions and 9 deletions

View File

@@ -75,6 +75,7 @@ export interface BadgeTemplateCfg {
center_fg?: string;
center_bg?: string;
center_rainbow?: boolean;
slow_pulse?: boolean; // when true: slow breathing pulse instead of fast linear cycle
};
// Allow arbitrary extra keys to preserve forward-compatibility.

View File

@@ -342,6 +342,7 @@ const PH_RAINBOW_BG = 'rgba(255,34,0,0.25)';
let punch_holes_left_rainbow = $derived(!!template_cfg?.punch_holes?.left_rainbow);
let punch_holes_right_rainbow = $derived(!!template_cfg?.punch_holes?.right_rainbow);
let punch_holes_center_rainbow = $derived(!!template_cfg?.punch_holes?.center_rainbow);
let punch_holes_slow_pulse = $derived(!!template_cfg?.punch_holes?.slow_pulse);
let punch_holes_colors = $derived.by(() => {
const ph = template_cfg?.punch_holes;
const shared_fg = ph?.fg || PH_DEFAULT_FG;
@@ -707,7 +708,9 @@ const code_to_icon: {
starts at a different phase (0°, 120°, 240°) and they cycle in unison
at the same speed — tri-phase RGB rather than drifting randomly. -->
{#if punch_holes_left && punch_holes_left_rainbow}
<div class="punch_hole_marker punch_hole_rainbow_left pointer-events-none absolute"
<div class="punch_hole_marker pointer-events-none absolute"
class:punch_hole_rainbow_left={!punch_holes_slow_pulse}
class:punch_hole_pulse_left={punch_holes_slow_pulse}
aria-hidden="true"
style="top: calc(0.25in + 1mm); left: calc(0.375in + 1mm); width: calc(0.625in - 2mm); height: calc(0.125in - 2mm); z-index: 10;">
<svg width="100%" height="100%" viewBox="0 0 50 8" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
@@ -728,7 +731,9 @@ const code_to_icon: {
</div>
{/if}
{#if punch_holes_right && punch_holes_right_rainbow}
<div class="punch_hole_marker punch_hole_rainbow_right pointer-events-none absolute"
<div class="punch_hole_marker pointer-events-none absolute"
class:punch_hole_rainbow_right={!punch_holes_slow_pulse}
class:punch_hole_pulse_right={punch_holes_slow_pulse}
aria-hidden="true"
style="top: calc(0.25in + 1mm); right: calc(0.375in + 1mm); width: calc(0.625in - 2mm); height: calc(0.125in - 2mm); z-index: 10;">
<svg width="100%" height="100%" viewBox="0 0 50 8" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
@@ -749,7 +754,9 @@ const code_to_icon: {
</div>
{/if}
{#if punch_holes_center && punch_holes_center_rainbow}
<div class="punch_hole_marker punch_hole_rainbow_center pointer-events-none absolute"
<div class="punch_hole_marker pointer-events-none absolute"
class:punch_hole_rainbow_center={!punch_holes_slow_pulse}
class:punch_hole_pulse_center={punch_holes_slow_pulse}
aria-hidden="true"
style="top: calc(0.25in + 1mm); left: 50%; transform: translateX(-50%); width: calc(0.625in - 2mm); height: calc(0.125in - 2mm); z-index: 10;">
<svg width="100%" height="100%" viewBox="0 0 50 8" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
@@ -1412,19 +1419,29 @@ const code_to_icon: {
* active they form a proper tri-phase RGB cycle. Two active slots are 120° apart.
* Negative animation-delay starts each slot mid-cycle at its designated phase.
*/
/* Cycle mode (default): fast linear hue rotation, 120° phase apart per slot */
@keyframes ae_punch_hole_rainbow {
from { filter: hue-rotate(0deg); }
to { filter: hue-rotate(360deg); }
}
.punch_hole_rainbow_left { animation: ae_punch_hole_rainbow 2.5s 0.000s linear infinite; }
.punch_hole_rainbow_right { animation: ae_punch_hole_rainbow 2.5s -0.833s linear infinite; }
.punch_hole_rainbow_center { animation: ae_punch_hole_rainbow 2.5s -1.667s linear infinite; }
.punch_hole_rainbow_left { animation: ae_punch_hole_rainbow 2.5s 0.000s linear infinite; }
.punch_hole_rainbow_right { animation: ae_punch_hole_rainbow 2.5s -0.833s linear infinite; }
.punch_hole_rainbow_center { animation: ae_punch_hole_rainbow 2.5s -1.667s linear infinite; }
/* Pulse mode: slow breathing — dim→bright while hue shifts 180° and back.
Same 120° phase offsets (6s ÷ 3 = 2s per slot) so they breathe in turn. */
@keyframes ae_punch_hole_pulse {
0%, 100% { filter: hue-rotate(0deg) brightness(0.55); }
50% { filter: hue-rotate(180deg) brightness(1.30); }
}
.punch_hole_pulse_left { animation: ae_punch_hole_pulse 6s 0.000s ease-in-out infinite; }
.punch_hole_pulse_right { animation: ae_punch_hole_pulse 6s -2.000s ease-in-out infinite; }
.punch_hole_pulse_center { animation: ae_punch_hole_pulse 6s -4.000s ease-in-out infinite; }
.punch_hole_print_rgb { display: none; }
@media print {
.punch_hole_rainbow_left,
.punch_hole_rainbow_right,
.punch_hole_rainbow_center { display: none; }
.punch_hole_rainbow_left, .punch_hole_rainbow_right, .punch_hole_rainbow_center,
.punch_hole_pulse_left, .punch_hole_pulse_right, .punch_hole_pulse_center { display: none; }
.punch_hole_print_rgb { display: block; }
}

View File

@@ -86,6 +86,8 @@ let cfg_punch_holes_right_rainbow = $state(false);
let cfg_punch_holes_center_fg = $state('');
let cfg_punch_holes_center_bg = $state('');
let cfg_punch_holes_center_rainbow = $state(false);
// Applies to all rainbow slots: slow breathing pulse instead of fast cycle
let cfg_punch_holes_slow_pulse = $state(false);
// Header bottom border. Empty color = no border.
let cfg_header_border_color = $state('');
let cfg_header_border_width = $state('');
@@ -197,6 +199,7 @@ async function load_template(id: string) {
cfg_punch_holes_center_fg = parsed_cfg?.punch_holes?.center_fg ?? '';
cfg_punch_holes_center_bg = parsed_cfg?.punch_holes?.center_bg ?? '';
cfg_punch_holes_center_rainbow = parsed_cfg?.punch_holes?.center_rainbow ?? false;
cfg_punch_holes_slow_pulse = parsed_cfg?.punch_holes?.slow_pulse ?? false;
cfg_header_border_color = parsed_cfg.header_border_color ?? '';
cfg_header_border_width = parsed_cfg.header_border_width ?? '';
cfg_header_padding_top = parsed_cfg.header_padding_top ?? '';
@@ -297,6 +300,7 @@ async function handle_submit() {
if (cfg_punch_holes_center_fg.trim()) ph.center_fg = cfg_punch_holes_center_fg.trim();
if (cfg_punch_holes_center_bg.trim()) ph.center_bg = cfg_punch_holes_center_bg.trim();
if (cfg_punch_holes_center_rainbow) ph.center_rainbow = true;
if (cfg_punch_holes_slow_pulse) ph.slow_pulse = true;
cfg_obj.punch_holes = ph;
} else {
delete cfg_obj.punch_holes;
@@ -477,6 +481,13 @@ function toggle_cfg_controls_auth_editable(key: string) {
{@render hole_colors('Left', cfg_punch_holes_left, cfg_punch_holes_left_fg, cfg_punch_holes_left_bg, cfg_punch_holes_left_rainbow, (v) => cfg_punch_holes_left_fg = v, (v) => cfg_punch_holes_left_bg = v, (v) => cfg_punch_holes_left_rainbow = v)}
{@render hole_colors('Right', cfg_punch_holes_right, cfg_punch_holes_right_fg, cfg_punch_holes_right_bg, cfg_punch_holes_right_rainbow, (v) => cfg_punch_holes_right_fg = v, (v) => cfg_punch_holes_right_bg = v, (v) => cfg_punch_holes_right_rainbow = v)}
{@render hole_colors('Center', cfg_punch_holes_center, cfg_punch_holes_center_fg, cfg_punch_holes_center_bg, cfg_punch_holes_center_rainbow, (v) => cfg_punch_holes_center_fg = v, (v) => cfg_punch_holes_center_bg = v, (v) => cfg_punch_holes_center_rainbow = v)}
{#if cfg_punch_holes_left_rainbow || cfg_punch_holes_right_rainbow || cfg_punch_holes_center_rainbow}
<label class="label flex items-center gap-2 pt-1 border-t border-surface-200-800">
<input type="checkbox" bind:checked={cfg_punch_holes_slow_pulse} class="checkbox" />
<span class="text-sm">Slow Pulse <span class="text-xs text-surface-400 italic">(gentle breathing instead of fast cycle)</span></span>
</label>
{/if}
</div>
<label class="label">