9a848e8c53
Implement a command palette accessible via CMD+K/Ctrl+K using the ninja-keys web component. Features include: - New /api/cmd-k endpoint serving dynamic CV entries (experiences, projects, courses) - Language-aware responses with 1-hour cache headers - Scroll-to-section functionality for quick navigation - Enhanced keyboard shortcuts modal with CMD+K documentation - Comprehensive test coverage for API and UI interactions Also includes cleanup of deprecated debug test files and various UI polish improvements to contact form, themes, and action bar components.
596 lines
26 KiB
JavaScript
596 lines
26 KiB
JavaScript
// CV Interactive Features - CSP-Compliant External JavaScript
|
|
// Extracted from inline scripts for security hardening
|
|
(function() {
|
|
'use strict';
|
|
|
|
// =============================================================================
|
|
// GLOBAL VARIABLES
|
|
// =============================================================================
|
|
|
|
// Flag to keep header visible after navigation
|
|
let keepHeaderVisible = false;
|
|
|
|
// Flag to track language switch in progress
|
|
let languageSwitching = false;
|
|
|
|
// Expose for testing (read-only access)
|
|
Object.defineProperty(window, 'languageSwitching', {
|
|
get: () => languageSwitching
|
|
});
|
|
|
|
// =============================================================================
|
|
// NAVIGATION & MENU SYSTEM
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize minimal menu control system
|
|
* CSS handles most logic, JS bridges hamburger to menu
|
|
* Click toggle for mobile, hover for desktop
|
|
*/
|
|
function initMenuSystem() {
|
|
const hamburgerBtn = document.querySelector('.hamburger-btn');
|
|
const menu = document.getElementById('navigation-menu');
|
|
|
|
if (!hamburgerBtn || !menu) return;
|
|
|
|
// Click handler for mobile - toggles menu-open class
|
|
hamburgerBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
menu.classList.toggle('menu-open');
|
|
});
|
|
|
|
// Close menu when clicking outside (for mobile)
|
|
document.addEventListener('click', (e) => {
|
|
if (!menu.contains(e.target) && !hamburgerBtn.contains(e.target)) {
|
|
menu.classList.remove('menu-open');
|
|
}
|
|
});
|
|
|
|
// Desktop hover support - show menu on hamburger hover
|
|
hamburgerBtn.addEventListener('mouseenter', () => menu.classList.add('menu-hover'));
|
|
|
|
// Hide menu when leaving hamburger if not hovering menu
|
|
hamburgerBtn.addEventListener('mouseleave', () => {
|
|
setTimeout(() => {
|
|
if (!menu.matches(':hover')) menu.classList.remove('menu-hover');
|
|
}, 100);
|
|
});
|
|
|
|
// Hide menu when leaving menu
|
|
menu.addEventListener('mouseleave', () => menu.classList.remove('menu-hover'));
|
|
|
|
// Position submenu dynamically (needed because fixed positioning)
|
|
const submenuTrigger = document.querySelector('.menu-item-submenu');
|
|
const submenuContent = document.querySelector('.submenu-content');
|
|
if (submenuTrigger && submenuContent) {
|
|
submenuTrigger.addEventListener('mouseenter', function() {
|
|
submenuContent.style.top = `${this.getBoundingClientRect().top}px`;
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-open sidebar accordions in landscape mobile mode AND desktop
|
|
* Ensures sidebar content is always visible except in portrait mobile
|
|
*/
|
|
function handleLandscapeAccordions() {
|
|
function openSidebarAccordionsIfNeeded() {
|
|
const isLandscape = window.matchMedia('(max-width: 915px) and (orientation: landscape)').matches;
|
|
const isDesktop = window.matchMedia('(min-width: 769px)').matches;
|
|
const isPortraitMobile = window.matchMedia('(max-width: 768px) and (orientation: portrait)').matches;
|
|
|
|
// Open accordions in landscape mobile OR desktop view
|
|
// Keep them closed ONLY in portrait mobile (≤768px)
|
|
if (isLandscape || isDesktop) {
|
|
document.querySelectorAll('.sidebar-accordion').forEach(accordion => {
|
|
accordion.setAttribute('open', '');
|
|
});
|
|
} else if (isPortraitMobile) {
|
|
// In portrait mobile, leave them closed (user can expand manually)
|
|
// Don't remove 'open' attribute if user has opened them
|
|
}
|
|
}
|
|
|
|
// Run on load
|
|
openSidebarAccordionsIfNeeded();
|
|
|
|
// Run on orientation change
|
|
window.addEventListener('orientationchange', () => {
|
|
setTimeout(openSidebarAccordionsIfNeeded, 100);
|
|
});
|
|
|
|
// Run on resize (for desktop browser testing)
|
|
window.addEventListener('resize', () => {
|
|
openSidebarAccordionsIfNeeded();
|
|
});
|
|
}
|
|
|
|
// Menu close on nav click is now handled by scrollToSection() in utils._hs
|
|
|
|
// =============================================================================
|
|
// PREFERENCES & LANGUAGE
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize user preferences from localStorage
|
|
* Handles language, theme, length, and icons persistence across sessions
|
|
*/
|
|
function initPreferences() {
|
|
// Language preference
|
|
const urlLang = new URLSearchParams(window.location.search).get('lang');
|
|
const savedLang = localStorage.getItem('cv-language');
|
|
|
|
if (!urlLang && savedLang) {
|
|
// URL is clean but we have a saved preference - redirect with lang parameter
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('lang', savedLang);
|
|
window.location.replace(url.toString());
|
|
} else if (urlLang) {
|
|
// Save URL language to localStorage
|
|
localStorage.setItem('cv-language', urlLang);
|
|
}
|
|
|
|
// Apply other preferences from localStorage on page load
|
|
// This ensures client-side preferences override server defaults
|
|
const savedTheme = localStorage.getItem('cv-theme');
|
|
let savedLength = localStorage.getItem('cv-length');
|
|
let savedIcons = localStorage.getItem('cv-icons');
|
|
|
|
// Migrate old localStorage values to new ones (one-time auto-migration)
|
|
if (savedLength === 'extended') {
|
|
savedLength = 'long';
|
|
localStorage.setItem('cv-length', 'long');
|
|
}
|
|
if (savedIcons === 'true') {
|
|
savedIcons = 'show';
|
|
localStorage.setItem('cv-icons', 'show');
|
|
} else if (savedIcons === 'false') {
|
|
savedIcons = 'hide';
|
|
localStorage.setItem('cv-icons', 'hide');
|
|
}
|
|
|
|
// Apply theme preference
|
|
const cvContainer = document.querySelector('.cv-container');
|
|
if (savedTheme === 'clean') {
|
|
cvContainer?.classList.add('theme-clean');
|
|
const themeToggles = document.querySelectorAll('#themeToggle, #themeToggleMenu');
|
|
themeToggles.forEach(toggle => toggle.checked = true);
|
|
} else if (savedTheme === 'default') {
|
|
cvContainer?.classList.remove('theme-clean');
|
|
const themeToggles = document.querySelectorAll('#themeToggle, #themeToggleMenu');
|
|
themeToggles.forEach(toggle => toggle.checked = false);
|
|
}
|
|
|
|
// Apply length preference
|
|
const cvPaper = document.querySelector('.cv-paper');
|
|
if (cvPaper && savedLength) {
|
|
if (savedLength === 'long') {
|
|
cvPaper.classList.remove('cv-short');
|
|
cvPaper.classList.add('cv-long');
|
|
const lengthToggles = document.querySelectorAll('#lengthToggle, #lengthToggleMenu');
|
|
lengthToggles.forEach(toggle => toggle.checked = true);
|
|
} else {
|
|
cvPaper.classList.remove('cv-long');
|
|
cvPaper.classList.add('cv-short');
|
|
const lengthToggles = document.querySelectorAll('#lengthToggle, #lengthToggleMenu');
|
|
lengthToggles.forEach(toggle => toggle.checked = false);
|
|
}
|
|
}
|
|
|
|
// Apply icons preference
|
|
if (cvPaper && savedIcons !== null) {
|
|
if (savedIcons === 'show') {
|
|
cvPaper.classList.add('show-icons');
|
|
const iconToggles = document.querySelectorAll('#iconToggle, #iconToggleMenu');
|
|
iconToggles.forEach(toggle => toggle.checked = true);
|
|
} else {
|
|
cvPaper.classList.remove('show-icons');
|
|
const iconToggles = document.querySelectorAll('#iconToggle, #iconToggleMenu');
|
|
iconToggles.forEach(toggle => toggle.checked = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ERROR HANDLING
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Display error toast notification
|
|
* CSS handles auto-hide animation via @keyframes toastLifecycle
|
|
* @param {string} message - Error message to display
|
|
*/
|
|
window.showError = function(message) {
|
|
const errorToast = document.getElementById('error-toast');
|
|
const errorMessage = document.getElementById('error-message');
|
|
|
|
errorMessage.textContent = message;
|
|
errorToast.classList.remove('show'); // Reset if already showing
|
|
|
|
// Trigger reflow to restart animation
|
|
void errorToast.offsetWidth;
|
|
|
|
errorToast.classList.add('show'); // CSS animation handles lifecycle
|
|
};
|
|
|
|
/**
|
|
* Display PDF download toast notification
|
|
* Shows progress and completion status for PDF downloads
|
|
* @param {Object} options - Toast configuration
|
|
* @param {string} options.icon - Icon emoji (📥, ✅, ⚠️)
|
|
* @param {string} options.title - Toast title
|
|
* @param {string} options.message - Toast message
|
|
* @param {number} options.duration - Auto-hide duration in ms (default: 5000)
|
|
*/
|
|
window.showPDFToast = function(options = {}) {
|
|
const toast = document.getElementById('pdf-toast');
|
|
const icon = document.getElementById('pdf-toast-icon');
|
|
const title = document.getElementById('pdf-toast-title');
|
|
const message = document.getElementById('pdf-toast-message');
|
|
const progressBar = document.getElementById('pdf-toast-progress');
|
|
|
|
// Set content
|
|
if (options.icon) icon.textContent = options.icon;
|
|
if (options.title) title.textContent = options.title;
|
|
if (options.message) message.textContent = options.message;
|
|
|
|
// Reset animation
|
|
toast.classList.remove('show');
|
|
void toast.offsetWidth; // Trigger reflow
|
|
|
|
// Reset progress bar animation
|
|
if (progressBar) {
|
|
progressBar.style.animation = 'none';
|
|
void progressBar.offsetWidth;
|
|
|
|
// Set duration if provided
|
|
const duration = options.duration || 5000;
|
|
progressBar.style.animation = `progressShrink ${duration}ms linear forwards`;
|
|
}
|
|
|
|
// Show toast
|
|
toast.classList.add('show');
|
|
|
|
// Auto-hide after duration
|
|
if (options.autoHide !== false) {
|
|
const duration = options.duration || 5000;
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, duration);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Hide PDF toast immediately
|
|
*/
|
|
window.hidePDFToast = function() {
|
|
const toast = document.getElementById('pdf-toast');
|
|
toast?.classList.remove('show');
|
|
};
|
|
|
|
// Error toast close handled by hyperscript inline in error-toast.html
|
|
|
|
// =============================================================================
|
|
// HTMX EVENT HANDLERS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize HTMX global event handlers
|
|
* Handles errors, analytics, and post-swap behaviors
|
|
*/
|
|
function initHTMXHandlers() {
|
|
// Variable to store scroll position for swaps that should preserve position
|
|
let savedScrollPosition = 0;
|
|
let shouldRestoreScroll = false;
|
|
|
|
// Save scroll position before swap
|
|
document.addEventListener('htmx:beforeSwap', function(evt) {
|
|
try {
|
|
const target = evt.detail.target;
|
|
|
|
// Only preserve scroll for toggle operations (not language changes)
|
|
// Language changes target #cv-content, toggles target .cv-paper or body
|
|
if (target && (target.classList?.contains('cv-paper') || target.tagName === 'BODY')) {
|
|
savedScrollPosition = window.pageYOffset;
|
|
shouldRestoreScroll = true;
|
|
} else {
|
|
shouldRestoreScroll = false;
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in htmx:beforeSwap handler:', e);
|
|
}
|
|
});
|
|
|
|
// Restore scroll position after swap (only for toggles)
|
|
document.addEventListener('htmx:afterSettle', function(evt) {
|
|
try {
|
|
if (shouldRestoreScroll && savedScrollPosition >= 0) {
|
|
window.scrollTo(0, savedScrollPosition);
|
|
savedScrollPosition = 0;
|
|
shouldRestoreScroll = false;
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in htmx:afterSettle handler:', e);
|
|
}
|
|
});
|
|
|
|
// Skeleton loader for language transitions
|
|
// Add .loading class when language button is clicked
|
|
document.addEventListener('htmx:beforeRequest', function(evt) {
|
|
try {
|
|
const element = evt.detail.elt;
|
|
if (element && element.classList && element.classList.contains('selector-btn')) {
|
|
// Set flag to track language switching
|
|
languageSwitching = true;
|
|
|
|
// Add loading class to page containers
|
|
const page1 = document.getElementById('cv-inner-content-page-1');
|
|
const page2 = document.getElementById('cv-inner-content-page-2');
|
|
if (page1) page1.classList.add('loading');
|
|
if (page2) page2.classList.add('loading');
|
|
|
|
console.log('Skeleton loader: Added .loading class to page containers');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in skeleton loader beforeRequest handler:', e);
|
|
}
|
|
});
|
|
|
|
// Remove .loading class after language transition completes
|
|
document.addEventListener('htmx:afterSettle', function(evt) {
|
|
try {
|
|
if (languageSwitching) {
|
|
// Wait for final render to complete
|
|
setTimeout(function() {
|
|
const page1 = document.getElementById('cv-inner-content-page-1');
|
|
const page2 = document.getElementById('cv-inner-content-page-2');
|
|
if (page1) page1.classList.remove('loading');
|
|
if (page2) page2.classList.remove('loading');
|
|
|
|
// Reset flag
|
|
languageSwitching = false;
|
|
|
|
console.log('Skeleton loader: Removed .loading class from page containers');
|
|
}, 100);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in skeleton loader afterSettle handler:', e);
|
|
}
|
|
});
|
|
|
|
// Sync toggle states between desktop and mobile menu
|
|
document.addEventListener('htmx:afterSwap', function(evt) {
|
|
try {
|
|
// After any swap, sync the corresponding elements
|
|
const target = evt.detail.target;
|
|
|
|
// Sync language buttons when CV page content swaps
|
|
if (target && target.classList && target.classList.contains('cv-page-content-wrapper')) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const currentLang = urlParams.get('lang') || 'en';
|
|
const enBtn = document.querySelector('button[aria-label="English"]');
|
|
const esBtn = document.querySelector('button[aria-label="Español"]');
|
|
if (enBtn && esBtn) {
|
|
if (currentLang === 'en') {
|
|
enBtn.classList.add('active');
|
|
esBtn.classList.remove('active');
|
|
} else {
|
|
esBtn.classList.add('active');
|
|
enBtn.classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync theme toggles (body swap) - though theme now uses hyperscript
|
|
if (target && target.classList.contains('cv-container')) {
|
|
const desktopToggle = document.getElementById('themeToggle');
|
|
const mobileToggle = document.getElementById('themeToggleMenu');
|
|
if (desktopToggle && mobileToggle) {
|
|
const isClean = document.querySelector('.cv-container')?.classList.contains('theme-clean');
|
|
desktopToggle.checked = isClean;
|
|
mobileToggle.checked = isClean;
|
|
}
|
|
}
|
|
|
|
// Sync length and logo toggles (.cv-paper swap)
|
|
if (target && target.classList && target.classList.contains('cv-paper')) {
|
|
// Sync length toggles
|
|
const desktopToggle = document.getElementById('lengthToggle');
|
|
const mobileToggle = document.getElementById('lengthToggleMenu');
|
|
if (desktopToggle && mobileToggle) {
|
|
const isLong = target.classList.contains('cv-long');
|
|
desktopToggle.checked = isLong;
|
|
mobileToggle.checked = isLong;
|
|
console.log(`Toggle sync - Length: desktop=${isLong}, mobile=${isLong}`);
|
|
}
|
|
|
|
// Sync icon toggles
|
|
const desktopIconToggle = document.getElementById('iconToggle');
|
|
const mobileIconToggle = document.getElementById('iconToggleMenu');
|
|
if (desktopIconToggle && mobileIconToggle) {
|
|
const showIcons = target.classList.contains('show-icons');
|
|
desktopIconToggle.checked = showIcons;
|
|
mobileIconToggle.checked = showIcons;
|
|
console.log(`Toggle sync - Icons: desktop=${showIcons}, mobile=${showIcons}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Error syncing toggles:', e);
|
|
}
|
|
});
|
|
|
|
// HTMX Global Error Handlers
|
|
document.addEventListener('htmx:responseError', function(evt) {
|
|
// Skip contact form errors - they handle their own validation errors
|
|
// Contact form returns 400 for validation which is expected, not a system error
|
|
const target = evt.detail.target;
|
|
if (target && target.id === 'contact-response') {
|
|
return; // Contact form handles its own errors
|
|
}
|
|
|
|
console.error('HTMX Response Error:', evt.detail);
|
|
console.error('Error details:', {
|
|
xhr: evt.detail.xhr,
|
|
target: evt.detail.target,
|
|
requestConfig: evt.detail.requestConfig
|
|
});
|
|
const lang = document.documentElement.lang;
|
|
const message = lang === 'es'
|
|
? 'Error al cargar el contenido. Por favor, inténtelo de nuevo.'
|
|
: 'Failed to load content. Please try again.';
|
|
window.showError(message);
|
|
});
|
|
|
|
document.addEventListener('htmx:sendError', function(evt) {
|
|
console.error('HTMX Send Error:', evt.detail);
|
|
const lang = document.documentElement.lang;
|
|
const message = lang === 'es'
|
|
? 'Error de conexión. Verifique su conexión a internet.'
|
|
: 'Connection error. Please check your internet connection.';
|
|
window.showError(message);
|
|
});
|
|
|
|
document.addEventListener('htmx:timeout', function(evt) {
|
|
console.error('HTMX Timeout:', evt.detail);
|
|
const lang = document.documentElement.lang;
|
|
const message = lang === 'es'
|
|
? 'La solicitud tardó demasiado. Por favor, inténtelo de nuevo.'
|
|
: 'Request timed out. Please try again.';
|
|
window.showError(message);
|
|
});
|
|
|
|
document.addEventListener('htmx:afterSwap', function(evt) {
|
|
// Smooth scroll to top on language change
|
|
if (evt.detail.target.id === 'cv-content') {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
// Track HTMX navigation events with Matomo
|
|
if (typeof _paq !== 'undefined' && evt.detail.target.id === 'cv-content') {
|
|
// Track language change as virtual pageview
|
|
const lang = new URLSearchParams(window.location.search).get('lang') || 'en';
|
|
_paq.push(['setCustomUrl', window.location.href]);
|
|
_paq.push(['setDocumentTitle', document.title]);
|
|
_paq.push(['trackPageView']);
|
|
}
|
|
});
|
|
|
|
// Log successful swaps for debugging
|
|
document.addEventListener('htmx:afterRequest', function(evt) {
|
|
if (evt.detail.successful) {
|
|
console.log('HTMX request successful:', evt.detail.pathInfo.requestPath);
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// INITIALIZATION
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize all CV interactive features when DOM is ready
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initMenuSystem();
|
|
initPreferences();
|
|
initHTMXHandlers();
|
|
handleLandscapeAccordions(); // Auto-open sidebar accordions in landscape mode
|
|
// Note: Scroll behavior now handled by hyperscript in index.html body tag
|
|
// Note: Zoom control buttons now handled by hyperscript (zoom._hs)
|
|
// initScrollBehaviorJS() removed - hyperscript handleScroll() is preferred
|
|
});
|
|
|
|
// =============================================================================
|
|
// HYPERSCRIPT-POWERED FEATURES (NO JS NEEDED)
|
|
// =============================================================================
|
|
|
|
// The following features have been moved to hyperscript for better
|
|
// maintainability, declarative syntax, and cleaner HTML templates.
|
|
// All hyperscript functions are defined in /static/hyperscript/functions._hs
|
|
|
|
// ZOOM CONTROL (Phase 5 - Eliminated ~343 lines)
|
|
// -----------------------------------------------
|
|
// Now handled by hyperscript in zoom-control.html
|
|
// Features:
|
|
// - Slider updates and real-time zoom application
|
|
// - Reset button (back to 100%)
|
|
// - Close/show toggle with localStorage persistence
|
|
// - Keyboard shortcuts (Ctrl/Cmd +/-/0)
|
|
// - Draggable positioning with bounds checking
|
|
// - Mobile detection and auto-disable
|
|
// - LocalStorage persistence (zoom level, visibility, position)
|
|
|
|
// SCROLL BEHAVIOR (Phase 6 - Eliminated ~59 lines)
|
|
// ------------------------------------------------
|
|
// Now handled by hyperscript on <body> element in index.html
|
|
// Functions: initScrollBehavior(), handleScroll()
|
|
// Features:
|
|
// - Header hide/show based on scroll direction (100px threshold)
|
|
// - Back-to-top button visibility (appears after 300px scroll)
|
|
// - At-bottom positioning for fixed buttons (within 50px of page bottom)
|
|
// - Menu visibility coordination when open
|
|
// - State tracking: lastScroll, scrollThreshold, keepHeaderVisible
|
|
|
|
// PRINT FUNCTION (Phase 6 - Eliminated ~44 lines, Fixed bug)
|
|
// ----------------------------------------------------------
|
|
// Now handled by hyperscript in action-buttons.html and hamburger-menu.html
|
|
// Function: printFriendly()
|
|
// Features:
|
|
// - Stores current theme, length, and zoom state
|
|
// - Applies clean theme + short version for printing
|
|
// - Resets zoom to 100% for consistent print output
|
|
// - Calls window.print()
|
|
// - Restores original state after print dialog closes
|
|
// - Properly restores zoom by triggering slider input event (fixes Phase 5 bug)
|
|
|
|
// MODALS (Phase 4A - Eliminated ~47 lines)
|
|
// ----------------------------------------
|
|
// Now handled by native HTML5 <dialog> element
|
|
// Features:
|
|
// - Open/close with .showModal() and .close()
|
|
// - Backdrop clicks (via ::backdrop CSS pseudo-element)
|
|
// - Escape key to close (built-in browser behavior)
|
|
// - Body scroll prevention (automatic with modal dialogs)
|
|
// - Focus trapping (automatic accessibility feature)
|
|
// No JavaScript needed! All modal logic is now in HTML/CSS.
|
|
|
|
// =============================================================================
|
|
// PDF MODAL HELPER FUNCTION
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Opens the PDF download modal
|
|
* Called from references section links with action="downloadPDF"
|
|
*/
|
|
window.openPdfModal = function() {
|
|
const pdfModal = document.querySelector('#pdf-modal');
|
|
if (pdfModal) {
|
|
// Apply mobile centering via inline styles with !important (overrides CSS !important)
|
|
if (window.innerWidth <= 768) {
|
|
// Reset inset FIRST (before setting top/left)
|
|
pdfModal.style.setProperty('inset', 'auto', 'important');
|
|
pdfModal.style.setProperty('margin', '0', 'important');
|
|
// Now set positioning with !important (after inset reset)
|
|
pdfModal.style.setProperty('position', 'fixed', 'important');
|
|
pdfModal.style.setProperty('top', '50%', 'important');
|
|
pdfModal.style.setProperty('left', '50%', 'important');
|
|
pdfModal.style.setProperty('right', 'auto', 'important');
|
|
pdfModal.style.setProperty('bottom', 'auto', 'important');
|
|
pdfModal.style.setProperty('transform', 'translate(-50%, -50%)', 'important');
|
|
}
|
|
pdfModal.showModal();
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// TOTAL REDUCTION SUMMARY
|
|
// =============================================================================
|
|
// Baseline: 954 lines
|
|
// Phase 4A: -285 lines (Native APIs, CSS, HTMX)
|
|
// Phase 5: -343 lines (Hyperscript zoom control)
|
|
// Phase 6: -87 lines (Hyperscript scroll & print + organization)
|
|
// Current: 239 lines (74.9% reduction)
|
|
// =============================================================================
|
|
|
|
})();
|