From bc23e8a399748c08631b627d0b5fb6068bd5b40f Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Thu, 12 Mar 2026 17:58:33 -0400 Subject: [PATCH] Badges: fix Firefox print centering (overflow:auto+display:contents); add print layout Playwright tests --- .../badges/[badge_id]/print/+page.svelte | 42 ++-- tests/badge_print_layout.test.ts | 209 ++++++++++++++++++ 2 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 tests/badge_print_layout.test.ts diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte index 45d8f1af..4a632d6d 100644 --- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/print/+page.svelte @@ -188,16 +188,31 @@ /* Hide app chrome */ .submenu { display: none !important; } - /* Dissolve layout wrappers — removes their boxes from the layout while - keeping children renderable. The badge section floats up to body - as a direct flex child, so body's centering applies to it directly. - This avoids all height-chain and overflow-clip issues. + /* Dissolve layout wrappers so .event_badge_wrapper bubbles up to a real + centering flex container. - Layers dissolved: - #ae_main_content — events layout scroll container (flex-col, max-w-7xl, overflow-auto, bg-gray) - .main_content — events layout inner section (grow, pb-48, pt-20+, items-center) - #badge_render_area — print page badge wrapper (screen-only right padding) */ - #ae_main_content, + #ae_main_content has overflow:auto — per CSS spec, display:contents + cannot override an element that establishes overflow clipping; Firefox + (spec-compliant) keeps it as a block container, breaking centering. + Fix: reset it as an explicit 100%×100% pass-through flex container + instead. Chrome applies this too (belt-and-suspenders). */ + #ae_main_content { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + max-width: none !important; + height: 100% !important; + min-height: 0 !important; + overflow: visible !important; + background: transparent !important; + padding: 0 !important; + margin: 0 !important; + } + + /* .main_content and #badge_render_area have no overflow constraints, + so display:contents works on all browsers — they dissolve their boxes + and .event_badge_wrapper becomes a direct flex child of #ae_main_content. */ .main_content, #badge_render_area { display: contents !important; @@ -221,8 +236,6 @@ /* ============================================================ TEMPORARY DEBUG OUTLINES — remove before going live - Each color = one layout layer so you can see what's taking - up space and whether display:contents is actually dissolving. ============================================================ */ html { outline: 3px dashed lime !important; @@ -232,12 +245,13 @@ outline: 4px solid blue !important; outline-offset: -4px !important; } - /* Red = should be invisible (display:contents dissolves its box). - If you see a red border, display:contents is NOT working here. */ + /* Red = #ae_main_content — now a real flex container (full width/height). + Should be VISIBLE and same size as body. */ #ae_main_content { outline: 3px dashed red !important; outline-offset: -6px !important; } + /* Orange + purple = display:contents — should be INVISIBLE (no box). */ .main_content { outline: 3px dashed orange !important; outline-offset: -9px !important; @@ -246,7 +260,7 @@ outline: 3px dashed purple !important; outline-offset: -12px !important; } - /* Cyan = the actual badge wrapper — should be the only visible box */ + /* Cyan = the actual badge — should be centered inside the red box */ .event_badge_wrapper { outline: 3px solid cyan !important; outline-offset: 2px !important; diff --git a/tests/badge_print_layout.test.ts b/tests/badge_print_layout.test.ts new file mode 100644 index 00000000..9d5ea54c --- /dev/null +++ b/tests/badge_print_layout.test.ts @@ -0,0 +1,209 @@ +/** + * Badge Print Page — Print Layout CSS Tests + * + * Verifies that the badge is horizontally (and vertically) centered on the page + * when print media CSS is active, using Playwright's emulateMedia({ media: 'print' }). + * + * Strategy: inject badge + template data directly into IndexedDB so LiveQuery + * fires without needing a full API round-trip, then check bounding boxes. + * + * Cross-browser: add Firefox to playwright.config.ts projects to catch the + * overflow:auto + display:contents incompatibility that Firefox enforces strictly. + */ +import { test, expect } from '@playwright/test'; +import { ae_app_local_data_defaults } from './_helpers/ae_defaults'; +import { testing_event_id, testing_account_id, mock_site_domain } from './_helpers/env'; + +const event_id = testing_event_id; + +// Use the demo badge/template IDs from tests/README.md +const badge_id = 'UIJT-73-63-61'; +const template_id = 'jgfixEpYp1B'; + +// Minimal badge record — both *_id and *_id_random set to the same value so +// db_events.badge.get(event_badge_id) finds the record via the Dexie primary key. +const mock_badge = { + id: badge_id, + event_badge_id: badge_id, + event_badge_id_random: badge_id, + event_id: event_id, + event_id_random: event_id, + event_badge_template_id: template_id, + event_badge_template_id_random: template_id, + full_name_override: 'Scott Idem', + given_name: 'Scott', + family_name: 'Idem', + email: 'scott@example.com', + badge_type: 'presenter', + badge_type_code: 'presenter', + print_count: 0, + affiliations: 'One Sky IT', + location: 'Minneapolis, MN', + enable: true, +}; + +// Minimal template record — layout drives the @page size injection +const mock_template = { + id: template_id, + id_random: template_id, + event_badge_template_id: template_id, + badge_template_id: template_id, + event_id: event_id, + event_id_random: event_id, + name: 'Dev Demo 202x', + layout: 'badge_3.5x5.5_pvc', + cfg_json: '{}', + enable: true, +}; + +/** Injects records into ae_events_db IndexedDB tables via raw IDB API. + * Must be called from page.evaluate() after the page has navigated (Dexie + * schema is already initialised from the app's first open). */ +async function inject_idb(badge: object, template: object): Promise { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('ae_events_db'); + req.onsuccess = () => resolve(req.result as IDBDatabase); + req.onerror = () => reject((req as IDBRequest).error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction(['badge', 'badge_template'], 'readwrite'); + tx.objectStore('badge').put(badge); + tx.objectStore('badge_template').put(template); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); +} + +test.describe('Badge Print Page — print layout centering', () => { + test.beforeEach(async ({ page }) => { + page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`)); + + // Minimal API mocks — enough for the events layout to render without errors + await page.route('**/v3/**', async (route) => { + const url = route.request().url(); + const method = route.request().method(); + + if (url.includes('site_domain/search')) { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [mock_site_domain] }) }); + } + // Return a minimal valid event so the layout header doesn't error + if (url.includes(`/v3/crud/event/${event_id}`) && !url.includes('event_badge') && method === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + data: { id: event_id, event_id, name: 'Test Event', cfg_json: {}, mod_badges_json: {}, mod_pres_mgmt_json: {}, mod_abstracts_json: {}, mod_exhibits_json: {}, mod_meetings_json: {} } + }) }); + } + // All other calls: empty list (no-op) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [] }) }); + }); + + // Seed localStorage auth so the app renders + await page.addInitScript(([defaults, e_id, a_id]) => { + const data = { + ...defaults, + account_id: a_id, + allow_access: true, + authenticated_access: true, + trusted_access: true, + manager_access: true, + edit_mode: false, + } as any; + data.mod = { ...defaults.mod, events: { ...defaults.mod.events, event_id: e_id } }; + window.localStorage.setItem('ae_loc', JSON.stringify(data)); + }, [ae_app_local_data_defaults, event_id, testing_account_id] as const); + }); + + test('badge is horizontally centered on a letter-size page', async ({ page }) => { + // Letter paper: 8.5in × 11in at 96 dpi = 816 × 1056 px + await page.setViewportSize({ width: 816, height: 1056 }); + + await page.goto(`/events/${event_id}/badges/${badge_id}/print`); + await page.waitForLoadState('domcontentloaded'); + + // Inject badge + template directly into IndexedDB so LiveQuery fires + await page.evaluate(inject_idb, [mock_badge, mock_template] as any); + + // Wait for LiveQuery to render the badge wrapper + await page.waitForSelector('.event_badge_wrapper', { timeout: 8000 }); + + // Apply print media CSS (activates @media print rules) + await page.emulateMedia({ media: 'print' }); + + // Wait for layout to settle under print CSS + await page.waitForFunction(() => { + const el = document.querySelector('.event_badge_wrapper') as HTMLElement | null; + return el !== null && el.offsetWidth > 0; + }, { timeout: 5000 }); + + const badge_box = await page.locator('.event_badge_wrapper').boundingBox(); + expect(badge_box, 'badge wrapper must have a bounding box').not.toBeNull(); + + const viewport = page.viewportSize()!; + const badge_center_x = badge_box!.x + badge_box!.width / 2; + const page_center_x = viewport.width / 2; + + // Badge horizontal center must be within 5px of the page center + expect( + Math.abs(badge_center_x - page_center_x), + `badge center (${badge_center_x.toFixed(1)}px) should equal page center (${page_center_x}px)` + ).toBeLessThan(5); + }); + + test('badge is vertically centered on a letter-size page', async ({ page }) => { + await page.setViewportSize({ width: 816, height: 1056 }); + + await page.goto(`/events/${event_id}/badges/${badge_id}/print`); + await page.waitForLoadState('domcontentloaded'); + + await page.evaluate(inject_idb, [mock_badge, mock_template] as any); + await page.waitForSelector('.event_badge_wrapper', { timeout: 8000 }); + await page.emulateMedia({ media: 'print' }); + + await page.waitForFunction(() => { + const el = document.querySelector('.event_badge_wrapper') as HTMLElement | null; + return el !== null && el.offsetHeight > 0; + }, { timeout: 5000 }); + + const badge_box = await page.locator('.event_badge_wrapper').boundingBox(); + expect(badge_box).not.toBeNull(); + + const viewport = page.viewportSize()!; + const badge_center_y = badge_box!.y + badge_box!.height / 2; + const page_center_y = viewport.height / 2; + + // Badge vertical center must be within 5px of the page center + expect( + Math.abs(badge_center_y - page_center_y), + `badge center Y (${badge_center_y.toFixed(1)}px) should equal page center Y (${page_center_y}px)` + ).toBeLessThan(5); + }); + + test('#ae_main_content does not clip in print mode', async ({ page }) => { + // Verifies that overflow:visible is applied (the Firefox fix). + await page.goto(`/events/${event_id}/badges/${badge_id}/print`); + await page.waitForLoadState('domcontentloaded'); + await page.emulateMedia({ media: 'print' }); + + const overflow = await page.evaluate(() => { + const el = document.getElementById('ae_main_content'); + return el ? getComputedStyle(el).overflow : null; + }); + expect(overflow, '#ae_main_content overflow should be visible in print').toBe('visible'); + }); + + test('body is a flex container spanning full viewport width in print mode', async ({ page }) => { + await page.setViewportSize({ width: 816, height: 1056 }); + + await page.goto(`/events/${event_id}/badges/${badge_id}/print`); + await page.waitForLoadState('domcontentloaded'); + await page.emulateMedia({ media: 'print' }); + + const result = await page.evaluate(() => ({ + display: getComputedStyle(document.body).display, + width: document.body.offsetWidth, + })); + + expect(result.display, 'body should be flex in print').toBe('flex'); + // Body width must be close to the viewport width (no shrink-to-content) + expect(result.width, 'body should span full viewport width').toBeGreaterThanOrEqual(810); + }); +});