diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte
index 822c54d7..11451182 100644
--- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte
+++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte
@@ -341,20 +341,53 @@ function font_size_reset(
// --- Accordion: one field section open at a time ---
let active_field: string | null = $state(null);
+// --- Unsaved-change guard ---
+// When the user tries to close or switch away from a dirty field, we DON'T
+// silently discard. Instead we set pending_close = true, which turns the Save
+// button bright red + pulsing so they notice they have unsaved changes.
+// A second close attempt (while pending_close is already true) actually discards.
+// WHY: kiosk attendees at a conference are confused when changes disappear without
+// warning; they can't lose a professional title or affiliation mid-queue.
+let pending_close = $state(false);
+
+/** Returns the dirty state for the given field key. */
+function is_dirty_for_field(field_key: string): boolean {
+ switch (field_key) {
+ case 'name': return is_dirty_name;
+ case 'title': return is_dirty_title;
+ case 'affiliations': return is_dirty_affiliations;
+ case 'location': return is_dirty_location;
+ case 'pronouns': return is_dirty_pronouns;
+ case 'allow_tracking': return is_dirty_allow_tracking;
+ case 'badge_type': return is_dirty_badge_type;
+ default: return false;
+ }
+}
+
function toggle_field(key: string) {
- // Capture whether the caller is closing the field that's already open.
+ // Capture whether the caller is re-clicking the field that's already open.
const was_open = active_field === key;
- // Always cancel the current field before switching — discards any unsaved edit
- // state so stale typed values don't bleed into the badge preview after the
- // accordion closes. The explicit [X] button calls cancel_field directly;
- // clicking away to another field (or re-clicking the pencil) must do the same.
- if (active_field) {
- cancel_field(active_field); // resets edit vars + sets active_field = null
+
+ if (active_field && !was_open) {
+ // User is trying to switch to a different field.
+ if (is_dirty_for_field(active_field) && !pending_close) {
+ // Dirty — warn instead of switching. The Save button turns red so
+ // the user knows they need to save (or cancel) the current field first.
+ pending_close = true;
+ return; // block the switch
+ }
+ // Either clean, or second attempt after warning — discard and switch.
+ cancel_field(active_field);
}
- if (!was_open) {
- active_field = key; // open the new field
+
+ if (was_open) {
+ // Clicking the same field header again: same guard as the X button.
+ cancel_field(key);
+ return;
}
- // If was_open: active_field stays null — the field is now closed.
+
+ pending_close = false;
+ active_field = key;
}
// --- Editable field state ---
@@ -416,6 +449,7 @@ async function save_field(field_key: string, data_kv: key_val) {
log_lvl
});
field_save_status = { ...field_save_status, [field_key]: 'done' };
+ pending_close = false;
// liveQuery auto-refreshes the badge; close the accordion after a brief confirmation.
setTimeout(() => {
field_save_status = { ...field_save_status, [field_key]: 'idle' };
@@ -434,6 +468,15 @@ async function save_field(field_key: string, data_kv: key_val) {
}
function cancel_field(field_key: string) {
+ // First cancel attempt while dirty → warn, don't discard.
+ // Second attempt (pending_close already set) → actually discard.
+ if (is_dirty_for_field(field_key) && !pending_close) {
+ pending_close = true;
+ return;
+ }
+
+ pending_close = false;
+
if (!$lq__event_badge_obj) {
active_field = null;
return;
@@ -888,41 +931,54 @@ let allow_tracking_open = $derived(
title="Remove override — restore original imported value"
aria-label="Revert to original value"
aria-hidden={!on_revert}>
-
+
-
+
+ title={pending_close ? 'Discard changes' : 'Cancel'}
+ aria-label={pending_close ? 'Discard changes' : 'Cancel editing'}>
+
+
{/snippet}