feat(badges): per-slot fg/bg colors for punch-out hole markers

Adds left_fg/left_bg, right_fg/right_bg, center_fg/center_bg to punch_holes
cfg_json, plus shared fg/bg fallback. Template form shows color pickers per
slot (only when slot is enabled). Defaults: #777777 stroke, rgba white fill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-04 20:01:03 -04:00
parent 4831f4b81b
commit 04c2042060
3 changed files with 85 additions and 25 deletions

View File

@@ -56,10 +56,20 @@ export interface BadgeTemplateCfg {
// Slots are pre-perforated on the badge stock — markers guide attendees to push them out. // Slots are pre-perforated on the badge stock — markers guide attendees to push them out.
// Hole dimensions: 5/8in wide × 1/8in tall, 1/4in from top, 3/8in from left/right edges. // Hole dimensions: 5/8in wide × 1/8in tall, 1/4in from top, 3/8in from left/right edges.
// Center hole: horizontally centered, same vertical position. // Center hole: horizontally centered, same vertical position.
// Colors: per-slot _fg/_bg override the shared fg/bg fallback. Unset = component defaults.
// fg = stroke + line color (hex). bg = rectangle fill color (hex).
punch_holes?: { punch_holes?: {
left?: boolean; left?: boolean;
right?: boolean; right?: boolean;
center?: boolean; center?: boolean;
fg?: string; // shared fallback stroke/line color
bg?: string; // shared fallback fill color
left_fg?: string;
left_bg?: string;
right_fg?: string;
right_bg?: string;
center_fg?: string;
center_bg?: string;
}; };
// Allow arbitrary extra keys to preserve forward-compatibility. // Allow arbitrary extra keys to preserve forward-compatibility.

View File

@@ -332,6 +332,20 @@ let punch_holes_left = $derived(!!template_cfg?.punch_holes?.left);
let punch_holes_right = $derived(!!template_cfg?.punch_holes?.right); 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.
const PH_DEFAULT_FG = '#777777';
const PH_DEFAULT_BG = 'rgba(255,255,255,0.4)';
let punch_holes_colors = $derived.by(() => {
const ph = template_cfg?.punch_holes;
const shared_fg = ph?.fg || PH_DEFAULT_FG;
const shared_bg = ph?.bg || PH_DEFAULT_BG;
return {
left: { fg: ph?.left_fg || shared_fg, bg: ph?.left_bg || shared_bg },
right: { fg: ph?.right_fg || shared_fg, bg: ph?.right_bg || shared_bg },
center: { fg: ph?.center_fg || shared_fg, bg: ph?.center_bg || shared_bg }
};
});
// Full inline style string for the badge_header div. // Full inline style string for the badge_header div.
// Combines margin-top, optional bottom border (color + width), and optional bottom padding. // Combines margin-top, optional bottom border (color + width), and optional bottom padding.
// Unset border_color = no border drawn. // Unset border_color = no border drawn.
@@ -679,9 +693,9 @@ const code_to_icon: {
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">
<rect x="0.5" y="0.5" width="49" height="7" fill="rgba(255,255,255,0.4)" stroke="#777" stroke-width="1" stroke-dasharray="4,2"/> <rect x="0.5" y="0.5" width="49" height="7" fill={punch_holes_colors.left.bg} stroke={punch_holes_colors.left.fg} stroke-width="1" stroke-dasharray="4,2"/>
<line x1="0" y1="0" x2="50" y2="8" stroke="#777" stroke-width="1"/> <line x1="0" y1="0" x2="50" y2="8" stroke={punch_holes_colors.left.fg} stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke="#777" stroke-width="1"/> <line x1="50" y1="0" x2="0" y2="8" stroke={punch_holes_colors.left.fg} stroke-width="1"/>
</svg> </svg>
</div> </div>
{/if} {/if}
@@ -690,9 +704,9 @@ const code_to_icon: {
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">
<rect x="0.5" y="0.5" width="49" height="7" fill="rgba(255,255,255,0.4)" stroke="#777" stroke-width="1" stroke-dasharray="4,2"/> <rect x="0.5" y="0.5" width="49" height="7" fill={punch_holes_colors.right.bg} stroke={punch_holes_colors.right.fg} stroke-width="1" stroke-dasharray="4,2"/>
<line x1="0" y1="0" x2="50" y2="8" stroke="#777" stroke-width="1"/> <line x1="0" y1="0" x2="50" y2="8" stroke={punch_holes_colors.right.fg} stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke="#777" stroke-width="1"/> <line x1="50" y1="0" x2="0" y2="8" stroke={punch_holes_colors.right.fg} stroke-width="1"/>
</svg> </svg>
</div> </div>
{/if} {/if}
@@ -701,9 +715,9 @@ const code_to_icon: {
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">
<rect x="0.5" y="0.5" width="49" height="7" fill="rgba(255,255,255,0.4)" stroke="#777" stroke-width="1" stroke-dasharray="4,2"/> <rect x="0.5" y="0.5" width="49" height="7" fill={punch_holes_colors.center.bg} stroke={punch_holes_colors.center.fg} stroke-width="1" stroke-dasharray="4,2"/>
<line x1="0" y1="0" x2="50" y2="8" stroke="#777" stroke-width="1"/> <line x1="0" y1="0" x2="50" y2="8" stroke={punch_holes_colors.center.fg} stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke="#777" stroke-width="1"/> <line x1="50" y1="0" x2="0" y2="8" stroke={punch_holes_colors.center.fg} stroke-width="1"/>
</svg> </svg>
</div> </div>
{/if} {/if}

View File

@@ -71,6 +71,13 @@ let cfg_header_margin_top = $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).
let cfg_punch_holes_left_fg = $state('');
let cfg_punch_holes_left_bg = $state('');
let cfg_punch_holes_right_fg = $state('');
let cfg_punch_holes_right_bg = $state('');
let cfg_punch_holes_center_fg = $state('');
let cfg_punch_holes_center_bg = $state('');
// 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('');
@@ -175,6 +182,12 @@ 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_bg = parsed_cfg?.punch_holes?.left_bg ?? '';
cfg_punch_holes_right_fg = parsed_cfg?.punch_holes?.right_fg ?? '';
cfg_punch_holes_right_bg = parsed_cfg?.punch_holes?.right_bg ?? '';
cfg_punch_holes_center_fg = parsed_cfg?.punch_holes?.center_fg ?? '';
cfg_punch_holes_center_bg = parsed_cfg?.punch_holes?.center_bg ?? '';
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_bottom = parsed_cfg.header_padding_bottom ?? ''; cfg_header_padding_bottom = parsed_cfg.header_padding_bottom ?? '';
@@ -258,11 +271,18 @@ async function handle_submit() {
// Punch-out hole markers: save the nested object only when at least one is enabled // Punch-out hole markers: save the nested object only when at least one is enabled
if (cfg_punch_holes_left || cfg_punch_holes_right || cfg_punch_holes_center) { if (cfg_punch_holes_left || cfg_punch_holes_right || cfg_punch_holes_center) {
cfg_obj.punch_holes = { const ph: NonNullable<BadgeTemplateCfg['punch_holes']> = {
left: cfg_punch_holes_left, left: cfg_punch_holes_left,
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_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_right_bg.trim()) ph.right_bg = cfg_punch_holes_right_bg.trim();
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();
cfg_obj.punch_holes = ph;
} else { } else {
delete cfg_obj.punch_holes; delete cfg_obj.punch_holes;
} }
@@ -399,26 +419,42 @@ function toggle_cfg_controls_auth_editable(key: string) {
<span>Header Path (URL) — top banner image (used when no background image)</span> <span>Header Path (URL) — top banner image (used when no background image)</span>
<input type="text" bind:value={header_path} class="input" /> <input type="text" bind:value={header_path} class="input" />
</label> </label>
<div class="space-y-1"> <div class="space-y-2">
<p class="text-sm font-medium">Punch-Out Hole Markers</p> <p class="text-sm font-medium">Punch-Out Hole Markers</p>
<p class="text-xs text-surface-400 italic"> <p class="text-xs text-surface-400 italic">
Show X overlays at the physical badge clip slot positions (5/8″ × 1/8″ slots, 1/4″ from top). Show X overlays at the physical badge clip slot positions (5/8″ × 1/8″ slots, 1/4″ from top).
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).
</p> </p>
<div class="flex flex-wrap gap-4"> {#snippet hole_colors(label: string, enabled: boolean, fg: string, bg: string, on_fg: (v: string) => void, on_bg: (v: string) => void)}
<label class="label flex items-center gap-2"> <div class="space-y-1 rounded border border-surface-200-800 p-2">
<input type="checkbox" bind:checked={cfg_punch_holes_left} class="checkbox" /> <label class="label flex items-center gap-2 font-medium">
<span>Left slot</span> <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" />
</label> <span>{label} slot</span>
<label class="label flex items-center gap-2"> </label>
<input type="checkbox" bind:checked={cfg_punch_holes_right} class="checkbox" /> {#if enabled}
<span>Right slot</span> <div class="grid grid-cols-2 gap-2 pl-6">
</label> <label class="label">
<label class="label flex items-center gap-2"> <span class="text-xs">Stroke / line (fg)</span>
<input type="checkbox" bind:checked={cfg_punch_holes_center} class="checkbox" /> <div class="flex items-center gap-1">
<span>Center slot</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" />
</label> <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> </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>
{/if}
</div>
{/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('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('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)}
</div> </div>
<label class="label"> <label class="label">