pub mod module;
pub mod ux_heuristics;
pub use module::MobileModule;
pub use ux_heuristics::{analyze_ux_heuristics, UxHeuristicFinding, UxHeuristics};
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::error::{AuditError, Result};
use crate::taxonomy::Severity;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileFriendliness {
pub score: u32,
pub viewport: ViewportAnalysis,
pub touch_targets: TouchTargetAnalysis,
pub font_sizes: FontSizeAnalysis,
pub content_sizing: ContentSizing,
pub issues: Vec<MobileIssue>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ViewportAnalysis {
pub has_viewport: bool,
pub viewport_content: Option<String>,
pub is_properly_configured: bool,
pub uses_device_width: bool,
pub has_initial_scale: bool,
pub is_scalable: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TouchTargetAnalysis {
pub total_targets: u32,
pub adequate_targets: u32,
pub small_targets: u32,
pub crowded_targets: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub small_by_context: Vec<(String, u32)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub small_target_samples: Vec<SmallTargetSample>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmallTargetSample {
pub selector: String,
pub width: u32,
pub height: u32,
pub context: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FontSizeAnalysis {
pub base_font_size: f32,
pub smallest_font_size: f32,
pub legible_percentage: f32,
pub uses_relative_units: bool,
pub small_interactive_count: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ContentSizing {
pub fits_viewport: bool,
pub has_horizontal_scroll: bool,
pub uses_responsive_images: bool,
pub uses_media_queries: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MobileIssue {
pub category: String,
pub issue_type: String,
pub message: String,
pub severity: Severity,
pub impact: String,
}
fn mobile_score_from_penalty(raw_penalty: u32) -> u32 {
const KNEE: f32 = 60.0;
let p = raw_penalty as f32;
let effective = if p > KNEE {
KNEE + (p - KNEE).sqrt() * 2.0
} else {
p
};
(100.0 - effective).round().clamp(5.0, 100.0) as u32
}
pub async fn analyze_mobile_friendliness(page: &Page) -> Result<MobileFriendliness> {
info!("Analyzing mobile friendliness...");
let js_code = r#"
(() => {
const result = {
viewport: {},
touchTargets: { total: 0, small: 0, crowded: 0 },
fonts: { base: 16, smallest: 16, legibleCount: 0, totalCount: 0 },
content: {}
};
// Viewport analysis
const viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
const content = viewport.getAttribute('content') || '';
result.viewport.content = content;
result.viewport.hasDeviceWidth = content.includes('width=device-width');
result.viewport.hasInitialScale = content.includes('initial-scale=1');
result.viewport.isScalable = !content.includes('user-scalable=no') &&
!content.includes('user-scalable=0');
}
// Touch targets analysis
const interactiveElements = document.querySelectorAll('a, button, input, select, textarea, [onclick], [role="button"]');
result.touchTargets.total = interactiveElements.length;
const smallByContext = {};
const smallTargetDetails = [];
interactiveElements.forEach(el => {
const rect = el.getBoundingClientRect();
// Skip elements that are fully hidden (behind hamburger, display:none, etc.)
if (rect.width === 0 && rect.height === 0) return;
// Skip visually hidden elements (sr-only pattern: clipped to 1×1px)
if (rect.width <= 1 && rect.height <= 1) return;
if (rect.width < 44 || rect.height < 44) {
result.touchTargets.small++;
// Classify context of small target
const tag = el.tagName.toLowerCase();
const parent = el.closest('nav, footer, header, aside, .social, [class*="social"], [class*="lang"]');
let ctx = 'sonstige';
if (parent) {
const pTag = parent.tagName.toLowerCase();
if (pTag === 'nav') ctx = 'navigation';
else if (pTag === 'footer') ctx = 'footer';
else if (pTag === 'header') ctx = 'header';
else if (pTag === 'aside') ctx = 'sidebar';
else ctx = 'social/utility';
} else if (tag === 'input' || tag === 'select' || tag === 'textarea') {
ctx = 'formular';
} else if (tag === 'button' || el.getAttribute('role') === 'button') {
ctx = 'button';
}
smallByContext[ctx] = (smallByContext[ctx] || 0) + 1;
// Capture selector for debugging (up to 10 samples)
if (smallTargetDetails.length < 10) {
let sel = tag;
if (el.id) sel = '#' + el.id;
else if (el.className && typeof el.className === 'string') {
const first = el.className.trim().split(/\s+/)[0];
if (first) sel = tag + '.' + first;
}
smallTargetDetails.push({ selector: sel, width: Math.round(rect.width), height: Math.round(rect.height), context: ctx });
}
}
});
result.touchTargets.smallByContext = smallByContext;
result.touchTargets.details = smallTargetDetails;
// Font analysis
const textElements = document.querySelectorAll('p, span, a, li, td, th, div, h1, h2, h3, h4, h5, h6');
let smallestFont = 100;
textElements.forEach(el => {
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
if (fontSize > 0) {
result.fonts.totalCount++;
if (fontSize >= 12) {
result.fonts.legibleCount++;
}
if (fontSize < smallestFont) {
smallestFont = fontSize;
}
}
});
result.fonts.smallest = smallestFont < 100 ? smallestFont : 16;
result.fonts.base = parseFloat(window.getComputedStyle(document.body).fontSize) || 16;
// Count interactive elements with small font (distinguishes real violations from decorative)
let smallInteractiveCount = 0;
document.querySelectorAll('a, button, label, input, select, textarea').forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
if (fontSize > 0 && fontSize < 12) smallInteractiveCount++;
});
result.fonts.smallInteractiveCount = smallInteractiveCount;
// Content sizing
result.content.viewportWidth = window.innerWidth;
result.content.documentWidth = document.documentElement.scrollWidth;
result.content.hasHorizontalScroll = document.documentElement.scrollWidth > window.innerWidth;
// Check for responsive images
const images = document.querySelectorAll('img');
let responsiveImages = 0;
images.forEach(img => {
if (img.srcset || img.sizes || window.getComputedStyle(img).maxWidth === '100%') {
responsiveImages++;
}
});
result.content.responsiveImages = responsiveImages;
result.content.totalImages = images.length;
// Check for media queries (approximate)
let hasMediaQueries = false;
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type === CSSRule.MEDIA_RULE) {
hasMediaQueries = true;
break;
}
}
} catch (e) {}
if (hasMediaQueries) break;
}
result.content.hasMediaQueries = hasMediaQueries;
// Check for relative font units (em, rem, %, vw)
let usesRelativeUnits = false;
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.style && rule.style.fontSize) {
const fs = rule.style.fontSize;
if (fs.match(/\d+(em|rem|%|vw)/)) {
usesRelativeUnits = true;
break;
}
}
}
} catch (e) {}
if (usesRelativeUnits) break;
}
result.fonts.usesRelativeUnits = usesRelativeUnits;
return JSON.stringify(result);
})()
"#;
let js_result = page
.evaluate(js_code)
.await
.map_err(|e| AuditError::CdpError(format!("Mobile analysis failed: {}", e)))?;
let json_str = js_result.value().and_then(|v| v.as_str()).unwrap_or("{}");
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap_or_default();
let vp = &parsed["viewport"];
let viewport_content = vp["content"].as_str().map(String::from);
let viewport = ViewportAnalysis {
has_viewport: viewport_content.is_some(),
viewport_content: viewport_content.clone(),
uses_device_width: vp["hasDeviceWidth"].as_bool().unwrap_or(false),
has_initial_scale: vp["hasInitialScale"].as_bool().unwrap_or(false),
is_scalable: vp["isScalable"].as_bool().unwrap_or(true),
is_properly_configured: vp["hasDeviceWidth"].as_bool().unwrap_or(false)
&& vp["hasInitialScale"].as_bool().unwrap_or(false),
};
let tt = &parsed["touchTargets"];
let total_targets = tt["total"].as_u64().unwrap_or(0) as u32;
let small_targets = tt["small"].as_u64().unwrap_or(0) as u32;
let small_by_context: Vec<(String, u32)> = tt["smallByContext"]
.as_object()
.map(|obj| {
let mut pairs: Vec<(String, u32)> = obj
.iter()
.map(|(k, v)| (k.clone(), v.as_u64().unwrap_or(0) as u32))
.collect();
pairs.sort_by_key(|b| std::cmp::Reverse(b.1));
pairs
})
.unwrap_or_default();
let small_target_samples: Vec<SmallTargetSample> = tt["details"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|item| {
Some(SmallTargetSample {
selector: item["selector"].as_str()?.to_string(),
width: item["width"].as_u64().unwrap_or(0) as u32,
height: item["height"].as_u64().unwrap_or(0) as u32,
context: item["context"].as_str().unwrap_or("").to_string(),
})
})
.collect()
})
.unwrap_or_default();
let touch_targets = TouchTargetAnalysis {
total_targets,
adequate_targets: total_targets.saturating_sub(small_targets),
small_targets,
crowded_targets: tt["crowded"].as_u64().unwrap_or(0) as u32,
small_by_context,
small_target_samples,
};
let fonts = &parsed["fonts"];
let total_count = fonts["totalCount"].as_u64().unwrap_or(1) as f32;
let legible_count = fonts["legibleCount"].as_u64().unwrap_or(0) as f32;
let font_sizes = FontSizeAnalysis {
base_font_size: fonts["base"].as_f64().unwrap_or(16.0) as f32,
smallest_font_size: fonts["smallest"].as_f64().unwrap_or(16.0) as f32,
legible_percentage: if total_count > 0.0 {
(legible_count / total_count) * 100.0
} else {
100.0
},
uses_relative_units: fonts["usesRelativeUnits"].as_bool().unwrap_or(false),
small_interactive_count: fonts["smallInteractiveCount"].as_u64().unwrap_or(0) as u32,
};
let content = &parsed["content"];
let content_sizing = ContentSizing {
fits_viewport: !content["hasHorizontalScroll"].as_bool().unwrap_or(false),
has_horizontal_scroll: content["hasHorizontalScroll"].as_bool().unwrap_or(false),
uses_responsive_images: content["responsiveImages"].as_u64().unwrap_or(0)
>= content["totalImages"].as_u64().unwrap_or(1) / 2,
uses_media_queries: content["hasMediaQueries"].as_bool().unwrap_or(false),
};
let mut issues = Vec::new();
if !viewport.has_viewport {
issues.push(MobileIssue {
category: "viewport".to_string(),
issue_type: "missing_viewport".to_string(),
message: "Missing viewport meta tag".to_string(),
severity: Severity::Critical,
impact: "Page won't scale properly on mobile devices".to_string(),
});
} else if !viewport.is_properly_configured {
issues.push(MobileIssue {
category: "viewport".to_string(),
issue_type: "improper_viewport".to_string(),
message: "Viewport is not properly configured".to_string(),
severity: Severity::Medium,
impact: "Page may not display correctly on all devices".to_string(),
});
}
if !viewport.is_scalable {
issues.push(MobileIssue {
category: "viewport".to_string(),
issue_type: "not_scalable".to_string(),
message: "Page disables zooming (user-scalable=no)".to_string(),
severity: Severity::Critical,
impact: "Users with visual impairments cannot zoom".to_string(),
});
}
if small_targets > 0 {
let context_detail = if !touch_targets.small_by_context.is_empty() {
let parts: Vec<String> = touch_targets
.small_by_context
.iter()
.map(|(ctx, count)| format!("{} {}", count, ctx))
.collect();
format!(" ({})", parts.join(", "))
} else {
String::new()
};
let sample_detail = if !touch_targets.small_target_samples.is_empty() {
let examples: Vec<String> = touch_targets
.small_target_samples
.iter()
.take(3)
.map(|s| format!("{} ({}×{}px)", s.selector, s.width, s.height))
.collect();
format!(" — e.g. {}", examples.join(", "))
} else {
String::new()
};
let severity = if small_targets >= 20 {
Severity::High
} else if small_targets >= 5 {
Severity::Medium
} else {
Severity::Low
};
issues.push(MobileIssue {
category: "touch_targets".to_string(),
issue_type: "small_targets".to_string(),
message: format!(
"{} touch targets are too small (<44x44px){}{}",
small_targets, context_detail, sample_detail
),
severity,
impact: "Difficult to tap on mobile devices".to_string(),
});
}
if font_sizes.smallest_font_size < 12.0 {
let (severity, impact) = if font_sizes.small_interactive_count > 0 {
(
Severity::Medium,
format!(
"{} interactive elements (links, buttons, labels) use text below 12px — difficult to read on mobile",
font_sizes.small_interactive_count
),
)
} else {
(
Severity::Low,
"Sub-12px text appears to be decorative only (not on interactive elements) — lower risk".to_string(),
)
};
issues.push(MobileIssue {
category: "fonts".to_string(),
issue_type: "small_fonts".to_string(),
message: format!(
"Smallest font size is {:.1}px (recommended: ≥12px)",
font_sizes.smallest_font_size
),
severity,
impact,
});
}
if content_sizing.has_horizontal_scroll {
issues.push(MobileIssue {
category: "content".to_string(),
issue_type: "horizontal_scroll".to_string(),
message: "Page has horizontal scrolling".to_string(),
severity: Severity::High,
impact: "Poor mobile user experience".to_string(),
});
}
let raw_penalty: u32 = issues
.iter()
.map(|issue| match issue.severity {
Severity::Critical => 20,
Severity::High => 20,
Severity::Medium => 10,
Severity::Low => 5,
})
.sum();
let score = mobile_score_from_penalty(raw_penalty);
info!(
"Mobile friendliness: score={}, issues={}",
score,
issues.len()
);
Ok(MobileFriendliness {
score,
viewport,
touch_targets,
font_sizes,
content_sizing,
issues,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_viewport_analysis_default() {
let viewport = ViewportAnalysis::default();
assert!(!viewport.has_viewport);
assert!(!viewport.is_properly_configured);
}
#[test]
fn test_mobile_score_softens_without_collapsing() {
assert_eq!(mobile_score_from_penalty(0), 100);
assert_eq!(mobile_score_from_penalty(20), 80);
assert_eq!(mobile_score_from_penalty(40), 60);
assert_eq!(mobile_score_from_penalty(60), 40);
let p70 = mobile_score_from_penalty(70);
let p90 = mobile_score_from_penalty(90);
let p140 = mobile_score_from_penalty(140);
assert!(p70 < 40 && p70 > p90, "70->{p70} 90->{p90}");
assert!(p90 > p140, "90->{p90} 140->{p140}");
assert!(p140 >= 5, "worst case must not hard-zero: {p140}");
}
}