diff --git a/static/css/01-foundation/_themes.css b/static/css/01-foundation/_themes.css
index ff0a07a..6edc993 100644
--- a/static/css/01-foundation/_themes.css
+++ b/static/css/01-foundation/_themes.css
@@ -290,16 +290,39 @@
right: auto !important;
width: 50px !important;
height: 50px !important;
- opacity: 0.7 !important;
+ /* Removed opacity: 1 !important to allow .footer-hovered to work */
transform: none !important;
- /* Position before info button: 5 buttons total */
- /* Download, Print, Shortcuts, Theme, Info */
- /* Total width: 5 * 50px + 4 * 10px = 290px */
- left: calc(50% + 35px) !important; /* Fourth button */
+ /* Position in 6-button layout: Download, Print, Shortcuts, Theme, Info, Back-to-top */
+ /* Total width: 6 * 50px + 5 * 10px = 350px */
+ left: calc(50% + 5px) !important; /* Fourth button */
}
- .color-theme-switcher:hover {
- opacity: 1 !important;
+ /* Show theme colors at 50% transparency by default on mobile */
+ .color-theme-switcher[data-theme-mode="light"] {
+ background: rgba(212, 178, 0, 0.5) !important; /* Gold at 50% */
+ }
+
+ .color-theme-switcher[data-theme-mode="dark"] {
+ background: rgba(1, 60, 119, 0.5) !important; /* Blue at 50% */
+ }
+
+ .color-theme-switcher[data-theme-mode="auto"] {
+ background: rgba(155, 89, 182, 0.5) !important; /* Purple at 50% */
+ }
+
+ /* Full color opacity on hover */
+ .color-theme-switcher:hover[data-theme-mode="light"] {
+ background: rgba(212, 178, 0, 1) !important; /* Full gold opacity */
+ transform: translateY(-3px) !important;
+ }
+
+ .color-theme-switcher:hover[data-theme-mode="dark"] {
+ background: rgba(1, 60, 119, 1) !important; /* Full blue opacity */
+ transform: translateY(-3px) !important;
+ }
+
+ .color-theme-switcher:hover[data-theme-mode="auto"] {
+ background: rgba(155, 89, 182, 1) !important; /* Full purple opacity */
transform: translateY(-3px) !important;
}
}
diff --git a/static/css/03-components/_experience.css b/static/css/03-components/_experience.css
index f4e07aa..26a0b39 100644
--- a/static/css/03-components/_experience.css
+++ b/static/css/03-components/_experience.css
@@ -451,18 +451,18 @@
footer {
text-align: center;
padding: 2rem;
- color: rgba(255,255,255,0.7);
+ color: var(--text-muted); /* Theme-aware color (light: #666666, dark: #b0b0b0) */
font-size: 0.85rem;
}
/* GitHub repository link styling */
.github-repo-link {
- color: whitesmoke !important;
+ color: var(--text-secondary) !important; /* Theme-aware link color */
transition: color 0.2s ease-in-out;
}
.github-repo-link:hover {
- color: #66B3FF !important;
+ color: var(--accent-blue) !important;
}
diff --git a/static/css/04-interactive/_modals.css b/static/css/04-interactive/_modals.css
index 0b040e6..c6fb6c6 100644
--- a/static/css/04-interactive/_modals.css
+++ b/static/css/04-interactive/_modals.css
@@ -739,8 +739,23 @@
}
}
+/* Mobile keyframes - must be outside media query */
+@keyframes modalFadeInMobile {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
/* Mobile: Single column - Button-like style */
-@media (max-width: 479px) {
+@media (max-width: 768px) {
+ /* Mobile centering is now handled via JavaScript in openPdfModal() */
+ /* This CSS provides fallback styling for mobile screens */
+
.pdf-download-modal {
max-width: calc(100% - 1rem);
}
diff --git a/static/css/04-interactive/_scroll-behavior.css b/static/css/04-interactive/_scroll-behavior.css
index 25e849c..f1c1679 100644
--- a/static/css/04-interactive/_scroll-behavior.css
+++ b/static/css/04-interactive/_scroll-behavior.css
@@ -111,7 +111,7 @@
/* Mobile adjustments - Flexbox button layout at bottom center */
@media (max-width: 900px) {
- /* Hide only zoom control on mobile */
+ /* Hide zoom control on mobile (keyboard shortcuts now visible) */
.zoom-toggle-btn,
.zoom-control {
display: none !important;
@@ -128,73 +128,199 @@
right: auto !important;
width: 50px !important;
height: 50px !important;
- opacity: 0.7 !important;
+ /* Removed opacity: 1 !important to allow .footer-hovered to work */
transform: none !important;
}
- /* Keep back-to-top button at bottom-right (same as desktop) */
+ /* Mobile: Show colors at 50% transparency by default */
+ .download-btn {
+ background: rgba(205, 96, 96, 0.5) !important; /* PDF red at 50% */
+ }
+
+ .print-friendly-btn {
+ background: rgba(255, 255, 255, 0.5) !important; /* White at 50% */
+ }
+
+ .print-friendly-btn iconify-icon {
+ color: #27ae60 !important; /* Green icon */
+ }
+
+ .shortcuts-btn {
+ background: rgba(243, 156, 18, 0.5) !important; /* Orange at 50% */
+ }
+
+ .info-button {
+ background: rgba(39, 174, 96, 0.5) !important; /* Green at 50% */
+ }
+
.back-to-top {
- position: fixed !important;
- bottom: 1.5rem !important;
- right: 1.5rem !important;
- width: 50px !important;
- height: 50px !important;
+ background: rgba(39, 174, 96, 0.5) !important; /* Green at 50% */
+ opacity: 0.5; /* Semi-transparent by default, full opacity when at-bottom */
}
/* Flexbox container behavior - buttons arrange themselves */
/* Buttons will be positioned using JavaScript or individual positioning */
/* For now, use fixed spacing from center */
- /* 5 buttons: Download, Print, Shortcuts, Theme, Info */
+ /* 6 buttons: Download, Print, Shortcuts, Theme, Info, Back-to-top */
/* Spacing: 10px gap between buttons, centered horizontally */
- /* Total width: 5 * 50px + 4 * 10px = 290px */
- /* Start position: 50% - 145px */
+ /* Total width: 6 * 50px + 5 * 10px = 350px */
+ /* Start position: 50% - 175px */
.download-btn {
- left: calc(50% - 145px) !important; /* First button */
+ left: calc(50% - 175px) !important; /* First button */
}
.print-friendly-btn {
- left: calc(50% - 85px) !important; /* Second button */
+ left: calc(50% - 115px) !important; /* Second button */
}
.shortcuts-btn {
- left: calc(50% - 25px) !important; /* Third button */
+ left: calc(50% - 55px) !important; /* Third button */
}
/* Theme switcher button - fourth position (defined in color-theme.css) */
- /* left: calc(50% + 35px) !important; */
+ /* left: calc(50% + 5px) !important; */
.info-button {
- left: calc(50% + 95px) !important; /* Fifth button (last) */
+ left: calc(50% + 65px) !important; /* Fifth button */
}
- /* Hover effects - only Y transform + enhanced shadow */
- .download-btn:hover,
- .download-btn.pdf-hover-sync,
- .print-friendly-btn:hover,
- .print-friendly-btn.print-hover-sync,
- .shortcuts-btn:hover,
- .info-button:hover,
+ /* Back-to-top button - now part of the button row (sixth button) */
+ .back-to-top {
+ position: fixed !important;
+ bottom: 1.5rem !important;
+ left: calc(50% + 125px) !important; /* Sixth button (last) */
+ right: auto !important; /* Override previous right positioning */
+ width: 50px !important;
+ height: 50px !important;
+ /* Removed fixed opacity - will be controlled by .at-bottom class */
+ display: flex !important; /* Ensure it's always displayed */
+ }
+
+ /* Always show back-to-top on mobile (don't wait for scroll) */
.back-to-top:hover {
- transform: translateY(-3px) !important;
opacity: 1 !important;
+ }
+
+ /* Hover effects - Full color opacity on hover */
+ .download-btn:hover,
+ .download-btn.pdf-hover-sync {
+ background: rgba(205, 96, 96, 1) !important; /* Full red opacity */
+ transform: translateY(-3px) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4) !important;
}
- /* Keep at-bottom state without transform */
- .info-button.at-bottom,
- .shortcuts-btn.at-bottom {
+ .print-friendly-btn:hover,
+ .print-friendly-btn.print-hover-sync {
+ background: rgba(255, 255, 255, 1) !important; /* Full white opacity */
+ transform: translateY(-3px) !important;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4) !important;
+ }
+
+ .shortcuts-btn:hover {
+ background: rgba(243, 156, 18, 1) !important; /* Full orange opacity */
+ transform: translateY(-3px) !important;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4) !important;
+ }
+
+ .info-button:hover {
+ background: rgba(39, 174, 96, 1) !important; /* Full green opacity */
+ transform: translateY(-3px) !important;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4) !important;
+ }
+
+ .back-to-top:hover {
+ background: rgba(39, 174, 96, 1) !important; /* Full green opacity */
+ transform: translateY(-3px) !important;
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4) !important;
+ }
+
+ /* Keep at-bottom state - full opacity colors for each button */
+ .download-btn.at-bottom {
+ background: rgba(205, 96, 96, 1) !important; /* Full red opacity */
opacity: 1 !important;
transform: none !important;
}
-}
-/* Very narrow mobile - Move back-to-top UP on RIGHT side to avoid overlap */
-@media (max-width: 483px) {
- .back-to-top {
- /* Stay on RIGHT side, just move UP higher */
- right: 1.5rem !important;
- bottom: 5.5rem !important; /* Higher position to clear bottom button row */
+ .print-friendly-btn.at-bottom {
+ background: rgba(255, 255, 255, 1) !important; /* Full white opacity */
+ opacity: 1 !important;
+ transform: none !important;
+ }
+
+ .shortcuts-btn.at-bottom {
+ background: rgba(243, 156, 18, 1) !important; /* Full orange opacity */
+ opacity: 1 !important;
+ transform: none !important;
+ }
+
+ .info-button.at-bottom {
+ background: rgba(39, 174, 96, 1) !important; /* Full green opacity */
+ opacity: 1 !important;
+ transform: none !important;
+ }
+
+ .back-to-top.at-bottom {
+ background: rgba(39, 174, 96, 1) !important; /* Full green opacity - NO transparency */
+ opacity: 1 !important;
+ transform: none !important;
+ }
+
+ /* Make all buttons semi-transparent when footer is hovered (applied via JS) */
+ .download-btn.footer-hovered,
+ .print-friendly-btn.footer-hovered,
+ .shortcuts-btn.footer-hovered,
+ .info-button.footer-hovered,
+ .back-to-top.footer-hovered,
+ .color-theme-switcher.footer-hovered {
+ opacity: 0.2 !important; /* Make buttons very transparent to see footer */
+ pointer-events: none !important; /* Prevent interaction when footer is hovered */
+ }
+}
+
+/* Very narrow mobile - Stack buttons vertically or adjust spacing if needed */
+@media (max-width: 483px) {
+ /* For very narrow screens, you may need to adjust button spacing or size */
+ /* For now, keep the same horizontal layout but buttons might overflow slightly */
+ /* Users can scroll horizontally if needed, or we can reduce button sizes */
+}
+
+/* ========================================
+ Mobile: Keep action bar visible (prevent hiding on scroll)
+ ======================================== */
+@media (max-width: 900px) {
+ /* Prevent action bar from hiding on scroll on mobile */
+ .action-bar.header-hidden {
+ transform: translateY(0) !important; /* Override hide behavior */
+ }
+
+ .navigation-menu.header-hidden {
+ transform: translateY(0) !important; /* Override hide behavior */
+ }
+
+ /* Add bottom padding to footer so text isn't hidden behind button bar */
+ footer.no-print {
+ padding-bottom: 120px !important; /* More clearance for 6 buttons + spacing */
+ transition: all 0.3s ease;
+ z-index: 1 !important; /* Keep footer behind buttons (buttons have z-index: 99) */
+ position: relative;
+ }
+
+ /* Footer hover effect - enlarge text when touched (for button transparency) */
+ footer.no-print.footer-hovered {
+ /* Only used for button transparency interaction */
+ }
+
+ /* Footer text automatically enlarges when at page bottom */
+ footer.no-print.at-bottom {
+ padding-bottom: 130px !important; /* Extra space when enlarged */
+ }
+
+ footer.no-print.at-bottom p,
+ footer.no-print.at-bottom a {
+ font-size: 1.2em !important;
+ font-weight: 500 !important;
+ transition: all 0.3s ease;
}
}
diff --git a/static/css/05-responsive/_breakpoints.css b/static/css/05-responsive/_breakpoints.css
index 20fdd98..2a29696 100644
--- a/static/css/05-responsive/_breakpoints.css
+++ b/static/css/05-responsive/_breakpoints.css
@@ -247,6 +247,42 @@
======================================== */
@media (max-width: 768px) {
+ /* ========================================
+ LAYOUT - Single column on mobile
+ ======================================== */
+ /* Collapse grid to single column */
+ .page-1 .page-content,
+ .page-2 .page-content {
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto auto;
+ }
+
+ /* Stack elements vertically: sidebar -> main */
+ .page-1 .cv-sidebar-left {
+ grid-column: 1;
+ grid-row: 1;
+ order: 1;
+ }
+
+ .page-1 .cv-main {
+ grid-column: 1;
+ grid-row: 2;
+ order: 2;
+ }
+
+ /* Stack elements vertically: main -> sidebar */
+ .page-2 .cv-main {
+ grid-column: 1;
+ grid-row: 1;
+ order: 1;
+ }
+
+ .page-2 .cv-sidebar-right {
+ grid-column: 1;
+ grid-row: 2;
+ order: 2;
+ }
+
/* ========================================
TYPOGRAPHY - Subtle font size reductions
======================================== */
@@ -442,6 +478,64 @@
margin-left: 1rem !important;
font-size: 0.85rem !important;
}
+
+ /* ========================================
+ SIDEBAR ACCORDION - MOBILE ONLY
+ ======================================== */
+ /* Show accordion header on mobile - matches CV title badges style */
+ .sidebar-accordion summary.sidebar-accordion-header {
+ display: flex !important;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 20px;
+ background: #303030 !important; /* Match CV title badges dark gray */
+ color: #ccc;
+ cursor: pointer;
+ border-radius: 0; /* No rounding - match title badges */
+ margin-bottom: 0;
+ font-weight: normal;
+ font-size: 0.9em;
+ text-transform: uppercase;
+ gap: 0.5rem;
+ list-style: none;
+ user-select: none;
+ border-bottom: 2px solid #34495e; /* Match title badges border */
+ }
+
+ /* Remove default details marker */
+ .sidebar-accordion summary.sidebar-accordion-header::-webkit-details-marker,
+ .sidebar-accordion summary.sidebar-accordion-header::marker {
+ display: none;
+ }
+
+ /* Chevron rotation when open */
+ .sidebar-accordion[open] summary.sidebar-accordion-header .chevron {
+ transform: rotate(180deg);
+ transition: transform 0.3s ease;
+ }
+
+ .sidebar-accordion summary.sidebar-accordion-header .chevron {
+ transition: transform 0.3s ease;
+ color: #ccc; /* Match header text color */
+ }
+
+ /* Accordion content animation */
+ .sidebar-accordion-content {
+ overflow: hidden;
+ transition: max-height 0.3s ease-in-out;
+ }
+
+ /* Hide when closed */
+ .sidebar-accordion:not([open]) .sidebar-accordion-content {
+ max-height: 0;
+ opacity: 0;
+ }
+
+ /* Show when open */
+ .sidebar-accordion[open] .sidebar-accordion-content {
+ max-height: 2000px;
+ opacity: 1;
+ }
}
/* ========================================
diff --git a/static/css/color-theme.css b/static/css/color-theme.css
index 34d8bc3..5a8492d 100644
--- a/static/css/color-theme.css
+++ b/static/css/color-theme.css
@@ -284,7 +284,7 @@
right: auto !important;
width: 50px !important;
height: 50px !important;
- opacity: 0.7 !important;
+ /* Removed opacity: 1 !important to allow .footer-hovered to work */
transform: none !important;
/* Position before info button: 5 buttons total */
/* Download, Print, Shortcuts, Theme, Info */
@@ -292,8 +292,32 @@
left: calc(50% + 35px) !important; /* Fourth button */
}
- .color-theme-switcher:hover {
- opacity: 1 !important;
+ /* Show theme colors at 50% transparency by default on mobile */
+ .color-theme-switcher[data-theme-mode="light"] {
+ background: rgba(212, 178, 0, 0.5) !important; /* Gold at 50% */
+ }
+
+ .color-theme-switcher[data-theme-mode="dark"] {
+ background: rgba(1, 60, 119, 0.5) !important; /* Blue at 50% */
+ }
+
+ .color-theme-switcher[data-theme-mode="auto"] {
+ background: rgba(155, 89, 182, 0.5) !important; /* Purple at 50% */
+ }
+
+ /* Full color opacity on hover */
+ .color-theme-switcher:hover[data-theme-mode="light"] {
+ background: rgba(212, 178, 0, 1) !important; /* Full gold opacity */
+ transform: translateY(-3px) !important;
+ }
+
+ .color-theme-switcher:hover[data-theme-mode="dark"] {
+ background: rgba(1, 60, 119, 1) !important; /* Full blue opacity */
+ transform: translateY(-3px) !important;
+ }
+
+ .color-theme-switcher:hover[data-theme-mode="auto"] {
+ background: rgba(155, 89, 182, 1) !important; /* Full purple opacity */
transform: translateY(-3px) !important;
}
}
diff --git a/static/js/footer-buttons-interaction.js b/static/js/footer-buttons-interaction.js
new file mode 100644
index 0000000..ef3e694
--- /dev/null
+++ b/static/js/footer-buttons-interaction.js
@@ -0,0 +1,46 @@
+/**
+ * Footer and Button Bar Interaction
+ * Makes button bar semi-transparent when hovering over footer area
+ * so footer content remains visible
+ */
+
+(function() {
+ 'use strict';
+
+ // Wait for DOM to be ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+ function init() {
+ const footer = document.querySelector('footer.no-print');
+ if (!footer) return;
+
+ // Get all fixed buttons
+ const buttons = document.querySelectorAll(
+ '.download-btn, .print-friendly-btn, .shortcuts-btn, ' +
+ '.info-button, .back-to-top, .color-theme-switcher'
+ );
+
+ // Add hover listeners to footer
+ footer.addEventListener('mouseenter', () => {
+ // Add class to footer itself for text enlargement
+ footer.classList.add('footer-hovered');
+ // Add class to buttons for transparency
+ buttons.forEach(btn => {
+ btn.classList.add('footer-hovered');
+ });
+ });
+
+ footer.addEventListener('mouseleave', () => {
+ // Remove class from footer
+ footer.classList.remove('footer-hovered');
+ // Remove class from buttons
+ buttons.forEach(btn => {
+ btn.classList.remove('footer-hovered');
+ });
+ });
+ }
+})();
diff --git a/static/js/main.js b/static/js/main.js
index 61673bd..b2b9ab7 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -607,6 +607,19 @@
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();
}
};
diff --git a/static/js/scroll-at-bottom-handler.js b/static/js/scroll-at-bottom-handler.js
new file mode 100644
index 0000000..905d863
--- /dev/null
+++ b/static/js/scroll-at-bottom-handler.js
@@ -0,0 +1,73 @@
+/**
+ * Scroll At-Bottom Handler
+ * Adds 'at-bottom' class to buttons and footer when user scrolls to page bottom
+ */
+
+(function() {
+ 'use strict';
+
+ // Wait for DOM to be ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+ function init() {
+ const footer = document.querySelector('footer.no-print');
+ if (!footer) return;
+
+ // Get all fixed buttons that need 'at-bottom' state
+ const buttons = document.querySelectorAll(
+ '.download-btn, .print-friendly-btn, .shortcuts-btn, ' +
+ '.info-button, .back-to-top, .color-theme-switcher'
+ );
+
+ if (buttons.length === 0) return;
+
+ // Throttle scroll events for better performance
+ let ticking = false;
+
+ function checkIfAtBottom() {
+ // Calculate if we're at the bottom of the page
+ // Allow 50px tolerance for smoother experience
+ const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
+ const windowHeight = window.innerHeight;
+ const documentHeight = document.documentElement.scrollHeight;
+ const distanceFromBottom = documentHeight - (scrollPosition + windowHeight);
+
+ const isAtBottom = distanceFromBottom <= 50;
+
+ if (isAtBottom) {
+ // Add 'at-bottom' class to all buttons and footer
+ buttons.forEach(btn => btn.classList.add('at-bottom'));
+ footer.classList.add('at-bottom');
+ } else {
+ // Remove 'at-bottom' class from all buttons and footer
+ buttons.forEach(btn => btn.classList.remove('at-bottom'));
+ footer.classList.remove('at-bottom');
+ }
+ }
+
+ function onScroll() {
+ if (!ticking) {
+ window.requestAnimationFrame(() => {
+ checkIfAtBottom();
+ ticking = false;
+ });
+ ticking = true;
+ }
+ }
+
+ // Check initial state
+ checkIfAtBottom();
+
+ // Listen to scroll events
+ window.addEventListener('scroll', onScroll, { passive: true });
+
+ // Also check on resize (mobile orientation changes, etc.)
+ window.addEventListener('resize', () => {
+ setTimeout(checkIfAtBottom, 100);
+ });
+ }
+})();
diff --git a/templates/index.html b/templates/index.html
index e67f2c5..9cca6a7 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -65,6 +65,12 @@
+
+
+
+
+
+
diff --git a/templates/partials/cv/sidebar.html b/templates/partials/cv/sidebar.html
index 760366d..1bfee0f 100644
--- a/templates/partials/cv/sidebar.html
+++ b/templates/partials/cv/sidebar.html
@@ -1,24 +1,26 @@
{{define "sidebar"}}
{{end}}
diff --git a/templates/partials/widgets/download-button.html b/templates/partials/widgets/download-button.html
index bcc2d00..0255ebd 100644
--- a/templates/partials/widgets/download-button.html
+++ b/templates/partials/widgets/download-button.html
@@ -5,7 +5,7 @@
class="fixed-btn download-btn no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}"
data-tooltip="{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}"
- onclick="document.getElementById('pdf-modal').showModal()"
+ onclick="openPdfModal()"
_="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)">
diff --git a/tests/mjs/34-mobile-button-opacity-test.mjs b/tests/mjs/34-mobile-button-opacity-test.mjs
new file mode 100755
index 0000000..a8bdea0
--- /dev/null
+++ b/tests/mjs/34-mobile-button-opacity-test.mjs
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+/**
+ * Test: Mobile Button Opacity - 50% default, 100% on hover
+ *
+ * Verifies that on mobile view (max-width: 900px):
+ * - All fixed buttons have 50% opacity (0.5) by default
+ * - Buttons become 100% opaque (1.0) on hover
+ * - Buttons: download, print-friendly, shortcuts, theme-switcher, info
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+async function testMobileButtonOpacity() {
+ console.log('๐งช Testing Mobile Button Opacity (50% default, 100% hover)');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache to ensure fresh CSS
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ // Navigate to the page
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+
+ // Wait for page to be fully loaded
+ await page.waitForTimeout(1000);
+
+ // Test buttons
+ const buttons = [
+ { selector: '.download-btn', name: 'Download' },
+ { selector: '.print-friendly-btn', name: 'Print Friendly' },
+ { selector: '.shortcuts-btn', name: 'Shortcuts' },
+ { selector: '.color-theme-switcher', name: 'Theme Switcher' },
+ { selector: '.info-button', name: 'Info' },
+ ];
+
+ console.log('\n๐ฑ Testing Mobile Button Opacities:');
+ console.log('-'.repeat(70));
+
+ let allTestsPassed = true;
+
+ for (const button of buttons) {
+ try {
+ // Check if button exists
+ const buttonElement = await page.$(button.selector);
+ if (!buttonElement) {
+ console.log(`โ ${button.name}: Button not found`);
+ allTestsPassed = false;
+ continue;
+ }
+
+ // Get default opacity
+ const defaultOpacity = await page.evaluate((sel) => {
+ const btn = document.querySelector(sel);
+ return btn ? window.getComputedStyle(btn).opacity : null;
+ }, button.selector);
+
+ // Test default opacity (should be 0.5)
+ const defaultOpacityNum = parseFloat(defaultOpacity);
+ const isDefaultCorrect = Math.abs(defaultOpacityNum - 0.5) < 0.01;
+
+ if (isDefaultCorrect) {
+ console.log(`โ
${button.name}: Default opacity = ${defaultOpacity} (expected ~0.5)`);
+ } else {
+ console.log(`โ ${button.name}: Default opacity = ${defaultOpacity} (expected ~0.5)`);
+ allTestsPassed = false;
+ }
+
+ // Hover over button and check opacity
+ await buttonElement.hover();
+ await page.waitForTimeout(500); // Wait for transition
+
+ const hoverOpacity = await page.evaluate((sel) => {
+ const btn = document.querySelector(sel);
+ return btn ? window.getComputedStyle(btn).opacity : null;
+ }, button.selector);
+
+ // Test hover opacity (should be 1.0)
+ const hoverOpacityNum = parseFloat(hoverOpacity);
+ const isHoverCorrect = Math.abs(hoverOpacityNum - 1.0) < 0.01;
+
+ if (isHoverCorrect) {
+ console.log(` โ
Hover opacity = ${hoverOpacity} (expected ~1.0)`);
+ } else {
+ console.log(` โ Hover opacity = ${hoverOpacity} (expected ~1.0)`);
+ allTestsPassed = false;
+ }
+
+ // Move mouse away to reset
+ await page.mouse.move(0, 0);
+ await page.waitForTimeout(500);
+
+ } catch (error) {
+ console.log(`โ ${button.name}: Error - ${error.message}`);
+ allTestsPassed = false;
+ }
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED - Mobile button opacity working correctly!');
+ console.log(' โข Default opacity: 0.5 (50% transparent)');
+ console.log(' โข Hover opacity: 1.0 (fully opaque)');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above for details');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+// Run the test
+testMobileButtonOpacity();
diff --git a/tests/mjs/35-mobile-colored-buttons-test.mjs b/tests/mjs/35-mobile-colored-buttons-test.mjs
new file mode 100755
index 0000000..7580770
--- /dev/null
+++ b/tests/mjs/35-mobile-colored-buttons-test.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+/**
+ * Test: Mobile Colored Buttons - Colors at 50% transparency, full on hover
+ *
+ * Verifies that on mobile view (max-width: 900px):
+ * - Buttons show their colors (not gray) at 50% transparency
+ * - On hover, colors become fully opaque (100%)
+ * - Colors match the "at-bottom" or hover states from desktop
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+// Helper to extract RGB values from background color
+function parseRGB(colorString) {
+ const match = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (!match) return null;
+ return {
+ r: parseInt(match[1]),
+ g: parseInt(match[2]),
+ b: parseInt(match[3]),
+ a: match[4] ? parseFloat(match[4]) : 1
+ };
+}
+
+async function testMobileColoredButtons() {
+ console.log('๐งช Testing Mobile Colored Buttons (50% transparent โ 100% on hover)');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+ await page.waitForTimeout(1000);
+
+ const buttons = [
+ { selector: '.download-btn', name: 'Download', expectedColor: { r: 205, g: 96, b: 96 } }, // Red
+ { selector: '.print-friendly-btn', name: 'Print', expectedColor: { r: 255, g: 255, b: 255 } }, // White
+ { selector: '.shortcuts-btn', name: 'Shortcuts', expectedColor: { r: 243, g: 156, b: 18 } }, // Orange
+ { selector: '.info-button', name: 'Info', expectedColor: { r: 39, g: 174, b: 96 } }, // Green
+ { selector: '.back-to-top', name: 'Back to Top', expectedColor: { r: 39, g: 174, b: 96 } }, // Green
+ ];
+
+ console.log('\n๐จ Testing Mobile Button Colors:');
+ console.log('-'.repeat(70));
+
+ let allTestsPassed = true;
+
+ for (const button of buttons) {
+ try {
+ const buttonElement = await page.$(button.selector);
+ if (!buttonElement) {
+ console.log(`โ ${button.name}: Button not found`);
+ allTestsPassed = false;
+ continue;
+ }
+
+ // Get default background color
+ const defaultBg = await page.evaluate((sel) => {
+ const btn = document.querySelector(sel);
+ return btn ? window.getComputedStyle(btn).backgroundColor : null;
+ }, button.selector);
+
+ const defaultColor = parseRGB(defaultBg);
+
+ // Check if color matches expected (RGB should match, alpha should be ~0.5)
+ if (defaultColor) {
+ const colorMatches =
+ Math.abs(defaultColor.r - button.expectedColor.r) <= 5 &&
+ Math.abs(defaultColor.g - button.expectedColor.g) <= 5 &&
+ Math.abs(defaultColor.b - button.expectedColor.b) <= 5;
+
+ const alphaCorrect = Math.abs(defaultColor.a - 0.5) < 0.1;
+
+ if (colorMatches && alphaCorrect) {
+ console.log(`โ
${button.name}: Color rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${defaultColor.a.toFixed(2)}) โ`);
+ } else {
+ console.log(`โ ${button.name}: Color rgba(${defaultColor.r}, ${defaultColor.g}, ${defaultColor.b}, ${defaultColor.a.toFixed(2)})`);
+ console.log(` Expected: rgba(${button.expectedColor.r}, ${button.expectedColor.g}, ${button.expectedColor.b}, ~0.5)`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log(`โ ${button.name}: Could not parse background color`);
+ allTestsPassed = false;
+ continue;
+ }
+
+ // Hover and check color becomes fully opaque
+ await buttonElement.hover();
+ await page.waitForTimeout(500);
+
+ const hoverBg = await page.evaluate((sel) => {
+ const btn = document.querySelector(sel);
+ return btn ? window.getComputedStyle(btn).backgroundColor : null;
+ }, button.selector);
+
+ const hoverColor = parseRGB(hoverBg);
+
+ if (hoverColor) {
+ const hoverAlphaCorrect = Math.abs(hoverColor.a - 1.0) < 0.1;
+
+ if (hoverAlphaCorrect) {
+ console.log(` โ
Hover: rgba(${hoverColor.r}, ${hoverColor.g}, ${hoverColor.b}, ${hoverColor.a.toFixed(2)}) โ`);
+ } else {
+ console.log(` โ Hover: rgba(${hoverColor.r}, ${hoverColor.g}, ${hoverColor.b}, ${hoverColor.a.toFixed(2)})`);
+ console.log(` Expected alpha: ~1.0`);
+ allTestsPassed = false;
+ }
+ }
+
+ // Move mouse away
+ await page.mouse.move(0, 0);
+ await page.waitForTimeout(500);
+
+ } catch (error) {
+ console.log(`โ ${button.name}: Error - ${error.message}`);
+ allTestsPassed = false;
+ }
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED - Mobile colored buttons working correctly!');
+ console.log(' โข Buttons show colors at 50% transparency');
+ console.log(' โข Colors become fully opaque (100%) on hover');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+testMobileColoredButtons();
diff --git a/tests/mjs/36-button-hover-and-footer-test.mjs b/tests/mjs/36-button-hover-and-footer-test.mjs
new file mode 100755
index 0000000..d27c5a8
--- /dev/null
+++ b/tests/mjs/36-button-hover-and-footer-test.mjs
@@ -0,0 +1,187 @@
+#!/usr/bin/env node
+/**
+ * Test: Button Hover Full Opacity + Footer Interaction
+ *
+ * Verifies on mobile view (max-width: 900px):
+ * 1. All buttons become 100% opaque (alpha = 1.0) on hover
+ * 2. Buttons become semi-transparent (opacity 0.2) when hovering footer
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+// Helper to extract RGB values from background color
+function parseRGB(colorString) {
+ const match = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (!match) return null;
+ return {
+ r: parseInt(match[1]),
+ g: parseInt(match[2]),
+ b: parseInt(match[3]),
+ a: match[4] ? parseFloat(match[4]) : 1
+ };
+}
+
+async function testButtonHoverAndFooter() {
+ console.log('๐งช Testing Button Hover Opacity + Footer Interaction');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+ await page.waitForTimeout(1500);
+
+ const buttons = [
+ { selector: '.download-btn', name: 'Download' },
+ { selector: '.print-friendly-btn', name: 'Print' },
+ { selector: '.shortcuts-btn', name: 'Shortcuts' },
+ { selector: '.color-theme-switcher', name: 'Theme' },
+ { selector: '.info-button', name: 'Info' },
+ { selector: '.back-to-top', name: 'Back to Top' },
+ ];
+
+ let allTestsPassed = true;
+
+ // TEST 1: All buttons reach 100% opacity on hover
+ console.log('\n๐ TEST 1: Button Hover - Full Opacity (100%)');
+ console.log('-'.repeat(70));
+
+ for (const button of buttons) {
+ try {
+ const buttonElement = await page.$(button.selector);
+ if (!buttonElement) {
+ console.log(`โ ${button.name}: Button not found`);
+ allTestsPassed = false;
+ continue;
+ }
+
+ // Hover over the button
+ await buttonElement.hover();
+ await page.waitForTimeout(500);
+
+ // Get hover background color
+ const hoverBg = await page.evaluate((sel) => {
+ const btn = document.querySelector(sel);
+ return btn ? window.getComputedStyle(btn).backgroundColor : null;
+ }, button.selector);
+
+ const hoverColor = parseRGB(hoverBg);
+
+ if (hoverColor) {
+ const alphaIs100 = Math.abs(hoverColor.a - 1.0) < 0.1;
+
+ if (alphaIs100) {
+ console.log(`โ
${button.name}: Hover opacity = ${hoverColor.a.toFixed(2)} (100%) โ`);
+ } else {
+ console.log(`โ ${button.name}: Hover opacity = ${hoverColor.a.toFixed(2)} (Expected 1.0)`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log(`โ ${button.name}: Could not parse hover background color`);
+ allTestsPassed = false;
+ }
+
+ // Move mouse away
+ await page.mouse.move(0, 0);
+ await page.waitForTimeout(300);
+
+ } catch (error) {
+ console.log(`โ ${button.name}: Error - ${error.message}`);
+ allTestsPassed = false;
+ }
+ }
+
+ // TEST 2: Footer hover makes buttons semi-transparent
+ console.log('\n๐ TEST 2: Footer Hover - Buttons Become Transparent (20%)');
+ console.log('-'.repeat(70));
+
+ try {
+ // Scroll to footer
+ await page.evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+ await page.waitForTimeout(500);
+
+ // Find footer element
+ const footer = await page.$('footer.no-print');
+ if (!footer) {
+ console.log('โ Footer element not found');
+ allTestsPassed = false;
+ } else {
+ // Hover over footer
+ await footer.hover();
+ await page.waitForTimeout(500);
+
+ // Check if buttons have footer-hovered class and opacity 0.2
+ for (const button of buttons) {
+ const buttonData = await page.evaluate((sel) => {
+ const btn = document.querySelector(sel);
+ if (!btn) return null;
+ return {
+ hasClass: btn.classList.contains('footer-hovered'),
+ opacity: window.getComputedStyle(btn).opacity
+ };
+ }, button.selector);
+
+ if (buttonData) {
+ const opacityValue = parseFloat(buttonData.opacity);
+ const opacityCorrect = Math.abs(opacityValue - 0.2) < 0.05;
+
+ if (buttonData.hasClass && opacityCorrect) {
+ console.log(`โ
${button.name}: Footer hover opacity = ${opacityValue.toFixed(2)} โ`);
+ } else {
+ console.log(`โ ${button.name}: Footer hover opacity = ${opacityValue.toFixed(2)} (Expected 0.2), Class: ${buttonData.hasClass}`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log(`โ ${button.name}: Could not check footer hover state`);
+ allTestsPassed = false;
+ }
+ }
+ }
+ } catch (error) {
+ console.log(`โ Footer hover test error: ${error.message}`);
+ allTestsPassed = false;
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED!');
+ console.log(' โข All buttons reach 100% opacity on hover');
+ console.log(' โข Buttons become 20% opacity when hovering footer');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+testButtonHoverAndFooter();
diff --git a/tests/mjs/37-footer-hover-programmatic.mjs b/tests/mjs/37-footer-hover-programmatic.mjs
new file mode 100755
index 0000000..852a7f3
--- /dev/null
+++ b/tests/mjs/37-footer-hover-programmatic.mjs
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+/**
+ * Test: Footer Hover Programmatic (JavaScript Events)
+ *
+ * Tests footer hover interaction by programmatically triggering mouseenter/mouseleave events
+ * instead of using Playwright's hover (which is blocked by overlapping buttons)
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+async function testFooterHoverProgrammatic() {
+ console.log('๐งช Testing Footer Hover with Programmatic Events');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+ await page.waitForTimeout(1500);
+
+ // Scroll to footer
+ await page.evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+ await page.waitForTimeout(500);
+
+ const buttons = [
+ '.download-btn',
+ '.print-friendly-btn',
+ '.shortcuts-btn',
+ '.color-theme-switcher',
+ '.info-button',
+ '.back-to-top'
+ ];
+
+ let allTestsPassed = true;
+
+ console.log('\n๐ Test 1: Check if footer-buttons-interaction.js is loaded');
+ console.log('-'.repeat(70));
+
+ const jsLoaded = await page.evaluate(() => {
+ const scripts = Array.from(document.querySelectorAll('script'));
+ return scripts.some(script => script.src.includes('footer-buttons-interaction.js'));
+ });
+
+ if (jsLoaded) {
+ console.log('โ
footer-buttons-interaction.js is loaded in the page');
+ } else {
+ console.log('โ footer-buttons-interaction.js NOT found in the page');
+ allTestsPassed = false;
+ }
+
+ console.log('\n๐ Test 2: Check if footer element exists');
+ console.log('-'.repeat(70));
+
+ const footerExists = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ return footer !== null;
+ });
+
+ if (footerExists) {
+ console.log('โ
Footer element (footer.no-print) found');
+ } else {
+ console.log('โ Footer element (footer.no-print) NOT found');
+ allTestsPassed = false;
+ }
+
+ console.log('\n๐ Test 3: Programmatically trigger footer mouseenter');
+ console.log('-'.repeat(70));
+
+ // Trigger mouseenter event on footer
+ const mouseEnterResult = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ if (!footer) return { success: false, error: 'Footer not found' };
+
+ const event = new MouseEvent('mouseenter', {
+ bubbles: true,
+ cancelable: true,
+ view: window
+ });
+ footer.dispatchEvent(event);
+
+ // Wait for event handlers to execute AND CSS transitions to complete (300ms)
+ return new Promise(resolve => {
+ setTimeout(() => {
+ const buttons = document.querySelectorAll(
+ '.download-btn, .print-friendly-btn, .shortcuts-btn, ' +
+ '.info-button, .back-to-top, .color-theme-switcher'
+ );
+
+ const results = {};
+ buttons.forEach(btn => {
+ const className = btn.className.split(' ').find(c => c.includes('btn') || c.includes('switcher'));
+ results[className] = {
+ hasClass: btn.classList.contains('footer-hovered'),
+ opacity: window.getComputedStyle(btn).opacity,
+ pointerEvents: window.getComputedStyle(btn).pointerEvents
+ };
+ });
+
+ resolve({ success: true, buttons: results });
+ }, 500); // Wait 500ms for 300ms transition to complete
+ });
+ });
+
+ if (mouseEnterResult.success) {
+ console.log('โ
Mouseenter event dispatched successfully');
+ console.log('\n Button states after footer mouseenter:');
+
+ for (const [btnName, data] of Object.entries(mouseEnterResult.buttons)) {
+ const opacityCorrect = Math.abs(parseFloat(data.opacity) - 0.2) < 0.05;
+ const pointerEventsCorrect = data.pointerEvents === 'none';
+
+ if (data.hasClass && opacityCorrect && pointerEventsCorrect) {
+ console.log(` โ
${btnName}: class=${data.hasClass}, opacity=${data.opacity}, pointerEvents=${data.pointerEvents}`);
+ } else {
+ console.log(` โ ${btnName}: class=${data.hasClass}, opacity=${data.opacity}, pointerEvents=${data.pointerEvents}`);
+ console.log(` Expected: class=true, opacity=~0.2, pointerEvents=none`);
+ allTestsPassed = false;
+ }
+ }
+ } else {
+ console.log(`โ Failed to dispatch mouseenter: ${mouseEnterResult.error}`);
+ allTestsPassed = false;
+ }
+
+ console.log('\n๐ Test 4: Programmatically trigger footer mouseleave');
+ console.log('-'.repeat(70));
+
+ // Trigger mouseleave event on footer
+ const mouseLeaveResult = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ if (!footer) return { success: false, error: 'Footer not found' };
+
+ const event = new MouseEvent('mouseleave', {
+ bubbles: true,
+ cancelable: true,
+ view: window
+ });
+ footer.dispatchEvent(event);
+
+ // Wait a moment for event handlers to execute
+ return new Promise(resolve => {
+ setTimeout(() => {
+ const buttons = document.querySelectorAll(
+ '.download-btn, .print-friendly-btn, .shortcuts-btn, ' +
+ '.info-button, .back-to-top, .color-theme-switcher'
+ );
+
+ const results = {};
+ buttons.forEach(btn => {
+ const className = btn.className.split(' ').find(c => c.includes('btn') || c.includes('switcher'));
+ results[className] = {
+ hasClass: btn.classList.contains('footer-hovered')
+ };
+ });
+
+ resolve({ success: true, buttons: results });
+ }, 500); // Wait 500ms for 300ms transition to complete
+ });
+ });
+
+ if (mouseLeaveResult.success) {
+ console.log('โ
Mouseleave event dispatched successfully');
+ console.log('\n Button states after footer mouseleave:');
+
+ for (const [btnName, data] of Object.entries(mouseLeaveResult.buttons)) {
+ if (!data.hasClass) {
+ console.log(` โ
${btnName}: footer-hovered class removed`);
+ } else {
+ console.log(` โ ${btnName}: footer-hovered class still present`);
+ allTestsPassed = false;
+ }
+ }
+ } else {
+ console.log(`โ Failed to dispatch mouseleave: ${mouseLeaveResult.error}`);
+ allTestsPassed = false;
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED!');
+ console.log(' โข JavaScript file loaded correctly');
+ console.log(' โข Footer element exists');
+ console.log(' โข Buttons become transparent (0.2) on footer hover');
+ console.log(' โข Buttons restore normal state on footer leave');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+testFooterHoverProgrammatic();
diff --git a/tests/mjs/38-mobile-fixes-verification.mjs b/tests/mjs/38-mobile-fixes-verification.mjs
new file mode 100755
index 0000000..1b8a270
--- /dev/null
+++ b/tests/mjs/38-mobile-fixes-verification.mjs
@@ -0,0 +1,181 @@
+#!/usr/bin/env node
+/**
+ * Test: Mobile Fixes Verification
+ *
+ * Verifies the three mobile fixes:
+ * 1. Shortcuts button is hidden on mobile
+ * 2. Action bar stays visible (no auto-hide on scroll)
+ * 3. Footer has bottom padding to prevent text hiding behind buttons
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+async function testMobileFixes() {
+ console.log('๐งช Testing Mobile Fixes (Action Bar, Shortcuts Hide, Footer Padding)');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+ await page.waitForTimeout(1500);
+
+ let allTestsPassed = true;
+
+ // TEST 1: Shortcuts button should be hidden on mobile
+ console.log('\n๐ TEST 1: Shortcuts button hidden on mobile');
+ console.log('-'.repeat(70));
+
+ const shortcutsBtn = await page.$('.shortcuts-btn');
+ if (shortcutsBtn) {
+ const isVisible = await shortcutsBtn.isVisible();
+ if (!isVisible) {
+ console.log('โ
Shortcuts button is hidden on mobile (display: none)');
+ } else {
+ console.log('โ Shortcuts button is visible on mobile (should be hidden)');
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ
Shortcuts button not found in DOM (correctly hidden)');
+ }
+
+ // TEST 2: Action bar should stay visible when scrolling
+ console.log('\n๐ TEST 2: Action bar stays visible on scroll (mobile)');
+ console.log('-'.repeat(70));
+
+ // Scroll down to trigger header-hidden class
+ await page.evaluate(() => {
+ window.scrollTo(0, 500);
+ });
+ await page.waitForTimeout(500);
+
+ const actionBarVisible = await page.evaluate(() => {
+ const actionBar = document.querySelector('.action-bar');
+ if (!actionBar) return { exists: false };
+
+ const hasHiddenClass = actionBar.classList.contains('header-hidden');
+ const transform = window.getComputedStyle(actionBar).transform;
+
+ return {
+ exists: true,
+ hasHiddenClass,
+ transform,
+ isTransformed: transform !== 'none' && transform !== 'matrix(1, 0, 0, 1, 0, 0)'
+ };
+ });
+
+ if (actionBarVisible.exists) {
+ if (!actionBarVisible.isTransformed) {
+ console.log(`โ
Action bar stays visible (transform: ${actionBarVisible.transform})`);
+ console.log(` header-hidden class: ${actionBarVisible.hasHiddenClass}, but transform overridden`);
+ } else {
+ console.log(`โ Action bar is transformed away (transform: ${actionBarVisible.transform})`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Action bar not found');
+ allTestsPassed = false;
+ }
+
+ // TEST 3: Footer should have bottom padding on mobile
+ console.log('\n๐ TEST 3: Footer has bottom padding on mobile');
+ console.log('-'.repeat(70));
+
+ // Scroll to bottom
+ await page.evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+ await page.waitForTimeout(500);
+
+ const footerPadding = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ if (!footer) return { exists: false };
+
+ const styles = window.getComputedStyle(footer);
+ return {
+ exists: true,
+ paddingBottom: styles.paddingBottom,
+ paddingBottomValue: parseInt(styles.paddingBottom, 10)
+ };
+ });
+
+ if (footerPadding.exists) {
+ // Expect at least 70px bottom padding (we set 80px)
+ if (footerPadding.paddingBottomValue >= 70) {
+ console.log(`โ
Footer has adequate bottom padding: ${footerPadding.paddingBottom}`);
+ } else {
+ console.log(`โ Footer padding insufficient: ${footerPadding.paddingBottom} (expected >= 70px)`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Footer element not found');
+ allTestsPassed = false;
+ }
+
+ // TEST 4: Hamburger button should be visible
+ console.log('\n๐ TEST 4: Hamburger button visible on mobile');
+ console.log('-'.repeat(70));
+
+ // Scroll back to top to see action bar
+ await page.evaluate(() => {
+ window.scrollTo(0, 0);
+ });
+ await page.waitForTimeout(500);
+
+ const hamburgerBtn = await page.$('.hamburger-btn');
+ if (hamburgerBtn) {
+ const isVisible = await hamburgerBtn.isVisible();
+ if (isVisible) {
+ console.log('โ
Hamburger button is visible on mobile');
+ } else {
+ console.log('โ Hamburger button is hidden on mobile (should be visible)');
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Hamburger button not found in DOM');
+ allTestsPassed = false;
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED!');
+ console.log(' โข Shortcuts button hidden on mobile');
+ console.log(' โข Action bar stays visible on scroll');
+ console.log(' โข Footer has bottom padding (80px)');
+ console.log(' โข Hamburger button visible on mobile');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+testMobileFixes();
diff --git a/tests/mjs/39-mobile-updates-verification.mjs b/tests/mjs/39-mobile-updates-verification.mjs
new file mode 100755
index 0000000..8038f7b
--- /dev/null
+++ b/tests/mjs/39-mobile-updates-verification.mjs
@@ -0,0 +1,216 @@
+#!/usr/bin/env node
+/**
+ * Test: Mobile Updates Verification
+ *
+ * Verifies the updated mobile features:
+ * 1. Keyboard shortcuts button is visible
+ * 2. All 6 buttons are positioned in a single row
+ * 3. Footer has 120px bottom padding
+ * 4. Footer hover effect enlarges text
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+async function testMobileUpdates() {
+ console.log('๐งช Testing Mobile Updates (6-Button Row, Footer Padding, Hover Effect)');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+ await page.waitForTimeout(1500);
+
+ let allTestsPassed = true;
+
+ // TEST 1: Keyboard shortcuts button should be visible on mobile
+ console.log('\n๐ TEST 1: Keyboard shortcuts button visible on mobile');
+ console.log('-'.repeat(70));
+
+ const shortcutsBtn = await page.$('.shortcuts-btn');
+ if (shortcutsBtn) {
+ const isVisible = await shortcutsBtn.isVisible();
+ if (isVisible) {
+ console.log('โ
Keyboard shortcuts button is visible on mobile');
+ } else {
+ console.log('โ Keyboard shortcuts button is hidden (should be visible)');
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Keyboard shortcuts button not found in DOM');
+ allTestsPassed = false;
+ }
+
+ // TEST 2: All 6 buttons should be positioned in a row
+ console.log('\n๐ TEST 2: All 6 buttons positioned in a row');
+ console.log('-'.repeat(70));
+
+ const buttonPositions = await page.evaluate(() => {
+ const buttons = [
+ { name: 'Download', selector: '.download-btn' },
+ { name: 'Print', selector: '.print-friendly-btn' },
+ { name: 'Shortcuts', selector: '.shortcuts-btn' },
+ { name: 'Theme', selector: '.color-theme-switcher' },
+ { name: 'Info', selector: '.info-button' },
+ { name: 'Back-to-top', selector: '.back-to-top' }
+ ];
+
+ return buttons.map(({ name, selector }) => {
+ const btn = document.querySelector(selector);
+ if (!btn) return { name, exists: false };
+
+ const rect = btn.getBoundingClientRect();
+ const styles = window.getComputedStyle(btn);
+ return {
+ name,
+ exists: true,
+ bottom: rect.bottom,
+ left: rect.left,
+ width: rect.width,
+ position: styles.position
+ };
+ });
+ });
+
+ // Check all buttons exist and are at same bottom position
+ const bottomPosition = buttonPositions[0]?.bottom;
+ let allButtonsAligned = true;
+
+ for (const btn of buttonPositions) {
+ if (!btn.exists) {
+ console.log(`โ ${btn.name}: Button not found`);
+ allTestsPassed = false;
+ allButtonsAligned = false;
+ } else {
+ const bottomMatch = Math.abs(btn.bottom - bottomPosition) < 5; // 5px tolerance
+ if (bottomMatch) {
+ console.log(`โ
${btn.name}: Positioned at bottom=${btn.bottom.toFixed(0)}px, left=${btn.left.toFixed(0)}px`);
+ } else {
+ console.log(`โ ${btn.name}: Wrong bottom position (${btn.bottom.toFixed(0)}px, expected ~${bottomPosition.toFixed(0)}px)`);
+ allTestsPassed = false;
+ allButtonsAligned = false;
+ }
+ }
+ }
+
+ if (allButtonsAligned && buttonPositions.every(b => b.exists)) {
+ console.log('โ
All 6 buttons are aligned in a row');
+ }
+
+ // TEST 3: Footer should have 120px bottom padding
+ console.log('\n๐ TEST 3: Footer has 120px bottom padding');
+ console.log('-'.repeat(70));
+
+ await page.evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+ await page.waitForTimeout(500);
+
+ const footerPadding = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ if (!footer) return { exists: false };
+
+ const styles = window.getComputedStyle(footer);
+ return {
+ exists: true,
+ paddingBottom: styles.paddingBottom,
+ paddingBottomValue: parseInt(styles.paddingBottom, 10)
+ };
+ });
+
+ if (footerPadding.exists) {
+ if (footerPadding.paddingBottomValue >= 110) {
+ console.log(`โ
Footer has adequate padding: ${footerPadding.paddingBottom}`);
+ } else {
+ console.log(`โ Footer padding insufficient: ${footerPadding.paddingBottom} (expected >= 110px)`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Footer element not found');
+ allTestsPassed = false;
+ }
+
+ // TEST 4: Footer hover effect should enlarge text
+ console.log('\n๐ TEST 4: Footer hover effect enlarges text');
+ console.log('-'.repeat(70));
+
+ // Get initial font size
+ const initialFontSize = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print p');
+ if (!footer) return null;
+ return parseFloat(window.getComputedStyle(footer).fontSize);
+ });
+
+ // Trigger footer hover by adding class
+ await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ if (footer) {
+ footer.classList.add('footer-hovered');
+ // Trigger transition
+ footer.offsetHeight; // Force reflow
+ }
+ });
+
+ await page.waitForTimeout(500); // Wait for CSS transition
+
+ const hoveredFontSize = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print p');
+ if (!footer) return null;
+ return parseFloat(window.getComputedStyle(footer).fontSize);
+ });
+
+ if (initialFontSize && hoveredFontSize) {
+ if (hoveredFontSize > initialFontSize) {
+ console.log(`โ
Footer text enlarged on hover: ${initialFontSize.toFixed(1)}px โ ${hoveredFontSize.toFixed(1)}px`);
+ } else {
+ console.log(`โ Footer text not enlarged: ${initialFontSize.toFixed(1)}px โ ${hoveredFontSize.toFixed(1)}px`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Could not measure footer font sizes');
+ allTestsPassed = false;
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED!');
+ console.log(' โข Keyboard shortcuts button visible');
+ console.log(' โข All 6 buttons in a single row');
+ console.log(' โข Footer has 120px bottom padding');
+ console.log(' โข Footer hover effect enlarges text');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+testMobileUpdates();
diff --git a/tests/mjs/40-back-to-top-and-footer-fixes.mjs b/tests/mjs/40-back-to-top-and-footer-fixes.mjs
new file mode 100755
index 0000000..7265673
--- /dev/null
+++ b/tests/mjs/40-back-to-top-and-footer-fixes.mjs
@@ -0,0 +1,188 @@
+#!/usr/bin/env node
+/**
+ * Test: Back-to-Top Button and Footer Text Fixes
+ *
+ * Verifies:
+ * 1. Back-to-top button shows full opacity (no transparency) when at page bottom
+ * 2. Footer text enlarges when footer is hovered/touched
+ */
+
+import { chromium } from 'playwright';
+
+const TEST_URL = 'http://localhost:1999';
+const VIEWPORT_WIDTH = 375; // Mobile width
+const VIEWPORT_HEIGHT = 812; // iPhone X height
+
+async function testBackToTopAndFooterFixes() {
+ console.log('๐งช Testing Back-to-Top Button Opacity & Footer Text Enlargement');
+ console.log('='.repeat(70));
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
+ deviceScaleFactor: 2,
+ });
+ const page = await context.newPage();
+
+ // Disable cache
+ await page.route('**/*', (route) => {
+ route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ },
+ });
+ });
+
+ try {
+ await page.goto(TEST_URL, { waitUntil: 'networkidle' });
+ console.log(`โ
Navigated to ${TEST_URL}`);
+ await page.waitForTimeout(1500);
+
+ let allTestsPassed = true;
+
+ // TEST 1: Back-to-top button should have full opacity when at bottom
+ console.log('\n๐ TEST 1: Back-to-top button full opacity at page bottom');
+ console.log('-'.repeat(70));
+
+ // Scroll to bottom
+ await page.evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+ await page.waitForTimeout(500);
+
+ const backToTopAtBottom = await page.evaluate(() => {
+ const btn = document.querySelector('.back-to-top');
+ if (!btn) return { exists: false };
+
+ const hasAtBottomClass = btn.classList.contains('at-bottom');
+ const styles = window.getComputedStyle(btn);
+ const opacity = parseFloat(styles.opacity);
+ const backgroundColor = styles.backgroundColor;
+
+ return {
+ exists: true,
+ hasAtBottomClass,
+ opacity,
+ backgroundColor
+ };
+ });
+
+ if (backToTopAtBottom.exists) {
+ // Should have opacity: 1 (full opacity, no transparency)
+ if (backToTopAtBottom.opacity >= 0.95) {
+ console.log(`โ
Back-to-top button has full opacity: ${backToTopAtBottom.opacity}`);
+ console.log(` Background: ${backToTopAtBottom.backgroundColor}`);
+ console.log(` Has .at-bottom class: ${backToTopAtBottom.hasAtBottomClass}`);
+ } else {
+ console.log(`โ Back-to-top button still has transparency: opacity=${backToTopAtBottom.opacity} (expected >= 0.95)`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Back-to-top button not found');
+ allTestsPassed = false;
+ }
+
+ // TEST 2: Footer text should enlarge when footer is hovered
+ console.log('\n๐ TEST 2: Footer text enlarges on hover');
+ console.log('-'.repeat(70));
+
+ // Get initial font size before hover
+ const initialFooterState = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ const footerP = document.querySelector('footer.no-print p');
+ if (!footer || !footerP) return { exists: false };
+
+ const styles = window.getComputedStyle(footerP);
+ return {
+ exists: true,
+ fontSize: parseFloat(styles.fontSize),
+ footerHasClass: footer.classList.contains('footer-hovered')
+ };
+ });
+
+ if (!initialFooterState.exists) {
+ console.log('โ Footer or footer paragraph not found');
+ allTestsPassed = false;
+ } else {
+ console.log(` Initial font size: ${initialFooterState.fontSize.toFixed(1)}px`);
+ console.log(` Footer has .footer-hovered class: ${initialFooterState.footerHasClass}`);
+
+ // Trigger footer hover by adding class via JavaScript
+ await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ if (footer) {
+ // Dispatch mouseenter event
+ const event = new MouseEvent('mouseenter', {
+ bubbles: true,
+ cancelable: true,
+ view: window
+ });
+ footer.dispatchEvent(event);
+ }
+ });
+
+ // Wait for CSS transition
+ await page.waitForTimeout(500);
+
+ // Get font size after hover
+ const hoveredFooterState = await page.evaluate(() => {
+ const footer = document.querySelector('footer.no-print');
+ const footerP = document.querySelector('footer.no-print p');
+ if (!footer || !footerP) return { exists: false };
+
+ const styles = window.getComputedStyle(footerP);
+ return {
+ exists: true,
+ fontSize: parseFloat(styles.fontSize),
+ footerHasClass: footer.classList.contains('footer-hovered'),
+ computedFontSize: styles.fontSize
+ };
+ });
+
+ if (hoveredFooterState.exists) {
+ console.log(` After hover font size: ${hoveredFooterState.fontSize.toFixed(1)}px`);
+ console.log(` Footer has .footer-hovered class: ${hoveredFooterState.footerHasClass}`);
+
+ if (hoveredFooterState.footerHasClass) {
+ console.log('โ
Footer received .footer-hovered class');
+ } else {
+ console.log('โ Footer did NOT receive .footer-hovered class');
+ allTestsPassed = false;
+ }
+
+ // Check if font size increased (should be 1.2x larger)
+ if (hoveredFooterState.fontSize > initialFooterState.fontSize) {
+ const increase = ((hoveredFooterState.fontSize - initialFooterState.fontSize) / initialFooterState.fontSize) * 100;
+ console.log(`โ
Footer text enlarged by ${increase.toFixed(1)}%`);
+ } else {
+ console.log(`โ Footer text did NOT enlarge (${initialFooterState.fontSize.toFixed(1)}px โ ${hoveredFooterState.fontSize.toFixed(1)}px)`);
+ allTestsPassed = false;
+ }
+ } else {
+ console.log('โ Could not measure hovered footer state');
+ allTestsPassed = false;
+ }
+ }
+
+ console.log('-'.repeat(70));
+
+ if (allTestsPassed) {
+ console.log('\nโ
ALL TESTS PASSED!');
+ console.log(' โข Back-to-top button has full opacity at page bottom');
+ console.log(' โข Footer text enlarges when hovered');
+ } else {
+ console.log('\nโ SOME TESTS FAILED - Check output above');
+ }
+
+ await browser.close();
+ process.exit(allTestsPassed ? 0 : 1);
+
+ } catch (error) {
+ console.error('\nโ Test error:', error);
+ await browser.close();
+ process.exit(1);
+ }
+}
+
+testBackToTopAndFooterFixes();
diff --git a/tests/mjs/41-mobile-accordion-test.mjs b/tests/mjs/41-mobile-accordion-test.mjs
new file mode 100755
index 0000000..5c5ef89
--- /dev/null
+++ b/tests/mjs/41-mobile-accordion-test.mjs
@@ -0,0 +1,180 @@
+#!/usr/bin/env bun
+
+/**
+ * Mobile Accordion Test
+ * ======================
+ * Tests the sidebar accordion functionality on mobile devices in the extended CV view.
+ *
+ * What this test verifies:
+ * - Accordion headers are visible on mobile (hidden on desktop)
+ * - Accordion opens and closes when clicked
+ * - Content is properly shown/hidden
+ * - Chevron icon rotates correctly
+ * - Both left and right sidebars work
+ */
+
+import { chromium } from 'playwright';
+
+const BASE_URL = 'http://localhost:1999';
+const MOBILE_VIEWPORT = { width: 375, height: 667 }; // iPhone SE size
+
+async function testMobileAccordion() {
+ console.log('๐งช Starting Mobile Accordion Test...\n');
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: MOBILE_VIEWPORT,
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15'
+ });
+ const page = await context.newPage();
+
+ let allTestsPassed = true;
+
+ try {
+ // Navigate to page with extended CV (lang param and let localStorage handle length)
+ console.log('๐ฑ Loading page in mobile viewport (375x667)...');
+
+ // First, set localStorage for extended CV
+ await page.goto(BASE_URL);
+ await page.evaluate(() => {
+ localStorage.setItem('cv-length', 'long');
+ });
+
+ // Reload with the extended CV setting
+ await page.goto(BASE_URL, { waitUntil: 'networkidle' });
+ await page.waitForTimeout(1500);
+
+ // Test Left Sidebar Accordion
+ console.log('\n๐ Testing LEFT sidebar accordion...');
+
+ const leftAccordion = await page.locator('.cv-sidebar-left .sidebar-accordion').first();
+ const leftHeader = await page.locator('.cv-sidebar-left .sidebar-accordion-header').first();
+ const leftContent = await page.locator('.cv-sidebar-left .sidebar-accordion-content').first();
+ const leftChevron = await page.locator('.cv-sidebar-left .sidebar-accordion-header .chevron').first();
+
+ // Check header is visible on mobile
+ const leftHeaderVisible = await leftHeader.isVisible();
+ console.log(` โ Left accordion header visible: ${leftHeaderVisible ? 'โ
YES' : 'โ NO'}`);
+ if (!leftHeaderVisible) allTestsPassed = false;
+
+ // Check if accordion is initially open
+ const leftInitiallyOpen = await leftAccordion.getAttribute('open');
+ console.log(` โ Left accordion initially open: ${leftInitiallyOpen !== null ? 'โ
YES' : 'โ NO'}`);
+
+ // Check content is visible when open
+ const leftContentVisible = await leftContent.isVisible();
+ console.log(` โ Left content visible when open: ${leftContentVisible ? 'โ
YES' : 'โ NO'}`);
+ if (!leftContentVisible && leftInitiallyOpen) allTestsPassed = false;
+
+ // Close the accordion
+ console.log('\n ๐ฑ๏ธ Clicking left accordion to close...');
+ await leftHeader.click();
+ await page.waitForTimeout(500); // Wait for animation
+
+ // Check accordion is closed
+ const leftNowClosed = await leftAccordion.getAttribute('open');
+ console.log(` โ Left accordion closed: ${leftNowClosed === null ? 'โ
YES' : 'โ NO'}`);
+ if (leftNowClosed !== null) allTestsPassed = false;
+
+ // Check content is hidden when closed
+ const leftContentHidden = !(await leftContent.isVisible());
+ console.log(` โ Left content hidden when closed: ${leftContentHidden ? 'โ
YES' : 'โ NO'}`);
+ if (!leftContentHidden) allTestsPassed = false;
+
+ // Open the accordion again
+ console.log('\n ๐ฑ๏ธ Clicking left accordion to re-open...');
+ await leftHeader.click();
+ await page.waitForTimeout(500); // Wait for animation
+
+ // Check accordion is open again
+ const leftReopened = await leftAccordion.getAttribute('open');
+ console.log(` โ Left accordion re-opened: ${leftReopened !== null ? 'โ
YES' : 'โ NO'}`);
+ if (leftReopened === null) allTestsPassed = false;
+
+ // Check content is visible again
+ const leftContentVisibleAgain = await leftContent.isVisible();
+ console.log(` โ Left content visible again: ${leftContentVisibleAgain ? 'โ
YES' : 'โ NO'}`);
+ if (!leftContentVisibleAgain) allTestsPassed = false;
+
+ // Test Right Sidebar Accordion
+ console.log('\n๐ Testing RIGHT sidebar accordion...');
+
+ const rightAccordion = await page.locator('.cv-sidebar-right .sidebar-accordion').first();
+ const rightHeader = await page.locator('.cv-sidebar-right .sidebar-accordion-header').first();
+ const rightContent = await page.locator('.cv-sidebar-right .sidebar-accordion-content').first();
+ const rightChevron = await page.locator('.cv-sidebar-right .sidebar-accordion-header .chevron').first();
+
+ // Check header is visible on mobile
+ const rightHeaderVisible = await rightHeader.isVisible();
+ console.log(` โ Right accordion header visible: ${rightHeaderVisible ? 'โ
YES' : 'โ NO'}`);
+ if (!rightHeaderVisible) allTestsPassed = false;
+
+ // Close and re-open right accordion
+ console.log('\n ๐ฑ๏ธ Clicking right accordion to close...');
+ await rightHeader.click();
+ await page.waitForTimeout(500);
+
+ const rightClosed = await rightAccordion.getAttribute('open');
+ console.log(` โ Right accordion closed: ${rightClosed === null ? 'โ
YES' : 'โ NO'}`);
+ if (rightClosed !== null) allTestsPassed = false;
+
+ console.log('\n ๐ฑ๏ธ Clicking right accordion to re-open...');
+ await rightHeader.click();
+ await page.waitForTimeout(500);
+
+ const rightReopened = await rightAccordion.getAttribute('open');
+ console.log(` โ Right accordion re-opened: ${rightReopened !== null ? 'โ
YES' : 'โ NO'}`);
+ if (rightReopened === null) allTestsPassed = false;
+
+ // Test styling
+ console.log('\n๐จ Testing accordion styling...');
+
+ const leftHeaderStyles = await leftHeader.evaluate(el => {
+ const styles = window.getComputedStyle(el);
+ return {
+ display: styles.display,
+ background: styles.backgroundColor,
+ padding: styles.padding,
+ cursor: styles.cursor,
+ borderRadius: styles.borderRadius
+ };
+ });
+
+ console.log(` โ Header display: ${leftHeaderStyles.display === 'flex' ? 'โ
flex' : 'โ ' + leftHeaderStyles.display}`);
+ console.log(` โ Header background: ${leftHeaderStyles.background}`);
+ console.log(` โ Header cursor: ${leftHeaderStyles.cursor === 'pointer' ? 'โ
pointer' : 'โ ' + leftHeaderStyles.cursor}`);
+
+ if (leftHeaderStyles.display !== 'flex' || leftHeaderStyles.cursor !== 'pointer') {
+ allTestsPassed = false;
+ }
+
+ // Take screenshot
+ console.log('\n๐ธ Taking screenshot...');
+ await page.screenshot({
+ path: 'tests/screenshots/mobile-accordion-extended.png',
+ fullPage: true
+ });
+ console.log(' Saved: tests/screenshots/mobile-accordion-extended.png');
+
+ } catch (error) {
+ console.error('\nโ Test failed with error:', error);
+ allTestsPassed = false;
+ } finally {
+ await browser.close();
+ }
+
+ // Final result
+ console.log('\n' + '='.repeat(50));
+ if (allTestsPassed) {
+ console.log('โ
ALL MOBILE ACCORDION TESTS PASSED!');
+ console.log('='.repeat(50));
+ process.exit(0);
+ } else {
+ console.log('โ SOME MOBILE ACCORDION TESTS FAILED');
+ console.log('='.repeat(50));
+ process.exit(1);
+ }
+}
+
+// Run test
+testMobileAccordion();
diff --git a/tests/mjs/43-mobile-accordion-and-modal-test.mjs b/tests/mjs/43-mobile-accordion-and-modal-test.mjs
new file mode 100755
index 0000000..2a9e192
--- /dev/null
+++ b/tests/mjs/43-mobile-accordion-and-modal-test.mjs
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+
+import { chromium } from 'playwright';
+
+const MOBILE_VIEWPORT = { width: 375, height: 667 }; // iPhone SE size
+
+(async () => {
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: MOBILE_VIEWPORT });
+ const page = await context.newPage();
+
+ try {
+ console.log('๐งช Testing Mobile Accordion and Modal Centering\n');
+ console.log(`๐ฑ Mobile Viewport: ${MOBILE_VIEWPORT.width}x${MOBILE_VIEWPORT.height}`);
+
+ // Navigate to extended view
+ await page.goto('http://localhost:1999/?lang=en&view=extended');
+ await page.waitForLoadState('networkidle');
+ console.log('โ
Extended view loaded\n');
+
+ // ===== TEST 1: SIDEBAR ACCORDION =====
+ console.log('๐ TEST 1: Sidebar Accordion Structure\n');
+
+ // Check for accordion elements
+ const accordionDetails = await page.locator('.sidebar-accordion').first();
+ const accordionSummary = await page.locator('.sidebar-accordion-header').first();
+ const accordionContent = await page.locator('.sidebar-accordion-content').first();
+
+ const detailsCount = await page.locator('.sidebar-accordion').count();
+ const summaryCount = await page.locator('.sidebar-accordion-header').count();
+ const contentCount = await page.locator('.sidebar-accordion-content').count();
+
+ console.log(`๐ Accordion Structure:`);
+ console.log(` โข Details elements: ${detailsCount}`);
+ console.log(` โข Summary elements: ${summaryCount}`);
+ console.log(` โข Content elements: ${contentCount}`);
+
+ // Check if accordion is initially open
+ const isOpen = await accordionDetails.evaluate(el => el.hasAttribute('open'));
+ console.log(` โข Initially open: ${isOpen ? 'โ
YES' : 'โ NO'}`);
+
+ // Check if summary is visible on mobile
+ const summaryDisplay = await accordionSummary.evaluate(el => {
+ const style = window.getComputedStyle(el);
+ return style.display;
+ });
+ console.log(` โข Summary display: ${summaryDisplay}`);
+ console.log(` โข Summary visible: ${summaryDisplay !== 'none' ? 'โ
YES' : 'โ NO'}`);
+
+ // Test accordion toggle
+ if (detailsCount > 0) {
+ console.log('\n๐ฑ๏ธ Testing Accordion Toggle...');
+
+ // Click to close
+ await accordionSummary.click();
+ await page.waitForTimeout(500);
+
+ const isClosedAfterClick = await accordionDetails.evaluate(el => !el.hasAttribute('open'));
+ console.log(` โข Closed after click: ${isClosedAfterClick ? 'โ
YES' : 'โ NO'}`);
+
+ // Check if content is hidden
+ const contentHidden = await accordionContent.evaluate(el => {
+ const style = window.getComputedStyle(el);
+ return style.maxHeight === '0px' || style.opacity === '0';
+ });
+ console.log(` โข Content hidden: ${contentHidden ? 'โ
YES' : 'โ NO'}`);
+
+ // Click to reopen
+ await accordionSummary.click();
+ await page.waitForTimeout(500);
+
+ const isReopenedAfterClick = await accordionDetails.evaluate(el => el.hasAttribute('open'));
+ console.log(` โข Reopened after click: ${isReopenedAfterClick ? 'โ
YES' : 'โ NO'}`);
+ }
+
+ // ===== TEST 2: MODAL CENTERING =====
+ console.log('\n๐ TEST 2: Modal Centering\n');
+
+ // Open the PDF modal
+ await page.click('#download-button');
+ await page.waitForTimeout(500);
+
+ const modal = await page.locator('#pdf-modal');
+ const isModalOpen = await modal.evaluate(el => el.hasAttribute('open'));
+ console.log(`๐ฆ Modal Status:`);
+ console.log(` โข Modal open: ${isModalOpen ? 'โ
YES' : 'โ NO'}`);
+
+ if (isModalOpen) {
+ // Get modal bounding box
+ const modalBox = await modal.boundingBox();
+
+ console.log(`\n๐ Modal Position:`);
+ console.log(` โข X position: ${modalBox.x.toFixed(2)}px`);
+ console.log(` โข Y position: ${modalBox.y.toFixed(2)}px`);
+ console.log(` โข Width: ${modalBox.width.toFixed(2)}px`);
+ console.log(` โข Height: ${modalBox.height.toFixed(2)}px`);
+
+ // Calculate centers
+ const modalCenterX = modalBox.x + modalBox.width / 2;
+ const modalCenterY = modalBox.y + modalBox.height / 2;
+ const viewportCenterX = MOBILE_VIEWPORT.width / 2;
+ const viewportCenterY = MOBILE_VIEWPORT.height / 2;
+
+ console.log(`\n๐ฏ Centering Analysis:`);
+ console.log(` โข Modal center X: ${modalCenterX.toFixed(2)}px`);
+ console.log(` โข Viewport center X: ${viewportCenterX.toFixed(2)}px`);
+ console.log(` โข Modal center Y: ${modalCenterY.toFixed(2)}px`);
+ console.log(` โข Viewport center Y: ${viewportCenterY.toFixed(2)}px`);
+
+ // Check if centered (10px tolerance)
+ const horizontalOffset = Math.abs(modalCenterX - viewportCenterX);
+ const verticalOffset = Math.abs(modalCenterY - viewportCenterY);
+ const TOLERANCE = 10;
+
+ console.log(`\n๐ Offset Analysis:`);
+ console.log(` โข Horizontal offset: ${horizontalOffset.toFixed(2)}px`);
+ console.log(` โข Vertical offset: ${verticalOffset.toFixed(2)}px`);
+ console.log(` โข Tolerance: ${TOLERANCE}px`);
+
+ const isHorizontallyCentered = horizontalOffset <= TOLERANCE;
+ const isVerticallyCentered = verticalOffset <= TOLERANCE;
+
+ console.log(`\nโ
Centering Results:`);
+ console.log(` โข Horizontally centered: ${isHorizontallyCentered ? 'โ
YES' : 'โ NO'}`);
+ console.log(` โข Vertically centered: ${isVerticallyCentered ? 'โ
YES' : 'โ NO'}`);
+
+ // Get CSS properties
+ const cssProps = await modal.evaluate(el => {
+ const style = window.getComputedStyle(el);
+ return {
+ position: style.position,
+ top: style.top,
+ left: style.left,
+ transform: style.transform
+ };
+ });
+
+ console.log(`\n๐จ CSS Properties:`);
+ console.log(` โข position: ${cssProps.position}`);
+ console.log(` โข top: ${cssProps.top}`);
+ console.log(` โข left: ${cssProps.left}`);
+ console.log(` โข transform: ${cssProps.transform}`);
+
+ // Overall test results
+ console.log(`\n๐ OVERALL RESULTS:`);
+ if (isHorizontallyCentered && isVerticallyCentered) {
+ console.log('โ
Modal is PROPERLY CENTERED on mobile!');
+ } else {
+ console.log('โ Modal is NOT centered on mobile');
+ console.log(` Needs adjustment: ${!isHorizontallyCentered ? 'horizontal' : ''} ${!isVerticallyCentered ? 'vertical' : ''}`);
+ }
+ }
+
+ console.log('\nโ
Test completed successfully!\n');
+
+ } catch (error) {
+ console.error('โ Test failed:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ } finally {
+ await browser.close();
+ }
+})();
diff --git a/tests/mjs/46-visual-accordion-style-test.mjs b/tests/mjs/46-visual-accordion-style-test.mjs
new file mode 100755
index 0000000..5981dde
--- /dev/null
+++ b/tests/mjs/46-visual-accordion-style-test.mjs
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+
+import { chromium } from 'playwright';
+import { writeFileSync } from 'fs';
+
+const MOBILE_VIEWPORT = { width: 375, height: 667 };
+
+(async () => {
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: MOBILE_VIEWPORT });
+ const page = await context.newPage();
+
+ try {
+ console.log('๐จ Visual Accordion Style Test\n');
+
+ await page.goto('http://localhost:1999/?lang=en&view=extended');
+ await page.waitForLoadState('networkidle');
+
+ // Check accordion header styling
+ const accordionHeader = await page.locator('.sidebar-accordion-header').first();
+
+ const styles = await accordionHeader.evaluate(el => {
+ const computed = window.getComputedStyle(el);
+ return {
+ background: computed.backgroundColor,
+ color: computed.color,
+ padding: computed.padding,
+ borderRadius: computed.borderRadius,
+ marginBottom: computed.marginBottom,
+ borderBottom: computed.borderBottom,
+ fontSize: computed.fontSize,
+ fontWeight: computed.fontWeight,
+ textTransform: computed.textTransform
+ };
+ });
+
+ console.log('๐ Accordion Header Styles:');
+ console.log(` โข Background: ${styles.background}`);
+ console.log(` โข Text Color: ${styles.color}`);
+ console.log(` โข Padding: ${styles.padding}`);
+ console.log(` โข Border Radius: ${styles.borderRadius}`);
+ console.log(` โข Margin Bottom: ${styles.marginBottom}`);
+ console.log(` โข Border Bottom: ${styles.borderBottom}`);
+ console.log(` โข Font Size: ${styles.fontSize}`);
+ console.log(` โข Font Weight: ${styles.fontWeight}`);
+ console.log(` โข Text Transform: ${styles.textTransform}`);
+
+ // Check if matches dark theme
+ const isDarkBackground = styles.background.includes('rgb(48, 48, 48)') ||
+ styles.background.includes('#303030');
+ const isLightText = styles.color.includes('rgb(204, 204, 204)') ||
+ styles.color.includes('#ccc');
+ const hasNoBorderRadius = styles.borderRadius === '0px';
+ const hasNoMarginBottom = styles.marginBottom === '0px';
+ const isUppercase = styles.textTransform === 'uppercase';
+
+ console.log('\nโ
Style Verification:');
+ console.log(` โข Dark background (#303030): ${isDarkBackground ? 'โ
' : 'โ'}`);
+ console.log(` โข Light text (#ccc): ${isLightText ? 'โ
' : 'โ'}`);
+ console.log(` โข No border radius: ${hasNoBorderRadius ? 'โ
' : 'โ'}`);
+ console.log(` โข No margin bottom: ${hasNoMarginBottom ? 'โ
' : 'โ'}`);
+ console.log(` โข Uppercase text: ${isUppercase ? 'โ
' : 'โ'}`);
+
+ const allMatch = isDarkBackground && isLightText && hasNoBorderRadius &&
+ hasNoMarginBottom && isUppercase;
+
+ console.log(`\n${allMatch ? 'โ
' : 'โ'} Accordion style ${allMatch ? 'MATCHES' : 'DOES NOT MATCH'} CV title badges style\n`);
+
+ // Take screenshot for visual verification
+ await page.screenshot({
+ path: 'tests/screenshots/accordion-mobile-styled.png',
+ fullPage: true
+ });
+ console.log('๐ธ Screenshot saved to tests/screenshots/accordion-mobile-styled.png\n');
+
+ } catch (error) {
+ console.error('โ Test failed:', error.message);
+ process.exit(1);
+ } finally {
+ await browser.close();
+ }
+})();