feat(badges): rainbow animation option for punch-out hole markers

Adds left/right/center_rainbow to punch_holes cfg_json. When enabled,
applies a CSS hue-rotate animation (2.5s loop) to the marker div using
a saturated red base color so the full visible spectrum appears. Template
form shows a Rainbow checkbox per slot; hides color pickers when active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-04 20:23:37 -04:00
parent 1a53a20995
commit 35c1324824
3 changed files with 87 additions and 43 deletions

View File

@@ -64,14 +64,17 @@ export interface BadgeTemplateCfg {
left?: boolean; left?: boolean;
right?: boolean; right?: boolean;
center?: boolean; center?: boolean;
fg?: string; // shared fallback stroke/line color fg?: string; // shared fallback stroke/line color
bg?: string; // shared fallback fill color bg?: string; // shared fallback fill color
left_fg?: string; left_fg?: string;
left_bg?: string; left_bg?: string;
left_rainbow?: boolean; // animated hue-rotate; overrides fg/bg base color with saturated red
right_fg?: string; right_fg?: string;
right_bg?: string; right_bg?: string;
right_rainbow?: boolean;
center_fg?: string; center_fg?: string;
center_bg?: string; center_bg?: string;
center_rainbow?: boolean;
}; };
// Allow arbitrary extra keys to preserve forward-compatibility. // Allow arbitrary extra keys to preserve forward-compatibility.

View File

@@ -333,16 +333,23 @@ let punch_holes_right = $derived(!!template_cfg?.punch_holes?.right);
let punch_holes_center = $derived(!!template_cfg?.punch_holes?.center); let punch_holes_center = $derived(!!template_cfg?.punch_holes?.center);
// Per-slot colors: slot-specific override → shared fallback → component default. // Per-slot colors: slot-specific override → shared fallback → component default.
const PH_DEFAULT_FG = '#777777'; // When rainbow is on and no explicit fg/bg is set, use a saturated base color so
const PH_DEFAULT_BG = 'rgba(255,255,255,0.4)'; // hue-rotate produces a full visible spectrum (gray has no hue to rotate).
const PH_DEFAULT_FG = '#777777';
const PH_DEFAULT_BG = 'rgba(255,255,255,0.4)';
const PH_RAINBOW_FG = '#ff2200'; // saturated red → full spectrum via hue-rotate
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_colors = $derived.by(() => { let punch_holes_colors = $derived.by(() => {
const ph = template_cfg?.punch_holes; const ph = template_cfg?.punch_holes;
const shared_fg = ph?.fg || PH_DEFAULT_FG; const shared_fg = ph?.fg || PH_DEFAULT_FG;
const shared_bg = ph?.bg || PH_DEFAULT_BG; const shared_bg = ph?.bg || PH_DEFAULT_BG;
return { return {
left: { fg: ph?.left_fg || shared_fg, bg: ph?.left_bg || shared_bg }, left: { fg: ph?.left_fg || (ph?.left_rainbow ? PH_RAINBOW_FG : shared_fg), bg: ph?.left_bg || (ph?.left_rainbow ? PH_RAINBOW_BG : shared_bg) },
right: { fg: ph?.right_fg || shared_fg, bg: ph?.right_bg || shared_bg }, right: { fg: ph?.right_fg || (ph?.right_rainbow ? PH_RAINBOW_FG : shared_fg), bg: ph?.right_bg || (ph?.right_rainbow ? PH_RAINBOW_BG : shared_bg) },
center: { fg: ph?.center_fg || shared_fg, bg: ph?.center_bg || shared_bg } center: { fg: ph?.center_fg || (ph?.center_rainbow ? PH_RAINBOW_FG : shared_fg), bg: ph?.center_bg || (ph?.center_rainbow ? PH_RAINBOW_BG : shared_bg) }
}; };
}); });
@@ -694,6 +701,7 @@ const code_to_icon: {
registration variance (slots are only 3mm tall, registration matters). --> registration variance (slots are only 3mm tall, registration matters). -->
{#if punch_holes_left} {#if punch_holes_left}
<div class="punch_hole_marker pointer-events-none absolute" <div class="punch_hole_marker pointer-events-none absolute"
class:punch_hole_rainbow={punch_holes_left_rainbow}
aria-hidden="true" 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;"> 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"> <svg width="100%" height="100%" viewBox="0 0 50 8" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
@@ -705,6 +713,7 @@ const code_to_icon: {
{/if} {/if}
{#if punch_holes_right} {#if punch_holes_right}
<div class="punch_hole_marker pointer-events-none absolute" <div class="punch_hole_marker pointer-events-none absolute"
class:punch_hole_rainbow={punch_holes_right_rainbow}
aria-hidden="true" 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;"> 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"> <svg width="100%" height="100%" viewBox="0 0 50 8" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
@@ -716,6 +725,7 @@ const code_to_icon: {
{/if} {/if}
{#if punch_holes_center} {#if punch_holes_center}
<div class="punch_hole_marker pointer-events-none absolute" <div class="punch_hole_marker pointer-events-none absolute"
class:punch_hole_rainbow={punch_holes_center_rainbow}
aria-hidden="true" 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;"> 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"> <svg width="100%" height="100%" viewBox="0 0 50 8" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
@@ -1325,6 +1335,20 @@ const code_to_icon: {
<style> <style>
/*
* Punch-hole rainbow animation: continuously rotates the hue of the marker div.
* A saturated base color (PH_RAINBOW_FG) is used so the full visible spectrum appears.
* On print the animation freezes at whatever frame the browser captures — this is fine,
* the marker still shows in some vivid color and remains within the hole boundary.
*/
@keyframes ae_punch_hole_rainbow {
from { filter: hue-rotate(0deg); }
to { filter: hue-rotate(360deg); }
}
.punch_hole_rainbow {
animation: ae_punch_hole_rainbow 2.5s linear infinite;
}
/* /*
* Force light-mode rendering on the badge print area. * Force light-mode rendering on the badge print area.
* Badges print on physical card stock (white by default, or a defined color set by * Badges print on physical card stock (white by default, or a defined color set by

View File

@@ -76,13 +76,16 @@ let cfg_header_padding_left = $state('');
let cfg_punch_holes_left = $state(false); let cfg_punch_holes_left = $state(false);
let cfg_punch_holes_right = $state(false); let cfg_punch_holes_right = $state(false);
let cfg_punch_holes_center = $state(false); let cfg_punch_holes_center = $state(false);
// Per-slot colors. Empty = use component default (#777777 fg, semi-transparent white bg). // Per-slot colors and rainbow. Empty color = use component default.
let cfg_punch_holes_left_fg = $state(''); let cfg_punch_holes_left_fg = $state('');
let cfg_punch_holes_left_bg = $state(''); let cfg_punch_holes_left_bg = $state('');
let cfg_punch_holes_right_fg = $state(''); let cfg_punch_holes_left_rainbow = $state(false);
let cfg_punch_holes_right_bg = $state(''); let cfg_punch_holes_right_fg = $state('');
let cfg_punch_holes_center_fg = $state(''); let cfg_punch_holes_right_bg = $state('');
let cfg_punch_holes_center_bg = $state(''); 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);
// Header bottom border. Empty color = no border. // Header bottom border. Empty color = no border.
let cfg_header_border_color = $state(''); let cfg_header_border_color = $state('');
let cfg_header_border_width = $state(''); let cfg_header_border_width = $state('');
@@ -185,12 +188,15 @@ async function load_template(id: string) {
cfg_punch_holes_left = parsed_cfg?.punch_holes?.left ?? false; cfg_punch_holes_left = parsed_cfg?.punch_holes?.left ?? false;
cfg_punch_holes_right = parsed_cfg?.punch_holes?.right ?? false; cfg_punch_holes_right = parsed_cfg?.punch_holes?.right ?? false;
cfg_punch_holes_center = parsed_cfg?.punch_holes?.center ?? false; cfg_punch_holes_center = parsed_cfg?.punch_holes?.center ?? false;
cfg_punch_holes_left_fg = parsed_cfg?.punch_holes?.left_fg ?? ''; cfg_punch_holes_left_fg = parsed_cfg?.punch_holes?.left_fg ?? '';
cfg_punch_holes_left_bg = parsed_cfg?.punch_holes?.left_bg ?? ''; cfg_punch_holes_left_bg = parsed_cfg?.punch_holes?.left_bg ?? '';
cfg_punch_holes_right_fg = parsed_cfg?.punch_holes?.right_fg ?? ''; cfg_punch_holes_left_rainbow = parsed_cfg?.punch_holes?.left_rainbow ?? false;
cfg_punch_holes_right_bg = parsed_cfg?.punch_holes?.right_bg ?? ''; cfg_punch_holes_right_fg = parsed_cfg?.punch_holes?.right_fg ?? '';
cfg_punch_holes_center_fg = parsed_cfg?.punch_holes?.center_fg ?? ''; cfg_punch_holes_right_bg = parsed_cfg?.punch_holes?.right_bg ?? '';
cfg_punch_holes_center_bg = parsed_cfg?.punch_holes?.center_bg ?? ''; cfg_punch_holes_right_rainbow = parsed_cfg?.punch_holes?.right_rainbow ?? false;
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_header_border_color = parsed_cfg.header_border_color ?? ''; cfg_header_border_color = parsed_cfg.header_border_color ?? '';
cfg_header_border_width = parsed_cfg.header_border_width ?? ''; cfg_header_border_width = parsed_cfg.header_border_width ?? '';
cfg_header_padding_top = parsed_cfg.header_padding_top ?? ''; cfg_header_padding_top = parsed_cfg.header_padding_top ?? '';
@@ -282,12 +288,15 @@ async function handle_submit() {
right: cfg_punch_holes_right, right: cfg_punch_holes_right,
center: cfg_punch_holes_center center: cfg_punch_holes_center
}; };
if (cfg_punch_holes_left_fg.trim()) ph.left_fg = cfg_punch_holes_left_fg.trim(); if (cfg_punch_holes_left_fg.trim()) ph.left_fg = cfg_punch_holes_left_fg.trim();
if (cfg_punch_holes_left_bg.trim()) ph.left_bg = cfg_punch_holes_left_bg.trim(); if (cfg_punch_holes_left_bg.trim()) ph.left_bg = cfg_punch_holes_left_bg.trim();
if (cfg_punch_holes_right_fg.trim()) ph.right_fg = cfg_punch_holes_right_fg.trim(); if (cfg_punch_holes_left_rainbow) ph.left_rainbow = true;
if (cfg_punch_holes_right_bg.trim()) ph.right_bg = cfg_punch_holes_right_bg.trim(); if (cfg_punch_holes_right_fg.trim()) ph.right_fg = cfg_punch_holes_right_fg.trim();
if (cfg_punch_holes_center_fg.trim()) ph.center_fg = cfg_punch_holes_center_fg.trim(); if (cfg_punch_holes_right_bg.trim()) ph.right_bg = cfg_punch_holes_right_bg.trim();
if (cfg_punch_holes_center_bg.trim()) ph.center_bg = cfg_punch_holes_center_bg.trim(); if (cfg_punch_holes_right_rainbow) ph.right_rainbow = true;
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;
cfg_obj.punch_holes = ph; cfg_obj.punch_holes = ph;
} else { } else {
delete cfg_obj.punch_holes; delete cfg_obj.punch_holes;
@@ -431,35 +440,43 @@ function toggle_cfg_controls_auth_editable(key: string) {
Markers help attendees know where to push out the perforations. Markers help attendees know where to push out the perforations.
Leave colors blank to use defaults (gray stroke, semi-transparent white fill). Leave colors blank to use defaults (gray stroke, semi-transparent white fill).
</p> </p>
{#snippet hole_colors(label: string, enabled: boolean, fg: string, bg: string, on_fg: (v: string) => void, on_bg: (v: string) => void)} {#snippet hole_colors(label: string, enabled: boolean, fg: string, bg: string, rainbow: boolean, on_fg: (v: string) => void, on_bg: (v: string) => void, on_rainbow: (v: boolean) => void)}
<div class="space-y-1 rounded border border-surface-200-800 p-2"> <div class="space-y-1 rounded border border-surface-200-800 p-2">
<label class="label flex items-center gap-2 font-medium"> <label class="label flex items-center gap-2 font-medium">
<input type="checkbox" checked={enabled} onchange={(e) => { if (e.target instanceof HTMLInputElement) { if (label === 'Left') cfg_punch_holes_left = e.target.checked; else if (label === 'Right') cfg_punch_holes_right = e.target.checked; else cfg_punch_holes_center = e.target.checked; } }} class="checkbox" /> <input type="checkbox" checked={enabled} onchange={(e) => { if (e.target instanceof HTMLInputElement) { if (label === 'Left') cfg_punch_holes_left = e.target.checked; else if (label === 'Right') cfg_punch_holes_right = e.target.checked; else cfg_punch_holes_center = e.target.checked; } }} class="checkbox" />
<span>{label} slot</span> <span>{label} slot</span>
</label> </label>
{#if enabled} {#if enabled}
<div class="grid grid-cols-2 gap-2 pl-6"> <div class="pl-6 space-y-2">
<label class="label"> <label class="label flex items-center gap-2">
<span class="text-xs">Stroke / line (fg)</span> <input type="checkbox" checked={rainbow} onchange={(e) => { if (e.target instanceof HTMLInputElement) on_rainbow(e.target.checked); }} class="checkbox" />
<div class="flex items-center gap-1"> <span class="text-sm">🌈 Rainbow</span>
<input type="color" value={fg || '#777777'} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_fg(e.target.value); }} class="w-8 h-7 p-0" />
<input type="text" value={fg} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_fg(e.target.value); }} class="input input-sm w-24" placeholder="#777777" />
</div>
</label> </label>
<label class="label"> {#if !rainbow}
<span class="text-xs">Fill (bg)</span> <div class="grid grid-cols-2 gap-2">
<div class="flex items-center gap-1"> <label class="label">
<input type="color" value={bg || '#ffffff'} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_bg(e.target.value); }} class="w-8 h-7 p-0" /> <span class="text-xs">Stroke / line (fg)</span>
<input type="text" value={bg} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_bg(e.target.value); }} class="input input-sm w-24" placeholder="#ffffff" /> <div class="flex items-center gap-1">
<input type="color" value={fg || '#777777'} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_fg(e.target.value); }} class="w-8 h-7 p-0" />
<input type="text" value={fg} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_fg(e.target.value); }} class="input input-sm w-24" placeholder="#777777" />
</div>
</label>
<label class="label">
<span class="text-xs">Fill (bg)</span>
<div class="flex items-center gap-1">
<input type="color" value={bg || '#ffffff'} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_bg(e.target.value); }} class="w-8 h-7 p-0" />
<input type="text" value={bg} oninput={(e) => { if (e.target instanceof HTMLInputElement) on_bg(e.target.value); }} class="input input-sm w-24" placeholder="#ffffff" />
</div>
</label>
</div> </div>
</label> {/if}
</div> </div>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{@render hole_colors('Left', cfg_punch_holes_left, cfg_punch_holes_left_fg, cfg_punch_holes_left_bg, (v) => cfg_punch_holes_left_fg = v, (v) => cfg_punch_holes_left_bg = v)} {@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, (v) => cfg_punch_holes_right_fg = v, (v) => cfg_punch_holes_right_bg = 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, (v) => cfg_punch_holes_center_fg = v, (v) => cfg_punch_holes_center_bg = 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)}
</div> </div>
<label class="label"> <label class="label">