feat: badge print UX improvements — chrome toggle, banner width, overlap fix, header centering

- Replace ae_comp__badge_obj_view_v2 with ae_comp__badge_obj_view (consolidated component)
- Add hide-chrome toggle ([H] shortcut + button) to hide site nav/footer/sys bar for clean print workspace
  — syncs $ae_loc.sys_menu.hide + $ae_sess.disable_sys_nav/footer with restore-on-unmount
- Add banner_full_width toggle (default true=100% width, false=natural pixel size for calibration)
- Center badge header image (display:block; margin:0 auto) — was left-aligned when narrower than badge
- Fix controls panel overlap: move from bottom-0 to bottom-24 to clear sys bar (84px tall)
- Add [H] keyboard shortcut for chrome toggle (guards against focus in inputs)
- Persist hide_chrome and banner_full_width in ae_badge_print_tweaks localStorage key
- Add sample header image assets (calibration SVG/PNG, hex blue SVG/PNG, demo PNG)
- Update badge PVC CSS layout and module docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-19 15:42:22 -04:00
parent 639e436854
commit 621a637b85
13 changed files with 354 additions and 171 deletions

View File

@@ -358,7 +358,7 @@ Firefox users can use "Save to PDF" directly — it just works.
- [x] Wire `style_href` via `<svelte:head>` in print page — done in `print/+page.svelte`; also in `properties_to_save`. (2026-03-18 verified)
- [x] Add `duplex` to `properties_to_save` — done. (2026-03-18 verified)
- [x] Add `duplex`-driven suppression to `badge_back` section — done in `ae_comp__badge_obj_view_v2.svelte`; `show_badge_back` derived from `duplex` field. Note: v1 (`ae_comp__badge_obj_view.svelte`) was archived to `~/tmp/gemini_trash/`; v2 is canonical. (2026-03-18 verified)
- [x] Add `duplex`-driven suppression to `badge_back` section — done in `ae_comp__badge_obj_view.svelte`; `show_badge_back` derived from `duplex` field.
- [ ] Make `layout` field drive actual card dimensions in the badge component — currently the Zebra ZC10L layout CSS (`badge_layout_zebra_zc10l_pvc.css`) sets dimensions correctly via `[data-layout="..."]` scoping, but fanfold layouts still use Tailwind defaults. Needs proper CSS for each layout code.
- [ ] Remove dead `exhibitor_info` / `presenter_info` / `staff_info` / `vip_info` / `vote_info` `{#if}` blocks from `ae_comp__badge_obj_view_v2.svelte` (if they were carried over from v1)
- [ ] Remove dead `exhibitor_info` / `presenter_info` / `staff_info` / `vip_info` / `vote_info` `{#if}` blocks from `ae_comp__badge_obj_view.svelte` (if they were carried over from v1)
- [ ] Improve `ae_comp__badge_template_form.svelte` to edit all relevant fields (currently minimal)

View File

@@ -31,7 +31,7 @@
- Once satisfied, staff prints the badge.
- The key differentiator vs the review form: **the live badge render** shows exactly how
the badge will print. Attendees and staff can see changes immediately.
- Component: `ae_comp__badge_obj_view.svelte` / `ae_comp__badge_obj_view_v2.svelte`
- Component: `ae_comp__badge_obj_view.svelte`
- Route: `/events/[event_id]/badges/[badge_id]/print/`
### Permission Model — Same Logic, Both Flows
@@ -69,8 +69,8 @@ Work needed:
or whether it should share/reuse the review form component alongside the badge render.
- **Do NOT use `email_override` as the send-to address** — always use `event_badge.email`.
### 1. Auto-Scaling Badge Text (v2) — In Progress
`ae_comp__badge_obj_view_v2.svelte` using `element_fit_text.svelte` (binary search auto-scale).
### 1. Auto-Scaling Badge Text — In Progress
`ae_comp__badge_obj_view.svelte` using `element_fit_text.svelte` (binary search auto-scale).
Toggle between v1 (heuristic) and v2 (auto-scale) on the print page via the `v1`/`v2` header button.
Heights tuned per layout in `fit_heights` derived object. Still needs visual tuning with real badges.
@@ -118,7 +118,7 @@ the MODULE doc TODO list was stale. `duplex` is in `properties_to_save`; v2 badg
**Files created/updated:**
- `src/lib/elements/action_fit_text.ts` — Svelte action
- `src/lib/elements/element_fit_text.svelte` — Component wrapper
- `src/routes/events/.../ae_comp__badge_obj_view_v2.svelte` — V2 badge render (canonical)
- `src/routes/events/.../ae_comp__badge_obj_view.svelte` — V2 badge render (canonical)
Debug blocks gated behind `$ae_loc.edit_mode` (hidden in production).
- `print/+page.svelte` — Always uses v2 now. v1/v2 toggle removed. Header redesigned for kiosk UX.
- `ae_comp__badge_print_controls.svelte` — Identity card at top, pronouns moved to attendee section,

View File

@@ -32,6 +32,16 @@
padding: 0;
gap: 0;
min-height: 0;
width: fit-content;
max-width: fit-content;
width: 3.5in;
max-width: 3.5in;
}
@media print {
[data-layout="badge_3.5x5.5_pvc"].event_badge_wrapper {
width: 3.5in !important;
height: 5.5in !important;
max-width: 3.5in !important;
max-height: 5.5in !important;
overflow: hidden !important;
}
}

View File

@@ -38,6 +38,11 @@
* values immediately without waiting for Save + liveQuery round-trip.
*/
preview_overrides?: Record<string, any> | null;
/**
* When true (default), header images stretch to 100% badge width (production view).
* Set false via the controls panel to see the image at its natural size (dev/testing).
*/
banner_full_width?: boolean;
log_lvl?: number;
}
@@ -52,6 +57,7 @@
font_size_affiliations,
font_size_location,
preview_overrides = null,
banner_full_width = true,
}: Props = $props();
// Badge layout CSS — compiled in, hot-reloads in dev.
@@ -60,6 +66,7 @@
import '$lib/ae_events/badges/css/badge_layout_epson_4x5_fanfold.css';
import '$lib/ae_events/badges/css/badge_layout_zebra_zc10l_pvc.css';
import { untrack } from 'svelte';
import { browser } from '$app/environment';
import { core_func } from '$lib/ae_core/ae_core_functions';
import { ae_loc, slct } from '$lib/stores/ae_stores';
@@ -133,9 +140,6 @@
$lq__event_badge_template_obj?.duplex == null || !!$lq__event_badge_template_obj?.duplex
);
// Receipt and ticket sections — disabled pending redesign.
let show_receipt = $derived(false);
let show_tickets = $derived(false);
/**
* Layout-aware section heights for Element_fit_text.
@@ -224,9 +228,17 @@
let qr_error_message = $state('');
// --- Demo background pattern (edit mode only, for preview/demo purposes) ---
// 0 = off, 14 = pattern variants. Cycles on each button click.
// 0 = off, 17 = pattern variants. Cycles on each button click.
// Automatically resets to 0 when global edit mode is turned off so the pattern
// never persists into a live print session.
let demo_bg_state = $state(0);
const DEMO_BG_PATTERNS = 5;
const DEMO_BG_PATTERNS = 7;
$effect(() => {
if (!$ae_loc.edit_mode) {
untrack(() => { demo_bg_state = 0; });
}
});
function cycle_demo_bg() {
demo_bg_state = (demo_bg_state + 1) % (DEMO_BG_PATTERNS + 1);
@@ -255,8 +267,11 @@
// 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.
// Paths use corner-to-corner cubic beziers so opposite tile edges match perfectly.
[`<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).
[`<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'],
];
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};`;
@@ -279,22 +294,8 @@
}
});
/* *** BEGIN *** Legacy ticket/option state — move to template config in future */
let option_ticket_1_override = $state('');
let option_ticket_2_override = $state('');
let option_ticket_3_override = $state('');
let option_other_1_override = $state('');
let option_other_2_override = $state('');
let option_ticket_1_display_opt = 'front_bool';
let option_ticket_2_display_opt = 'front_bool';
let option_ticket_3_display_opt = 'front_bool';
let option_other_1_display_opt = 'back_html';
let option_other_2_display_opt = 'back_html';
// Maps badge option field values → Lucide icon components.
// option_1 = dietary/allergy flag; option_2 = special designation (first-time, etc.)
// Maps other_1_code (dietary/allergy) and other_2_code (special designation e.g.
// First Time Attendee) values → Lucide icon components for badge display.
// WHY: Replaces the old code_to_html FA string map — no FontAwesome dependency.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const code_to_icon: { option_1: Record<string, any>; option_2: Record<string, any> } = {
@@ -335,7 +336,7 @@
onclick={cycle_demo_bg}
title="Cycle demo background pattern — edit mode only"
>
🎨 {demo_bg_state > 0 ? ['stripes','dots','diamonds','grid','swirls'][demo_bg_state - 1] : 'demo bg'}
🎨 {demo_bg_state > 0 ? ['stripes','dots','diamonds','grid','swirls','in-grid','mm-grid'][demo_bg_state - 1] : 'demo bg'}
</button>
{/if}
</div>
@@ -396,6 +397,7 @@
>
<img
class="header_image"
class:header_full_width={banner_full_width}
src={$lq__event_badge_template_obj.header_path}
alt="check header path"
/>
@@ -532,24 +534,17 @@
</div>
{/if}
{#if ['front_bool', 'front_back_bool'].includes(option_ticket_1_display_opt) || ['front_bool', 'front_back_bool'].includes(option_ticket_2_display_opt) || ['front_bool', 'front_back_bool'].includes(option_ticket_3_display_opt) || $lq__event_badge_template_obj?.show_qr_front}
<!-- flex-col so ticket icons row and QR stack vertically; QR is centered -->
<!-- Ticket indicators (front) + front QR if template enables it.
ticket_N_code fields drive the Star icons — empty = no indicator. -->
{#if eff_badge?.ticket_1_code || eff_badge?.ticket_2_code || eff_badge?.ticket_3_code || $lq__event_badge_template_obj?.show_qr_front}
<div class="special flex flex-col items-center w-full">
<div class="flex flex-row justify-between w-full">
<span class="badge_body_special_left">
{#if option_ticket_1_override}<span
class="ticket_1_code fg_green"
><Star size="1em" /></span>{/if}
{#if eff_badge?.ticket_1_code}<span class="ticket_1_code fg_green"><Star size="1em" /></span>{/if}
</span>
<span class="badge_body_special_right">
{#if option_ticket_2_override}
<span class="ticket_2_code fg_red"
><Star size="1em" /></span>
{/if}
{#if option_ticket_3_override}
<span class="ticket_3_code fg_blue"
><Star size="1em" /></span>
{/if}
{#if eff_badge?.ticket_2_code}<span class="ticket_2_code fg_red"><Star size="1em" /></span>{/if}
{#if eff_badge?.ticket_3_code}<span class="ticket_3_code fg_blue"><Star size="1em" /></span>{/if}
</span>
</div>
{#if $lq__event_badge_template_obj?.show_qr_front}
@@ -582,14 +577,12 @@
"
title={effective_badge_type_code}
>
{#if option_other_1_override && ['front_bool', 'front_back_bool'].includes(option_other_1_display_opt)}
<span class="badge_footer_special_left"
><Biohazard size="1em" /></span
>
{:else if option_other_1_override && ['front_html', 'front_back_html'].includes(option_other_1_display_opt)}
{@const Icon1 = code_to_icon.option_1[option_other_1_override]}
<!-- other_1_code = dietary/allergy flag (left slot).
Looks up icon from code_to_icon map; falls back to generic Biohazard. -->
{#if eff_badge?.other_1_code}
{@const Icon1 = code_to_icon.option_1[eff_badge.other_1_code]}
<span class="badge_footer_special_left">
{#if Icon1}<Icon1 size="1em" />{/if}
{#if Icon1}<Icon1 size="1em" />{:else}<Biohazard size="1em" />{/if}
</span>
{/if}
@@ -600,14 +593,11 @@
>{badge_type_name}</span
>
{#if option_other_2_override && ['front_bool', 'front_back_bool'].includes(option_other_2_display_opt)}
<span class="badge_footer_special_right"
><Asterisk size="1em" /></span
>
{:else if option_other_2_override && ['front_html', 'front_back_html'].includes(option_other_2_display_opt)}
{@const Icon2 = code_to_icon.option_2[option_other_2_override]}
<!-- other_2_code = special designation (right slot), e.g. First Time Attendee. -->
{#if eff_badge?.other_2_code}
{@const Icon2 = code_to_icon.option_2[eff_badge.other_2_code]}
<span class="badge_footer_special_right">
{#if Icon2}<Icon2 size="1em" />{/if}
{#if Icon2}<Icon2 size="1em" />{:else}<Asterisk size="1em" />{/if}
</span>
{/if}
</div>
@@ -656,6 +646,7 @@
>
<img
class="header_image"
class:header_full_width={banner_full_width}
src={$lq__event_badge_template_obj.secondary_header_path}
alt="check secondary header path"
/>
@@ -664,6 +655,7 @@
<div class="badge_back_header image max-w-xl">
<img
class="header_image"
class:header_full_width={banner_full_width}
src={$lq__event_badge_template_obj.header_path}
alt="check primary header path"
/>
@@ -768,7 +760,7 @@
</ol>
</div>
{#if $lq__event_badge_obj.other_1 || $lq__event_badge_obj.other_2 || $lq__event_badge_obj.ticket_1_code || $lq__event_badge_obj.ticket_2_code || $lq__event_badge_obj.ticket_3_code || $lq__event_badge_obj.ticket_4_code || $lq__event_badge_obj.ticket_5_code}
{#if $lq__event_badge_obj?.other_1_code || $lq__event_badge_obj?.other_2_code || $lq__event_badge_obj?.ticket_1_code || $lq__event_badge_obj?.ticket_2_code || $lq__event_badge_obj?.ticket_3_code || $lq__event_badge_obj?.ticket_4_code || $lq__event_badge_obj?.ticket_5_code}
<div class="attendee_information">
<ul>
{#if $lq__event_badge_obj.ticket_1_code}
@@ -799,26 +791,18 @@
</ul>
{#if $ae_loc.administrator_access}
{#if $lq__event_badge_obj.other_1}
{#if $lq__event_badge_obj?.other_1_code}
{@const BIcon1 = code_to_icon.option_1[$lq__event_badge_obj.other_1_code]}
<div class="other_1">
Other 1: {$lq__event_badge_obj.other_1}
{#if option_other_1_override && ['back_bool', 'front_back_bool'].includes(option_other_1_display_opt)}
- {option_other_1_override}
{:else if option_other_1_override && ['back_html', 'front_back_html'].includes(option_other_1_display_opt)}
{@const BIcon1 = code_to_icon.option_1[option_other_1_override]}
- {option_other_1_override}{#if BIcon1} <BIcon1 size="0.85em" class="inline" />{/if}
{/if}
Other 1: {$lq__event_badge_obj.other_1_code}
{#if BIcon1} <BIcon1 size="0.85em" class="inline" />{/if}
</div>
{/if}
{#if $lq__event_badge_obj.other_2}
{#if $lq__event_badge_obj?.other_2_code}
{@const BIcon2 = code_to_icon.option_2[$lq__event_badge_obj.other_2_code]}
<div class="other_2">
Other 2: {$lq__event_badge_obj.other_2}
{#if option_other_2_override && ['back_bool', 'front_back_bool'].includes(option_other_2_display_opt)}
- {option_other_2_override}
{:else if option_other_2_override && ['back_html', 'front_back_html'].includes(option_other_2_display_opt)}
{@const BIcon2 = code_to_icon.option_2[option_other_2_override]}
- {option_other_2_override}{#if BIcon2} <BIcon2 size="0.85em" class="inline" />{/if}
{/if}
Other 2: {$lq__event_badge_obj.other_2_code}
{#if BIcon2} <BIcon2 size="0.85em" class="inline" />{/if}
</div>
{/if}
{/if}
@@ -919,91 +903,8 @@
{/if}
<!-- *** fold under section end *** -->
<!-- *** receipt section start *** -->
{#if show_receipt}
<section class="receipt hidden">
<div class="receipt_header">
<img
class="badge_logo max-w-sm"
src={$lq__event_badge_template_obj.logo_path}
alt="check badge logo"
/>
<div class="banner_text">
<div class="row_one">
{$lq__event_badge_template_obj.header_row_1}
</div>
<div class="row_two">
{$lq__event_badge_template_obj.header_row_2}
</div>
</div>
</div>
<div class="receipt_content">
<div>
Account Number: <span class="BT_ID">BT_ID</span><br />
Order Number:
<span class="Order_Number">Order_Number</span><br />
</div>
</div>
<div class="receipt_footer">
<p class="printed">Receipt Printed</p>
</div>
</section>
{/if}
<!-- *** receipt section end *** -->
<!-- *** ticket section start *** -->
{#if show_tickets}
<section class="tickets_left_container hidden">
<div class="tickets">
<div class="ticket_container ticket_1">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_1_code}{@html $lq__event_badge_template_obj.ticket_1_text}{/if}
</div>
</div>
<div class="ticket_container ticket_2">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_2_code}{@html $lq__event_badge_template_obj.ticket_2_text}{/if}
</div>
</div>
<div class="ticket_container ticket_3">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_3_code}{@html $lq__event_badge_template_obj.ticket_3_text}{/if}
</div>
</div>
<div class="ticket_container ticket_4">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_4_code}{@html $lq__event_badge_template_obj.ticket_4_text}{/if}
</div>
</div>
</div>
</section>
<section class="tickets_right_container hidden">
<div class="tickets">
<div class="ticket_container ticket_5">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_5_code}{@html $lq__event_badge_template_obj.ticket_5_text}{/if}
</div>
</div>
<div class="ticket_container ticket_6">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_6_code}{@html $lq__event_badge_template_obj.ticket_6_text}{/if}
</div>
</div>
<div class="ticket_container ticket_7">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_7_code}{@html $lq__event_badge_template_obj.ticket_7_text}{/if}
</div>
</div>
<div class="ticket_container ticket_8">
<div class="ticket_body">
{#if $lq__event_badge_obj.ticket_8_code}{@html $lq__event_badge_template_obj.ticket_8_text}{/if}
</div>
</div>
</div>
</section>
{/if}
<!-- *** ticket section end *** -->
<!-- Receipt and standalone ticket tear-off sections reserved for future fanfold layouts.
Not rendered until show_receipt / show_tickets is wired to template config. -->
{/if}
<!-- End if for lq__event_badge_obj && lq__event_badge_template_obj -->
</section>
@@ -1081,4 +982,28 @@
background-color: white;
color: #1a1a1a;
}
/*
* Header image: center horizontally within the badge.
* <img> defaults to display:inline, which left-aligns any image narrower than
* its container. display:block + margin:auto centers it regardless of image width.
* max-width:100% prevents overflow; height:auto preserves aspect ratio.
* Applied to all three header_image uses: front primary, back secondary, back fallback.
*/
.header_image {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
/*
* banner_full_width mode (default on): stretches the header image to the full badge
* width so it fills the banner slot edge-to-edge.
* Toggle off in the controls panel staff section to see the image at its natural
* pixel size — useful for calibration and checking source image dimensions.
*/
.header_image.header_full_width {
width: 100%;
}
</style>

View File

@@ -22,7 +22,7 @@
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions';
import { browser } from '$app/environment';
import { Check, ChevronDown, Info, LoaderCircle, Pencil, Printer, RotateCcw, X } from '@lucide/svelte';
import { Check, ChevronDown, Eye, EyeOff, Info, LoaderCircle, Pencil, Printer, RotateCcw, X } from '@lucide/svelte';
interface Props {
event_id: string;
event_badge_id: string;
@@ -42,6 +42,24 @@
* as the user types — no save required.
*/
preview_overrides?: Record<string, any> | null;
/**
* Per-browser print position calibration (mm). Stored in localStorage by the
* parent page so each workstation/printer can be tuned independently.
* Positive X = shift right; positive Y = shift down.
*/
print_offset_x?: number;
print_offset_y?: number;
/**
* Hides the page header and sys bar for a clean badge-preview workspace.
* Toggle with the [H] keyboard shortcut or this button. Stored in localStorage.
*/
hide_chrome?: boolean;
/**
* When true (default), header images stretch to 100% badge width.
* Toggle off to see the image at its natural pixel size for calibration.
* Stored in localStorage.
*/
banner_full_width?: boolean;
log_lvl?: number;
}
@@ -55,6 +73,10 @@
font_size_affiliations = $bindable(null),
font_size_location = $bindable(null),
preview_overrides = $bindable(null),
print_offset_x = $bindable(0),
print_offset_y = $bindable(0),
hide_chrome = $bindable(false),
banner_full_width = $bindable(true),
log_lvl = 0
}: Props = $props();
@@ -125,6 +147,10 @@
try { return JSON.parse(raw); } catch { return []; }
});
// --- Print position offset configuration ---
const OFFSET_STEP = 0.5; // mm per click
const OFFSET_MAX = 15; // mm in either direction
// --- Font size configuration ---
const FONT_SIZE_STEP = 2;
const FONT_SIZE_DEFAULTS = { name: 58, title: 34, affiliations: 38, location: 34 } as const;
@@ -879,6 +905,42 @@
<span class="flex-1 border-t border-gray-200 dark:border-gray-700"></span>
</div>
<!-- Chrome visibility toggle: hides page header + sys bar for a clean workspace.
Keyboard shortcut [H] does the same thing from anywhere on the page. -->
<div class="flex items-center gap-2 px-2 py-1.5">
<button
type="button"
class="btn btn-sm w-full justify-between"
class:preset-tonal-warning={hide_chrome}
class:preset-tonal-surface={!hide_chrome}
onclick={() => hide_chrome = !hide_chrome}
title={hide_chrome ? 'Show site nav, footer, and menu bar' : 'Hide site nav, footer, and menu bar for a clean workspace'}
>
<span class="flex items-center gap-1.5">
{#if hide_chrome}
<Eye size="13" class="shrink-0" />
Show nav/footer/menu
{:else}
<EyeOff size="13" class="shrink-0" />
Hide nav/footer/menu
{/if}
</span>
<span class="text-[10px] opacity-40">[H]</span>
</button>
</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">
<input
type="checkbox"
class="checkbox"
bind:checked={banner_full_width}
/>
<span class="text-xs">Banner full width</span>
</label>
</div>
<!-- Debug Outlines Toggle (for print testing) -->
<div class="flex items-center gap-2 px-2 py-1.5">
<label class="flex items-center gap-2 cursor-pointer select-none">
@@ -891,6 +953,78 @@
</label>
</div>
<!-- Print Position Offset
Per-browser calibration for physical printer alignment.
Stored in localStorage by the parent page — each workstation keeps
its own values across sessions without any server config change.
Positive X = shift right; positive Y = shift down. 0.5mm steps. -->
<div class="px-2 py-1.5">
<p class="field-label mb-1.5">
Print position
<span class="text-[9px] font-normal normal-case tracking-normal text-gray-400 ml-1">saved to browser</span>
</p>
<div class="space-y-1">
<!-- X axis: left ← / right → -->
<div class="flex items-center gap-1" role="group" aria-label="Horizontal print offset">
<span class="text-[10px] text-gray-400 w-8 shrink-0">L/R</span>
<button
type="button"
class="btn btn-xs preset-tonal-surface px-2 min-h-0 h-6 text-base leading-none"
onclick={() => print_offset_x = Math.max(-OFFSET_MAX, print_offset_x - OFFSET_STEP)}
disabled={print_offset_x <= -OFFSET_MAX}
title="Shift badge left"
aria-label="Shift left"
></button>
<span class="font-mono text-[11px] w-14 text-center tabular-nums"
class:text-gray-400={print_offset_x === 0}
class:text-amber-600={print_offset_x !== 0}
aria-live="polite"
>{print_offset_x > 0 ? '+' : ''}{print_offset_x.toFixed(1)} mm</span>
<button
type="button"
class="btn btn-xs preset-tonal-surface px-2 min-h-0 h-6 text-base leading-none"
onclick={() => print_offset_x = Math.min(OFFSET_MAX, print_offset_x + OFFSET_STEP)}
disabled={print_offset_x >= OFFSET_MAX}
title="Shift badge right"
aria-label="Shift right"
>+</button>
</div>
<!-- Y axis: up ↑ / down ↓ -->
<div class="flex items-center gap-1" role="group" aria-label="Vertical print offset">
<span class="text-[10px] text-gray-400 w-8 shrink-0">U/D</span>
<button
type="button"
class="btn btn-xs preset-tonal-surface px-2 min-h-0 h-6 text-base leading-none"
onclick={() => print_offset_y = Math.max(-OFFSET_MAX, print_offset_y - OFFSET_STEP)}
disabled={print_offset_y <= -OFFSET_MAX}
title="Shift badge up"
aria-label="Shift up"
></button>
<span class="font-mono text-[11px] w-14 text-center tabular-nums"
class:text-gray-400={print_offset_y === 0}
class:text-amber-600={print_offset_y !== 0}
aria-live="polite"
>{print_offset_y > 0 ? '+' : ''}{print_offset_y.toFixed(1)} mm</span>
<button
type="button"
class="btn btn-xs preset-tonal-surface px-2 min-h-0 h-6 text-base leading-none"
onclick={() => print_offset_y = Math.min(OFFSET_MAX, print_offset_y + OFFSET_STEP)}
disabled={print_offset_y >= OFFSET_MAX}
title="Shift badge down"
aria-label="Shift down"
>+</button>
</div>
</div>
{#if print_offset_x !== 0 || print_offset_y !== 0}
<button
type="button"
class="btn btn-xs preset-tonal-warning mt-1.5 w-full"
onclick={() => { print_offset_x = 0; print_offset_y = 0; }}
title="Reset print position to center"
>↺ Reset position</button>
{/if}
</div>
<!-- === BADGE TYPE === (only when template defines badge_type_list) -->
{#if badge_type_code_li.length > 0}
<div class="field-card rounded-lg overflow-hidden" class:field-card--active={active_field === 'badge_type'}>

View File

@@ -9,13 +9,14 @@
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { browser } from '$app/environment';
import { ae_loc } from '$lib/stores/ae_stores';
import { ae_loc, ae_sess } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_loc } from '$lib/stores/ae_events_stores';
import { page } from '$app/state';
import { ArrowLeft, Eye, LoaderCircle, Mail, Printer } from '@lucide/svelte';
import Comp_badge_obj_view_v2 from '../ae_comp__badge_obj_view_v2.svelte';
import Comp_badge_obj_view from '../ae_comp__badge_obj_view.svelte';
import Comp_badge_print_controls from '../ae_comp__badge_print_controls.svelte';
let event_badge_id = $derived(page.params.badge_id);
@@ -102,6 +103,64 @@
let font_size_affiliations: number | null = $state(null);
let font_size_location: number | null = $state(null);
// Per-browser print workstation tweaks.
// Each workstation/printer is calibrated independently — values are stored in
// localStorage so they survive page reloads and browser restarts without any
// server config change.
// WHY localStorage not a store: this is intentionally per-device, not per-user or
// per-event. Different printers at the same venue may need different offsets.
const _PRINT_TWEAKS_KEY = 'ae_badge_print_tweaks';
const _saved_tweaks = (() => {
if (!browser) return null;
try { return JSON.parse(localStorage.getItem(_PRINT_TWEAKS_KEY) ?? 'null'); } catch { return null; }
})();
// Print position offset (mm). Positive X = shift right; positive Y = shift down.
let print_offset_x: number = $state(_saved_tweaks?.x ?? 0);
let print_offset_y: number = $state(_saved_tweaks?.y ?? 0);
// Banner width: true = 100% (production), false = natural pixel size (dev/calibration).
let banner_full_width: boolean = $state(_saved_tweaks?.banner_full_width ?? 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);
// Sync chrome visibility with hide_chrome. Restores all original states on page leave.
// Hides three things:
// 1. $ae_loc.sys_menu.hide — the fixed bottom-right AE sys bar
// 2. $ae_sess.disable_sys_nav — the Events layout <nav class="submenu"> (site header)
// 3. $ae_sess.disable_sys_footer — the Events layout <footer> (site footer)
const _restore_sys_bar = $ae_loc.sys_menu.hide;
const _restore_sys_nav = $ae_sess.disable_sys_nav;
const _restore_sys_footer = $ae_sess.disable_sys_footer;
$effect(() => {
$ae_loc.sys_menu.hide = hide_chrome;
$ae_sess.disable_sys_nav = hide_chrome;
$ae_sess.disable_sys_footer = hide_chrome;
return () => {
$ae_loc.sys_menu.hide = _restore_sys_bar;
$ae_sess.disable_sys_nav = _restore_sys_nav;
$ae_sess.disable_sys_footer = _restore_sys_footer;
};
});
// 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 }));
});
// Inject print offset as @media print CSS. Uses translate() so the centering
// logic in ae-print-badge.css stays intact — this just shifts the final position.
// The effect re-runs whenever either offset changes, removing the old <style> tag
// and inserting a fresh one (always last in <head>, so it wins the cascade).
$effect(() => {
const el = document.createElement('style');
el.textContent = `@media print { .event_badge_wrapper { transform: translate(calc(-50% + ${print_offset_x}mm), calc(-50% + ${print_offset_y}mm)) !important; } }`;
document.head.appendChild(el);
return () => el.remove();
});
// Real-time preview: the controls panel sets this to the current edit field
// value on every keystroke. The badge render merges it over the saved badge
// object so the user can see changes as they type, before saving.
@@ -132,14 +191,32 @@
$lq__event_badge_template_obj?.layout === 'badge_4x5_fanfold' ? '4in 10in' :
'4in 12in' // Default: badge_4x6_fanfold or layout not yet set
);
// @page size + optional per-template margin override from cfg_json.print_margin.
// print_margin_cfg is parsed from the template's cfg_json field — allows per-event
// margin tuning without a code deploy (edit cfg_json in the DB, refresh page).
// For fine-tuning per-printer/workstation, use the print_offset_x/y controls instead.
$effect(() => {
const m = print_margin_cfg;
const margin_val = m
? `${m.top ?? '0'} ${m.right ?? '0'} ${m.bottom ?? '0'} ${m.left ?? '0'}`
: '0';
const el = document.createElement('style');
el.textContent = `@page { size: ${page_size_css}; margin: 0; }`;
el.textContent = `@page { size: ${page_size_css}; margin: ${margin_val}; }`;
document.head.appendChild(el);
return () => el.remove();
});
</script>
<svelte:window onkeydown={(e) => {
// [H] key: toggle header/sys-bar chrome for a clean print workspace.
// Guard: don't fire when typing in form fields.
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
if (e.key === 'h' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
hide_chrome = !hide_chrome;
}
}} />
<svelte:head>
<title>
&AElig;: Print Badge —
@@ -188,8 +265,10 @@
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
<!-- Kiosk header: screen-only chrome. Minimal — the badge render is the focus. -->
<header class="print:hidden w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-3 pb-2">
<!-- Kiosk header: screen-only chrome. Minimal — the badge render is the focus.
Hidden when hide_chrome is on (toggle with [H] or the controls panel button). -->
<header class="print:hidden w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-3 pb-2"
class:hidden={hide_chrome}>
<!-- Left: Back + attendee name + status indicator -->
<div class="flex flex-row gap-2 items-center min-w-0">
@@ -283,7 +362,7 @@
<div id="badge_render_area" class="print:pr-0 transition-all duration-200" class:pr-80={is_editing} class:pr-64={!is_editing}>
<!-- null → Element_fit_text auto-scales; a number → manual size override from controls. -->
<!-- preview_overrides: non-null while a field card is open in controls — gives live preview. -->
<Comp_badge_obj_view_v2
<Comp_badge_obj_view
event_id={$lq__event_badge_obj.event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
@@ -294,6 +373,7 @@
font_size_affiliations={font_size_affiliations}
font_size_location={font_size_location}
{preview_overrides}
{banner_full_width}
/>
</div>
@@ -301,8 +381,9 @@
Normally w-64 (256px); expands to w-80 (320px) while a field is being edited
so the scaled value text and form inputs have room without clipping.
Fixed right-0 means width growth expands leftward into the badge area.
top-20 clears the page header (~80px). Independently scrollable. -->
<div class="print:hidden fixed right-0 top-20 bottom-0 overflow-y-auto z-30
top-20 clears the page header (~80px). Independently scrollable.
bottom-24: clears the sys bar (fixed bottom-12 h-9 = 84px from bottom). -->
<div class="print:hidden fixed right-0 top-20 bottom-24 overflow-y-auto z-30
border-l border-gray-200 dark:border-gray-700
bg-white dark:bg-zinc-900 p-2
transition-all duration-200"
@@ -318,6 +399,10 @@
bind:font_size_affiliations
bind:font_size_location
bind:preview_overrides
bind:print_offset_x
bind:print_offset_y
bind:hide_chrome
bind:banner_full_width
/>
</div>

View File

@@ -3,7 +3,7 @@
import { events_func } from '$lib/ae_events_functions';
import { ae_api } from '$lib/stores/ae_stores';
import { events_slct } from '$lib/stores/ae_events_stores';
import Comp_badge_obj_view from '../[badge_id]/ae_comp__badge_obj_view_v2.svelte';
import Comp_badge_obj_view from '../[badge_id]/ae_comp__badge_obj_view.svelte';
import type { ae_EventBadge } from '$lib/types/ae_types';
import { ArrowLeft, Printer } from '@lucide/svelte';
interface Props {

View File

@@ -80,7 +80,10 @@
}
/* ============================================================
TEMPORARY DEBUG OUTLINES — remove before going live
Debug outlines — toggled via "Show debug outlines" checkbox
in the Staff section of the print controls panel. Gated on
the html.debug_outlines class set by ae_comp__badge_print_controls.
These are intentionally permanent — not temporary.
============================================================ */
html.debug_outlines {
outline: 3px dashed lime !important;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,6 @@
<svg width="332" height="72" viewBox="0 0 332 72" xmlns="http://www.w3.org/2000/svg">
<rect width="332" height="72" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1" />
<rect x="53" y="24" width="48" height="12" rx="4" fill="#ef4444" fill-opacity="0.4" stroke="#b91c1c" />
<rect x="231" y="24" width="48" height="12" rx="4" fill="#ef4444" fill-opacity="0.4" stroke="#b91c1c" />
<text x="166" y="42" font-family="monospace" font-size="10" fill="#ef4444" text-anchor="middle">CLIP SLOT DANGER ZONE</text>
</svg>

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,20 @@
<svg width="332" height="72" viewBox="0 0 332 72" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e3a8a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
<pattern id="hexagons" width="20" height="17.32" patternUnits="userSpaceOnUse" patternTransform="scale(0.5)">
<path d="M10 0 L20 5.77 L20 17.32 L10 23.09 L0 17.32 L0 5.77 Z" fill="none" stroke="white" stroke-width="1" stroke-opacity="0.1" />
</pattern>
<filter id="softShadow" x="0" y="0" width="200%" height="200%">
<feOffset result="offOut" in="SourceAlpha" dx="1" dy="1" />
<feGaussianBlur result="blurOut" in="offOut" stdDeviation="1" />
<feBlend in="SourceGraphic" in2="blurOut" mode="normal" />
</filter>
</defs>
<rect width="332" height="72" fill="url(#grad)" />
<rect width="332" height="72" fill="url(#hexagons)" />
<text x="166" y="32" font-family="sans-serif" font-weight="bold" font-size="18" fill="white" text-anchor="middle" filter="url(#softShadow)">ONE SKY IT</text>
<text x="166" y="50" font-family="sans-serif" font-size="11" font-style="italic" fill="#93c5fd" text-anchor="middle">AETHER PLATFORM</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB