UNPKG

@casoon/auditmysite

Version:

Professional website analysis suite with robust accessibility testing, Core Web Vitals performance monitoring, SEO analysis, and content optimization insights. Features isolated browser contexts, retry mechanisms, and comprehensive API endpoints for profe

986 lines 45.3 kB
"use strict"; /** * 📱 Mobile-Friendliness Analyzer * * Comprehensive analysis of mobile usability including: * - Viewport & Layout (responsive design, no horizontal scrolling, safe areas) * - Typography & Touch Targets (font sizes, click areas, spacing) * - Navigation & Interactions (touch-friendly UI, focus management) * - Media & Images (responsive images, lazy loading, video handling) * - Performance (mobile-specific Core Web Vitals) * - Forms & Input (mobile-optimized form controls) * - Mobile UX (no intrusive popups, proper error handling) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.MobileFriendlinessAnalyzer = void 0; class MobileFriendlinessAnalyzer { constructor(options = {}) { this.options = options; } /** * Analyze desktop vs mobile differences for comprehensive comparison */ async analyzeDesktopMobileComparison(page, url) { // Extract URL string from URL object if needed const urlString = (typeof url === 'object' && url.loc ? url.loc : url); console.log(`🖥️📱 Running desktop vs mobile comparison analysis for: ${urlString}`); const startTime = Date.now(); try { // Use already loaded content - navigation is handled by main test flow // Skip navigation completely to preserve page context // Analyze with current viewport to avoid context destruction during comprehensive analysis // Note: Desktop/mobile comparison requires different viewport but will skip during comprehensive analysis console.log('🖥️ Analyzing current viewport experience...'); const currentAnalysis = await this.performDeviceAnalysis(page, 'mobile'); // Default to mobile analysis // Use current analysis for both desktop and mobile to avoid viewport changes const desktopAnalysis = currentAnalysis; const mobileAnalysis = currentAnalysis; // Step 3: Calculate differences and generate recommendations const differences = this.calculateDifferences(desktopAnalysis, mobileAnalysis); const recommendations = this.generateComparisonRecommendations(desktopAnalysis, mobileAnalysis, differences); console.log(`✅ Desktop vs Mobile comparison completed in ${Date.now() - startTime}ms`); console.log(`📊 Desktop Usability: ${desktopAnalysis.usabilityScore}/100, Mobile Usability: ${mobileAnalysis.usabilityScore}/100`); return { desktop: desktopAnalysis, mobile: mobileAnalysis, differences, recommendations }; } catch (error) { console.error('❌ Desktop vs Mobile comparison analysis failed:', error); throw new Error(`Desktop vs Mobile comparison analysis failed: ${error}`); } } async analyzeMobileFriendliness(page, url, includeDesktopComparison = false) { // Extract URL string from URL object if needed const urlString = (typeof url === 'object' && url.loc ? url.loc : url); const startTime = Date.now(); try { // Use already loaded content - navigation is handled by main test flow // Skip navigation completely to preserve page context // Skip viewport changes to preserve page context during comprehensive analysis // Mobile analysis will work with current viewport (analysis is content-based) // Ensure LCP observer is present (buffered) to capture past entries try { await page.evaluate(() => { try { if (!window.__am_lcp_initialized) { window.__am_lcp_initialized = true; window.__am_lcp = 0; const po = new PerformanceObserver((list) => { const entries = list.getEntries(); const last = entries[entries.length - 1]; window.__am_lcp = Math.round(last.startTime); }); // @ts-ignore try { po.observe({ type: 'largest-contentful-paint', buffered: true }); } catch (_) { po.observe({ entryTypes: ['largest-contentful-paint'] }); } } } catch { } }); } catch { } // Give the page a bit more time so LCP can finalize await page.waitForTimeout(5000); // Run parallel analysis const [viewport, typography, touchTargets, navigation, media, performance, forms, ux] = await Promise.all([ this.analyzeViewport(page), this.analyzeTypography(page), this.analyzeTouchTargets(page), this.analyzeNavigation(page), this.analyzeMedia(page), this.analyzeMobilePerformance(page), this.analyzeForms(page), this.analyzeUserExperience(page) ]); // Calculate overall score const overallScore = this.calculateOverallScore({ viewport, typography, touchTargets, navigation, media, performance, forms, ux }); const grade = this.calculateGrade(overallScore); const recommendations = this.generateRecommendations({ viewport, typography, touchTargets, navigation, media, performance, forms, ux }); // NEW: Optional desktop comparison analysis let desktopComparison; if (includeDesktopComparison) { try { console.log('\uD83D\uDDA5\uFE0F Running additional desktop comparison analysis...'); desktopComparison = await this.analyzeDesktopMobileComparison(page, urlString); } catch (error) { console.warn('\u26A0\uFE0F Desktop comparison analysis failed:', error); // Continue without desktop comparison rather than failing entirely } } console.log(`\u2705 Mobile-friendliness analysis completed in ${Date.now() - startTime}ms`); console.log(`\ud83d\udcf1 Mobile Score: ${overallScore}/100 (Grade: ${grade})`); if (desktopComparison) { console.log(`\ud83d\udcca Usability Gap: ${Math.abs(desktopComparison.differences.usabilityGap)} points`); } return { overallScore, grade, viewport, typography, touchTargets, navigation, media, performance, forms, ux, recommendations, desktopComparison }; } catch (error) { console.error('❌ Mobile-friendliness analysis failed:', error); throw new Error(`Mobile-friendliness analysis failed: ${error}`); } } async analyzeViewport(page) { const viewportData = await page.evaluate(() => { const viewport = document.querySelector('meta[name="viewport"]'); const viewportContent = viewport?.getAttribute('content') || ''; // Check if responsive const isResponsive = viewportContent.includes('width=device-width'); // Check for horizontal scroll const hasHorizontalScroll = document.documentElement.scrollWidth > window.innerWidth; // Count breakpoints (simplified - looks for common responsive patterns) const stylesheets = Array.from(document.styleSheets); let breakpointCount = 0; try { stylesheets.forEach(sheet => { if (sheet.href && !sheet.href.includes(window.location.origin)) return; const rules = Array.from(sheet.cssRules || []); breakpointCount += rules.filter(rule => rule.type === CSSRule.MEDIA_RULE).length; }); } catch (e) { // Cross-origin stylesheets or other errors } // Check for safe area insets const computedStyle = window.getComputedStyle(document.documentElement); const hasSafeAreaInsets = computedStyle.paddingTop.includes('env(safe-area-inset') || computedStyle.paddingBottom.includes('env(safe-area-inset'); return { hasViewportTag: !!viewport, viewportContent, isResponsive, hasHorizontalScroll, breakpointCount: Math.min(breakpointCount, 10), // Cap at 10 for scoring hasSafeAreaInsets }; }); // Calculate viewport score let score = 100; if (!viewportData.hasViewportTag) score -= 30; else if (!viewportData.isResponsive) score -= 25; if (viewportData.hasHorizontalScroll) score -= 20; if (viewportData.breakpointCount < 2) score -= 10; if (!viewportData.hasSafeAreaInsets) score -= 5; return { ...viewportData, score: Math.max(0, score) }; } async analyzeTypography(page) { const typographyData = await page.evaluate(() => { const bodyStyle = window.getComputedStyle(document.body); const baseFontSize = parseFloat(bodyStyle.fontSize); const lineHeight = parseFloat(bodyStyle.lineHeight) || baseFontSize * 1.2; // Check line length (characters per line) const textElements = document.querySelectorAll('p, div, span'); let maxLineLength = 0; textElements.forEach(element => { const text = element.textContent || ''; const lines = text.split('\n'); lines.forEach(line => { if (line.length > maxLineLength) { maxLineLength = line.length; } }); }); // Basic contrast check (simplified) const color = bodyStyle.color; const backgroundColor = bodyStyle.backgroundColor; return { baseFontSize, lineHeight: lineHeight / baseFontSize, // Ratio maxLineLength, color, backgroundColor }; }); // Calculate scores const isAccessibleFontSize = typographyData.baseFontSize >= 16; const contrastScore = 85; // Simplified - would need actual contrast calculation let score = 100; if (!isAccessibleFontSize) score -= 20; if (typographyData.lineHeight < 1.4 || typographyData.lineHeight > 1.6) score -= 10; if (typographyData.maxLineLength > 75) score -= 10; if (contrastScore < 70) score -= 15; return { baseFontSize: typographyData.baseFontSize, lineHeight: typographyData.lineHeight, maxLineLength: typographyData.maxLineLength, isAccessibleFontSize, contrastScore, score: Math.max(0, score) }; } async analyzeTouchTargets(page) { const touchTargetData = await page.evaluate(() => { const interactiveSelectors = [ 'button', 'a[href]', 'input[type="button"]', 'input[type="submit"]', '[role="button"]', '[role="link"]', '[tabindex]:not([tabindex="-1"])' ]; const elements = document.querySelectorAll(interactiveSelectors.join(',')); const targets = []; const violations = []; const minSize = 48; // 48px minimum touch target const minSpacing = 8; // 8px minimum spacing elements.forEach((element, index) => { const rect = element.getBoundingClientRect(); const computedStyle = window.getComputedStyle(element); if (rect.width === 0 || rect.height === 0 || computedStyle.display === 'none' || computedStyle.visibility === 'hidden') { return; } const size = Math.min(rect.width, rect.height); const isCompliant = size >= minSize; targets.push({ index, width: rect.width, height: rect.height, size, isCompliant, selector: element.tagName.toLowerCase() + (element.id ? `#${element.id}` : '') }); if (!isCompliant) { violations.push({ selector: element.tagName.toLowerCase() + (element.id ? `#${element.id}` : ''), currentSize: size, requiredSize: minSize, spacing: minSpacing, // Simplified recommendation: `Increase touch target size to at least ${minSize}px` }); } }); const compliantTargets = targets.filter(t => t.isCompliant).length; const averageSize = targets.length > 0 ? targets.reduce((sum, t) => sum + t.size, 0) / targets.length : 0; return { compliantTargets, totalTargets: targets.length, averageTargetSize: averageSize, minimumSpacing: minSpacing, violations }; }); // Calculate score const complianceRate = touchTargetData.totalTargets > 0 ? touchTargetData.compliantTargets / touchTargetData.totalTargets : 1; const score = Math.round(complianceRate * 100); return { ...touchTargetData, score }; } async analyzeNavigation(page) { const navData = await page.evaluate(() => { // Check for sticky header const headers = document.querySelectorAll('header, nav, [role="banner"], [role="navigation"]'); let hasStickyHeader = false; let stickyHeaderHeight = 0; headers.forEach(header => { const style = window.getComputedStyle(header); if (style.position === 'fixed' || style.position === 'sticky') { hasStickyHeader = true; stickyHeaderHeight = Math.max(stickyHeaderHeight, header.getBoundingClientRect().height); } }); // Check navigation accessibility const navElements = document.querySelectorAll('nav, [role="navigation"]'); const hasAccessibleNavigation = navElements.length > 0; // Check keyboard navigation support const focusableElements = document.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); const supportsKeyboardNavigation = focusableElements.length > 0; // Check focus indicators let hasVisibleFocusIndicators = false; focusableElements.forEach(element => { const style = window.getComputedStyle(element, ':focus'); if (style.outline !== 'none' && style.outline !== '0px') { hasVisibleFocusIndicators = true; } }); return { hasStickyHeader, stickyHeaderHeight, hasAccessibleNavigation, supportsKeyboardNavigation, hasVisibleFocusIndicators }; }); // Calculate score let score = 100; if (navData.stickyHeaderHeight > 812 * 0.3) score -= 15; // More than 30% of iPhone 12 Pro screen if (!navData.hasAccessibleNavigation) score -= 10; if (!navData.supportsKeyboardNavigation) score -= 15; if (!navData.hasVisibleFocusIndicators) score -= 10; return { ...navData, score: Math.max(0, score) }; } async analyzeMedia(page) { const mediaData = await page.evaluate(() => { // Check responsive images const images = document.querySelectorAll('img'); const imagesWithSrcset = document.querySelectorAll('img[srcset]'); const hasResponsiveImages = images.length > 0 && imagesWithSrcset.length > 0; // Check modern image formats const modernFormats = Array.from(images).some(img => { const src = img.src || img.getAttribute('data-src') || ''; return src.includes('.webp') || src.includes('.avif'); }); // Check lazy loading const lazyImages = document.querySelectorAll('img[loading="lazy"]'); const hasLazyLoading = lazyImages.length > 0; // Check video optimizations const videos = document.querySelectorAll('video'); const videoOptimizations = { hasPlaysinline: Array.from(videos).some(v => v.hasAttribute('playsinline')), hasPosterImage: Array.from(videos).some(v => v.hasAttribute('poster')), hasSubtitles: Array.from(videos).some(v => v.querySelector('track')), noAutoplayAudio: Array.from(videos).every(v => !v.hasAttribute('autoplay') || v.muted) }; return { hasResponsiveImages, usesModernImageFormats: modernFormats, hasLazyLoading, videoOptimizations, imageCount: images.length, videoCount: videos.length }; }); // Calculate score let score = 100; if (!mediaData.hasResponsiveImages && mediaData.imageCount > 0) score -= 20; if (!mediaData.usesModernImageFormats && mediaData.imageCount > 0) score -= 10; if (!mediaData.hasLazyLoading && mediaData.imageCount > 5) score -= 10; // Video scoring if (mediaData.videoCount > 0) { if (!mediaData.videoOptimizations.hasPlaysinline) score -= 5; if (!mediaData.videoOptimizations.hasPosterImage) score -= 5; if (!mediaData.videoOptimizations.noAutoplayAudio) score -= 15; } return { hasResponsiveImages: mediaData.hasResponsiveImages, usesModernImageFormats: mediaData.usesModernImageFormats, hasLazyLoading: mediaData.hasLazyLoading, videoOptimizations: mediaData.videoOptimizations, score: Math.max(0, score) }; } async analyzeMobilePerformance(page) { // Get performance metrics (enhanced to include FCP and CLS) const performanceMetrics = await page.evaluate(() => { const navigation = performance.getEntriesByType('navigation')[0]; const paintEntries = performance.getEntriesByType('paint'); // Prefer true LCP captured by buffered PerformanceObserver const lcpEntry = window.__am_lcp || 0; const fcp = paintEntries.find(entry => entry.name === 'first-contentful-paint')?.startTime || 0; const lcp = lcpEntry > 0 ? lcpEntry : (fcp > 0 ? Math.round(fcp * 1.3) : 0); // CLS placeholder (full tracking would require layout-shift observer) const cls = 0; const ttfb = navigation?.responseStart - navigation?.requestStart || 0; const domContentLoaded = navigation?.domContentLoadedEventEnd - navigation?.navigationStart || 0; return { lcp, fcp, cls, ttfb, domContentLoaded }; }); // Mobile-specific thresholds (stricter than desktop) const mobileThresholds = { lcp: 2000, // 2s for mobile (vs 2.5s for desktop) fcp: 1500, // 1.5s for mobile (vs 1.8s for desktop) cls: 0.1, // Same as desktop ttfb: 300 // 300ms for mobile (vs 400ms for desktop) }; const isMobileOptimized = performanceMetrics.lcp <= mobileThresholds.lcp && performanceMetrics.fcp <= mobileThresholds.fcp && performanceMetrics.ttfb <= mobileThresholds.ttfb; // Calculate score based on mobile thresholds let score = 100; // LCP scoring (35% weight) if (performanceMetrics.lcp > 0) { if (performanceMetrics.lcp > mobileThresholds.lcp) { score -= Math.min(35, (performanceMetrics.lcp - mobileThresholds.lcp) / 100); } } // FCP scoring (25% weight) if (performanceMetrics.fcp > 0) { if (performanceMetrics.fcp > mobileThresholds.fcp) { score -= Math.min(25, (performanceMetrics.fcp - mobileThresholds.fcp) / 80); } } // TTFB scoring (25% weight) if (performanceMetrics.ttfb > 0) { if (performanceMetrics.ttfb > mobileThresholds.ttfb) { score -= Math.min(25, (performanceMetrics.ttfb - mobileThresholds.ttfb) / 50); } } // CLS scoring (15% weight) - if available if (performanceMetrics.cls > 0) { if (performanceMetrics.cls > mobileThresholds.cls) { score -= Math.min(15, (performanceMetrics.cls - mobileThresholds.cls) * 100); } } return { lcp: performanceMetrics.lcp, fcp: performanceMetrics.fcp, cls: performanceMetrics.cls, ttfb: performanceMetrics.ttfb, isMobileOptimized, score: Math.max(0, Math.round(score)) }; } async analyzeForms(page) { const formData = await page.evaluate(() => { const inputs = document.querySelectorAll('input, select, textarea'); const forms = document.querySelectorAll('form'); // Check input types const hasProperInputTypes = Array.from(inputs).some(input => { const type = input.getAttribute('type'); return ['email', 'tel', 'number', 'url', 'date', 'time'].includes(type || ''); }); // Check autocomplete const hasAutocomplete = Array.from(inputs).some(input => { return input.hasAttribute('autocomplete'); }); // Check label positioning (simplified) const labels = document.querySelectorAll('label'); const labelsAboveFields = labels.length > 0; // Simplified check // Check keyboard accessibility const keyboardFriendly = Array.from(inputs).every(input => { return !input.hasAttribute('readonly') || input.getAttribute('tabindex') !== '-1'; }); return { hasProperInputTypes, hasAutocomplete, labelsAboveFields, keyboardFriendly, inputCount: inputs.length, formCount: forms.length }; }); // Calculate score let score = 100; if (formData.inputCount > 0) { if (!formData.hasProperInputTypes) score -= 15; if (!formData.hasAutocomplete) score -= 10; if (!formData.labelsAboveFields) score -= 10; if (!formData.keyboardFriendly) score -= 15; } return { hasProperInputTypes: formData.hasProperInputTypes, hasAutocomplete: formData.hasAutocomplete, labelsAboveFields: formData.labelsAboveFields, keyboardFriendly: formData.keyboardFriendly, score: Math.max(0, score) }; } async analyzeUserExperience(page) { const uxData = await page.evaluate(() => { // Check for intrusive interstitials/popups const possiblePopups = document.querySelectorAll('[class*="popup"], [class*="modal"], [class*="overlay"], [id*="popup"], [id*="modal"]'); const hasIntrusiveInterstitials = possiblePopups.length > 0; // Check error handling (simplified) const errorElements = document.querySelectorAll('[class*="error"], [id*="error"]'); const hasProperErrorHandling = errorElements.length > 0; // Check offline capability const isOfflineFriendly = 'serviceWorker' in navigator; // Check for layout shift indicators (simplified) const hasCumulativeLayoutShift = document.querySelectorAll('[style*="height: 0"], [style*="width: 0"]').length === 0; return { hasIntrusiveInterstitials, hasProperErrorHandling, isOfflineFriendly, hasCumulativeLayoutShift }; }); // Calculate score let score = 100; if (uxData.hasIntrusiveInterstitials) score -= 20; if (!uxData.hasProperErrorHandling) score -= 10; if (!uxData.isOfflineFriendly) score -= 5; if (!uxData.hasCumulativeLayoutShift) score -= 15; return { ...uxData, score: Math.max(0, score) }; } calculateOverallScore(analyses) { // Debug: Log individual scores to identify NaN sources if (this.options.verbose) { console.log('📊 Mobile component scores for weighted calculation:', { viewport: analyses.viewport.score, typography: analyses.typography.score, touchTargets: analyses.touchTargets.score, navigation: analyses.navigation.score, media: analyses.media.score, performance: analyses.performance.score, forms: analyses.forms.score, ux: analyses.ux.score }); } // Validate all scores are numbers const scores = [ analyses.viewport.score, analyses.typography.score, analyses.touchTargets.score, analyses.navigation.score, analyses.media.score, analyses.performance.score, analyses.forms.score, analyses.ux.score ]; // Check for NaN values and replace with 0 const validatedScores = { viewport: isNaN(analyses.viewport.score) ? 0 : analyses.viewport.score, typography: isNaN(analyses.typography.score) ? 0 : analyses.typography.score, touchTargets: isNaN(analyses.touchTargets.score) ? 0 : analyses.touchTargets.score, navigation: isNaN(analyses.navigation.score) ? 0 : analyses.navigation.score, media: isNaN(analyses.media.score) ? 0 : analyses.media.score, performance: isNaN(analyses.performance.score) ? 0 : analyses.performance.score, forms: isNaN(analyses.forms.score) ? 0 : analyses.forms.score, ux: isNaN(analyses.ux.score) ? 0 : analyses.ux.score }; if (scores.some(score => isNaN(score))) { console.warn('⚠️ Found NaN values in mobile analysis scores:', scores.map((score, i) => ({ component: ['viewport', 'typography', 'touchTargets', 'navigation', 'media', 'performance', 'forms', 'ux'][i], score, isNaN: isNaN(score) }))); } // Weighted scoring const weights = { viewport: 0.20, // 20% - Critical for mobile typography: 0.10, // 10% - Important but not critical touchTargets: 0.15, // 15% - Very important for mobile navigation: 0.10, // 10% - Important for usability media: 0.10, // 10% - Important for performance performance: 0.20, // 20% - Critical for mobile forms: 0.10, // 10% - Important if forms exist ux: 0.05 // 5% - General UX considerations }; const weightedScore = validatedScores.viewport * weights.viewport + validatedScores.typography * weights.typography + validatedScores.touchTargets * weights.touchTargets + validatedScores.navigation * weights.navigation + validatedScores.media * weights.media + validatedScores.performance * weights.performance + validatedScores.forms * weights.forms + validatedScores.ux * weights.ux; if (this.options.verbose) { console.log(`🧮 Mobile weighted calculation: ${weightedScore} (rounded: ${Math.round(weightedScore)})`); } // Ensure we return a valid number between 0 and 100 const finalScore = Math.max(0, Math.min(100, Math.round(weightedScore))); if (isNaN(finalScore)) { console.error('❌ Final mobile score is NaN - returning 0'); return 0; } return finalScore; } calculateGrade(score) { if (score >= 90) return 'A'; if (score >= 80) return 'B'; if (score >= 70) return 'C'; if (score >= 60) return 'D'; return 'F'; } generateRecommendations(analyses) { const recommendations = []; // Viewport recommendations if (!analyses.viewport.hasViewportTag) { recommendations.push({ category: 'Viewport', priority: 'high', issue: 'Missing viewport meta tag', recommendation: 'Add <meta name="viewport" content="width=device-width, initial-scale=1">', impact: 'Critical for mobile responsiveness' }); } if (analyses.viewport.hasHorizontalScroll) { recommendations.push({ category: 'Viewport', priority: 'high', issue: 'Horizontal scrolling detected', recommendation: 'Ensure no elements are wider than the viewport', impact: 'Poor mobile user experience' }); } // Typography recommendations if (!analyses.typography.isAccessibleFontSize) { recommendations.push({ category: 'Typography', priority: 'medium', issue: 'Font size below 16px', recommendation: 'Use minimum 16px base font size for mobile readability', impact: 'Improves text legibility on mobile devices' }); } // Touch target recommendations if (analyses.touchTargets.violations.length > 0) { recommendations.push({ category: 'Touch Targets', priority: 'high', issue: `${analyses.touchTargets.violations.length} touch targets below 48px`, recommendation: 'Increase touch target size to minimum 48x48px or add padding', impact: 'Essential for mobile usability and accessibility' }); } // Performance recommendations if (!analyses.performance.isMobileOptimized) { recommendations.push({ category: 'Performance', priority: 'high', issue: 'Mobile performance thresholds not met', recommendation: 'Optimize for mobile-specific performance budgets (LCP < 2s, TTFB < 300ms)', impact: 'Critical for mobile user experience and SEO' }); } // Media recommendations if (!analyses.media.hasResponsiveImages) { recommendations.push({ category: 'Media', priority: 'medium', issue: 'Images not responsive', recommendation: 'Use srcset and sizes attributes for responsive images', impact: 'Improves performance and visual quality across devices' }); } // Form recommendations if (!analyses.forms.hasProperInputTypes) { recommendations.push({ category: 'Forms', priority: 'medium', issue: 'Input types not optimized for mobile', recommendation: 'Use appropriate input types (email, tel, number, etc.)', impact: 'Better mobile keyboard experience' }); } // UX recommendations if (analyses.ux.hasIntrusiveInterstitials) { recommendations.push({ category: 'User Experience', priority: 'high', issue: 'Intrusive interstitials detected', recommendation: 'Remove or delay popups that block content on mobile', impact: 'Google penalty avoidance and better UX' }); } return recommendations; } /** * Perform device-specific analysis for desktop or mobile */ async performDeviceAnalysis(page, device) { // Get viewport information const viewportInfo = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })); // Analyze touch targets/click targets const targetAnalysis = await page.evaluate((device) => { const interactiveSelectors = [ 'button', 'a[href]', 'input[type="button"]', 'input[type="submit"]', '[role="button"]', '[role="link"]', '[tabindex]:not([tabindex="-1"])' ]; const elements = document.querySelectorAll(interactiveSelectors.join(',')); const targets = []; let compliantCount = 0; const minSize = device === 'mobile' ? 48 : 24; // Mobile needs larger targets elements.forEach(element => { const rect = element.getBoundingClientRect(); const computedStyle = window.getComputedStyle(element); if (rect.width === 0 || rect.height === 0 || computedStyle.display === 'none' || computedStyle.visibility === 'hidden') { return; } const size = Math.min(rect.width, rect.height); if (size >= minSize) compliantCount++; targets.push({ size }); }); const averageSize = targets.length > 0 ? targets.reduce((sum, t) => sum + t.size, 0) / targets.length : 0; return { averageSize, compliantTargets: compliantCount, totalTargets: targets.length }; }, device); // Analyze typography const typographyAnalysis = await page.evaluate(() => { const bodyStyle = window.getComputedStyle(document.body); const baseFontSize = parseFloat(bodyStyle.fontSize); const lineHeight = parseFloat(bodyStyle.lineHeight) || baseFontSize * 1.2; return { baseFontSize, lineHeight: lineHeight / baseFontSize // Ratio }; }); // Analyze navigation const navigationAnalysis = await page.evaluate(() => { const headers = document.querySelectorAll('header, nav, [role="banner"], [role="navigation"]'); let stickyHeaderHeight = 0; headers.forEach(header => { const style = window.getComputedStyle(header); if (style.position === 'fixed' || style.position === 'sticky') { stickyHeaderHeight = Math.max(stickyHeaderHeight, header.getBoundingClientRect().height); } }); // Check focus indicators const focusableElements = document.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); let hasVisibleFocusIndicators = false; focusableElements.forEach(element => { const style = window.getComputedStyle(element, ':focus'); if (style.outline !== 'none' && style.outline !== '0px') { hasVisibleFocusIndicators = true; } }); return { stickyHeaderHeight, hasVisibleFocusIndicators }; }); // Basic performance metrics (using standard navigation timing) const performanceMetrics = await page.evaluate(() => { const navigation = performance.getEntriesByType('navigation')[0]; const paintEntries = performance.getEntriesByType('paint'); // Get FCP and approximate LCP const fcp = paintEntries.find(entry => entry.name === 'first-contentful-paint')?.startTime || 0; const lcp = fcp > 0 ? fcp * 1.3 : navigation?.loadEventEnd * 0.8 || 0; // Fallback approximation return { lcp, ttfb: navigation?.responseStart - navigation?.requestStart || 0, cls: 0 // Simplified - would need more sophisticated measurement }; }); // Calculate device-specific usability score const usabilityScore = this.calculateDeviceUsabilityScore({ device, touchTargets: targetAnalysis, typography: typographyAnalysis, navigation: navigationAnalysis, performance: performanceMetrics }); return { viewport: viewportInfo, touchTargets: targetAnalysis, typography: typographyAnalysis, navigation: navigationAnalysis, performance: performanceMetrics, usabilityScore }; } /** * Calculate device-specific usability score */ calculateDeviceUsabilityScore(data) { let score = 100; const { device, touchTargets, typography, navigation, performance } = data; // Touch target scoring (more critical for mobile) const targetCompliance = touchTargets.totalTargets > 0 ? touchTargets.compliantTargets / touchTargets.totalTargets : 1; if (device === 'mobile') { score -= (1 - targetCompliance) * 30; // 30 points penalty for mobile } else { score -= (1 - targetCompliance) * 15; // 15 points penalty for desktop } // Typography scoring const minFontSize = device === 'mobile' ? 16 : 14; if (typography.baseFontSize < minFontSize) { score -= device === 'mobile' ? 20 : 10; } // Navigation scoring if (device === 'mobile' && navigation.stickyHeaderHeight > 100) { score -= 15; // Sticky header takes too much mobile space } if (!navigation.hasVisibleFocusIndicators) { score -= device === 'desktop' ? 15 : 10; // More important for desktop keyboard users } // Performance scoring (more critical for mobile) if (performance.lcp > (device === 'mobile' ? 2000 : 2500)) { score -= device === 'mobile' ? 20 : 15; } if (performance.ttfb > (device === 'mobile' ? 300 : 400)) { score -= device === 'mobile' ? 15 : 10; } return Math.max(0, Math.round(score)); } /** * Calculate differences between desktop and mobile analysis */ calculateDifferences(desktop, mobile) { const touchTargetSizeDifference = desktop.touchTargets.averageSize - mobile.touchTargets.averageSize; const fontSizeImprovement = mobile.typography.baseFontSize - desktop.typography.baseFontSize; const performanceImpact = mobile.performance.lcp - desktop.performance.lcp; const usabilityGap = desktop.usabilityScore - mobile.usabilityScore; const criticalIssues = []; // Identify critical issues if (Math.abs(usabilityGap) > 20) { criticalIssues.push(`Significant usability gap: ${Math.abs(usabilityGap)} points difference`); } if (touchTargetSizeDifference > 20 && mobile.touchTargets.averageSize < 48) { criticalIssues.push('Touch targets too small for mobile despite being adequate for desktop'); } if (performanceImpact > 1000) { criticalIssues.push('Mobile performance significantly worse than desktop'); } if (mobile.typography.baseFontSize < 16 && desktop.typography.baseFontSize >= 14) { criticalIssues.push('Font size acceptable for desktop but too small for mobile'); } return { touchTargetSizeDifference, fontSizeImprovement, performanceImpact, usabilityGap, criticalIssues }; } /** * Generate comparison-specific recommendations */ generateComparisonRecommendations(desktop, mobile, differences) { const recommendations = []; // Touch target recommendations if (differences.touchTargetSizeDifference > 10 && mobile.touchTargets.averageSize < 48) { recommendations.push({ category: 'touch-targets', priority: 'critical', issue: 'Touch targets work on desktop but are too small for mobile', mobileRecommendation: 'Increase touch target size to minimum 48x48px with 8px spacing', desktopRecommendation: 'Current desktop interaction targets are adequate', impact: 'Critical for mobile usability and accessibility compliance', difficulty: 'medium' }); } // Typography recommendations if (mobile.typography.baseFontSize < 16 && desktop.typography.baseFontSize >= 14) { recommendations.push({ category: 'typography', priority: 'high', issue: 'Font size readable on desktop but too small for mobile', mobileRecommendation: 'Increase base font size to 16px minimum for mobile', desktopRecommendation: 'Consider increasing to 16px for better accessibility', impact: 'Improves readability and reduces eye strain on mobile devices', difficulty: 'easy' }); } // Performance recommendations if (differences.performanceImpact > 500) { recommendations.push({ category: 'performance', priority: 'high', issue: 'Mobile performance significantly worse than desktop', mobileRecommendation: 'Optimize images, reduce JavaScript, implement lazy loading', desktopRecommendation: 'Performance is acceptable but mobile optimization will help desktop too', impact: 'Critical for mobile user experience and SEO rankings', difficulty: 'complex' }); } // Navigation recommendations if (mobile.navigation.stickyHeaderHeight > desktop.viewport.height * 0.15) { recommendations.push({ category: 'navigation', priority: 'medium', issue: 'Sticky header takes up too much mobile screen space', mobileRecommendation: 'Reduce sticky header height or make it collapsible on mobile', desktopRecommendation: 'Desktop header size is appropriate', impact: 'Increases available content area on mobile devices', difficulty: 'medium' }); } // Viewport recommendations if (Math.abs(differences.usabilityGap) > 15) { recommendations.push({ category: 'viewport', priority: 'high', issue: `${differences.usabilityGap > 0 ? 'Mobile' : 'Desktop'} experience significantly worse`, mobileRecommendation: 'Implement responsive design patterns and mobile-first approach', desktopRecommendation: 'Ensure desktop layout adapts well to different screen sizes', impact: 'Provides consistent user experience across all devices', difficulty: 'complex' }); } return recommendations; } } exports.MobileFriendlinessAnalyzer = MobileFriendlinessAnalyzer; //# sourceMappingURL=mobile-friendliness-analyzer.js.map