use serde::{Deserialize, Serialize};
use tracing::info;
use crate::accessibility::AXTree;
use crate::ux::scoring::{dimension_score, saturating_penalty, weighted_average};
const CTA_KEYWORDS_DE: &[&str] = &[
"kaufen",
"bestellen",
"kontakt",
"anfrage",
"starten",
"registrieren",
"anmelden",
"buchen",
"jetzt",
"kostenlos",
"testen",
"demo",
"termin",
"beratung",
"angebot",
"download",
"herunterladen",
"newsletter",
"abonnieren",
"warenkorb",
"kasse",
];
const CTA_KEYWORDS_EN: &[&str] = &[
"buy",
"order",
"contact",
"start",
"register",
"sign up",
"book",
"free",
"trial",
"demo",
"schedule",
"get started",
"download",
"subscribe",
"cart",
"checkout",
"request",
"apply",
];
const GENERIC_LABELS: &[&str] = &[
"mehr",
"hier",
"klicken",
"weiter",
"link",
"more",
"click here",
"read more",
"learn more",
"hier klicken",
"mehr erfahren",
"details",
"weiterlesen",
"info",
];
const TRUST_KEYWORDS: &[&str] = &[
"kontakt",
"impressum",
"datenschutz",
"agb",
"über uns",
"about",
"partner",
"referenz",
"kunden",
"zertifikat",
"auszeichnung",
"bewertung",
"erfahrung",
"garantie",
"sicherheit",
"ssl",
"tüv",
"iso",
"dsgvo",
"privacy",
"terms",
"imprint",
"contact",
];
const W_CTA: f64 = 0.30;
const W_HIERARCHY: f64 = 0.20;
const W_CONTENT: f64 = 0.20;
const W_TRUST: f64 = 0.15;
const W_COGNITIVE: f64 = 0.15;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UxAnalysis {
pub score: u32,
pub grade: String,
pub cta_clarity: UxDimension,
pub visual_hierarchy: UxDimension,
pub content_clarity: UxDimension,
pub trust_signals: UxDimension,
pub cognitive_load: UxDimension,
pub issues: Vec<UxIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UxDimension {
pub name: String,
pub score: u32,
pub weight: f64,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UxIssue {
pub dimension: String,
pub severity: String,
pub problem: String,
pub impact: String,
pub recommendation: String,
}
pub fn analyze_ux(tree: &AXTree) -> UxAnalysis {
info!("Analyzing UX heuristics...");
let mut issues = Vec::new();
let cta_clarity = analyze_cta_clarity(tree, &mut issues);
let visual_hierarchy = analyze_visual_hierarchy(tree, &mut issues);
let content_clarity = analyze_content_clarity(tree, &mut issues);
let trust_signals = analyze_trust_signals(tree, &mut issues);
let cognitive_load = analyze_cognitive_load(tree, &mut issues);
let score = weighted_average(&[
(cta_clarity.score, W_CTA),
(visual_hierarchy.score, W_HIERARCHY),
(content_clarity.score, W_CONTENT),
(trust_signals.score, W_TRUST),
(cognitive_load.score, W_COGNITIVE),
]);
let grade = match score {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
60..=69 => "D",
_ => "F",
}
.to_string();
info!("UX analysis: score={}, issues={}", score, issues.len());
UxAnalysis {
score,
grade,
cta_clarity,
visual_hierarchy,
content_clarity,
trust_signals,
cognitive_load,
issues,
}
}
fn analyze_cta_clarity(tree: &AXTree, issues: &mut Vec<UxIssue>) -> UxDimension {
let buttons = tree.nodes_with_role("button");
let links = tree.links();
let mut cta_count = 0;
let mut generic_count = 0;
let mut primary_found = false;
for node in buttons.iter().chain(links.iter()) {
let name = node.name.as_deref().unwrap_or("").to_lowercase();
if name.is_empty() {
continue;
}
let is_cta = CTA_KEYWORDS_DE
.iter()
.chain(CTA_KEYWORDS_EN.iter())
.any(|kw| name.contains(kw));
let is_generic = GENERIC_LABELS
.iter()
.any(|g| name.trim() == *g || name.starts_with(g));
if is_cta {
cta_count += 1;
primary_found = true;
}
if is_generic {
generic_count += 1;
}
}
let mut penalties = Vec::new();
if !primary_found {
penalties.push(45.0);
issues.push(UxIssue {
dimension: "CTA Clarity".into(),
severity: "high".into(),
problem: "Kein erkennbarer Call-to-Action gefunden".into(),
impact: "Nutzer wissen nicht, was der nächste Schritt ist".into(),
recommendation: "Primären CTA klar hervorheben und eindeutig benennen".into(),
});
} else if cta_count > 5 {
let p = saturating_penalty((cta_count - 5) as f64, 15.0, 5.0);
penalties.push(p);
issues.push(UxIssue {
dimension: "CTA Clarity".into(),
severity: "medium".into(),
problem: format!("{} konkurrierende Call-to-Actions gefunden", cta_count),
impact: "Zu viele gleichwertige Handlungsaufforderungen verwirren Nutzer".into(),
recommendation: "Einen primären CTA priorisieren, sekundäre visuell zurücknehmen"
.into(),
});
}
if generic_count > 0 {
let p = saturating_penalty(generic_count as f64, 20.0, 5.0);
penalties.push(p);
if generic_count >= 3 {
issues.push(UxIssue {
dimension: "CTA Clarity".into(),
severity: "medium".into(),
problem: format!(
"{} generische Linktexte (\"mehr\", \"hier\", \"klicken\")",
generic_count
),
impact: "Nutzer können Ziele nicht unterscheiden".into(),
recommendation: "Links mit beschreibenden Texten versehen, die das Ziel benennen"
.into(),
});
}
}
let score = dimension_score(&penalties, 100.0);
let summary = if score >= 85 {
"Call-to-Actions sind klar und verständlich".into()
} else if score >= 60 {
"CTAs vorhanden, aber teilweise unklar oder konkurrierend".into()
} else {
"CTAs fehlen oder sind nicht erkennbar".into()
};
UxDimension {
name: "CTA Clarity".into(),
score,
weight: W_CTA,
summary,
}
}
fn analyze_visual_hierarchy(tree: &AXTree, issues: &mut Vec<UxIssue>) -> UxDimension {
let headings = tree.headings();
let mut penalties = Vec::new();
let h1_count = headings
.iter()
.filter(|h| h.heading_level() == Some(1))
.count();
if h1_count == 0 {
penalties.push(40.0);
issues.push(UxIssue {
dimension: "Visual Hierarchy".into(),
severity: "high".into(),
problem: "Keine H1-Überschrift vorhanden".into(),
impact: "Seitenthema ist für Nutzer und Suchmaschinen nicht erkennbar".into(),
recommendation: "Genau eine H1-Überschrift mit dem Hauptthema der Seite setzen".into(),
});
} else if h1_count > 1 {
penalties.push(15.0);
issues.push(UxIssue {
dimension: "Visual Hierarchy".into(),
severity: "medium".into(),
problem: format!("{} H1-Überschriften gefunden", h1_count),
impact: "Seite hat keinen klaren Hauptfokus".into(),
recommendation: "Nur eine H1-Überschrift pro Seite verwenden".into(),
});
}
let mut last_level: u8 = 0;
let mut skip_count = 0;
for h in &headings {
if let Some(level) = h.heading_level() {
if last_level > 0 && level > last_level + 1 {
skip_count += 1;
}
last_level = level;
}
}
if skip_count > 0 {
let p = saturating_penalty(skip_count as f64, 30.0, 3.0);
penalties.push(p);
if skip_count >= 2 {
issues.push(UxIssue {
dimension: "Visual Hierarchy".into(),
severity: "medium".into(),
problem: format!(
"Heading-Hierarchie {} mal übersprungen (z. B. H2 → H4)",
skip_count
),
impact: "Seitenstruktur ist für Screenreader und Nutzer unklar".into(),
recommendation: "Heading-Ebenen lückenlos aufbauen (H1 → H2 → H3)".into(),
});
}
}
let dom_size = tree.len();
if dom_size > 2000 {
let excess = (dom_size - 2000) as f64;
let p = saturating_penalty(excess, 20.0, 2000.0);
penalties.push(p);
if dom_size > 4000 {
issues.push(UxIssue {
dimension: "Visual Hierarchy".into(),
severity: "low".into(),
problem: format!("Sehr großer DOM mit {} Knoten", dom_size),
impact: "Hohe visuelle Komplexität kann Nutzer überfordern".into(),
recommendation: "Seitenstruktur vereinfachen, weniger verschachtelte Elemente"
.into(),
});
}
}
let score = dimension_score(&penalties, 100.0);
let summary = if score >= 85 {
"Klare visuelle Hierarchie mit logischer Heading-Struktur".into()
} else if score >= 60 {
"Grundstruktur vorhanden, aber Heading-Hierarchie lückenhaft".into()
} else {
"Schwache visuelle Struktur — Seitenfokus nicht erkennbar".into()
};
UxDimension {
name: "Visual Hierarchy".into(),
score,
weight: W_HIERARCHY,
summary,
}
}
fn analyze_content_clarity(tree: &AXTree, issues: &mut Vec<UxIssue>) -> UxDimension {
let headings = tree.headings();
let mut penalties = Vec::new();
let mut total_text_len = 0usize;
let mut _text_node_count = 0usize;
for node in tree.iter() {
if let Some(role) = node.role.as_deref() {
if matches!(
role,
"StaticText" | "paragraph" | "listitem" | "cell" | "heading"
) {
if let Some(name) = &node.name {
total_text_len += name.len();
_text_node_count += 1;
}
}
}
}
let word_count = total_text_len / 6;
if word_count < 50 {
penalties.push(40.0);
issues.push(UxIssue {
dimension: "Content Clarity".into(),
severity: "high".into(),
problem: format!(
"Sehr wenig Textinhalt im Accessibility Tree (~{} Wörter, ohne rein visuelle oder nicht zugängliche Inhalte)",
word_count
),
impact: "Nutzer erhalten nicht genügend Information für eine Entscheidung".into(),
recommendation: "Relevanten Inhalt ergänzen, der den Seitenzweck klar vermittelt"
.into(),
});
} else if word_count < 100 {
penalties.push(20.0);
}
if word_count > 200 && headings.len() < 3 {
penalties.push(25.0);
issues.push(UxIssue {
dimension: "Content Clarity".into(),
severity: "medium".into(),
problem: "Viel Text ohne ausreichende Zwischenüberschriften".into(),
impact: "Nutzer können Inhalte nicht scannen und finden relevante Stellen nicht".into(),
recommendation: "Text mit Zwischenüberschriften (H2, H3) gliedern".into(),
});
}
if word_count > 1000 && headings.len() < 5 {
penalties.push(15.0);
}
let score = dimension_score(&penalties, 100.0);
let summary = if score >= 85 {
"Inhalte sind klar strukturiert und in angemessenem Umfang vorhanden".into()
} else if score >= 60 {
"Inhalte vorhanden, aber Struktur oder Umfang verbesserungswürdig".into()
} else {
"Unzureichende Inhalte oder fehlende Textstruktur".into()
};
UxDimension {
name: "Content Clarity".into(),
score,
weight: W_CONTENT,
summary,
}
}
fn analyze_trust_signals(tree: &AXTree, issues: &mut Vec<UxIssue>) -> UxDimension {
let links = tree.links();
let mut penalties = Vec::new();
let mut contact_found = false;
let mut impressum_found = false;
let mut privacy_found = false;
let mut trust_keyword_count = 0;
for node in links.iter() {
let name = node.name.as_deref().unwrap_or("").to_lowercase();
if name.is_empty() {
continue;
}
if name.contains("kontakt") || name.contains("contact") {
contact_found = true;
}
if name.contains("impressum") || name.contains("imprint") {
impressum_found = true;
}
if name.contains("datenschutz") || name.contains("privacy") {
privacy_found = true;
}
if TRUST_KEYWORDS.iter().any(|kw| name.contains(kw)) {
trust_keyword_count += 1;
}
}
if !contact_found {
for node in tree.iter() {
if matches!(
node.role.as_deref(),
Some("heading" | "StaticText" | "paragraph")
) {
let name = node.name.as_deref().unwrap_or("").to_lowercase();
if name.contains("kontakt") || name.contains("contact") {
contact_found = true;
break;
}
}
}
}
if !contact_found {
penalties.push(30.0);
issues.push(UxIssue {
dimension: "Trust Signals".into(),
severity: "high".into(),
problem: "Kein Kontakt-Link erkennbar".into(),
impact: "Kein Kontakt-Link auf dieser Seite erkennbar (heuristisch — Kontakt könnte bewusst ausgelagert sein).".into(),
recommendation: "Kontaktseite oder Kontaktinformationen gut sichtbar verlinken".into(),
});
}
if !impressum_found {
penalties.push(20.0);
issues.push(UxIssue {
dimension: "Trust Signals".into(),
severity: "medium".into(),
problem: "Kein Impressum-Link erkennbar".into(),
impact: "Rechtlich erforderlich in DACH — signalisiert mangelnde Seriosität".into(),
recommendation: "Impressum im Footer verlinken".into(),
});
}
if !privacy_found {
penalties.push(15.0);
issues.push(UxIssue {
dimension: "Trust Signals".into(),
severity: "medium".into(),
problem: "Kein Datenschutz-Link erkennbar".into(),
impact: "DSGVO-Pflicht, stärkt Nutzervertrauen".into(),
recommendation: "Datenschutzerklärung im Footer verlinken".into(),
});
}
if trust_keyword_count < 3 {
penalties.push(15.0);
}
let score = dimension_score(&penalties, 100.0);
let summary = if score >= 85 {
"Vertrauenssignale vorhanden (Kontakt, Impressum, Datenschutz)".into()
} else if score >= 60 {
"Grundlegende Vertrauenssignale teilweise vorhanden".into()
} else {
"Wichtige Vertrauenssignale fehlen".into()
};
UxDimension {
name: "Trust Signals".into(),
score,
weight: W_TRUST,
summary,
}
}
fn analyze_cognitive_load(tree: &AXTree, issues: &mut Vec<UxIssue>) -> UxDimension {
let link_count = tree.links().len();
let interactive_count = tree.iter().filter(|n| n.is_interactive()).count();
let dom_size = tree.len();
let mut penalties = Vec::new();
if link_count > 40 {
let excess = (link_count - 40) as f64;
let p = saturating_penalty(excess, 30.0, 80.0);
penalties.push(p);
if link_count > 80 {
issues.push(UxIssue {
dimension: "Cognitive Load".into(),
severity: "medium".into(),
problem: format!("{} Links auf der Seite", link_count),
impact: "Hohe Linkdichte überfordert Nutzer bei der Orientierung".into(),
recommendation: "Navigation vereinfachen, Links priorisieren und gruppieren".into(),
});
}
}
if interactive_count > 50 {
let excess = (interactive_count - 50) as f64;
let p = saturating_penalty(excess, 25.0, 50.0);
penalties.push(p);
if interactive_count > 100 {
issues.push(UxIssue {
dimension: "Cognitive Load".into(),
severity: "medium".into(),
problem: format!("{} interaktive Elemente auf der Seite", interactive_count),
impact: "Zu viele Interaktionsmöglichkeiten erschweren die Orientierung".into(),
recommendation: "Interaktive Elemente reduzieren oder in Abschnitte gruppieren"
.into(),
});
}
}
if dom_size > 1500 {
let excess = (dom_size - 1500) as f64;
let p = saturating_penalty(excess, 20.0, 1000.0);
penalties.push(p);
}
let score = dimension_score(&penalties, 100.0);
let summary = if score >= 85 {
"Angemessene Komplexität — Seite ist übersichtlich".into()
} else if score >= 60 {
"Leicht erhöhte Komplexität — Navigation noch handhabbar".into()
} else {
"Hohe Komplexität — Seite wirkt überladen".into()
};
UxDimension {
name: "Cognitive Load".into(),
score,
weight: W_COGNITIVE,
summary,
}
}