feat: badge v2 auto-scaling text with Element_fit_text

Adds binary-search font auto-scaling for badge text fields, replacing
the character-count heuristic in v1. New files:

- action_fit_text.ts: Svelte action using binary search + MutationObserver
  + ResizeObserver. Pass null to disable (manual override mode).
- element_fit_text.svelte: Component wrapper with min/max/manual_size/
  height/width props. height prop required for overflow detection to work.
- ae_comp__badge_obj_view_v2.svelte: Badge render using Element_fit_text
  for name/title/affiliations/location in display mode. font_size_* props
  default to undefined (auto-scale) instead of numeric defaults.
  fit_heights derived object provides layout-aware section heights for
  badge_3.5x5.5_pvc, badge_4x5_fanfold, and badge_4x6_fanfold layouts.
  flex_justify() maps shorthand ('around','between','even') to CSS values.
  Edit mode uses plain divs — inputs are never auto-scaled.

print/+page.svelte: Added v1/v2 toggle button in header. V1 preserved
as fallback. font_size_* passed as null (not ?? undefined) to v2 so
auto-scaling is active by default; manual override from print controls
still disables it per-field.

Docs: PROJECT__AE_Events_Badges_Review_Print.md updated with kiosk
workflow design intent, email address rule (always event_badge.email),
permission model alignment gap (TASK 4.0), and v2 implementation status.
TODO__Agents.md: completed items removed, badge polish tasks updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-12 13:23:47 -04:00
parent dfad2831d6
commit 2198c55e27
6 changed files with 1789 additions and 38 deletions

View File

@@ -1,7 +1,7 @@
# PROJECT: AE Events Badges — Review Form & Print Font Controls # PROJECT: AE Events Badges — Review Form & Print Font Controls
**Created:** 2026-02-27 **Created:** 2026-02-27
**Last Updated:** 2026-03-02 **Last Updated:** 2026-03-12
**Branch:** `ae_app_3x_llm` **Branch:** `ae_app_3x_llm`
**Priority:** HIGH — first live event is Axonius, NYC, mid-April 2026 **Priority:** HIGH — first live event is Axonius, NYC, mid-April 2026
**Owner:** Scott Idem / One Sky IT **Owner:** Scott Idem / One Sky IT
@@ -9,9 +9,72 @@
--- ---
## Design Intent — Two Complementary Flows
### Flow 1: Remote Badge Review (email link)
- Staff emails a review link to the attendee before the event.
- Attendee opens the link on their own device, reviews their badge info, and edits permitted fields.
- **Email address rule:** Always send to `event_badge.email` — never `email_override`.
`email_override` is a display/badge field only. It cannot be trusted as a delivery address
(attendee may have changed it to something different for badge display purposes).
- Component: `ae_comp__badge_review_form.svelte` — plain form, no badge render.
- Route: `/events/[event_id]/badges/[badge_id]/review/`
### Flow 2: Kiosk / Onsite Badge Station (print page)
- Hardware: a laptop + badge printer (Epson fanfold or Zebra PVC card) at the check-in table.
- At the event, an attendee walks up to a badge station (check-in kiosk).
- A staff member or volunteer pulls up the attendee's badge on the print page.
- The **print page is a kiosk tool**, not just a print queue:
- Attendee reviews their badge info and can edit permitted fields **in real time**,
with the live badge render updating as they make changes.
- Staff/volunteers are present to assist with any questions.
- 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`
- Route: `/events/[event_id]/badges/[badge_id]/print/`
### Permission Model — Same Logic, Both Flows
Both flows should respect the same permission model:
- **Attendee-level** (basic Authenticated access): can edit `pronouns_override`,
`full_name_override`, `professional_title_override`, `affiliations_override`,
`location_override`, `phone_override`, `email_override`, `allow_tracking`, `agree_to_tc`.
- **Staff-level** (trusted_access+): all attendee fields + `email`, `badge_type_code_override`,
`badge_type_override`, `hide`, `priority`, `notes`, and font size controls.
- Permissions are configured per-event in `event.mod_badges_json.edit_permissions`.
Hardcoded defaults are used until that config is implemented.
**Current gap (TASK 4):** The print page edit button is currently gated to trusted_access only.
It needs to be accessible to attendees at the kiosk (with appropriate field-level gating),
matching the permission model already implemented in `ae_comp__badge_review_form.svelte`.
---
## Next Up for Badges (TASK 4) ## Next Up for Badges (TASK 4)
### 1. QR Code on Badge Front — `ae_comp__badge_obj_view.svelte` ### 0. Kiosk Editing — Print Page Permission Model Alignment
**This is the most important gap before the first live event.**
Currently the print page edit button is staff-only (trusted_access gate). At the kiosk,
attendees need to be able to edit their own fields (same attendee-level permissions as the
review form), with staff-only fields gated appropriately.
Work needed:
- Wire the same `can_edit_fields` / `can_edit(field)` permission logic into the print page
that `ae_comp__badge_review_form.svelte` already uses.
- The edit panel on the print page should show attendee-editable fields to all authenticated
users, and staff-only fields to trusted_access+.
- The badge render (v1 or v2) should update live as the attendee edits fields.
- Consider whether the print page needs its own inline edit panel (sidebar or overlay)
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).
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.
### 2. QR Code on Badge Front — `ae_comp__badge_obj_view.svelte`
The badge template has a `show_qr` flag (or similar). When toggled on, the QR code should The badge template has a `show_qr` flag (or similar). When toggled on, the QR code should
appear on the front face of the printed badge. Currently QR is only shown on the review form. appear on the front face of the printed badge. Currently QR is only shown on the review form.
@@ -30,18 +93,38 @@ appear on the front face of the printed badge. Currently QR is only shown on the
- Must be hidden on `ae_comp__badge_obj_view.svelte` when `show_qr` is falsy. - Must be hidden on `ae_comp__badge_obj_view.svelte` when `show_qr` is falsy.
### 2. Badge Print Controls — UX Improvements (ae_comp__badge_print_controls.svelte) ### 2. Badge Print Controls — UX Improvements (ae_comp__badge_print_controls.svelte)
- Scott has identified areas for improvement — TBD next session
- Consider: keyboard shortcuts (+ / -) for font sizing while a field is active - Consider: keyboard shortcuts (+ / -) for font sizing while a field is active
- Consider: "Apply to all badges" workflow for font size presets - Consider: "Apply to all badges" workflow for font size presets
### 3. Leads Module ### 4. Leads Module
Next major work after badge polish. See `documentation/MODULE__AE_Events_Leads.md` (if it Next major work after badge polish. See `documentation/MODULE__AE_Events_Leads.md` (if it
exists) for context. Exhibitor lead scanning via QR code. exists) for context. Exhibitor lead scanning via QR code at exhibitor booth → capture attendee
badge data, gated by `allow_tracking` on the badge.
--- ---
## Implementation Status ## Implementation Status
### ⏳ TASK 4.0: Kiosk Editing — NOT STARTED (2026-03-12)
Print page edit access needs to be opened to attendee-level permissions, not just trusted_access.
The permission model, field list, and `can_edit()` helper from `ae_comp__badge_review_form.svelte`
should be the reference. See Design Intent section above.
### ⏳ TASK 4.1: Auto-Scaling Badge Text v2 — IN PROGRESS (2026-03-12)
**Files created:**
- `src/lib/elements/action_fit_text.ts` — Svelte action: binary-search font scaling with
MutationObserver + ResizeObserver + requestAnimationFrame.
- `src/lib/elements/element_fit_text.svelte` — Component wrapper. Key prop: `height` (required
for binary search to work — without it, offsetHeight == scrollHeight always).
- `src/routes/events/.../ae_comp__badge_obj_view_v2.svelte` — V2 badge render using
Element_fit_text for name/title/affiliations/location in display mode.
`fit_heights` derived object provides layout-aware heights per field per badge layout.
`font_size_*` props default to `undefined` (auto-scale) rather than numeric defaults (v1 behavior).
Manual overrides from print controls still work — any number disables auto-scale for that field.
**Toggle:** `v1`/`v2` button in print page header. V1 preserved as fallback.
**Status:** Working — heights in `fit_heights` still need visual tuning with real badge stock.
### ✅ TASK 3: Badge Print Controls Panel — COMPLETE (2026-03-02) ### ✅ TASK 3: Badge Print Controls Panel — COMPLETE (2026-03-02)
**Files created/modified:** **Files created/modified:**

View File

@@ -2,8 +2,6 @@
> Use this file to track steps for complex features or bug fixes. > Use this file to track steps for complex features or bug fixes.
> **Status:** <20> Stable — ongoing development. > **Status:** <20> Stable — ongoing development.
## 📋 Open: Security
- [x] **PUBLIC_AE_API_SECRET_KEY Audit:** Completed 2026-03-11. Key is `PUBLIC_*` by design (always in client bundle). Highest-risk anonymous path now uses limited-permission `PUBLIC_AE_BOOTSTRAP_KEY`. Full server-side migration would require a major API proxy refactor — not justified given JWT + account_id auth layers. `manifest.webmanifest/+server.ts` is a minor cleanup candidate (could use bootstrap key instead), but no security urgency. Current state is acceptable.
## 🚧 Upcoming High Priority ## 🚧 Upcoming High Priority
@@ -46,27 +44,10 @@ lead record look like in the DB?
- **`window.print()` for badge print button:** Wire the existing `handle_print_badge()` to trigger `window.print()`. Browser print works well across Chrome/Chromium/Firefox — no Electron needed. - **`window.print()` for badge print button:** Wire the existing `handle_print_badge()` to trigger `window.print()`. Browser print works well across Chrome/Chromium/Firefox — no Electron needed.
- **Input Field Audit:** Several input fields are missing `name`/`id` attributes or `data-testid`. Known examples: badge override fields in `ae_comp__badge_obj_view.svelte`; template name input in `ae_comp__badge_template_form.svelte`. Matters for: accessibility, autofill, label associations, and test targeting. (For tests, use `getByLabel()` rather than `input[value*=...]` which only checks the HTML attribute, not the Svelte-bound DOM property.) - **Input Field Audit:** Several input fields are missing `name`/`id` attributes or `data-testid`. Known examples: badge override fields in `ae_comp__badge_obj_view.svelte`; template name input in `ae_comp__badge_template_form.svelte`. Matters for: accessibility, autofill, label associations, and test targeting. (For tests, use `getByLabel()` rather than `input[value*=...]` which only checks the HTML attribute, not the Svelte-bound DOM property.)
### [UX] Session Expired & Access Denied (identified 2026-03-10)
Two related UX gaps to handle together:
**1. Session Expired banner (API 401/403 mid-session):**
- `flag_expired` in root `+layout.svelte` is declared but never set — it was always intended for this
- Add a small writable store or custom event (e.g., `ae_auth_error` in `ae_stores`) that API helpers (`api_get_object.ts`, `api_post_object.ts`, `api_patch_object.ts`) can fire when they get a 401 or 403
- Root layout watches the store and sets `flag_expired = true`
- Render a non-blocking dismissible banner (not full-screen): "Session expired. Please sign in again." with a link to the sign-in control
- Especially relevant for Launcher (event staff on tablets may not notice silent failures)
**2. Standardize Access Denied UI (non-IDAA routes only — IDAA layout is intentionally custom):**
- Currently inconsistent across the app:
- Root layout: full-screen `flag_denied` (site access key gate — keep this, it's correct)
- `/core` layout: silent redirect to home — should show a brief message instead
- `/events/[event_id]/settings`: inline raw text string — should use a consistent banner component
- `/events/.../badges/.../review`: inline `<h3>Access Denied</h3>` with no context or action
- Create a reusable `element_access_denied.svelte` component (small: icon + message + optional action button)
- Swap the ad-hoc patterns to use it consistently
## ✅ Completed (2026-03) ## ✅ Completed (2026-03)
- [x] **[Security]** `PUBLIC_AE_API_SECRET_KEY` audit complete. Key is `PUBLIC_*` by design (always in client bundle). Highest-risk anonymous path uses limited-permission `PUBLIC_AE_BOOTSTRAP_KEY`. Full server-side migration not justified given JWT + account_id auth layers. Current state acceptable. (2026-03-11)
- [x] **[UX]** Session Expired banner — `ae_auth_error` store wired to API helpers; root layout sets `flag_expired` on 401/403; non-blocking dismissible banner rendered. (2026-03-12)
- [x] **[UX]** Access Denied UI standardized — `element_access_denied.svelte` created; `/core` layout, `/events/settings`, and `/events/badges/review` updated to use it. (2026-03-12)
- [x] **[Build]** Rollup/Vite circular dependency warnings eliminated — `manualChunks` in `vite.config.ts` colocates all `svelte/*` internals into a single `svelte-vendor` chunk, preventing `runtime.js` / `index-client.js` split (~35 warnings gone). (2026-03-11) - [x] **[Build]** Rollup/Vite circular dependency warnings eliminated — `manualChunks` in `vite.config.ts` colocates all `svelte/*` internals into a single `svelte-vendor` chunk, preventing `runtime.js` / `index-client.js` split (~35 warnings gone). (2026-03-11)
- [x] **[Refactor]** `try_cache` audit + sponsorship/event_file/hosted_file SWR alignment — removed vestigial `try_cache` params from `generate_qr_code`, `ae_core_functions` wrappers; added SWR fast/slow path to sponsorship loaders; changed `event_file` and `hosted_file` single-object loader defaults from `false``true` for consistency. (2026-03-11) - [x] **[Refactor]** `try_cache` audit + sponsorship/event_file/hosted_file SWR alignment — removed vestigial `try_cache` params from `generate_qr_code`, `ae_core_functions` wrappers; added SWR fast/slow path to sponsorship loaders; changed `event_file` and `hosted_file` single-object loader defaults from `false``true` for consistency. (2026-03-11)
- [x] **[DevOps]** Frontend + Backend unified into single `aether_container_env` Docker Compose. `ae_app` service live with healthcheck, single exposed port (`AE_APP_NODE_PORT`), internal `ae_api` networking. Deploy scripts in `package.json` both target `../aether_container_env/docker-compose.yml`. (2026-03-10) - [x] **[DevOps]** Frontend + Backend unified into single `aether_container_env` Docker Compose. `ae_app` service live with healthcheck, single exposed port (`AE_APP_NODE_PORT`), internal `ae_api` networking. Deploy scripts in `package.json` both target `../aether_container_env/docker-compose.yml`. (2026-03-10)

View File

@@ -0,0 +1,87 @@
/**
* action_fit_text.ts
*
* Svelte action that auto-scales text inside a fixed-size container using
* binary search. Reacts to content changes (MutationObserver) and container
* resize (ResizeObserver).
*
* Usage:
* <div use:fit_text={{ min: 14, max: 48 }}>Some text</div>
*
* Pass null/undefined to disable (e.g. when a manual font size is active):
* <div use:fit_text={manual_size != null ? null : { min: 14, max: 48 }}>
*/
export interface FitTextParams {
min?: number;
max?: number;
}
export function fit_text(
node: HTMLElement,
params: FitTextParams | null | undefined
) {
if (!params) return {};
let { min = 16, max = 80 } = params;
// overflow:hidden is required so scrollWidth/scrollHeight accurately reflect overflow
const prev_overflow = node.style.overflow;
node.style.overflow = 'hidden';
function fits(): boolean {
return node.scrollWidth <= node.offsetWidth && node.scrollHeight <= node.offsetHeight;
}
function fit() {
if (!params) return;
// Try max first — if it fits, no search needed
node.style.fontSize = max + 'px';
if (fits()) return;
// Binary search between min and max
let lo = min;
let hi = max;
while (lo < hi - 1) {
const mid = Math.floor((lo + hi) / 2);
node.style.fontSize = mid + 'px';
if (fits()) {
lo = mid;
} else {
hi = mid;
}
}
node.style.fontSize = lo + 'px';
}
// Re-fit when text content changes (handles {@html} updates)
const mutation_observer = new MutationObserver(fit);
mutation_observer.observe(node, { childList: true, subtree: true, characterData: true });
// Re-fit when the container is resized (e.g. window resize, panel open/close)
const resize_observer = new ResizeObserver(fit);
resize_observer.observe(node);
// Defer initial fit to after the DOM has laid out
requestAnimationFrame(fit);
return {
update(new_params: FitTextParams | null | undefined) {
if (!new_params) {
node.style.overflow = prev_overflow;
return;
}
params = new_params;
min = new_params.min ?? 16;
max = new_params.max ?? 80;
node.style.overflow = 'hidden';
requestAnimationFrame(fit);
},
destroy() {
mutation_observer.disconnect();
resize_observer.disconnect();
node.style.overflow = prev_overflow;
}
};
}

View File

@@ -0,0 +1,98 @@
<script lang="ts">
/**
* element_fit_text.svelte
*
* Wrapper component for the fit_text Svelte action. Renders a div that
* auto-scales its font size to fill the available space (binary search).
*
* CRITICAL — height must be constrained for auto-scaling to work:
* The action checks scrollHeight <= offsetHeight to detect overflow.
* If the wrapper div has no explicit height it expands to fit content,
* making scrollHeight == offsetHeight always — so the binary search
* returns max font immediately (appears broken / no scaling).
*
* Set height via the `height` prop (CSS string, e.g. "1.5in", "3rem", "80px"),
* OR apply a Tailwind height class via the `class` prop (e.g. "h-[1.5in]"),
* OR let a parent flex container with a defined size control the height
* and pass class="h-full" to fill it.
*
* Props:
* min — minimum font size in px (default: 16)
* max — maximum font size in px (default: 80)
* manual_size — when set, disables auto-scaling and applies this font size directly
* disabled — when true, neither auto-scaling nor manual_size is applied
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
* class — extra Tailwind/CSS classes for the wrapper div
* style — extra inline styles for the wrapper div
*
* Example — auto-scale to fill an explicitly sized region:
* <Element_fit_text min={20} max={80} height="1.5in" class="leading-none">
* {attendee_name}
* </Element_fit_text>
*
* Example — auto-scale filling a flex parent (parent must have a defined height):
* <!-- parent: flex-1 min-h-0 overflow-hidden -->
* <Element_fit_text min={20} max={80} class="h-full leading-none">
* {attendee_name}
* </Element_fit_text>
*
* Example — manual override (disables auto-scale, applies fixed size):
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
* {attendee_name}
* </Element_fit_text>
*/
import { fit_text } from './action_fit_text';
import type { Snippet } from 'svelte';
interface Props {
min?: number;
max?: number;
/** When set, disables auto-scaling and applies this size directly via inline style. */
manual_size?: number | null;
/** When true, neither auto-scaling nor manual_size is applied. */
disabled?: boolean;
/**
* Explicit CSS height for the wrapper div (e.g. "1.5in", "80px", "3rem").
* REQUIRED for auto-scaling unless height is controlled via class or a flex parent.
* Without a constrained height, offsetHeight == scrollHeight always → max font returned.
*/
height?: string;
/** Explicit CSS width for the wrapper div. Usually not needed — inherits from parent. */
width?: string;
class?: string;
style?: string;
children?: Snippet;
}
let {
min = 16,
max = 80,
manual_size = null,
disabled = false,
height,
width,
class: extra_class = '',
style: extra_style = '',
children
}: Props = $props();
// Pass null to the action when auto-scaling should be suppressed
let action_params = $derived(disabled || manual_size != null ? null : { min, max });
// Compose the final inline style.
// Priority: manual_size → height/width → extra_style (caller's additional styles)
let computed_style = $derived(() => {
const parts: string[] = [];
if (manual_size != null) parts.push(`font-size: ${manual_size}px`);
if (height) parts.push(`height: ${height}`);
if (width) parts.push(`width: ${width}`);
if (extra_style) parts.push(extra_style);
return parts.join('; ');
});
</script>
<div class={extra_class} style={computed_style()} use:fit_text={action_params}>
{@render children?.()}
</div>

View File

@@ -17,8 +17,13 @@
import { ArrowLeft, Eye, LoaderCircle, Mail, Printer } from 'lucide-svelte'; import { ArrowLeft, Eye, LoaderCircle, Mail, Printer } from 'lucide-svelte';
import Comp_badge_obj_view from '../ae_comp__badge_obj_view.svelte'; import Comp_badge_obj_view from '../ae_comp__badge_obj_view.svelte';
import Comp_badge_obj_view_v2 from '../ae_comp__badge_obj_view_v2.svelte';
import Comp_badge_print_controls from '../ae_comp__badge_print_controls.svelte'; import Comp_badge_print_controls from '../ae_comp__badge_print_controls.svelte';
// V2 toggle: temporary — lets staff compare auto-scaling text (v2) vs heuristic sizing (v1).
// Remove once v2 is verified and v1 is retired.
let use_v2 = $state(false);
let event_badge_id = $derived(page.params.badge_id); let event_badge_id = $derived(page.params.badge_id);
let event_id = $derived(page.params.event_id); let event_id = $derived(page.params.event_id);
@@ -171,6 +176,16 @@
<!-- Right: Action buttons --> <!-- Right: Action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0"> <div class="flex flex-row gap-1 items-center shrink-0">
<!-- V1/V2 toggle: temporary comparison tool — remove once v2 is verified -->
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1 font-mono"
onclick={() => (use_v2 = !use_v2)}
title={use_v2 ? 'Switch to v1 (heuristic sizing)' : 'Switch to v2 (auto-scaling text)'}
>
{use_v2 ? 'v2' : 'v1'}
</button>
<!-- 1. Print Now: Trusted+, not printed OR Edit Mode (reprint) --> <!-- 1. Print Now: Trusted+, not printed OR Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)} {#if is_trusted && (!is_printed || is_edit_mode)}
<button <button
@@ -221,17 +236,33 @@
pr-64 offsets the badge area so it's not hidden under the fixed controls panel. pr-64 offsets the badge area so it's not hidden under the fixed controls panel.
On print, pr-64 is cleared and the fixed controls panel is hidden. --> On print, pr-64 is cleared and the fixed controls panel is hidden. -->
<div class="print:pr-0 pr-64"> <div class="print:pr-0 pr-64">
<Comp_badge_obj_view {#if use_v2}
event_id={$lq__event_badge_obj.event_id as string} <!-- V2: pass null directly (not ?? undefined) so auto-scaling is active by default.
event_badge_id={event_badge_id as string} null → Element_fit_text auto-scales; a number → manual override from print controls. -->
{lq__event_badge_obj} <Comp_badge_obj_view_v2
{lq__event_badge_template_obj} event_id={$lq__event_badge_obj.event_id as string}
is_review_mode={false} event_badge_id={event_badge_id as string}
font_size_name={font_size_name ?? undefined} {lq__event_badge_obj}
font_size_title={font_size_title ?? undefined} {lq__event_badge_template_obj}
font_size_affiliations={font_size_affiliations ?? undefined} is_review_mode={false}
font_size_location={font_size_location ?? undefined} font_size_name={font_size_name}
/> font_size_title={font_size_title}
font_size_affiliations={font_size_affiliations}
font_size_location={font_size_location}
/>
{:else}
<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}
{lq__event_badge_template_obj}
is_review_mode={false}
font_size_name={font_size_name ?? undefined}
font_size_title={font_size_title ?? undefined}
font_size_affiliations={font_size_affiliations ?? undefined}
font_size_location={font_size_location ?? undefined}
/>
{/if}
</div> </div>
<!-- Controls panel: fixed to the right edge of the viewport — screen-only. <!-- Controls panel: fixed to the right edge of the viewport — screen-only.