feat: badge print — two-line name toggle + leading-none tightening + calibration SVG

- Add name_two_lines toggle (default: true) — uses CSS horizontal padding scaled by
  character count to coax short names (e.g. "Scott Idem") into a natural two-line wrap
  without a hard <br>; three tiers: ≤12 chars (18%), ≤20 (8%), ≤28 (2%), >28 no pad
- Inner <div> (block element) used inside Element_fit_text for class: directives —
  Svelte scoped CSS requires static class names in the template; dynamic strings and
  class: on component elements both fail to match scoped CSS rules
- Add leading-none to all four Element_fit_text fields (name, title, affiliations,
  location) — line-height must be set at the wrapper div level where fit_text measures
  scrollHeight, otherwise the binary-search scaler returns inflated sizes
- name_two_lines state persisted to localStorage (ae_badge_print_tweaks key) alongside
  existing print_offset, hide_chrome, and banner_full_width tweaks
- Rewrite badge_header_calibration.svg as a precise SVG ruler with labeled tick marks
  (major at 1in intervals, minor at 0.25in) for accurate physical print calibration
- Gate debug outline CSS on html.debug_outlines class (set by controls panel) so
  outlines never appear in normal print mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-19 17:55:49 -04:00
parent 621a637b85
commit fdd8691e2e
5 changed files with 272 additions and 32 deletions

View File

@@ -43,6 +43,13 @@
* Set false via the controls panel to see the image at its natural size (dev/testing).
*/
banner_full_width?: boolean;
/**
* When true (default), the person's name is split at the last space and rendered
* on two lines. Gives short names like "Scott Idem" the same two-line visual weight
* as long names. Set false for names that genuinely read better on one line.
* Toggle lives in the controls panel; persisted in localStorage per workstation.
*/
name_two_lines?: boolean;
log_lvl?: number;
}
@@ -58,6 +65,7 @@
font_size_location,
preview_overrides = null,
banner_full_width = true,
name_two_lines = true,
}: Props = $props();
// Badge layout CSS — compiled in, hot-reloads in dev.
@@ -102,6 +110,19 @@
let display_name = $derived(
eff_badge?.full_name_override ?? eff_badge?.full_name ?? ''
);
// Two-line name padding: when name_two_lines is on, apply horizontal padding scaled
// to name length so CSS word-wrap does the break naturally — no hard <br> needed.
// Short names (≤12 chars) get heavy padding to force a break at the space.
// Progressively less padding as names get longer; very long names wrap on their own.
// Breakpoints are character-count heuristics — adjust if specific names look off.
// name_pad_* — three tiers of horizontal padding to coax short names into two lines.
// Svelte scopes component style rules via compile-time class name hashing; dynamic class
// strings are invisible to the compiler so the hash never matches. class:name={cond} is the
// correct pattern — the literal class names appear in the template for the compiler to find.
let name_pad_short = $derived(name_two_lines && display_name.trim().length <= 12);
let name_pad_mid = $derived(name_two_lines && display_name.trim().length > 12 && display_name.trim().length <= 20);
let name_pad_long = $derived(name_two_lines && display_name.trim().length > 20 && display_name.trim().length <= 28);
let display_title = $derived(
eff_badge?.professional_title_override ?? eff_badge?.professional_title ?? ''
);
@@ -258,20 +279,20 @@
const [bg, fg] = palettes[effective_badge_type_code?.toLowerCase() ?? ''] ?? ['#f1f5f9', '#64748b'];
// Each entry: [svg string, tile size]. Swirls use a larger tile for smoother repeats.
const patterns: [string, string][] = [
// 1: diagonal stripes
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><line x1='0' y1='24' x2='24' y2='0' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/><line x1='-12' y1='24' x2='12' y2='0' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/><line x1='12' y1='24' x2='36' y2='0' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/></svg>`, '24px 24px'],
// 2: polka dots
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><circle cx='12' cy='12' r='5' fill='${fg}' fill-opacity='0.35'/></svg>`, '24px 24px'],
// 3: diamonds
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><polygon points='12,3 21,12 12,21 3,12' fill='none' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/></svg>`, '24px 24px'],
// 4: grid
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><line x1='12' y1='0' x2='12' y2='24' stroke='${fg}' stroke-width='1' stroke-opacity='0.4'/><line x1='0' y1='12' x2='24' y2='12' stroke='${fg}' stroke-width='1' stroke-opacity='0.4'/></svg>`, '24px 24px'],
// 5: swirls — two crossing S-curves (yin-yang tiling) + echo curves for depth.
[`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'><rect width='64' height='64' fill='${bg}'/><path d='M0,0 C0,32 64,32 64,64' fill='none' stroke='${fg}' stroke-width='2.5' stroke-opacity='0.45' stroke-linecap='round'/><path d='M64,0 C64,32 0,32 0,64' fill='none' stroke='${fg}' stroke-width='2.5' stroke-opacity='0.45' stroke-linecap='round'/><path d='M0,16 C16,32 48,32 64,48' fill='none' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.22' stroke-linecap='round'/><path d='M64,16 C48,32 16,32 0,48' fill='none' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.22' stroke-linecap='round'/><circle cx='32' cy='32' r='4' fill='${fg}' fill-opacity='0.18'/></svg>`, '64px 64px'],
// 6: Inch Calibration Grid (1 inch squares, 0.25 inch subdivisions).
[`<svg xmlns='http://www.w3.org/2000/svg' width='96' height='96'><rect width='96' height='96' fill='white' fill-opacity='0.9'/><path d='M24 0 v96 M48 0 v96 M72 0 v96 M0 24 h96 M0 48 h96 M0 72 h96' stroke='${fg}' stroke-width='0.5' stroke-opacity='0.2'/><rect width='96' height='96' fill='none' stroke='${fg}' stroke-width='1' stroke-opacity='0.5'/><text x='2' y='10' font-family='monospace' font-size='8' fill='${fg}' opacity='0.5'>1in</text></svg>`, '96px 96px'],
// 7: Metric Calibration Grid (10mm squares, 1mm subdivisions).
// 1: Metric Calibration Grid (10mm squares, 1mm subdivisions).
[`<svg xmlns='http://www.w3.org/2000/svg' width='37.795' height='37.795'><rect width='37.795' height='37.795' fill='white' fill-opacity='0.9'/><path d='M3.78 0 v37.8 M7.56 0 v37.8 M11.34 0 v37.8 M15.12 0 v37.8 M18.9 0 v37.8 M22.68 0 v37.8 M26.46 0 v37.8 M30.24 0 v37.8 M34.02 0 v37.8 M0 3.78 h37.8 M0 7.56 h37.8 M0 11.34 h37.8 M0 15.12 h37.8 M0 18.9 h37.8 M0 22.68 h37.8 M0 26.46 h37.8 M0 30.24 h37.8 M0 34.02 h37.8' stroke='${fg}' stroke-width='0.25' stroke-opacity='0.2'/><rect width='37.795' height='37.795' fill='none' stroke='${fg}' stroke-width='0.8' stroke-opacity='0.5'/><text x='2' y='8' font-family='monospace' font-size='6' fill='${fg}' opacity='0.5'>1cm</text></svg>`, '37.795px 37.795px'],
// 2: Inch Calibration Grid (1 inch squares, 0.25 inch subdivisions).
[`<svg xmlns='http://www.w3.org/2000/svg' width='96' height='96'><rect width='96' height='96' fill='white' fill-opacity='0.9'/><path d='M24 0 v96 M48 0 v96 M72 0 v96 M0 24 h96 M0 48 h96 M0 72 h96' stroke='${fg}' stroke-width='0.5' stroke-opacity='0.2'/><rect width='96' height='96' fill='none' stroke='${fg}' stroke-width='1' stroke-opacity='0.5'/><text x='2' y='10' font-family='monospace' font-size='8' fill='${fg}' opacity='0.5'>1in</text></svg>`, '96px 96px'],
// 3: swirls — two crossing S-curves (yin-yang tiling) + echo curves for depth.
[`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'><rect width='64' height='64' fill='${bg}'/><path d='M0,0 C0,32 64,32 64,64' fill='none' stroke='${fg}' stroke-width='2.5' stroke-opacity='0.45' stroke-linecap='round'/><path d='M64,0 C64,32 0,32 0,64' fill='none' stroke='${fg}' stroke-width='2.5' stroke-opacity='0.45' stroke-linecap='round'/><path d='M0,16 C16,32 48,32 64,48' fill='none' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.22' stroke-linecap='round'/><path d='M64,16 C48,32 16,32 0,48' fill='none' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.22' stroke-linecap='round'/><circle cx='32' cy='32' r='4' fill='${fg}' fill-opacity='0.18'/></svg>`, '64px 64px'],
// 4: diagonal stripes
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><line x1='0' y1='24' x2='24' y2='0' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/><line x1='-12' y1='24' x2='12' y2='0' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/><line x1='12' y1='24' x2='36' y2='0' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/></svg>`, '24px 24px'],
// 5: polka dots
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><circle cx='12' cy='12' r='5' fill='${fg}' fill-opacity='0.35'/></svg>`, '24px 24px'],
// 6: diamonds
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><polygon points='12,3 21,12 12,21 3,12' fill='none' stroke='${fg}' stroke-width='1.5' stroke-opacity='0.5'/></svg>`, '24px 24px'],
// 7: grid
[`<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='${bg}'/><line x1='12' y1='0' x2='12' y2='24' stroke='${fg}' stroke-width='1' stroke-opacity='0.4'/><line x1='0' y1='12' x2='24' y2='12' stroke='${fg}' stroke-width='1' stroke-opacity='0.4'/></svg>`, '24px 24px'],
];
const [svg, size] = patterns[(demo_bg_state - 1) % DEMO_BG_PATTERNS];
return `background-image: url("data:image/svg+xml,${encodeURIComponent(svg)}"); background-repeat: repeat; background-size: ${size};`;
@@ -336,7 +357,7 @@
onclick={cycle_demo_bg}
title="Cycle demo background pattern edit mode only"
>
🎨 {demo_bg_state > 0 ? ['stripes','dots','diamonds','grid','swirls','in-grid','mm-grid'][demo_bg_state - 1] : 'demo bg'}
🎨 {demo_bg_state > 0 ? ['mm-grid','in-grid','swirls','stripes','dots','diamonds','grid'][demo_bg_state - 1] : 'demo bg'}
</button>
{/if}
</div>
@@ -348,7 +369,6 @@
flex flex-row flex-wrap gap-4
items-stretch justify-center
p-2 mx-auto
outline-2 outline-dashed outline-blue-500
min-h-[6.0in]
max-w-[8.5in] overflow-visible
"
@@ -367,7 +387,7 @@
p-0 m-0 overflow-visible
text-center
relative
outline-4 outline-red-500/50 hover:outline-red-700/75
hover:outline-2 hover:outline-dashed hover:outline-red-500/75
group
"
style={demo_bg_style}
@@ -455,15 +475,20 @@
max={80}
manual_size={font_size_name ?? null}
height={fit_heights.name}
class="full_name_override_all hover:bg-pink-100/50"
class="full_name_override_all hover:bg-pink-100/50 leading-none"
>
<span class="full_name_override">
<div
class="full_name_override"
class:name_pad_short={name_pad_short}
class:name_pad_mid={name_pad_mid}
class:name_pad_long={name_pad_long}
>
{#if display_name}
{@html display_name.trim()}
{display_name.trim()}
{:else}
-- no name --
{/if}
</span>
</div>
</Element_fit_text>
{#if display_title}
@@ -476,7 +501,7 @@
max={38}
manual_size={font_size_title ?? null}
height={fit_heights.title}
class="professional_title italic hover:bg-pink-100/50"
class="professional_title italic hover:bg-pink-100/50 leading-none"
>
{@html display_title}
</Element_fit_text>
@@ -508,7 +533,7 @@
max={40}
manual_size={font_size_affiliations ?? null}
height={fit_heights.affiliations}
class="affiliations hover:bg-pink-100/50"
class="affiliations hover:bg-pink-100/50 leading-none"
>
{@html display_affiliations}
</Element_fit_text>
@@ -524,7 +549,7 @@
max={34}
manual_size={font_size_location ?? null}
height={fit_heights.location}
class="location hover:bg-pink-100/50"
class="location hover:bg-pink-100/50 leading-none"
>
<span class="city state_province country"
>{@html display_location}</span
@@ -619,7 +644,7 @@
p-0 m-0 overflow-visible
text-left text-sm
relative
outline-4 outline-green-500/50 hover:outline-green-700/75
hover:outline-2 hover:outline-dashed hover:outline-green-500/75
group
"
>
@@ -974,6 +999,7 @@
.badge_front,
.badge_back {
outline: none !important;
box-shadow: none !important;
}
}
@@ -981,8 +1007,37 @@
.badge_back {
background-color: white;
color: #1a1a1a;
/*
* Standard badge/card corner radius: 3.18mm = 1/8 inch = ~12px at 96dpi.
* Matches the physical die-cut corners of the printed card stock.
*/
border-radius: 12px;
/*
* Layered card shadow: gives each badge face a strong physical-card feel on screen.
* Three layers: crisp base edge → medium spread → wide ambient — creates
* convincing depth without looking like a UI box-shadow.
* Suppressed on print — no decoration on the physical output.
*/
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.20),
0 6px 20px rgba(0, 0, 0, 0.14),
0 16px 48px rgba(0, 0, 0, 0.08);
}
/*
* Two-line name padding: applied to the Element_fit_text wrapper div (not the inner span).
* Squeezes the available text width so CSS word-wrap breaks the name at its natural space
* — no hard <br> needed. Padding on the block div also ensures text-align:center works
* correctly for all wrapped lines (inline-element padding breaks centering on each line).
* Short names (≤12 chars, e.g. "Scott Idem") need heavy padding to force the break.
* Progressively less padding as names get longer; very long names wrap on their own.
* Percentages are relative to the Element_fit_text div width, so the effect scales
* correctly regardless of badge template or font size.
*/
.name_pad_short { padding-left: 18%; padding-right: 18%; }
.name_pad_mid { padding-left: 8%; padding-right: 8%; }
.name_pad_long { padding-left: 2%; padding-right: 2%; }
/*
* Header image: center horizontally within the badge.
* <img> defaults to display:inline, which left-aligns any image narrower than

View File

@@ -60,6 +60,8 @@
* Stored in localStorage.
*/
banner_full_width?: boolean;
/** When true (default), name uses padding to prefer two-line layout. Stored in localStorage. */
name_two_lines?: boolean;
log_lvl?: number;
}
@@ -77,6 +79,7 @@
print_offset_y = $bindable(0),
hide_chrome = $bindable(false),
banner_full_width = $bindable(true),
name_two_lines = $bindable(true),
log_lvl = 0
}: Props = $props();
@@ -929,6 +932,18 @@
</button>
</div>
<!-- Name layout: two-line (preferred) vs single-line -->
<div class="flex items-center gap-2 px-2 py-1.5">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
class="checkbox"
bind:checked={name_two_lines}
/>
<span class="text-xs">Name: prefer two lines</span>
</label>
</div>
<!-- Banner width toggle: 100% (production) vs natural size (dev/calibration) -->
<div class="flex items-center gap-2 px-2 py-1.5">
<label class="flex items-center gap-2 cursor-pointer select-none">

View File

@@ -122,6 +122,11 @@
// Banner width: true = 100% (production), false = natural pixel size (dev/calibration).
let banner_full_width: boolean = $state(_saved_tweaks?.banner_full_width ?? true);
// Name layout: true = prefer two lines (first / last split), false = one line.
// Default true — most badge templates look best with the name split across two lines.
// Stored in localStorage so the preference persists per workstation across reloads.
let name_two_lines: boolean = $state(_saved_tweaks?.name_two_lines ?? true);
// hide_chrome: hides the page header and sys bar for a clean badge-preview workspace.
// Toggled via the [H] keyboard shortcut or the button in the controls panel staff section.
let hide_chrome: boolean = $state(_saved_tweaks?.hide_chrome ?? false);
@@ -147,7 +152,7 @@
// Persist all tweaks to localStorage
$effect(() => {
if (browser) localStorage.setItem(_PRINT_TWEAKS_KEY, JSON.stringify({ x: print_offset_x, y: print_offset_y, hide_chrome, banner_full_width }));
if (browser) localStorage.setItem(_PRINT_TWEAKS_KEY, JSON.stringify({ x: print_offset_x, y: print_offset_y, hide_chrome, banner_full_width, name_two_lines }));
});
// Inject print offset as @media print CSS. Uses translate() so the centering
@@ -374,6 +379,7 @@
font_size_location={font_size_location}
{preview_overrides}
{banner_full_width}
{name_two_lines}
/>
</div>
@@ -403,6 +409,7 @@
bind:print_offset_y
bind:hide_chrome
bind:banner_full_width
bind:name_two_lines
/>
</div>