added zoom in buttons
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test fixed button sizes at different zoom levels
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
async function testButtonSizes() {
|
||||
console.log('🧪 Testing Fixed Button Sizes at Different Zoom Levels\n');
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:1999/?lang=en', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const zoomSlider = page.locator('#zoom-slider');
|
||||
await zoomSlider.waitFor({ state: 'visible' });
|
||||
|
||||
// Test at different zoom levels
|
||||
const zoomLevels = [25, 100, 175];
|
||||
const results = {};
|
||||
|
||||
for (const zoomLevel of zoomLevels) {
|
||||
console.log(`\n📏 Testing at ${zoomLevel}% zoom...`);
|
||||
|
||||
// Set zoom level
|
||||
await zoomSlider.fill(zoomLevel.toString());
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Measure button sizes
|
||||
const backToTop = await page.locator('#back-to-top').boundingBox();
|
||||
const infoButton = await page.locator('#info-button').boundingBox();
|
||||
const shortcutsButton = await page.locator('#shortcuts-button').boundingBox();
|
||||
|
||||
results[zoomLevel] = {
|
||||
backToTop: backToTop ? Math.round(backToTop.width) : 0,
|
||||
infoButton: infoButton ? Math.round(infoButton.width) : 0,
|
||||
shortcutsButton: shortcutsButton ? Math.round(shortcutsButton.width) : 0
|
||||
};
|
||||
|
||||
console.log(` Back-to-top: ${results[zoomLevel].backToTop}px`);
|
||||
console.log(` Info button: ${results[zoomLevel].infoButton}px`);
|
||||
console.log(` Shortcuts button: ${results[zoomLevel].shortcutsButton}px`);
|
||||
}
|
||||
|
||||
// Check if sizes are consistent
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 BUTTON SIZE CONSISTENCY CHECK');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const baseSize = results[100];
|
||||
let allConsistent = true;
|
||||
|
||||
for (const [zoomLevel, sizes] of Object.entries(results)) {
|
||||
if (zoomLevel === '100') continue;
|
||||
|
||||
const backTopDiff = Math.abs(sizes.backToTop - baseSize.backToTop);
|
||||
const infoDiff = Math.abs(sizes.infoButton - baseSize.infoButton);
|
||||
const shortcutsDiff = Math.abs(sizes.shortcutsButton - baseSize.shortcutsButton);
|
||||
|
||||
const maxDiff = Math.max(backTopDiff, infoDiff, shortcutsDiff);
|
||||
|
||||
if (maxDiff <= 2) {
|
||||
console.log(`✅ ${zoomLevel}% zoom: Buttons stay consistent (max diff: ${maxDiff}px)`);
|
||||
} else {
|
||||
console.log(`❌ ${zoomLevel}% zoom: Buttons changed size (max diff: ${maxDiff}px)`);
|
||||
allConsistent = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (allConsistent) {
|
||||
console.log('✅ SUCCESS: Fixed buttons maintain consistent size at all zoom levels!');
|
||||
} else {
|
||||
console.log('❌ FAIL: Fixed buttons change size with zoom level');
|
||||
}
|
||||
|
||||
console.log('\n⏸️ Browser will stay open for 5 seconds for manual verification...');
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error.message);
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log('\n✅ Test completed\n');
|
||||
}
|
||||
}
|
||||
|
||||
testButtonSizes();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('http://localhost:1999');
|
||||
|
||||
// Clear all localStorage
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
console.log('✅ localStorage cleared');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>HTMX Indicator Test</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"></script>
|
||||
<link rel="stylesheet" href="http://localhost:1999/static/css/main.css">
|
||||
<style>
|
||||
body {
|
||||
padding: 40px;
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
font-family: system-ui;
|
||||
}
|
||||
.test-section {
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background: #34495e;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.test-btn {
|
||||
padding: 10px 20px;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px;
|
||||
}
|
||||
.test-btn:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
.htmx-request {
|
||||
background: #27ae60 !important;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #1a252f;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HTMX Loading Indicators Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Button with Child Indicator (Default Pattern)</h2>
|
||||
<p>Click button - spinner should appear INSIDE button during request</p>
|
||||
<button class="test-btn"
|
||||
hx-get="http://localhost:1999/switch-language?lang=en"
|
||||
hx-target="#result1"
|
||||
hx-swap="innerHTML">
|
||||
Switch Language
|
||||
<iconify-icon icon="mdi:loading"
|
||||
class="htmx-indicator spinning small light"
|
||||
width="14"
|
||||
height="14"></iconify-icon>
|
||||
</button>
|
||||
<div id="result1" class="status">Result will appear here</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Language Selector (Actual Component)</h2>
|
||||
<p>This mirrors the actual language selector from the CV</p>
|
||||
<div class="language-selector">
|
||||
<button class="selector-btn"
|
||||
hx-get="http://localhost:1999/switch-language?lang=en"
|
||||
hx-target="#result2"
|
||||
hx-swap="innerHTML">
|
||||
<span>English</span>
|
||||
<iconify-icon icon="mdi:loading"
|
||||
class="htmx-indicator spinning small light"
|
||||
width="14"
|
||||
height="14"
|
||||
aria-label="Loading"></iconify-icon>
|
||||
</button>
|
||||
<button class="selector-btn"
|
||||
hx-get="http://localhost:1999/switch-language?lang=es"
|
||||
hx-target="#result2"
|
||||
hx-swap="innerHTML">
|
||||
<span>Español</span>
|
||||
<iconify-icon icon="mdi:loading"
|
||||
class="htmx-indicator spinning small light"
|
||||
width="14"
|
||||
height="14"
|
||||
aria-label="Loading"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div id="result2" class="status">Result will appear here</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test 3: CSS Verification</h2>
|
||||
<p>Manually verify CSS rules are applied:</p>
|
||||
<div class="status">
|
||||
<div>1. Open DevTools</div>
|
||||
<div>2. Click a button above</div>
|
||||
<div>3. Watch Network tab for request</div>
|
||||
<div>4. Check Elements tab - button should have class "htmx-request"</div>
|
||||
<div>5. Check Computed styles - iconify-icon.htmx-indicator should have opacity: 1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Debug: CSS Rules Status</h2>
|
||||
<div class="status" id="css-debug"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Debug: Check if CSS rules are loaded
|
||||
setTimeout(() => {
|
||||
const indicator = document.querySelector('.htmx-indicator');
|
||||
if (indicator) {
|
||||
const styles = window.getComputedStyle(indicator);
|
||||
const debug = document.getElementById('css-debug');
|
||||
debug.innerHTML = `
|
||||
<strong>CSS Computed Values for .htmx-indicator:</strong><br>
|
||||
opacity: ${styles.opacity}<br>
|
||||
display: ${styles.display}<br>
|
||||
transition: ${styles.transition}<br>
|
||||
pointer-events: ${styles.pointerEvents}<br>
|
||||
<br>
|
||||
<strong>Expected:</strong><br>
|
||||
opacity: 0 (hidden by default)<br>
|
||||
display: inline-flex<br>
|
||||
transition: opacity 200ms ease-in-out<br>
|
||||
pointer-events: none
|
||||
`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Monitor HTMX events
|
||||
document.body.addEventListener('htmx:beforeRequest', (e) => {
|
||||
console.log('🚀 HTMX Request Starting:', e.detail);
|
||||
console.log(' Target element:', e.detail.elt);
|
||||
console.log(' Has htmx-request class:', e.detail.elt.classList.contains('htmx-request'));
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (e) => {
|
||||
console.log('✅ HTMX Request Complete:', e.detail);
|
||||
console.log(' Target element:', e.detail.elt);
|
||||
console.log(' Has htmx-request class:', e.detail.elt.classList.contains('htmx-request'));
|
||||
});
|
||||
|
||||
// Monitor class changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
const target = mutation.target;
|
||||
const hasRequest = target.classList.contains('htmx-request');
|
||||
console.log(`📝 Class change on ${target.tagName}:`, {
|
||||
hasHtmxRequest: hasRequest,
|
||||
allClasses: Array.from(target.classList)
|
||||
});
|
||||
|
||||
if (hasRequest) {
|
||||
const indicator = target.querySelector('.htmx-indicator');
|
||||
if (indicator) {
|
||||
const opacity = window.getComputedStyle(indicator).opacity;
|
||||
console.log(` → Indicator opacity: ${opacity} (should be 1)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Observe all buttons
|
||||
document.querySelectorAll('button[hx-get]').forEach(btn => {
|
||||
observer.observe(btn, { attributes: true });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Inline Loading - No Blocking Overlay</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.language-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* INLINE LOADING STATES - NO BLOCKING OVERLAY */
|
||||
.cv-page-content-wrapper {
|
||||
position: relative;
|
||||
transition: opacity 200ms ease-in-out,
|
||||
transform 200ms ease-in-out,
|
||||
filter 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.cv-page-content-wrapper.htmx-swapping {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.99);
|
||||
pointer-events: none;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.cv-page-content-wrapper.htmx-settling {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
background: #f9fafb;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 2rem;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
background: #dbeafe;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.test-info h3 {
|
||||
margin-top: 0;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.checklist li:before {
|
||||
content: "✓ ";
|
||||
color: #059669;
|
||||
font-weight: bold;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status">✓ No Blocking Overlay</div>
|
||||
|
||||
<h1>Inline Loading States Test</h1>
|
||||
|
||||
<div class="test-info">
|
||||
<h3>What to Observe:</h3>
|
||||
<ul class="checklist">
|
||||
<li>NO full-page overlay appears when switching languages</li>
|
||||
<li>Language button shows inline spinner during request</li>
|
||||
<li>CV content fades/blurs slightly during swap (inline effect)</li>
|
||||
<li>Everything else remains accessible (no blocking)</li>
|
||||
<li>Smooth transition without page blocking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Language Selector (With Inline Indicators)</h2>
|
||||
<div class="language-buttons">
|
||||
<button hx-get="http://localhost:1999/switch-language?lang=en"
|
||||
hx-target="#language-selector"
|
||||
hx-swap="outerHTML swap:250ms settle:250ms"
|
||||
hx-indicator="#lang-indicator-en"
|
||||
class="active">
|
||||
English
|
||||
<span id="lang-indicator-en" class="htmx-indicator indicator spinning">⟳</span>
|
||||
</button>
|
||||
<button hx-get="http://localhost:1999/switch-language?lang=es"
|
||||
hx-target="#language-selector"
|
||||
hx-swap="outerHTML swap:250ms settle:250ms"
|
||||
hx-indicator="#lang-indicator-es">
|
||||
Español
|
||||
<span id="lang-indicator-es" class="htmx-indicator indicator spinning">⟳</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="language-selector"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>CV Content (With Inline Loading States)</h2>
|
||||
<div id="cv-inner-content-page-1" class="cv-page-content-wrapper">
|
||||
<div class="content-area">
|
||||
<h3>CV Content Page 1</h3>
|
||||
<p>This content will fade and blur slightly during language transitions.</p>
|
||||
<p><strong>Observe:</strong> No blocking overlay appears - just a subtle inline effect!</p>
|
||||
<p>You can still scroll and interact with other parts of the page during the transition.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Additional Scrollable Content</h2>
|
||||
<p>This section demonstrates that the page remains functional during language transitions.</p>
|
||||
<p>Try scrolling, clicking around, or interacting with other elements while switching languages.</p>
|
||||
<div style="height: 200px; background: linear-gradient(to bottom, #dbeafe, #bfdbfe); border-radius: 4px; padding: 1rem;">
|
||||
<p><strong>Key Improvement:</strong></p>
|
||||
<ul>
|
||||
<li>✓ Before: Full-page overlay blocked everything</li>
|
||||
<li>✓ After: Inline loading states, no blocking</li>
|
||||
<li>✓ Language buttons show inline spinners</li>
|
||||
<li>✓ Content areas show subtle blur/fade</li>
|
||||
<li>✓ Rest of UI remains accessible</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Monitor HTMX events for debugging
|
||||
document.body.addEventListener('htmx:beforeRequest', (e) => {
|
||||
console.log('✓ HTMX Request Starting:', e.detail.target.id);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
console.log('✓ HTMX Swap Complete:', e.detail.target.id);
|
||||
});
|
||||
|
||||
// NO skeleton loader JavaScript needed!
|
||||
console.log('✓ Test page loaded - NO blocking overlay code present');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Skeleton Loader Fix - Manual Test</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 { color: #333; }
|
||||
|
||||
.test-section {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.language-selector-wrapper {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.selector-btn {
|
||||
padding: 8px 16px;
|
||||
margin: 0 4px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selector-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
#skeleton-loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 250ms ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#skeleton-loader.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.status {
|
||||
background: white;
|
||||
border: 2px solid #ddd;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
border-color: #28a745;
|
||||
background: #d4edda;
|
||||
}
|
||||
|
||||
#console {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.console-line.before { color: #4ec9b0; }
|
||||
.console-line.after { color: #ce9178; }
|
||||
.console-line.skeleton { color: #dcdcaa; }
|
||||
|
||||
.instructions {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #28a745;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #dc3545;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔬 Skeleton Loader Fix - Manual Verification</h1>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>📋 Test Instructions:</h3>
|
||||
<ol>
|
||||
<li>Click the "Switch to Spanish" button below</li>
|
||||
<li>Watch for the dark overlay to appear briefly</li>
|
||||
<li>The overlay should disappear after the content loads</li>
|
||||
<li>Check the console log below for event tracking</li>
|
||||
<li>Try switching back and forth multiple times</li>
|
||||
</ol>
|
||||
<p><strong>✅ PASS</strong>: Overlay appears and disappears smoothly</p>
|
||||
<p><strong>❌ FAIL</strong>: Overlay stays visible permanently</p>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Language Selector (HTMX + Hyperscript):</h3>
|
||||
|
||||
<!-- Skeleton Loader -->
|
||||
<div id="skeleton-loader">
|
||||
<div>🔄 LOADING... (This should disappear!)</div>
|
||||
</div>
|
||||
|
||||
<!-- This wrapper has the hyperscript handlers -->
|
||||
<div class="language-selector-wrapper"
|
||||
_="on htmx:beforeRequest from .selector-btn
|
||||
add .active to #skeleton-loader
|
||||
log 'BEFORE: Skeleton activated'
|
||||
end
|
||||
on htmx:afterSwap from .selector-btn
|
||||
wait 100ms
|
||||
remove .active from #skeleton-loader
|
||||
log 'AFTER: Skeleton deactivated'
|
||||
end">
|
||||
|
||||
<!-- This inner element gets swapped (outerHTML) -->
|
||||
<div class="language-selector" id="language-selector">
|
||||
<button class="selector-btn active"
|
||||
hx-get="/mock-response-en"
|
||||
hx-target="#language-selector"
|
||||
hx-swap="outerHTML swap:250ms settle:250ms"
|
||||
onclick="mockSwitch('en')">
|
||||
English
|
||||
</button>
|
||||
<button class="selector-btn"
|
||||
hx-get="/mock-response-es"
|
||||
hx-target="#language-selector"
|
||||
hx-swap="outerHTML swap:250ms settle:250ms"
|
||||
onclick="mockSwitch('es')">
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Status Monitor:</h3>
|
||||
<div class="status" id="status-skeleton">
|
||||
<strong>Skeleton Loader:</strong> <span id="skeleton-state">Hidden (opacity: 0)</span>
|
||||
</div>
|
||||
<div class="status" id="status-events">
|
||||
<strong>Last Event:</strong> <span id="last-event">None</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Console Log:</h3>
|
||||
<div id="console"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mock server responses since we're testing standalone
|
||||
let currentLang = 'en';
|
||||
|
||||
function mockSwitch(lang) {
|
||||
currentLang = lang;
|
||||
|
||||
// Simulate server delay
|
||||
setTimeout(() => {
|
||||
const newHTML = lang === 'en'
|
||||
? `<div class="language-selector" id="language-selector">
|
||||
<button class="selector-btn active" hx-get="/mock-response-en" hx-target="#language-selector" hx-swap="outerHTML swap:250ms settle:250ms" onclick="mockSwitch('en')">English</button>
|
||||
<button class="selector-btn" hx-get="/mock-response-es" hx-target="#language-selector" hx-swap="outerHTML swap:250ms settle:250ms" onclick="mockSwitch('es')">Español</button>
|
||||
</div>`
|
||||
: `<div class="language-selector" id="language-selector">
|
||||
<button class="selector-btn" hx-get="/mock-response-en" hx-target="#language-selector" hx-swap="outerHTML swap:250ms settle:250ms" onclick="mockSwitch('en')">English</button>
|
||||
<button class="selector-btn active" hx-get="/mock-response-es" hx-target="#language-selector" hx-swap="outerHTML swap:250ms settle:250ms" onclick="mockSwitch('es')">Español</button>
|
||||
</div>`;
|
||||
|
||||
// Let HTMX handle the swap
|
||||
htmx.trigger('#language-selector', 'htmx:beforeSwap', {
|
||||
serverResponse: newHTML
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Monitor skeleton state
|
||||
const skeleton = document.getElementById('skeleton-loader');
|
||||
const statusSkeleton = document.getElementById('status-skeleton');
|
||||
const statusEvents = document.getElementById('status-events');
|
||||
const lastEvent = document.getElementById('last-event');
|
||||
const consoleLog = document.getElementById('console');
|
||||
const skeletonState = document.getElementById('skeleton-state');
|
||||
|
||||
function addConsoleLog(message, type = '') {
|
||||
const line = document.createElement('div');
|
||||
line.className = `console-line ${type}`;
|
||||
line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
consoleLog.appendChild(line);
|
||||
consoleLog.scrollTop = consoleLog.scrollHeight;
|
||||
}
|
||||
|
||||
function updateSkeletonStatus() {
|
||||
const hasActive = skeleton.classList.contains('active');
|
||||
const opacity = window.getComputedStyle(skeleton).opacity;
|
||||
|
||||
skeletonState.textContent = hasActive
|
||||
? `Visible (opacity: ${opacity})`
|
||||
: `Hidden (opacity: ${opacity})`;
|
||||
|
||||
statusSkeleton.className = hasActive ? 'status active' : 'status';
|
||||
}
|
||||
|
||||
// Watch for class changes on skeleton
|
||||
const observer = new MutationObserver(() => {
|
||||
updateSkeletonStatus();
|
||||
const hasActive = skeleton.classList.contains('active');
|
||||
addConsoleLog(
|
||||
hasActive ? 'Skeleton SHOWN (.active added)' : 'Skeleton HIDDEN (.active removed)',
|
||||
'skeleton'
|
||||
);
|
||||
});
|
||||
observer.observe(skeleton, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Monitor HTMX events
|
||||
document.body.addEventListener('htmx:beforeRequest', (e) => {
|
||||
if (e.detail.elt.classList.contains('selector-btn')) {
|
||||
addConsoleLog('htmx:beforeRequest - Language switch starting', 'before');
|
||||
lastEvent.textContent = 'htmx:beforeRequest';
|
||||
statusEvents.className = 'status active';
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.detail.target.id === 'language-selector') {
|
||||
addConsoleLog('htmx:afterSwap - Language switch complete', 'after');
|
||||
lastEvent.textContent = 'htmx:afterSwap';
|
||||
statusEvents.className = 'status active';
|
||||
|
||||
// Re-initialize hyperscript on new elements
|
||||
htmx.process(document.querySelector('.language-selector-wrapper'));
|
||||
}
|
||||
});
|
||||
|
||||
// Initial status
|
||||
addConsoleLog('Test page loaded - Ready to test', 'skeleton');
|
||||
updateSkeletonStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+578
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* COMPREHENSIVE VERIFICATION TEST SUITE
|
||||
* Tests both HTMX indicators and shortcuts button visibility fixes
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE_URL = 'http://localhost:1999';
|
||||
const RESULTS = {
|
||||
passed: [],
|
||||
failed: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
function log(status, message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const icons = { pass: '✅', fail: '❌', warn: '⚠️', info: 'ℹ️' };
|
||||
console.log(`[${timestamp}] ${icons[status] || icons.info} ${message}`);
|
||||
|
||||
if (status === 'pass') RESULTS.passed.push(message);
|
||||
if (status === 'fail') RESULTS.failed.push(message);
|
||||
if (status === 'warn') RESULTS.warnings.push(message);
|
||||
}
|
||||
|
||||
function measureTime(start) {
|
||||
return `${Date.now() - start}ms`;
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function test1_HTMXLoadingIndicators(page) {
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
log('info', 'TEST 1: HTMX Loading Indicators (Feature 003)');
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
|
||||
try {
|
||||
// Navigate to page
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
log('pass', 'Page loaded successfully');
|
||||
|
||||
// Test 1.1: Verify indicator elements exist
|
||||
log('info', 'Test 1.1: Checking indicator elements exist...');
|
||||
const enIndicator = page.locator('#lang-indicator-en');
|
||||
const esIndicator = page.locator('#lang-indicator-es');
|
||||
|
||||
await enIndicator.waitFor({ state: 'attached', timeout: 5000 });
|
||||
await esIndicator.waitFor({ state: 'attached', timeout: 5000 });
|
||||
log('pass', 'Both language indicators found in DOM');
|
||||
|
||||
// Test 1.2: Verify initial opacity is 0 (hidden)
|
||||
log('info', 'Test 1.2: Checking initial indicator opacity...');
|
||||
const enInitialOpacity = await enIndicator.evaluate(el =>
|
||||
window.getComputedStyle(el).opacity
|
||||
);
|
||||
const esInitialOpacity = await esIndicator.evaluate(el =>
|
||||
window.getComputedStyle(el).opacity
|
||||
);
|
||||
|
||||
if (enInitialOpacity === '0' && esInitialOpacity === '0') {
|
||||
log('pass', `Indicators hidden initially (opacity: ${enInitialOpacity})`);
|
||||
} else {
|
||||
log('fail', `Indicators should be hidden (EN: ${enInitialOpacity}, ES: ${esInitialOpacity})`);
|
||||
}
|
||||
|
||||
// Test 1.3: Click EN button and verify indicator appears
|
||||
log('info', 'Test 1.3: Testing EN button loading indicator...');
|
||||
|
||||
// Get the ES button (since we're on EN by default)
|
||||
const esButton = page.locator('button.selector-btn[data-short="ES"]');
|
||||
await esButton.waitFor({ state: 'visible' });
|
||||
|
||||
// Set up monitoring for opacity changes
|
||||
const opacityPromise = page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
const indicator = document.querySelector('#lang-indicator-es');
|
||||
let maxOpacity = 0;
|
||||
let opacityChanges = [];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const currentOpacity = parseFloat(window.getComputedStyle(indicator).opacity);
|
||||
opacityChanges.push(currentOpacity);
|
||||
maxOpacity = Math.max(maxOpacity, currentOpacity);
|
||||
});
|
||||
|
||||
observer.observe(indicator.parentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Check opacity every 10ms for 2 seconds
|
||||
let checks = 0;
|
||||
const interval = setInterval(() => {
|
||||
const currentOpacity = parseFloat(window.getComputedStyle(indicator).opacity);
|
||||
opacityChanges.push(currentOpacity);
|
||||
maxOpacity = Math.max(maxOpacity, currentOpacity);
|
||||
checks++;
|
||||
|
||||
if (checks > 200) { // 2 seconds
|
||||
clearInterval(interval);
|
||||
observer.disconnect();
|
||||
resolve({ maxOpacity, opacityChanges: opacityChanges.filter(o => o > 0) });
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// Click the button
|
||||
const clickTime = Date.now();
|
||||
await esButton.click();
|
||||
|
||||
// Wait for the opacity monitoring to complete
|
||||
const opacityData = await opacityPromise;
|
||||
const responseTime = measureTime(clickTime);
|
||||
|
||||
log('info', `Request completed in ${responseTime}`);
|
||||
log('info', `Max indicator opacity: ${opacityData.maxOpacity}`);
|
||||
log('info', `Opacity changes detected: ${opacityData.opacityChanges.length}`);
|
||||
|
||||
if (opacityData.maxOpacity >= 0.9) {
|
||||
log('pass', `Indicator became visible (max opacity: ${opacityData.maxOpacity})`);
|
||||
} else if (opacityData.maxOpacity > 0) {
|
||||
log('warn', `Indicator partially visible but not fully (max: ${opacityData.maxOpacity})`);
|
||||
} else {
|
||||
log('warn', 'Indicator not visible on fast request (expected on localhost - will verify with throttled test)');
|
||||
}
|
||||
|
||||
// Test 1.4: Verify indicator faded out after request
|
||||
await sleep(500);
|
||||
const finalOpacity = await esIndicator.evaluate(el =>
|
||||
window.getComputedStyle(el).opacity
|
||||
);
|
||||
|
||||
if (finalOpacity === '0') {
|
||||
log('pass', `Indicator hidden after request (opacity: ${finalOpacity})`);
|
||||
} else {
|
||||
log('warn', `Indicator may not have faded out (opacity: ${finalOpacity})`);
|
||||
}
|
||||
|
||||
// Test 1.5: Take screenshot during loading
|
||||
log('info', 'Test 1.5: Capturing screenshot during loading...');
|
||||
|
||||
// Click back to EN to trigger another loading state
|
||||
await sleep(500);
|
||||
const enButton = page.locator('button.selector-btn[data-short="EN"]');
|
||||
|
||||
// Start click and immediately capture
|
||||
const screenshotPromise = page.screenshot({
|
||||
path: '/Users/txeo/Git/yo/cv/test-screenshots/htmx-indicator-loading.png',
|
||||
fullPage: false
|
||||
});
|
||||
|
||||
await enButton.click();
|
||||
await screenshotPromise;
|
||||
|
||||
log('pass', 'Screenshot captured: test-screenshots/htmx-indicator-loading.png');
|
||||
|
||||
// Test 1.6: Network throttling test
|
||||
log('info', 'Test 1.6: Testing with slow 3G network...');
|
||||
|
||||
// Slow 3G preset - only delay the specific endpoint
|
||||
let requestIntercepted = false;
|
||||
await page.route('**/switch-language**', async route => {
|
||||
if (!requestIntercepted) {
|
||||
requestIntercepted = true;
|
||||
await sleep(800); // Simulate 800ms delay
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
const slowClickTime = Date.now();
|
||||
|
||||
// Click and monitor
|
||||
const slowOpacityPromise = page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
const indicator = document.querySelector('#lang-indicator-en');
|
||||
let maxOpacity = 0;
|
||||
const interval = setInterval(() => {
|
||||
const opacity = parseFloat(window.getComputedStyle(indicator).opacity);
|
||||
maxOpacity = Math.max(maxOpacity, opacity);
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve(maxOpacity);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
await enButton.click();
|
||||
const slowOpacity = await slowOpacityPromise;
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
const slowResponseTime = measureTime(slowClickTime);
|
||||
|
||||
log('info', `Slow request completed in ${slowResponseTime}`);
|
||||
log('info', `Mid-request opacity: ${slowOpacity}`);
|
||||
|
||||
if (slowOpacity >= 0.9) {
|
||||
log('pass', `Indicator visible during slow request (opacity: ${slowOpacity})`);
|
||||
} else {
|
||||
log('fail', `Indicator not visible during slow request (opacity: ${slowOpacity})`);
|
||||
}
|
||||
|
||||
// Unroute to restore normal speed
|
||||
await page.unroute('**/switch-language**');
|
||||
|
||||
} catch (error) {
|
||||
log('fail', `Test 1 error: ${error.message}`);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function test2_ShortcutsButtonVisibility(page) {
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
log('info', 'TEST 2: Shortcuts Button Visibility (Feature 001)');
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
|
||||
try {
|
||||
// Ensure we're on the page
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Test 2.1: Verify button exists and is visible
|
||||
log('info', 'Test 2.1: Checking shortcuts button exists...');
|
||||
const shortcutsBtn = page.locator('.shortcuts-btn');
|
||||
|
||||
await shortcutsBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
log('pass', 'Shortcuts button found and visible');
|
||||
|
||||
// Test 2.2: Measure initial opacity
|
||||
log('info', 'Test 2.2: Measuring button opacity...');
|
||||
const opacity = await shortcutsBtn.evaluate(el =>
|
||||
window.getComputedStyle(el).opacity
|
||||
);
|
||||
|
||||
const opacityNum = parseFloat(opacity);
|
||||
log('info', `Button opacity: ${opacity}`);
|
||||
|
||||
if (opacityNum === 0.6) {
|
||||
log('pass', `Button opacity is exactly 0.6 as expected`);
|
||||
} else if (opacityNum >= 0.5 && opacityNum <= 0.7) {
|
||||
log('warn', `Button opacity close to target (${opacity} vs 0.6)`);
|
||||
} else {
|
||||
log('fail', `Button opacity incorrect (${opacity}, expected 0.6)`);
|
||||
}
|
||||
|
||||
// Test 2.3: Verify button is actually visible to users
|
||||
log('info', 'Test 2.3: Verifying visual discoverability...');
|
||||
const boundingBox = await shortcutsBtn.boundingBox();
|
||||
|
||||
if (boundingBox) {
|
||||
log('pass', `Button has dimensions: ${boundingBox.width}x${boundingBox.height}px`);
|
||||
log('info', `Position: (${boundingBox.x}, ${boundingBox.y})`);
|
||||
} else {
|
||||
log('fail', 'Button has no bounding box (may not be rendered)');
|
||||
}
|
||||
|
||||
// Test 2.4: Test hover state
|
||||
log('info', 'Test 2.4: Testing hover state...');
|
||||
await shortcutsBtn.hover();
|
||||
await sleep(500); // Wait for transition
|
||||
|
||||
const hoverOpacity = await shortcutsBtn.evaluate(el =>
|
||||
window.getComputedStyle(el).opacity
|
||||
);
|
||||
|
||||
if (parseFloat(hoverOpacity) === 1.0) {
|
||||
log('pass', `Hover opacity is 1.0 (full visibility)`);
|
||||
} else {
|
||||
log('warn', `Hover opacity: ${hoverOpacity} (expected 1.0)`);
|
||||
}
|
||||
|
||||
// Test 2.5: Take screenshot
|
||||
log('info', 'Test 2.5: Capturing button screenshot...');
|
||||
await page.screenshot({
|
||||
path: '/Users/txeo/Git/yo/cv/test-screenshots/shortcuts-button-visible.png',
|
||||
fullPage: false
|
||||
});
|
||||
log('pass', 'Screenshot captured: test-screenshots/shortcuts-button-visible.png');
|
||||
|
||||
// Test 2.6: Verify functionality
|
||||
log('info', 'Test 2.6: Testing button functionality...');
|
||||
await shortcutsBtn.click();
|
||||
await sleep(300);
|
||||
|
||||
// Check if modal opened
|
||||
const modal = page.locator('.shortcuts-modal, [id*="shortcut"], [class*="modal"]');
|
||||
const modalVisible = await modal.isVisible().catch(() => false);
|
||||
|
||||
if (modalVisible) {
|
||||
log('pass', 'Shortcuts modal opened successfully');
|
||||
|
||||
// Test ESC to close
|
||||
await page.keyboard.press('Escape');
|
||||
await sleep(300);
|
||||
|
||||
const modalClosed = await modal.isVisible().catch(() => false);
|
||||
if (!modalClosed) {
|
||||
log('pass', 'Modal closes with ESC key');
|
||||
} else {
|
||||
log('warn', 'Modal may not close with ESC');
|
||||
}
|
||||
} else {
|
||||
log('fail', 'Modal did not open on button click');
|
||||
}
|
||||
|
||||
// Test 2.7: Check info button consistency
|
||||
log('info', 'Test 2.7: Verifying info button has same opacity...');
|
||||
const infoBtn = page.locator('.info-button');
|
||||
const infoBtnExists = await infoBtn.count();
|
||||
|
||||
if (infoBtnExists > 0) {
|
||||
const infoOpacity = await infoBtn.evaluate(el =>
|
||||
window.getComputedStyle(el).opacity
|
||||
);
|
||||
|
||||
if (parseFloat(infoOpacity) === 0.6) {
|
||||
log('pass', `Info button also has opacity 0.6 (consistency maintained)`);
|
||||
} else {
|
||||
log('warn', `Info button opacity: ${infoOpacity} (expected 0.6)`);
|
||||
}
|
||||
} else {
|
||||
log('info', 'Info button not found (may not be on this page)');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log('fail', `Test 2 error: ${error.message}`);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function test3_RegressionTests(page) {
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
log('info', 'TEST 3: Regression Testing (Ensure Nothing Broke)');
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
|
||||
try {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Test 3.1: Skeleton loader still works
|
||||
log('info', 'Test 3.1: Verifying skeleton loader animation...');
|
||||
|
||||
const skeletonExists = await page.locator('#skeleton-loader').count();
|
||||
if (skeletonExists > 0) {
|
||||
log('pass', 'Skeleton loader element found');
|
||||
|
||||
// Trigger language switch
|
||||
const esButton = page.locator('button.selector-btn[data-short="ES"]');
|
||||
|
||||
const skeletonActivated = await page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
const skeleton = document.querySelector('#skeleton-loader');
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.target.classList.contains('active')) {
|
||||
observer.disconnect();
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(skeleton, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(false);
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
await esButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (skeletonActivated) {
|
||||
log('pass', 'Skeleton loader activated during language switch');
|
||||
} else {
|
||||
log('warn', 'Skeleton loader may not be activating');
|
||||
}
|
||||
} else {
|
||||
log('info', 'Skeleton loader not found (may not be used)');
|
||||
}
|
||||
|
||||
// Test 3.2: No console errors
|
||||
log('info', 'Test 3.2: Checking for console errors...');
|
||||
const errors = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await sleep(1000);
|
||||
|
||||
if (errors.length === 0) {
|
||||
log('pass', 'No console errors detected');
|
||||
} else {
|
||||
log('fail', `Console errors found: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Test 3.3: No layout shifts
|
||||
log('info', 'Test 3.3: Measuring Cumulative Layout Shift...');
|
||||
|
||||
const cls = await page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
let clsValue = 0;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe({ type: 'layout-shift', buffered: true });
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(clsValue);
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
log('info', `CLS Score: ${cls.toFixed(3)}`);
|
||||
|
||||
if (cls < 0.1) {
|
||||
log('pass', 'Excellent CLS score (< 0.1)');
|
||||
} else if (cls < 0.25) {
|
||||
log('warn', `CLS needs improvement (${cls.toFixed(3)})`);
|
||||
} else {
|
||||
log('fail', `Poor CLS score (${cls.toFixed(3)})`);
|
||||
}
|
||||
|
||||
// Test 3.4: Page load performance
|
||||
log('info', 'Test 3.4: Measuring page load performance...');
|
||||
|
||||
const perfMetrics = await page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType('navigation')[0];
|
||||
return {
|
||||
loadTime: perf.loadEventEnd - perf.fetchStart,
|
||||
domContentLoaded: perf.domContentLoadedEventEnd - perf.fetchStart,
|
||||
firstPaint: performance.getEntriesByType('paint')[0]?.startTime || 0
|
||||
};
|
||||
});
|
||||
|
||||
log('info', `Load time: ${perfMetrics.loadTime.toFixed(0)}ms`);
|
||||
log('info', `DOMContentLoaded: ${perfMetrics.domContentLoaded.toFixed(0)}ms`);
|
||||
log('info', `First Paint: ${perfMetrics.firstPaint.toFixed(0)}ms`);
|
||||
|
||||
if (perfMetrics.loadTime < 3000) {
|
||||
log('pass', 'Page loads in under 3 seconds');
|
||||
} else {
|
||||
log('warn', `Page load time: ${perfMetrics.loadTime.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log('fail', `Test 3 error: ${error.message}`);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
log('info', 'FINAL TEST REPORT');
|
||||
log('info', '═══════════════════════════════════════════════════════');
|
||||
|
||||
console.log('\n📊 SUMMARY:');
|
||||
console.log(` ✅ Passed: ${RESULTS.passed.length}`);
|
||||
console.log(` ❌ Failed: ${RESULTS.failed.length}`);
|
||||
console.log(` ⚠️ Warnings: ${RESULTS.warnings.length}`);
|
||||
|
||||
if (RESULTS.failed.length > 0) {
|
||||
console.log('\n❌ FAILURES:');
|
||||
RESULTS.failed.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
|
||||
}
|
||||
|
||||
if (RESULTS.warnings.length > 0) {
|
||||
console.log('\n⚠️ WARNINGS:');
|
||||
RESULTS.warnings.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
|
||||
}
|
||||
|
||||
console.log('\n📈 FEATURE GRADES:');
|
||||
|
||||
// Feature 003: HTMX Indicators
|
||||
const indicatorTests = RESULTS.passed.filter(m =>
|
||||
m.includes('indicator') || m.includes('Indicator')
|
||||
).length;
|
||||
const indicatorFails = RESULTS.failed.filter(m =>
|
||||
m.includes('indicator') || m.includes('Indicator')
|
||||
).length;
|
||||
|
||||
let feature003Grade = 'F';
|
||||
if (indicatorFails === 0 && indicatorTests >= 5) feature003Grade = 'A';
|
||||
else if (indicatorFails === 0 && indicatorTests >= 3) feature003Grade = 'B';
|
||||
else if (indicatorFails <= 1) feature003Grade = 'C';
|
||||
else if (indicatorFails <= 2) feature003Grade = 'D';
|
||||
|
||||
console.log(` Feature 003 (HTMX Indicators): ${feature003Grade} (${indicatorTests} tests passed, ${indicatorFails} failed)`);
|
||||
|
||||
// Feature 001: Shortcuts Button
|
||||
const buttonTests = RESULTS.passed.filter(m =>
|
||||
m.includes('Button') || m.includes('button') || m.includes('opacity')
|
||||
).length;
|
||||
const buttonFails = RESULTS.failed.filter(m =>
|
||||
m.includes('Button') || m.includes('button') || m.includes('opacity')
|
||||
).length;
|
||||
|
||||
let feature001Grade = 'A-';
|
||||
if (buttonFails === 0 && buttonTests >= 6) feature001Grade = 'A';
|
||||
else if (buttonFails === 0 && buttonTests >= 4) feature001Grade = 'A-';
|
||||
else if (buttonFails <= 1) feature001Grade = 'B+';
|
||||
else if (buttonFails <= 2) feature001Grade = 'B';
|
||||
|
||||
console.log(` Feature 001 (Shortcuts Button): ${feature001Grade} (${buttonTests} tests passed, ${buttonFails} failed)`);
|
||||
|
||||
console.log('\n📸 SCREENSHOTS:');
|
||||
console.log(' - test-screenshots/htmx-indicator-loading.png');
|
||||
console.log(' - test-screenshots/shortcuts-button-visible.png');
|
||||
|
||||
const overallSuccess = RESULTS.failed.length === 0;
|
||||
console.log(`\n${overallSuccess ? '✅ ALL TESTS PASSED' : '❌ SOME TESTS FAILED'}\n`);
|
||||
|
||||
return overallSuccess;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🧪 COMPREHENSIVE VERIFICATION TEST SUITE');
|
||||
console.log('Testing HTMX Indicators + Shortcuts Button Fixes\n');
|
||||
|
||||
// Create screenshots directory
|
||||
const { mkdir } = await import('fs/promises');
|
||||
await mkdir('/Users/txeo/Git/yo/cv/test-screenshots', { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage']
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Run all test suites
|
||||
await test1_HTMXLoadingIndicators(page);
|
||||
await test2_ShortcutsButtonVisibility(page);
|
||||
await test3_RegressionTests(page);
|
||||
|
||||
// Generate report
|
||||
const success = await generateReport();
|
||||
|
||||
await browser.close();
|
||||
|
||||
process.exit(success ? 0 : 1);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,122 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const screenshotsDir = join(__dirname, 'test-screenshots');
|
||||
|
||||
// Ensure screenshots directory exists
|
||||
if (!fs.existsSync(screenshotsDir)) {
|
||||
fs.mkdirSync(screenshotsDir);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('🎨 Testing Zoom Toggle Button Visual States...\n');
|
||||
|
||||
await page.goto('http://localhost:1999');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Clear localStorage for fresh start
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const toggleBtn = page.locator('#zoom-toggle-button');
|
||||
|
||||
// State 1: Inactive (default)
|
||||
console.log('📸 State 1: Inactive (default)');
|
||||
const inactiveStyles = await toggleBtn.evaluate(el => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return {
|
||||
background: styles.backgroundColor,
|
||||
color: styles.color,
|
||||
opacity: styles.opacity
|
||||
};
|
||||
});
|
||||
console.log(' Background:', inactiveStyles.background);
|
||||
console.log(' Icon color:', inactiveStyles.color);
|
||||
console.log(' Opacity:', inactiveStyles.opacity);
|
||||
await page.screenshot({ path: join(screenshotsDir, '1-inactive.png') });
|
||||
|
||||
// State 2: Inactive hover
|
||||
console.log('\n📸 State 2: Inactive hover');
|
||||
await toggleBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
const inactiveHoverStyles = await toggleBtn.evaluate(el => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return {
|
||||
background: styles.backgroundColor,
|
||||
color: styles.color,
|
||||
opacity: styles.opacity,
|
||||
transform: styles.transform
|
||||
};
|
||||
});
|
||||
console.log(' Background:', inactiveHoverStyles.background);
|
||||
console.log(' Icon color:', inactiveHoverStyles.color);
|
||||
console.log(' Opacity:', inactiveHoverStyles.opacity);
|
||||
console.log(' Transform:', inactiveHoverStyles.transform);
|
||||
await page.screenshot({ path: join(screenshotsDir, '2-inactive-hover.png') });
|
||||
|
||||
// Move mouse away
|
||||
await page.mouse.move(500, 500);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// State 3: Active (zoom opened)
|
||||
console.log('\n📸 State 3: Active (zoom opened)');
|
||||
await toggleBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
const activeStyles = await toggleBtn.evaluate(el => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return {
|
||||
background: styles.backgroundColor,
|
||||
color: styles.color,
|
||||
opacity: styles.opacity
|
||||
};
|
||||
});
|
||||
console.log(' Background:', activeStyles.background);
|
||||
console.log(' Icon color:', activeStyles.color);
|
||||
console.log(' Opacity:', activeStyles.opacity);
|
||||
await page.screenshot({ path: join(screenshotsDir, '3-active.png') });
|
||||
|
||||
// State 4: Active hover
|
||||
console.log('\n📸 State 4: Active hover');
|
||||
await toggleBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
const activeHoverStyles = await toggleBtn.evaluate(el => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return {
|
||||
background: styles.backgroundColor,
|
||||
color: styles.color,
|
||||
opacity: styles.opacity,
|
||||
transform: styles.transform,
|
||||
boxShadow: styles.boxShadow
|
||||
};
|
||||
});
|
||||
console.log(' Background:', activeHoverStyles.background);
|
||||
console.log(' Icon color:', activeHoverStyles.color);
|
||||
console.log(' Opacity:', activeHoverStyles.opacity);
|
||||
console.log(' Transform:', activeHoverStyles.transform);
|
||||
console.log(' Box shadow:', activeHoverStyles.boxShadow);
|
||||
await page.screenshot({ path: join(screenshotsDir, '4-active-hover.png') });
|
||||
|
||||
console.log('\n✅ Visual state tests complete!');
|
||||
console.log(`📁 Screenshots saved to: ${screenshotsDir}`);
|
||||
console.log('\n🎯 Summary:');
|
||||
console.log(' - Inactive: Gray icon (#888), 60% opacity');
|
||||
console.log(' - Inactive hover: Lighter gray bg, brighter icon (#aaa), 80% opacity');
|
||||
console.log(' - Active: Blue bg, white icon, 100% opacity');
|
||||
console.log(' - Active hover: Darker blue, blue glow, slight scale');
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zoom Control Debug Test</title>
|
||||
|
||||
<!-- Hyperscript - MUST load BEFORE component -->
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
|
||||
<!-- Iconify for icons -->
|
||||
<script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.test-area {
|
||||
margin: 20px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Zoom Control Styles */
|
||||
.zoom-control {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: rgba(128, 128, 128, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoom-control:hover {
|
||||
opacity: 1;
|
||||
background: rgba(128, 128, 128, 0.85);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.zoom-close-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(128, 128, 128, 0.6);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.zoom-close-btn:hover {
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.4);
|
||||
}
|
||||
|
||||
.zoom-value {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 180px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(200, 200, 200, 0.5);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid rgba(180, 180, 180, 0.8);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.zoom-reset-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 50px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zoom-reset-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#show-zoom-menu-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(128, 128, 128, 0.7);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 899;
|
||||
}
|
||||
|
||||
.log-area {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #00ff00;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 4px;
|
||||
padding: 2px;
|
||||
border-left: 2px solid #00ff00;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
border-left-color: #ff0000;
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.log-entry.success {
|
||||
border-left-color: #00ff00;
|
||||
color: #66ff66;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-area">
|
||||
<h1>Zoom Control Debug Test</h1>
|
||||
<p>Testing the zoom control component to identify issues with:</p>
|
||||
<ol>
|
||||
<li><strong>X button not working</strong> - Click the X button (top-right corner of zoom control)</li>
|
||||
<li><strong>Drag not working</strong> - Try to drag the zoom control by clicking and holding</li>
|
||||
</ol>
|
||||
|
||||
<h2>Instructions:</h2>
|
||||
<ul>
|
||||
<li>The zoom control should appear at the bottom center of the screen</li>
|
||||
<li>Click the X button to hide it - a "Show Zoom" button should appear</li>
|
||||
<li>Try dragging the zoom control around the screen</li>
|
||||
<li>Check the console log (right side) for debugging info</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 40px; padding: 20px; background: #e0f7ff; border-radius: 8px;">
|
||||
<h3>Expected Behavior:</h3>
|
||||
<ul>
|
||||
<li>✅ X button should hide the zoom control and show "Show Zoom" button</li>
|
||||
<li>✅ Dragging should move the zoom control around the screen</li>
|
||||
<li>✅ Position should be saved to localStorage</li>
|
||||
<li>✅ Dragging should NOT trigger when clicking slider or buttons</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Console -->
|
||||
<div class="log-area" id="debugLog">
|
||||
<div class="log-entry success">Debug console initialized</div>
|
||||
</div>
|
||||
|
||||
<!-- Show Zoom Button (hidden by default) -->
|
||||
<button id="show-zoom-menu-btn"
|
||||
_="on click
|
||||
log 'Show zoom button clicked'
|
||||
remove { display: 'none' } from #zoom-control
|
||||
add { display: 'none' } to me
|
||||
set localStorage['cv-zoom-visible'] to 'true'">
|
||||
Show Zoom Control
|
||||
</button>
|
||||
|
||||
<!-- Zoom Control Component -->
|
||||
<div id="zoom-control" class="zoom-control no-print" role="group" aria-label="Zoom control"
|
||||
_="on load
|
||||
log 'Zoom control loaded'
|
||||
if window.innerWidth <= 768
|
||||
log 'Mobile device detected - exiting'
|
||||
exit
|
||||
end
|
||||
set savedZoom to localStorage.getItem('cv-zoom')
|
||||
if savedZoom
|
||||
log 'Restoring zoom: ' + savedZoom
|
||||
set my value to savedZoom
|
||||
send input to #zoom-slider
|
||||
end
|
||||
set isVisible to localStorage.getItem('cv-zoom-visible')
|
||||
if isVisible is 'false'
|
||||
log 'Zoom control was hidden - showing button'
|
||||
add { display: 'none' } to me
|
||||
remove { display: 'none' } from #show-zoom-menu-btn
|
||||
end
|
||||
set savedPos to localStorage.getItem('cv-zoom-position')
|
||||
if savedPos
|
||||
log 'Restoring position: ' + savedPos
|
||||
set pos to JSON.parse(savedPos)
|
||||
set my *bottom to pos.bottom
|
||||
set my *left to pos.left
|
||||
set my *transform to 'none'
|
||||
end
|
||||
|
||||
on mousedown(clientX, clientY)
|
||||
log 'Mousedown on zoom-control at: ' + clientX + ', ' + clientY
|
||||
log 'Event target: ' + event.target.className
|
||||
if event.target.closest('.zoom-slider, .zoom-close-btn, .zoom-reset-btn')
|
||||
log 'Click on interactive element - exiting drag handler'
|
||||
exit
|
||||
end
|
||||
|
||||
log 'Starting drag'
|
||||
set isDragging to true
|
||||
set my *transition to 'none'
|
||||
|
||||
set rect to my getBoundingClientRect()
|
||||
set initialX to clientX - rect.left
|
||||
set initialY to clientY - rect.top
|
||||
log 'Initial offsets: ' + initialX + ', ' + initialY
|
||||
|
||||
halt the event
|
||||
|
||||
on mousemove(clientX, clientY) from document
|
||||
if isDragging is not true exit end
|
||||
log 'Dragging to: ' + clientX + ', ' + clientY
|
||||
|
||||
halt the event
|
||||
|
||||
set currentX to clientX - initialX
|
||||
set currentY to clientY - initialY
|
||||
|
||||
set maxX to window.innerWidth - my offsetWidth
|
||||
set maxY to window.innerHeight - my offsetHeight
|
||||
|
||||
set currentX to Math.max(0, Math.min(currentX, maxX))
|
||||
set currentY to Math.max(0, Math.min(currentY, maxY))
|
||||
|
||||
set my *left to `${currentX}px`
|
||||
set my *bottom to `${window.innerHeight - currentY - my offsetHeight}px`
|
||||
set my *transform to 'none'
|
||||
|
||||
on mouseup from document
|
||||
if isDragging is not true exit end
|
||||
log 'Drag ended - saving position'
|
||||
|
||||
set isDragging to false
|
||||
set my *transition to 'all 0.3s ease'
|
||||
|
||||
set position to { bottom: my *bottom, left: my *left }
|
||||
log 'Saving position: ' + JSON.stringify(position)
|
||||
set localStorage['cv-zoom-position'] to JSON.stringify(position)">
|
||||
|
||||
<button
|
||||
id="zoom-close"
|
||||
class="zoom-close-btn"
|
||||
aria-label="Close zoom control"
|
||||
title="Close"
|
||||
_="on click
|
||||
log 'Close button clicked!'
|
||||
add { display: 'none' } to #zoom-control
|
||||
remove { display: 'none' } from #show-zoom-menu-btn
|
||||
set localStorage['cv-zoom-visible'] to 'false'
|
||||
log 'Zoom control hidden, show button displayed'">
|
||||
<iconify-icon icon="mdi:close" width="16" height="16"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<span class="zoom-value zoom-value-min" aria-hidden="true">25</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
id="zoom-slider"
|
||||
class="zoom-slider"
|
||||
min="25"
|
||||
max="175"
|
||||
step="1"
|
||||
value="100"
|
||||
aria-label="Adjust zoom level"
|
||||
_="on input
|
||||
set zoomValue to my value as a Number
|
||||
log 'Zoom changed to: ' + zoomValue
|
||||
set localStorage['cv-zoom'] to zoomValue">
|
||||
|
||||
<span class="zoom-value zoom-value-max" aria-hidden="true">175</span>
|
||||
|
||||
<button
|
||||
id="zoom-reset"
|
||||
class="zoom-reset-btn"
|
||||
aria-label="Reset zoom to 100%"
|
||||
title="Reset"
|
||||
_="on click
|
||||
log 'Reset button clicked'
|
||||
set #zoom-slider's value to 100
|
||||
send input to #zoom-slider
|
||||
send focus to #zoom-slider">
|
||||
<span id="zoom-value-current">100</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Enhanced logging function
|
||||
function log(message, type = 'info') {
|
||||
const logArea = document.getElementById('debugLog');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry ' + type;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
entry.textContent = `[${timestamp}] ${message}`;
|
||||
logArea.appendChild(entry);
|
||||
logArea.scrollTop = logArea.scrollHeight;
|
||||
|
||||
// Also log to browser console
|
||||
console.log(`[${timestamp}] ${message}`);
|
||||
}
|
||||
|
||||
// Override hyperscript log to use our custom logger
|
||||
window.log = log;
|
||||
|
||||
// Add click listener to close button for debugging
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const closeBtn = document.getElementById('zoom-close');
|
||||
if (closeBtn) {
|
||||
log('Close button found in DOM', 'success');
|
||||
|
||||
// Add native click listener for debugging
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
log('Native click listener triggered on close button', 'success');
|
||||
});
|
||||
} else {
|
||||
log('Close button NOT found in DOM!', 'error');
|
||||
}
|
||||
|
||||
const zoomControl = document.getElementById('zoom-control');
|
||||
if (zoomControl) {
|
||||
log('Zoom control found in DOM', 'success');
|
||||
|
||||
// Add mousedown listener for debugging
|
||||
zoomControl.addEventListener('mousedown', function(e) {
|
||||
log(`Native mousedown on: ${e.target.className}`, 'info');
|
||||
});
|
||||
} else {
|
||||
log('Zoom control NOT found in DOM!', 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Final Zoom Control Test - X Button and Drag
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
async function testZoomControl() {
|
||||
console.log('🧪 Final Zoom Control Test - X Button & Drag\n');
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Navigate
|
||||
console.log('📄 Loading http://localhost:1999/?lang=en');
|
||||
await page.goto('http://localhost:1999/?lang=en', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const zoomControl = page.locator('#zoom-control');
|
||||
await zoomControl.waitFor({ state: 'visible', timeout: 5000 });
|
||||
console.log('✅ Zoom control loaded\n');
|
||||
|
||||
// TEST 1: X Button
|
||||
console.log('🧪 TEST 1: X Button Click');
|
||||
const closeButton = page.locator('#zoom-close');
|
||||
await closeButton.click({ force: true });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const isHidden = await zoomControl.evaluate(el =>
|
||||
window.getComputedStyle(el).display === 'none'
|
||||
);
|
||||
|
||||
if (isHidden) {
|
||||
console.log('✅ SUCCESS: X button works! Zoom control is hidden\n');
|
||||
} else {
|
||||
console.log('❌ FAIL: X button did not hide zoom control\n');
|
||||
throw new Error('X button test failed');
|
||||
}
|
||||
|
||||
// Show it again for drag test
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('zoom-control').style.display = '';
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// TEST 2: Drag Functionality
|
||||
console.log('🧪 TEST 2: Drag Functionality');
|
||||
|
||||
const initialBox = await zoomControl.boundingBox();
|
||||
console.log(` Initial position: x=${Math.round(initialBox.x)}, y=${Math.round(initialBox.y)}`);
|
||||
|
||||
// Click on grey background area (not on buttons)
|
||||
const dragX = initialBox.x + 60; // Left side, away from buttons
|
||||
const dragY = initialBox.y + initialBox.height / 2;
|
||||
|
||||
await page.mouse.move(dragX, dragY);
|
||||
await page.mouse.down();
|
||||
|
||||
// Drag to new location
|
||||
const targetX = dragX + 300;
|
||||
const targetY = dragY - 150;
|
||||
console.log(` Dragging to: x=${Math.round(targetX)}, y=${Math.round(targetY)}`);
|
||||
|
||||
await page.mouse.move(targetX, targetY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const finalBox = await zoomControl.boundingBox();
|
||||
console.log(` Final position: x=${Math.round(finalBox.x)}, y=${Math.round(finalBox.y)}`);
|
||||
|
||||
const deltaX = Math.abs(finalBox.x - initialBox.x);
|
||||
const deltaY = Math.abs(finalBox.y - initialBox.y);
|
||||
|
||||
if (deltaX > 100 || deltaY > 50) {
|
||||
console.log(`✅ SUCCESS: Drag works! Moved ${Math.round(deltaX)}px horizontally, ${Math.round(deltaY)}px vertically\n`);
|
||||
} else {
|
||||
console.log(`❌ FAIL: Drag did not work properly. Only moved ${Math.round(deltaX)}px, ${Math.round(deltaY)}px\n`);
|
||||
throw new Error('Drag test failed');
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('='.repeat(60));
|
||||
console.log('✅ ALL TESTS PASSED!');
|
||||
console.log('='.repeat(60));
|
||||
console.log('✅ X button hides zoom control');
|
||||
console.log('✅ Drag functionality works correctly');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n🎉 Zoom control is fully functional!');
|
||||
console.log('\n⏸️ Browser will stay open for 5 seconds...');
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error.message);
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log('\n✅ Test completed\n');
|
||||
}
|
||||
}
|
||||
|
||||
testZoomControl();
|
||||
@@ -0,0 +1,389 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zoom Control - FIXED VERSION</title>
|
||||
|
||||
<!-- Hyperscript - MUST load BEFORE component -->
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
|
||||
<!-- Iconify for icons -->
|
||||
<script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f0f0f0;
|
||||
min-height: 2000px; /* Make page scrollable for testing */
|
||||
}
|
||||
|
||||
.test-area {
|
||||
margin: 20px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
background: #d4edda;
|
||||
border: 2px solid #28a745;
|
||||
color: #155724;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.test-steps {
|
||||
background: #fff3cd;
|
||||
border: 2px solid #ffc107;
|
||||
color: #856404;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Zoom Control Styles */
|
||||
.zoom-control {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: rgba(128, 128, 128, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zoom-control:hover {
|
||||
opacity: 1;
|
||||
background: rgba(128, 128, 128, 0.85);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.zoom-close-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(128, 128, 128, 0.6);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.zoom-close-btn:hover {
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.4);
|
||||
}
|
||||
|
||||
.zoom-value {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 180px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(200, 200, 200, 0.5);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid rgba(180, 180, 180, 0.8);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.zoom-reset-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 50px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zoom-reset-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#show-zoom-menu-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(128, 128, 128, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
z-index: 899;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#show-zoom-menu-btn:hover {
|
||||
background: rgba(128, 128, 128, 1);
|
||||
transform: translateX(-50%) scale(1.05);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-area">
|
||||
<h1>🔧 Zoom Control - FIXED VERSION</h1>
|
||||
|
||||
<div class="success-box">
|
||||
<h3>✅ Fixes Applied:</h3>
|
||||
<ol>
|
||||
<li><strong>X Button Fix</strong>: Added <code>pointer-events: none</code> to the iconify-icon element to prevent it from capturing clicks</li>
|
||||
<li><strong>X Button Fix</strong>: Added <code>halt the event</code> to prevent event propagation</li>
|
||||
<li><strong>Drag Fix</strong>: Improved the mousedown handler to properly check for interactive elements using both <code>classList.contains()</code> and <code>closest()</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="test-steps">
|
||||
<h3>🧪 Test Steps:</h3>
|
||||
<ol>
|
||||
<li><strong>Test X Button</strong>:
|
||||
<ul>
|
||||
<li>Click the X button (top-right corner of zoom control)</li>
|
||||
<li>✅ Expected: Zoom control should disappear and "Show Zoom Control" button should appear at bottom</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Test Show Button</strong>:
|
||||
<ul>
|
||||
<li>Click the "Show Zoom Control" button</li>
|
||||
<li>✅ Expected: Zoom control should reappear and button should disappear</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Test Dragging</strong>:
|
||||
<ul>
|
||||
<li>Click and hold on the grey background of the zoom control (NOT on slider/buttons)</li>
|
||||
<li>Move your mouse while holding</li>
|
||||
<li>✅ Expected: Zoom control should move with your mouse</li>
|
||||
<li>Release the mouse</li>
|
||||
<li>✅ Expected: Position should be saved (reload page to verify)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Test Slider</strong>:
|
||||
<ul>
|
||||
<li>Click and drag the slider</li>
|
||||
<li>✅ Expected: Slider should move, NOT trigger drag mode</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Key Changes Made:</h2>
|
||||
<pre style="background: #f8f9fa; padding: 15px; border-radius: 5px; overflow-x: auto;">
|
||||
<strong>1. Close Button (zoom-control.html:76-81)</strong>
|
||||
- Added: halt the event
|
||||
- Added: style="pointer-events: none;" to iconify-icon
|
||||
|
||||
<strong>2. Drag Handler (zoom-control.html:26-42)</strong>
|
||||
- Changed from: event.target.closest('.zoom-slider, .zoom-close-btn, .zoom-reset-btn')
|
||||
- Changed to: Multiple specific checks:
|
||||
* if target.classList.contains('zoom-slider') exit end
|
||||
* if target.classList.contains('zoom-close-btn') exit end
|
||||
* if target.classList.contains('zoom-reset-btn') exit end
|
||||
* if target.closest('.zoom-close-btn') exit end
|
||||
* if target.closest('.zoom-reset-btn') exit end
|
||||
</pre>
|
||||
|
||||
<div style="margin-top: 40px; padding: 20px; background: #e7f3ff; border-radius: 8px;">
|
||||
<h3>📝 Testing Checklist:</h3>
|
||||
<p>Mark off as you test:</p>
|
||||
<ul style="list-style: none; padding-left: 0;">
|
||||
<li>☐ X button hides zoom control</li>
|
||||
<li>☐ Show button reveals zoom control</li>
|
||||
<li>☐ Dragging works (click on grey background)</li>
|
||||
<li>☐ Slider doesn't trigger drag mode</li>
|
||||
<li>☐ Reset button doesn't trigger drag mode</li>
|
||||
<li>☐ Position persists after page reload</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show Zoom Button (hidden by default) -->
|
||||
<button id="show-zoom-menu-btn"
|
||||
_="on click
|
||||
halt the event
|
||||
remove { display: 'none' } from #zoom-control
|
||||
add { display: 'none' } to me
|
||||
set localStorage['cv-zoom-visible'] to 'true'">
|
||||
Show Zoom Control
|
||||
</button>
|
||||
|
||||
<!-- Zoom Control Component - FIXED VERSION -->
|
||||
<div id="zoom-control" class="zoom-control no-print" role="group" aria-label="Zoom control"
|
||||
_="on load
|
||||
if window.innerWidth <= 768
|
||||
exit
|
||||
end
|
||||
set savedZoom to localStorage.getItem('cv-zoom')
|
||||
if savedZoom
|
||||
set my value to savedZoom
|
||||
send input to #zoom-slider
|
||||
end
|
||||
set isVisible to localStorage.getItem('cv-zoom-visible')
|
||||
if isVisible is 'false'
|
||||
add { display: 'none' } to me
|
||||
remove { display: 'none' } from #show-zoom-menu-btn
|
||||
end
|
||||
set savedPos to localStorage.getItem('cv-zoom-position')
|
||||
if savedPos
|
||||
set pos to JSON.parse(savedPos)
|
||||
set my *bottom to pos.bottom
|
||||
set my *left to pos.left
|
||||
set my *transform to 'none'
|
||||
end
|
||||
|
||||
on mousedown(clientX, clientY)
|
||||
-- FIXED: Check if click is on interactive elements (slider, buttons)
|
||||
set target to event.target
|
||||
if target.classList.contains('zoom-slider') exit end
|
||||
if target.classList.contains('zoom-close-btn') exit end
|
||||
if target.classList.contains('zoom-reset-btn') exit end
|
||||
if target.closest('.zoom-close-btn') exit end
|
||||
if target.closest('.zoom-reset-btn') exit end
|
||||
|
||||
set isDragging to true
|
||||
set my *transition to 'none'
|
||||
|
||||
set rect to my getBoundingClientRect()
|
||||
set initialX to clientX - rect.left
|
||||
set initialY to clientY - rect.top
|
||||
|
||||
halt the event
|
||||
|
||||
on mousemove(clientX, clientY) from document
|
||||
if isDragging is not true exit end
|
||||
|
||||
halt the event
|
||||
|
||||
set currentX to clientX - initialX
|
||||
set currentY to clientY - initialY
|
||||
|
||||
set maxX to window.innerWidth - my offsetWidth
|
||||
set maxY to window.innerHeight - my offsetHeight
|
||||
|
||||
set currentX to Math.max(0, Math.min(currentX, maxX))
|
||||
set currentY to Math.max(0, Math.min(currentY, maxY))
|
||||
|
||||
set my *left to `${currentX}px`
|
||||
set my *bottom to `${window.innerHeight - currentY - my offsetHeight}px`
|
||||
set my *transform to 'none'
|
||||
|
||||
on mouseup from document
|
||||
if isDragging is not true exit end
|
||||
|
||||
set isDragging to false
|
||||
set my *transition to 'all 0.3s ease'
|
||||
|
||||
set position to { bottom: my *bottom, left: my *left }
|
||||
set localStorage['cv-zoom-position'] to JSON.stringify(position)">
|
||||
|
||||
<!-- FIXED: Close button with pointer-events: none on icon -->
|
||||
<button
|
||||
id="zoom-close"
|
||||
class="zoom-close-btn"
|
||||
aria-label="Close zoom control"
|
||||
title="Close"
|
||||
_="on click
|
||||
halt the event
|
||||
add { display: 'none' } to #zoom-control
|
||||
remove { display: 'none' } from #show-zoom-menu-btn
|
||||
set localStorage['cv-zoom-visible'] to 'false'">
|
||||
<iconify-icon icon="mdi:close" width="16" height="16" style="pointer-events: none;"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<span class="zoom-value zoom-value-min" aria-hidden="true">25</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
id="zoom-slider"
|
||||
class="zoom-slider"
|
||||
min="25"
|
||||
max="175"
|
||||
step="1"
|
||||
value="100"
|
||||
aria-label="Adjust zoom level"
|
||||
_="on input
|
||||
set zoomValue to my value as a Number
|
||||
put zoomValue into #zoom-value-current
|
||||
set localStorage['cv-zoom'] to zoomValue">
|
||||
|
||||
<span class="zoom-value zoom-value-max" aria-hidden="true">175</span>
|
||||
|
||||
<button
|
||||
id="zoom-reset"
|
||||
class="zoom-reset-btn"
|
||||
aria-label="Reset zoom to 100%"
|
||||
title="Reset"
|
||||
_="on click
|
||||
set #zoom-slider's value to 100
|
||||
send input to #zoom-slider
|
||||
send focus to #zoom-slider">
|
||||
<span id="zoom-value-current">100</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
console.log('🧪 Zoom Control Test Page - FIXED VERSION');
|
||||
console.log('Open browser console to see debug messages');
|
||||
console.log('localStorage keys used: cv-zoom, cv-zoom-visible, cv-zoom-position');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Zoom Control Functionality Test
|
||||
* Tests the X button and drag functionality
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
async function testZoomControl() {
|
||||
console.log('🧪 Starting Zoom Control Test...\n');
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Navigate to the CV page
|
||||
console.log('📄 Navigating to http://localhost:1999/?lang=en');
|
||||
await page.goto('http://localhost:1999/?lang=en', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for zoom control to be visible
|
||||
console.log('⏳ Waiting for zoom control to load...');
|
||||
const zoomControl = page.locator('#zoom-control');
|
||||
await zoomControl.waitFor({ state: 'visible', timeout: 5000 });
|
||||
console.log('✅ Zoom control is visible\n');
|
||||
|
||||
// Test 1: X Button Click
|
||||
console.log('🧪 TEST 1: X Button Click');
|
||||
console.log(' Locating X button...');
|
||||
const closeButton = page.locator('#zoom-close');
|
||||
await closeButton.waitFor({ state: 'visible' });
|
||||
|
||||
const closeButtonBox = await closeButton.boundingBox();
|
||||
console.log(` X button position: x=${closeButtonBox.x}, y=${closeButtonBox.y}`);
|
||||
|
||||
console.log(' Clicking X button...');
|
||||
await closeButton.click({ force: true });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check if zoom control is hidden
|
||||
const isHidden = await zoomControl.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display === 'none';
|
||||
});
|
||||
|
||||
if (isHidden) {
|
||||
console.log('✅ X button works! Zoom control is hidden');
|
||||
} else {
|
||||
console.log('❌ X button failed! Zoom control is still visible');
|
||||
}
|
||||
|
||||
// Check if show button appeared
|
||||
const showButton = page.locator('#show-zoom-menu-btn');
|
||||
const showButtonVisible = await showButton.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none';
|
||||
});
|
||||
|
||||
if (showButtonVisible) {
|
||||
console.log('✅ Show zoom button is visible');
|
||||
} else {
|
||||
console.log('❌ Show zoom button did not appear');
|
||||
}
|
||||
|
||||
// Test 2: Show Button Click
|
||||
console.log('\n🧪 TEST 2: Show Button Click');
|
||||
console.log(' Clicking show zoom button...');
|
||||
await showButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const isVisibleAgain = await zoomControl.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none';
|
||||
});
|
||||
|
||||
if (isVisibleAgain) {
|
||||
console.log('✅ Show button works! Zoom control is visible again');
|
||||
} else {
|
||||
console.log('❌ Show button failed! Zoom control is still hidden');
|
||||
}
|
||||
|
||||
// Test 3: Drag Functionality
|
||||
console.log('\n🧪 TEST 3: Drag Functionality');
|
||||
|
||||
// Get initial position
|
||||
const initialPosition = await zoomControl.boundingBox();
|
||||
console.log(` Initial position: x=${Math.round(initialPosition.x)}, y=${Math.round(initialPosition.y)}`);
|
||||
|
||||
// Calculate drag target (click on the grey background, not on buttons)
|
||||
const dragStartX = initialPosition.x + 100; // Middle of control
|
||||
const dragStartY = initialPosition.y + initialPosition.height / 2;
|
||||
|
||||
console.log(' Starting drag operation...');
|
||||
await page.mouse.move(dragStartX, dragStartY);
|
||||
await page.mouse.down();
|
||||
|
||||
// Drag to new position
|
||||
const newX = dragStartX + 200;
|
||||
const newY = dragStartY - 100;
|
||||
console.log(` Dragging to: x=${Math.round(newX)}, y=${Math.round(newY)}`);
|
||||
|
||||
await page.mouse.move(newX, newY, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get final position
|
||||
const finalPosition = await zoomControl.boundingBox();
|
||||
console.log(` Final position: x=${Math.round(finalPosition.x)}, y=${Math.round(finalPosition.y)}`);
|
||||
|
||||
const movedX = Math.abs(finalPosition.x - initialPosition.x);
|
||||
const movedY = Math.abs(finalPosition.y - initialPosition.y);
|
||||
|
||||
if (movedX > 50 || movedY > 50) {
|
||||
console.log(`✅ Drag works! Moved ${Math.round(movedX)}px horizontally, ${Math.round(movedY)}px vertically`);
|
||||
} else {
|
||||
console.log(`❌ Drag failed! Only moved ${Math.round(movedX)}px horizontally, ${Math.round(movedY)}px vertically`);
|
||||
}
|
||||
|
||||
// Test 4: Slider Doesn't Trigger Drag
|
||||
console.log('\n🧪 TEST 4: Slider Click Doesn\'t Trigger Drag');
|
||||
const slider = page.locator('#zoom-slider');
|
||||
const sliderBox = await slider.boundingBox();
|
||||
|
||||
console.log(' Clicking on slider...');
|
||||
await slider.click({ force: true });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const positionAfterSlider = await zoomControl.boundingBox();
|
||||
const sliderMovedX = Math.abs(positionAfterSlider.x - finalPosition.x);
|
||||
const sliderMovedY = Math.abs(positionAfterSlider.y - finalPosition.y);
|
||||
|
||||
if (sliderMovedX < 5 && sliderMovedY < 5) {
|
||||
console.log('✅ Slider click doesn\'t trigger drag mode');
|
||||
} else {
|
||||
console.log(`❌ Slider click triggered drag! Moved ${Math.round(sliderMovedX)}px, ${Math.round(sliderMovedY)}px`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 TEST SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`${isHidden ? '✅' : '❌'} X button hides zoom control`);
|
||||
console.log(`${showButtonVisible ? '✅' : '❌'} Show button appears when hidden`);
|
||||
console.log(`${isVisibleAgain ? '✅' : '❌'} Show button reveals zoom control`);
|
||||
console.log(`${movedX > 50 || movedY > 50 ? '✅' : '❌'} Drag functionality works`);
|
||||
console.log(`${sliderMovedX < 5 && sliderMovedY < 5 ? '✅' : '❌'} Slider doesn't trigger drag`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n⏸️ Browser will stay open for 10 seconds for manual inspection...');
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed with error:', error.message);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log('\n✅ Test completed');
|
||||
}
|
||||
}
|
||||
|
||||
testZoomControl();
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Zoom Persistence</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Testing localStorage for zoom visibility</h1>
|
||||
|
||||
<button onclick="closeZoom()">Close Zoom (set to 'false')</button>
|
||||
<button onclick="showZoom()">Show Zoom (set to 'true')</button>
|
||||
<button onclick="clearZoom()">Clear localStorage</button>
|
||||
<button onclick="checkValue()">Check Current Value</button>
|
||||
|
||||
<div id="output" style="margin-top: 20px; padding: 10px; background: #f0f0f0;"></div>
|
||||
|
||||
<script>
|
||||
function closeZoom() {
|
||||
localStorage.setItem('cv-zoom-visible', 'false');
|
||||
checkValue();
|
||||
}
|
||||
|
||||
function showZoom() {
|
||||
localStorage.setItem('cv-zoom-visible', 'true');
|
||||
checkValue();
|
||||
}
|
||||
|
||||
function clearZoom() {
|
||||
localStorage.removeItem('cv-zoom-visible');
|
||||
checkValue();
|
||||
}
|
||||
|
||||
function checkValue() {
|
||||
const value = localStorage.getItem('cv-zoom-visible');
|
||||
const output = document.getElementById('output');
|
||||
|
||||
output.innerHTML = `
|
||||
<strong>Current value:</strong> ${value === null ? 'null (not set)' : `"${value}"`}<br>
|
||||
<strong>Type:</strong> ${typeof value}<br>
|
||||
<strong>Is 'false'?</strong> ${value === 'false'}<br>
|
||||
<strong>Is 'true'?</strong> ${value === 'true'}<br>
|
||||
<strong>Is null?</strong> ${value === null}
|
||||
`;
|
||||
}
|
||||
|
||||
// Check on load
|
||||
checkValue();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('🧪 Testing Zoom Toggle Button Implementation...\n');
|
||||
|
||||
// Navigate to the page
|
||||
await page.goto('http://localhost:1999');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
console.log('✅ Page loaded');
|
||||
|
||||
// Test 1: Check for blinking - zoom control should be hidden from start
|
||||
console.log('\n📋 Test 1: No blinking on load (zoom should start hidden)');
|
||||
|
||||
const zoomControl = page.locator('#zoom-control');
|
||||
const isHidden = await zoomControl.evaluate(el => el.classList.contains('zoom-hidden'));
|
||||
|
||||
if (isHidden) {
|
||||
console.log('✅ PASS: Zoom control starts hidden (no blinking)');
|
||||
} else {
|
||||
console.log('❌ FAIL: Zoom control is visible on load (will blink)');
|
||||
}
|
||||
|
||||
// Test 2: Verify toggle button exists and is positioned correctly
|
||||
console.log('\n📋 Test 2: Toggle button exists and positioned');
|
||||
|
||||
const toggleBtn = page.locator('#zoom-toggle-button');
|
||||
await toggleBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
const toggleBox = await toggleBtn.boundingBox();
|
||||
console.log(`✅ Toggle button found at position: left=${toggleBox.x}px, top=${toggleBox.y}px`);
|
||||
|
||||
// Test 3: Check initial state (should be dimmed)
|
||||
console.log('\n📋 Test 3: Toggle button initial state (should be dimmed)');
|
||||
|
||||
const hasActiveClass = await toggleBtn.evaluate(el => el.classList.contains('zoom-active'));
|
||||
const opacity = await toggleBtn.evaluate(el => window.getComputedStyle(el).opacity);
|
||||
|
||||
if (!hasActiveClass && parseFloat(opacity) === 0.5) {
|
||||
console.log('✅ PASS: Toggle button is dimmed (opacity 0.5, no zoom-active class)');
|
||||
} else {
|
||||
console.log(`❌ FAIL: Toggle button state incorrect (zoom-active: ${hasActiveClass}, opacity: ${opacity})`);
|
||||
}
|
||||
|
||||
// Test 4: Click toggle button to show zoom
|
||||
console.log('\n📋 Test 4: Click toggle button to show zoom');
|
||||
|
||||
await toggleBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const zoomVisible = await zoomControl.evaluate(el => !el.classList.contains('zoom-hidden'));
|
||||
const toggleActive = await toggleBtn.evaluate(el => el.classList.contains('zoom-active'));
|
||||
const newOpacity = await toggleBtn.evaluate(el => window.getComputedStyle(el).opacity);
|
||||
|
||||
if (zoomVisible && toggleActive && parseFloat(newOpacity) === 1) {
|
||||
console.log('✅ PASS: Zoom control shown, toggle button active (opacity 1, blue background)');
|
||||
} else {
|
||||
console.log(`❌ FAIL: Zoom toggle failed (visible: ${zoomVisible}, active: ${toggleActive}, opacity: ${newOpacity})`);
|
||||
}
|
||||
|
||||
// Test 5: Click toggle button again to hide zoom
|
||||
console.log('\n📋 Test 5: Click toggle button to hide zoom');
|
||||
|
||||
await toggleBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const zoomHidden = await zoomControl.evaluate(el => el.classList.contains('zoom-hidden'));
|
||||
const toggleInactive = await toggleBtn.evaluate(el => !el.classList.contains('zoom-active'));
|
||||
const dimOpacity = await toggleBtn.evaluate(el => window.getComputedStyle(el).opacity);
|
||||
|
||||
if (zoomHidden && toggleInactive && parseFloat(dimOpacity) === 0.5) {
|
||||
console.log('✅ PASS: Zoom control hidden, toggle button dimmed again');
|
||||
} else {
|
||||
console.log(`❌ FAIL: Zoom toggle hide failed (hidden: ${zoomHidden}, inactive: ${toggleInactive}, opacity: ${dimOpacity})`);
|
||||
}
|
||||
|
||||
// Test 6: Verify localStorage persistence
|
||||
console.log('\n📋 Test 6: Verify localStorage persistence');
|
||||
|
||||
const storageValue = await page.evaluate(() => localStorage.getItem('cv-zoom-visible'));
|
||||
console.log(` localStorage cv-zoom-visible: "${storageValue}"`);
|
||||
|
||||
if (storageValue === 'false') {
|
||||
console.log('✅ PASS: localStorage correctly set to "false"');
|
||||
} else {
|
||||
console.log(`❌ FAIL: localStorage incorrect (expected "false", got "${storageValue}")`);
|
||||
}
|
||||
|
||||
// Test 7: Refresh page and verify zoom stays hidden
|
||||
console.log('\n📋 Test 7: Refresh page and verify no blinking + stays hidden');
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500); // Wait a bit to catch any blinking
|
||||
|
||||
const stillHidden = await zoomControl.evaluate(el => el.classList.contains('zoom-hidden'));
|
||||
const toggleStillDimmed = await toggleBtn.evaluate(el => !el.classList.contains('zoom-active'));
|
||||
|
||||
if (stillHidden && toggleStillDimmed) {
|
||||
console.log('✅ PASS: After refresh, zoom stays hidden and toggle stays dimmed');
|
||||
} else {
|
||||
console.log(`❌ FAIL: After refresh, state incorrect (hidden: ${stillHidden}, dimmed: ${toggleStillDimmed})`);
|
||||
}
|
||||
|
||||
// Test 8: Show zoom again and refresh to test persistence
|
||||
console.log('\n📋 Test 8: Show zoom, refresh, verify it stays visible');
|
||||
|
||||
await toggleBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const staysVisible = await zoomControl.evaluate(el => !el.classList.contains('zoom-hidden'));
|
||||
const toggleStaysActive = await toggleBtn.evaluate(el => el.classList.contains('zoom-active'));
|
||||
|
||||
if (staysVisible && toggleStaysActive) {
|
||||
console.log('✅ PASS: After refresh, zoom stays visible and toggle stays active');
|
||||
} else {
|
||||
console.log(`❌ FAIL: Persistence failed (visible: ${staysVisible}, active: ${toggleStaysActive})`);
|
||||
}
|
||||
|
||||
// Test 9: Test X button still works
|
||||
console.log('\n📋 Test 9: Test X button close functionality');
|
||||
|
||||
const closeBtn = page.locator('#zoom-close');
|
||||
await closeBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const closedByX = await zoomControl.evaluate(el => el.classList.contains('zoom-hidden'));
|
||||
const toggleDimmedAgain = await toggleBtn.evaluate(el => !el.classList.contains('zoom-active'));
|
||||
|
||||
if (closedByX && toggleDimmedAgain) {
|
||||
console.log('✅ PASS: X button closes zoom and syncs toggle button state');
|
||||
} else {
|
||||
console.log(`❌ FAIL: X button failed (hidden: ${closedByX}, toggle dimmed: ${toggleDimmedAgain})`);
|
||||
}
|
||||
|
||||
// Test 10: Verify button positioning relative to shortcuts button
|
||||
console.log('\n📋 Test 10: Verify button positioning');
|
||||
|
||||
const shortcutsBtn = page.locator('#shortcuts-button');
|
||||
const shortcutsBox = await shortcutsBtn.boundingBox();
|
||||
|
||||
console.log(` Shortcuts button: bottom=${1080 - shortcutsBox.y}px (should be ~6rem = ~96px)`);
|
||||
console.log(` Toggle button: bottom=${1080 - toggleBox.y}px (should be ~10rem = ~160px)`);
|
||||
|
||||
const verticalGap = (1080 - toggleBox.y - toggleBox.height) - (1080 - shortcutsBox.y);
|
||||
console.log(` Vertical gap between buttons: ${Math.abs(verticalGap)}px`);
|
||||
|
||||
if (Math.abs(verticalGap) > 50) {
|
||||
console.log('✅ PASS: Toggle button is clearly above shortcuts button');
|
||||
} else {
|
||||
console.log('⚠️ WARNING: Buttons might be too close together');
|
||||
}
|
||||
|
||||
console.log('\n🎯 All tests completed! Browser will stay open for 10 seconds for visual inspection...\n');
|
||||
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
await browser.close();
|
||||
console.log('✅ Test suite finished');
|
||||
})();
|
||||
Reference in New Issue
Block a user