6 Commits

Author SHA1 Message Date
Scott Idem
a68b85e1f1 fix(badges): use direct token classes for field_actions buttons
btn + preset-filled-* renders transparent on gray/surface backgrounds
(Skeleton v4 CSS variable specificity issue — documented in
GUIDE__AE_UI_Style_Guidelines.md §12).

Replace all three buttons in field_actions (Save, Revert, Cancel) with
direct Tailwind token classes: bg-warning-500, bg-error-500,
bg-success-500, bg-surface-200-800 etc. Save button now visibly renders
in amber (dirty), red + pulse (pending_close), green (saved).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:19:03 -04:00
Scott Idem
0199c2e2c9 fix(badges): guard unsaved edits — warn on close, error on second close
When a field accordion has unsaved changes and the user tries to close
(X button, same-header click, or switching to another field), we now set
pending_close = true instead of silently discarding.

- Save button turns bright red + animate-pulse with label "Save first (or × to discard)"
- X button turns red with "Discard changes" tooltip
- Field stays open — no data is lost
- Second close attempt (pending_close already true) actually discards
- Saving normally clears pending_close and closes the accordion

WHY: kiosk attendees at a live event were silently losing typed overrides
(professional title, affiliations, etc.) when switching fields mid-queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:07:26 -04:00
Scott Idem
126eb77be2 fix(badges): cancel edit state on field switch, not just on explicit cancel
toggle_field only changed active_field — it never called cancel_field for the
previously open field. Unsaved typed values stayed in edit_full_name_override etc.,
so reopening a field would show the stale typed value and re-apply it to the badge
preview, even though the user had already moved on.

New logic: capture was_open, always call cancel_field for the current field (resets
edit vars + sets active_field = null), then open the new field if it wasn't the one
being closed. Closing a field by re-clicking its pencil now also discards unsaved state,
consistent with the explicit [X] button behavior.

Also: add global placeholder CSS fix to TODO__Agents.md (scoped workaround already
in ae_comp__badge_print_controls; long-term fix belongs in app.css or theme file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:55:22 -04:00
Scott Idem
7733ef8708 fix(badges): auto-focus input after accordion animation + mute placeholder text
Auto-focus: requestAnimationFrame (~16ms) fired before the 185ms accordion
animation ended — input was still 0px/clipped so focus() silently failed.
Changed to setTimeout(210ms) so focus lands after the animation completes.

Placeholder color: placeholders show the current badge value (e.g. 'John Smith')
so without explicit styling they look identical to filled text. Added scoped
CSS rules setting placeholder to gray-400 (light) / gray-500 (dark) so it reads
clearly as a hint rather than existing content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:42:58 -04:00
Scott Idem
a81d65ce7e fix(badges): detect and surface PATCH failure in handle_print_badge
update_ae_obj__event_badge returns false on API failure without throwing,
so the old code always treated a failed PATCH as success — badge printed,
count not saved, page navigated away silently.

Now: check the return value explicitly. On failure — still fire window.print()
(physical print must never be blocked) and still navigate back, but show a
visible red error state ('Printed — count NOT saved (see staff)') and hold
for 4s so a kiosk operator can see it before the loop resets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:36:56 -04:00
Scott Idem
3d81cb5a83 fix(badges): strip milliseconds/Z from datetime before PATCH
MariaDB rejects ISO 8601 with milliseconds ('2026-04-14T20:29:15.784Z').
print_last_datetime and print_first_datetime must be 'YYYY-MM-DDTHH:MM:SS'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:32:07 -04:00
2 changed files with 156 additions and 27 deletions

View File

@@ -145,6 +145,26 @@ functionality, but pollutes the console and may cause unhandled promise rejectio
- **Badge print controls UX polish:** Scott has improvements in mind — TBD next session.
File: `ae_comp__badge_print_controls.svelte`.
### [CSS] Global placeholder text color — too dark in light mode
Placeholder text inherits full input text color in light mode (Tailwind CSS default), making
placeholders indistinguishable from filled-in values. Most visible in badge print controls
where placeholders show the actual badge value (e.g. "John Smith").
Workaround: scoped `::placeholder` rule added to `ae_comp__badge_print_controls.svelte`
(gray-400 light / gray-500 dark) — `commit 7733ef8`.
**Long-term fix:** Add a global rule to the main CSS (e.g. `src/app.css` or a theme file):
```css
::placeholder {
color: #9ca3af; /* gray-400 */
opacity: 1; /* overrides Firefox's 0.54 default */
}
.dark ::placeholder {
color: #6b7280; /* gray-500 */
}
```
Once the global rule is in place, remove the scoped workaround from the badge controls.
### [Leads] Exhibitor Lead Scanning — IN PROGRESS (demo-ready prep)

View File

@@ -215,7 +215,9 @@ async function handle_print_badge() {
if (!$lq__event_badge_obj?.event_badge_id) return;
print_status = 'loading';
const now = new Date().toISOString();
// MariaDB datetime columns do not accept ISO 8601 with milliseconds or 'Z' suffix.
// Slice to 'YYYY-MM-DDTHH:MM:SS' — the format the DB expects.
const now = new Date().toISOString().slice(0, 19);
const is_first_print = print_count === 0;
const data_to_update: key_val = {
print_count: print_count + 1,
@@ -224,13 +226,31 @@ async function handle_print_badge() {
if (is_first_print) data_to_update.print_first_datetime = now;
try {
await events_func.update_ae_obj__event_badge({
const result = await events_func.update_ae_obj__event_badge({
api_cfg: $ae_api,
event_id,
event_badge_id: $lq__event_badge_obj.event_badge_id,
data_kv: data_to_update,
log_lvl
});
// update_ae_obj__event_badge returns false/null on API failure without throwing.
// A falsy result means the PATCH failed (e.g. 400/401/500) — the DB was NOT updated.
// We still print the physical badge (never block the physical print), but show a
// visible error so staff can catch a runaway "already printed" situation and fix
// the count manually via the Admin print count field in the Staff section.
if (!result) {
console.error('Badge print controls: PATCH returned falsy — DB count not updated.');
print_status = 'error';
// Still fire the physical print — the attendee must get their badge.
if (browser) window.print();
// Hold the error state long enough for a kiosk operator to notice before
// the loop returns to search for the next attendee.
await new Promise<void>((r) => setTimeout(r, 4000));
if (browser) window.location.href = `/events/${event_id}/badges`;
return;
}
print_status = 'done';
// Trigger browser print dialog after count is recorded
if (browser) window.print();
@@ -241,9 +261,9 @@ async function handle_print_badge() {
} catch (err) {
console.error('Badge print controls: print error:', err);
print_status = 'error';
setTimeout(() => {
print_status = 'idle';
}, 3000);
if (browser) window.print();
await new Promise<void>((r) => setTimeout(r, 4000));
if (browser) window.location.href = `/events/${event_id}/badges`;
}
}
@@ -321,8 +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) {
active_field = active_field === key ? null : key;
// Capture whether the caller is re-clicking the field that's already open.
const was_open = active_field === key;
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) {
// Clicking the same field header again: same guard as the X button.
cancel_field(key);
return;
}
pending_close = false;
active_field = key;
}
// --- Editable field state ---
@@ -384,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' };
@@ -402,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;
@@ -557,7 +632,10 @@ let select_ref_badge_type: HTMLSelectElement | undefined = $state();
$effect(() => {
const field = active_field;
if (!field) return;
requestAnimationFrame(() => {
// Wait for the accordion CSS animation (185ms ease-out) to finish before focusing.
// requestAnimationFrame fires at ~16ms — the input is still 0px / overflow:hidden
// at that point, so focus() silently fails. 210ms clears the animation safely.
setTimeout(() => {
switch (field) {
case 'name':
input_ref_name?.focus();
@@ -581,7 +659,7 @@ $effect(() => {
select_ref_badge_type?.focus();
break;
}
});
}, 210);
});
// --- Dirty detection: compare live edit value against the last-saved badge obj ---
@@ -840,11 +918,16 @@ let allow_tracking_open = $derived(
)}
{@const status = field_save_status[field_key]}
{@const save_visible = is_dirty || (status && status !== 'idle')}
<!-- btn + preset-filled-* goes transparent on gray/surface backgrounds (Skeleton v4
CSS variable specificity issue — see GUIDE__AE_UI_Style_Guidelines.md §12).
Use direct Tailwind token classes for all three buttons here. -->
<div class="mt-2 flex gap-2">
<!-- Revert: always occupies left slot; invisible when no override exists -->
<button
type="button"
class="btn btn-sm preset-tonal-warning shrink-0"
class="flex h-8 shrink-0 cursor-pointer items-center justify-center rounded-lg px-2
bg-warning-500/15 text-warning-700 dark:text-warning-300
hover:bg-warning-500/30 transition-colors"
class:invisible={!on_revert}
class:pointer-events-none={!on_revert}
onclick={on_revert}
@@ -853,41 +936,54 @@ let allow_tracking_open = $derived(
title="Remove override restore original imported value"
aria-label="Revert to original value"
aria-hidden={!on_revert}><RotateCcw size="13" /></button>
<!-- Save: always in centre slot; invisible+inert when clean -->
<!-- Save: always in centre slot; invisible+inert when clean.
Turns bright red + pulses when pending_close is set — the user tried
to close while dirty. They must save OR press X again to discard. -->
<button
type="button"
class="btn btn-sm flex-1 transition-colors"
class="flex h-8 flex-1 cursor-pointer items-center justify-center gap-1
rounded-lg px-3 text-sm font-semibold text-white transition-colors
disabled:cursor-not-allowed disabled:opacity-50"
class:invisible={!save_visible}
class:pointer-events-none={!save_visible}
class:preset-filled-warning={save_visible &&
is_dirty &&
(!status || status === 'idle')}
class:preset-tonal-surface={status === 'saving'}
class:preset-filled-success={status === 'done'}
class:preset-tonal-error={status === 'error'}
class:bg-error-500={(pending_close && is_dirty && (!status || status === 'idle')) || (status === 'error' && !pending_close)}
class:animate-pulse={pending_close && is_dirty && (!status || status === 'idle')}
class:bg-warning-500={!pending_close && save_visible && is_dirty && (!status || status === 'idle')}
class:bg-surface-400={status === 'saving'}
class:bg-success-500={status === 'done'}
disabled={!save_visible || status === 'saving'}
tabindex={save_visible ? 0 : -1}
onclick={on_save}
title="Save changes"
title={pending_close ? 'Unsaved changes — save or press × to discard' : 'Save changes'}
aria-label="Save changes"
aria-hidden={!save_visible}>
{#if status === 'saving'}
<LoaderCircle size="14" class="mr-1 animate-spin" /> Saving…
<LoaderCircle size="14" class="animate-spin" /> Saving…
{:else if status === 'done'}
<Check size="14" class="mr-1" /> Saved
{:else if status === 'error'}
<Check size="14" /> Saved
{:else if status === 'error' && !pending_close}
Error — retry
{:else if pending_close}
Save first (or × to discard)
{:else}
<Check size="14" class="mr-1" /> Save
<Check size="14" /> Save
{/if}
</button>
<!-- Cancel: always visible at right end -->
<!-- Cancel: always visible at right end.
When pending_close is active, turns red so the user knows
a second tap will actually discard their edit. -->
<button
type="button"
class="btn btn-sm preset-tonal-surface"
class="flex h-8 cursor-pointer items-center justify-center rounded-lg px-2 transition-colors"
class:bg-surface-200-800={!pending_close}
class:hover:bg-surface-300-700={!pending_close}
class:bg-error-500={pending_close}
class:text-white={pending_close}
onclick={on_cancel}
title="Cancel"
aria-label="Cancel editing"><X size="14" /></button>
title={pending_close ? 'Discard changes' : 'Cancel'}
aria-label={pending_close ? 'Discard changes' : 'Cancel editing'}>
<X size="14" />
</button>
</div>
{/snippet}
@@ -922,7 +1018,7 @@ let allow_tracking_open = $derived(
{:else if print_status === 'done'}
<Check size="14" /> Printed!
{:else if print_status === 'error'}
Error — try again
Printed — count NOT saved (see staff)
{:else}
<Printer size="14" />
{is_printed ? `Reprint (${print_count}×)` : 'Print Badge'}
@@ -2111,6 +2207,19 @@ let allow_tracking_open = $derived(
font-size: 1.05rem;
}
/* Placeholder text must read clearly as a hint, not as filled content.
The placeholders show the current badge value (e.g. "John Smith") so
without this they look identical to a filled-in field. */
.ctrl-accordion-inner input::placeholder,
.ctrl-accordion-inner textarea::placeholder {
color: #9ca3af; /* gray-400 — visually distinct from input text */
opacity: 1; /* Firefox sets opacity: 0.54 by default; override it */
}
:global(.dark) .ctrl-accordion-inner input::placeholder,
:global(.dark) .ctrl-accordion-inner textarea::placeholder {
color: #6b7280; /* gray-500 in dark mode — readable but clearly a hint */
}
/* ---- Entrance animation for form content ----
Triggered each time .open is applied to the parent accordion.
Pairs with the height animation: content fades + zooms in from