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.
// 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.
// 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?: {
left?: boolean;
right?: 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.

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_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.
// Combines margin-top, optional bottom border (color + width), and optional bottom padding.
// Unset border_color = no border drawn.
@@ -679,9 +693,9 @@ const code_to_icon: {
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">
<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"/>
<line x1="0" y1="0" x2="50" y2="8" stroke="#777" stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke="#777" stroke-width="1"/>
<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={punch_holes_colors.left.fg} stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke={punch_holes_colors.left.fg} stroke-width="1"/>
</svg>
</div>
{/if}
@@ -690,9 +704,9 @@ const code_to_icon: {
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">
<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"/>
<line x1="0" y1="0" x2="50" y2="8" stroke="#777" stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke="#777" stroke-width="1"/>
<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={punch_holes_colors.right.fg} stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke={punch_holes_colors.right.fg} stroke-width="1"/>
</svg>
</div>
{/if}
@@ -701,9 +715,9 @@ const code_to_icon: {
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">
<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"/>
<line x1="0" y1="0" x2="50" y2="8" stroke="#777" stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke="#777" stroke-width="1"/>
<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={punch_holes_colors.center.fg} stroke-width="1"/>
<line x1="50" y1="0" x2="0" y2="8" stroke={punch_holes_colors.center.fg} stroke-width="1"/>
</svg>
</div>
{/if}

View File

@@ -71,6 +71,13 @@ let cfg_header_margin_top = $state('');
let cfg_punch_holes_left = $state(false);
let cfg_punch_holes_right = $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.
let cfg_header_border_color = $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_right = parsed_cfg?.punch_holes?.right ?? 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_width = parsed_cfg.header_border_width ?? '';
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
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,
right: cfg_punch_holes_right,
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 {
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>
<input type="text" bind:value={header_path} class="input" />
</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-xs text-surface-400 italic">
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.
Leave colors blank to use defaults (gray stroke, semi-transparent white fill).
</p>
<div class="flex flex-wrap gap-4">
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_punch_holes_left} class="checkbox" />
<span>Left slot</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_punch_holes_right} class="checkbox" />
<span>Right slot</span>
</label>
<label class="label flex items-center gap-2">
<input type="checkbox" bind:checked={cfg_punch_holes_center} class="checkbox" />
<span>Center slot</span>
</label>
</div>
{#snippet hole_colors(label: string, enabled: boolean, fg: string, bg: string, on_fg: (v: string) => void, on_bg: (v: string) => void)}
<div class="space-y-1 rounded border border-surface-200-800 p-2">
<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" />
<span>{label} slot</span>
</label>
{#if enabled}
<div class="grid grid-cols-2 gap-2 pl-6">
<label class="label">
<span class="text-xs">Stroke / line (fg)</span>
<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>
{/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>
<label class="label">