use renderreport::components::advanced::{
ChecklistPanel, ChecklistRow, DevicePreview, DiagnosisPanel, DiagnosisRow, Divider, List,
MetricStrip, MetricStripItem, PageBreak, RecommendationCard, SectionHeaderSplit,
};
use renderreport::components::charts::{Chart, ChartType};
use renderreport::components::text::Label;
use renderreport::components::{AuditTable, CardDashboard, DashboardCard, Finding, TableColumn};
use renderreport::prelude::*;
use super::design;
use super::appendix::build_cli_snapshot_table;
use super::bik_guide::render_bik_guide_annex;
use super::detail_modules::{
mobile_category_label, render_a11y_journey_findings, render_ai_transparency,
render_ai_visibility, render_best_practices, render_budget_violations, render_commerce,
render_content_visibility, render_dark_mode, render_design_quality, render_html_conform,
render_journey, render_mobile, render_network_dns, render_performance,
render_screen_reader_section, render_search_experience, render_security, render_seo,
render_source_quality, render_tech_stack, render_ux, score_band_label,
};
use super::diagnosis::render_diagnosis_section;
use super::en301549::render_en301549_annex;
use super::findings::render_finding_technical;
use super::helpers::{
manual_recheck_instruction, map_severity, priority_label_i18n, role_label_i18n,
};
use super::wcag_coverage::render_wcag_coverage_section;
use crate::audit::AuditReport;
use crate::cli::{AnnexKind, ReportLevel};
use crate::i18n::I18n;
use crate::output::module::active_report_modules;
use crate::output::report_model::*;
pub(super) fn render_part_divider(
mut builder: renderreport::engine::ReportBuilder,
part_num: u8,
title: &str,
intro: &str,
audience_title: &str,
audience_body: &str,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let eyebrow = if en {
if part_num == 3 {
"PART 3 OF 3 · TECHNICAL APPENDIX".to_string()
} else {
format!("PART {} OF 3", part_num)
}
} else if part_num == 3 {
"TEIL 3 VON 3 · TECHNISCHER ANHANG".to_string()
} else {
format!("TEIL {} VON 3", part_num)
};
if part_num > 1 {
builder = builder.add_component(PageBreak::new());
}
builder = builder
.add_component(
Label::new(format!("{:02}", part_num))
.with_size("72pt")
.bold()
.with_color(design::tokens::MUTED),
)
.add_component(
SectionHeaderSplit::new(title, intro)
.with_eyebrow(eyebrow)
.with_level(1),
)
.add_component(
Label::new(format!("{}: {}", audience_title, audience_body))
.with_size("10.5pt")
.with_color(design::tokens::NEUTRAL),
);
if part_num == 3 {
builder = builder
.add_component(Divider::thick().with_color(design::tokens::MUTED))
.add_component(PageBreak::new());
}
builder
}
fn build_severity_counter_strip(vm: &ReportViewModel, i18n: &I18n) -> MetricStrip {
let en = i18n.locale() == "en";
let count_accent = |n: u32, hue: &'static str| {
if n > 0 {
hue
} else {
design::tokens::SUCCESS
}
};
let items = vec![
MetricStripItem::new(
if en {
"WCAG occurrences"
} else {
"WCAG-Vorkommen"
},
vm.severity.total.to_string(),
)
.with_accent(design::tokens::INK),
MetricStripItem::new(
if en { "Critical" } else { "Kritisch" },
vm.severity.critical.to_string(),
)
.with_accent(count_accent(vm.severity.critical, design::tokens::DANGER)),
MetricStripItem::new(
if en { "High" } else { "Hoch" },
vm.severity.high.to_string(),
)
.with_accent(count_accent(vm.severity.high, design::tokens::DANGER)),
MetricStripItem::new(
if en { "Medium" } else { "Mittel" },
vm.severity.medium.to_string(),
)
.with_accent(count_accent(vm.severity.medium, design::tokens::WARN_DEEP)),
];
MetricStrip::new(items).compact()
}
fn module_dashboard_card(module: &ModuleScore, i18n: &I18n) -> DashboardCard {
DashboardCard {
name: super::helpers::module_name_with_taxonomy_suffix(
&module.name,
&module.measurement_type,
i18n,
),
score: module.score,
interpretation: if module.interpretation.is_empty() {
format!("{}/100", module.score)
} else {
module.interpretation.clone()
},
good_threshold: 75,
warn_threshold: 40,
}
}
fn render_module_split_dashboards(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let indicator_modules: Vec<&ModuleScore> = vm
.modules
.dashboard
.iter()
.filter(|m| m.measurement_type != "measured")
.collect();
let strong: Vec<DashboardCard> = indicator_modules
.iter()
.filter(|m| m.score >= 75)
.map(|m| module_dashboard_card(m, i18n))
.collect();
let weak: Vec<DashboardCard> = indicator_modules
.iter()
.filter(|m| m.score < 75)
.map(|m| module_dashboard_card(m, i18n))
.collect();
if !strong.is_empty() {
builder = builder.add_component(CardDashboard::new(strong).with_title(if en {
"What works particularly well"
} else {
"Was besonders gut funktioniert"
}));
}
if !weak.is_empty() {
builder = builder.add_component(CardDashboard::new(weak).with_title(if en {
"Where optimization pays off"
} else {
"Wo sich Optimierung lohnt"
}));
} else if !indicator_modules.is_empty() {
let risk_gated = matches!(
vm.summary.certificate.as_str(),
"EINGESCHRÄNKT" | "NICHT BESTANDEN"
);
let message = if risk_gated {
if en {
"The additional indicator modules (UX, Journey, …) are in good shape, but operability/legal risk elsewhere in this report still requires action — see Management Summary."
} else {
"Die zusätzlichen Indikator-Module (UX, Journey, …) sind in gutem Zustand, dennoch besteht an anderer Stelle in diesem Report Bedienbarkeits- oder rechtliches Risiko, das Handeln erfordert — siehe Management-Zusammenfassung."
}
} else if en {
"The additional indicator modules (UX, Journey, …) are in good shape — no module needs prioritized optimization."
} else {
"Die zusätzlichen Indikator-Module (UX, Journey, …) sind in gutem Zustand — kein Modul erfordert vorrangige Optimierung."
};
builder = builder.add_component(Label::new(message).with_size("10.5pt").with_color(
if risk_gated {
design::tokens::WARN_DEEP
} else {
design::tokens::SUCCESS
},
));
}
builder
}
struct DimensionRow {
label: String,
description: String,
status: &'static str,
}
#[allow(clippy::too_many_arguments)]
fn compute_dimension_rows(
severity_critical: u32,
severity_high: u32,
cover_critical_issues: u32,
summary_score: u32,
seo_score: u32,
perf_score: u32,
mobile_score: u32,
en: bool,
) -> Vec<DimensionRow> {
let mut rows = Vec::with_capacity(5);
let user_desc = if severity_critical > 0 || severity_high > 0 {
if en {
"Screen reader and keyboard navigation blocked at critical points."
} else {
"Screenreader und Tastaturnavigation an kritischen Stellen blockiert oder beeinträchtigt."
}
} else {
if en {
"No major usability limitations detected."
} else {
"Keine wesentlichen Einschränkungen der Nutzbarkeit erkannt."
}
};
let user_status = if severity_critical > 0 {
"bad"
} else if severity_high > 0 {
"warn"
} else {
"good"
};
rows.push(DimensionRow {
label: if en { "Usability" } else { "Nutzbarkeit" }.to_string(),
description: user_desc.to_string(),
status: user_status,
});
let legal_desc = if cover_critical_issues > 0 {
if en {
"BFSG-relevant violations found (WCAG Level A/AA) — resolution recommended."
} else {
"BFSG-relevante Verstöße (WCAG Level A/AA) gefunden — Behebung empfohlen."
}
} else {
if en {
"No BFSG-relevant violations detected within the automated scope."
} else {
"Im automatisierten Prüfumfang keine BFSG-relevanten Verstöße erkannt."
}
};
let legal_status = if cover_critical_issues > 0 {
"bad"
} else {
"good"
};
rows.push(DimensionRow {
label: if en {
"BFSG-relevant technical criteria"
} else {
"BFSG-relevante technische Kriterien"
}
.to_string(),
description: legal_desc.to_string(),
status: legal_status,
});
let business_desc = if summary_score < 60 {
if en {
"High risk of process abandonment and conversion loss."
} else {
"Hohes Risiko von Prozessabbrüchen und Conversion-Verlusten."
}
} else if summary_score < 80 {
if en {
"Usability hurdles may reduce conversion rate."
} else {
"Nutzungshürden können Conversion-Rate reduzieren."
}
} else {
if en {
"Low business risk, minor optimizations recommended."
} else {
"Geringes geschäftliches Risiko, kleine Optimierungen empfohlen."
}
};
let business_status = if summary_score < 60 {
"bad"
} else if summary_score < 80 {
"warn"
} else {
"good"
};
rows.push(DimensionRow {
label: if en {
"Conversion & Business Risk"
} else {
"Conversion & Business-Risiko"
}
.to_string(),
description: business_desc.to_string(),
status: business_status,
});
let seo_desc = if seo_score < 70 {
if en {
"Missing metadata or structured schemas reduce search engine and AI crawlability."
} else {
"Fehlende Metadaten oder strukturierte Daten behindern Google- und KI-Crawler."
}
} else if seo_score < 90 {
if en {
"Some optimization potential for search engines and AI agents."
} else {
"Optimierungspotenzial für Suchmaschinen und KI-Agenten vorhanden."
}
} else {
if en {
"Excellent discoverability and schema definition."
} else {
"Sehr gute Auffindbarkeit und strukturierte Schema-Daten."
}
};
let seo_status = if seo_score < 70 {
"bad"
} else if seo_score < 90 {
"warn"
} else {
"good"
};
rows.push(DimensionRow {
label: if en {
"SEO & AI Visibility"
} else {
"SEO & KI-Sichtbarkeit"
}
.to_string(),
description: seo_desc.to_string(),
status: seo_status,
});
let speed_desc = if perf_score < 60 || mobile_score < 60 {
if en {
"High loading latency or touch target layout issues frustrate mobile visitors."
} else {
"Hohe Ladezeiten oder Touch-Target-Mängel frustrieren mobile Besucher."
}
} else if perf_score < 85 || mobile_score < 85 {
if en {
"Moderate performance bottlenecks on mobile networks."
} else {
"Mäßige Performance-Verzögerungen im Mobilfunknetz."
}
} else {
if en {
"Fast load speed and optimal responsive viewport."
} else {
"Schnelle Ladezeit und optimale responsive Darstellung."
}
};
let speed_status = if perf_score < 60 || mobile_score < 60 {
"bad"
} else if perf_score < 85 || mobile_score < 85 {
"warn"
} else {
"good"
};
rows.push(DimensionRow {
label: if en {
"Performance & Mobile"
} else {
"Ladezeit & Mobilfreundlichkeit"
}
.to_string(),
description: speed_desc.to_string(),
status: speed_status,
});
rows
}
pub(super) fn render_risks_and_strengths(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let module_score = |name: &str| -> u32 {
vm.modules
.module_scores
.iter()
.find(|m| m.name == name)
.map(|m| m.score)
.unwrap_or(100)
};
let seo_score = module_score("SEO");
let perf_score = module_score("Performance");
let mobile_score = module_score("Mobile");
let dimensions = compute_dimension_rows(
vm.severity.critical,
vm.severity.high,
vm.cover.critical_issues,
vm.summary.score,
seo_score,
perf_score,
mobile_score,
en,
);
let (mut risks, strengths): (Vec<DimensionRow>, Vec<DimensionRow>) =
dimensions.into_iter().partition(|d| d.status != "good");
risks.sort_by_key(|d| if d.status == "bad" { 0u8 } else { 1u8 });
if risks.is_empty() {
let (automated, total) = crate::wcag::coverage::coverage_stats();
let empty_state = if en {
format!(
"No priority risks identified within the automated audit scope. This result applies to the automated audit scope only ({automated} of about {total} testable WCAG 2.1 AA criteria); criteria requiring manual review are listed in the appendix."
)
} else {
format!(
"Keine prioritären Risiken im automatisierten Prüfumfang erkannt. Diese Einschätzung bezieht sich ausschließlich auf den automatisierten Prüfumfang ({automated} von ca. {total} testbaren WCAG-2.1-AA-Kriterien); Kriterien mit manuellem Prüfbedarf sind im Anhang aufgeführt."
)
};
builder = builder.add_component(Callout::success(&empty_state).with_title(if en {
"Key Risks"
} else {
"Wichtigste Risiken"
}));
} else {
let title = if en {
format!("Key Risks ({} identified)", risks.len())
} else {
format!("Wichtigste Risiken ({} erkannt)", risks.len())
};
let rows: Vec<ChecklistRow> = risks
.into_iter()
.map(|d| ChecklistRow::new(d.label, d.description).with_status(d.status))
.collect();
builder = builder.add_component(ChecklistPanel::new(rows).with_title(title));
}
if !strengths.is_empty() {
let rows: Vec<ChecklistRow> = strengths
.into_iter()
.map(|d| ChecklistRow::new(d.label, d.description).with_status("good"))
.collect();
builder = builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Strengths within the audited scope"
} else {
"Stärken im geprüften Umfang"
}));
}
builder
}
struct TopMeasure {
module: String,
title: String,
detail: String,
}
impl TopMeasure {
fn render(&self, index: usize, extra: usize, en: bool) -> String {
let more = match (extra, en) {
(0, _) => String::new(),
(n, true) => format!(" (+{n} more of the same kind)"),
(n, false) => format!(" (+{n} weitere gleicher Art)"),
};
if self.title.is_empty() {
format!("{}. {} — {}{more}", index, self.module, self.detail)
} else {
format!(
"{}. {} — {}: {}{more}",
index, self.module, self.title, self.detail
)
}
}
}
fn cross_module_measures(vm: &ReportViewModel, i18n: &I18n) -> Vec<(TopMeasure, usize)> {
use crate::wcag::Severity;
let details = &vm.module_details;
let module_label = |key: &str| -> String {
let translated = i18n.t(&format!("module-{key}"));
if translated == format!("module-{key}") {
key.to_string()
} else {
translated
}
};
let cost_of = |name: &str| -> u32 {
vm.modules
.module_scores
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name) && m.contributes_to_overall)
.map(|m| (100u32.saturating_sub(m.score)) * m.weight_pct)
.unwrap_or(0)
};
let severity_rank = |s: Severity| match s {
Severity::Critical => 0u8,
Severity::High => 1,
Severity::Medium => 2,
Severity::Low => 3,
};
let mut ranked: Vec<(u8, u32, TopMeasure)> = Vec::new();
if let Some(sec) = details.security.as_ref() {
let cost = cost_of("Security");
for (title, severity, message) in &sec.issues {
ranked.push((
severity_rank(*severity),
cost,
TopMeasure {
module: module_label("security"),
title: title.clone(),
detail: message.clone(),
},
));
}
}
if let Some(mobile) = details.mobile.as_ref() {
let cost = cost_of("Mobile");
for (category, severity, message) in &mobile.issues {
ranked.push((
severity_rank(*severity),
cost,
TopMeasure {
module: module_label("mobile"),
title: mobile_category_label(category, i18n),
detail: message.clone(),
},
));
}
}
if let Some(seo) = details.seo.as_ref() {
let cost = cost_of("SEO");
for (title, severity, message) in &seo.meta_issues {
ranked.push((
severity_rank(*severity),
cost,
TopMeasure {
module: module_label("seo"),
title: title.clone(),
detail: message.clone(),
},
));
}
for (_issue_type, message, _severity_label) in &seo.technical_issues {
ranked.push((
severity_rank(Severity::Medium),
cost,
TopMeasure {
module: module_label("seo"),
title: String::new(),
detail: message.clone(),
},
));
}
}
if let Some(perf) = details.performance.as_ref() {
let cost = cost_of("Performance");
for recommendation in &perf.recommendations {
ranked.push((
severity_rank(Severity::Medium),
cost,
TopMeasure {
module: module_label("performance"),
title: String::new(),
detail: recommendation.clone(),
},
));
}
}
ranked.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
let mut collapsed: Vec<(TopMeasure, usize)> = Vec::new();
for (_, _, measure) in ranked {
let duplicate = !measure.title.is_empty()
&& collapsed
.iter()
.any(|(kept, _)| kept.module == measure.module && kept.title == measure.title);
if duplicate {
if let Some(entry) = collapsed
.iter_mut()
.find(|(kept, _)| kept.module == measure.module && kept.title == measure.title)
{
entry.1 += 1;
}
} else {
collapsed.push((measure, 0));
}
}
collapsed
}
pub(super) fn build_top_measures_list(vm: &ReportViewModel, i18n: &I18n) -> List {
let en = i18n.locale() == "en";
let list_title = if en {
"5 Key Measures"
} else {
"Die 5 wichtigsten Maßnahmen"
};
let mut list = List::new().with_title(list_title);
let accessibility = module_label_accessibility(i18n);
let mut measures: Vec<(TopMeasure, usize)> = vm
.findings
.top_findings
.iter()
.take(5)
.map(|group| {
(
TopMeasure {
module: accessibility.clone(),
title: group.title.clone(),
detail: group.recommendation.clone(),
},
0,
)
})
.collect();
if measures.len() < 5 {
let missing = 5 - measures.len();
measures.extend(cross_module_measures(vm, i18n).into_iter().take(missing));
}
if measures.is_empty() {
let no_measures = if en {
"No urgent actions required."
} else {
"Keine dringenden Maßnahmen erforderlich."
};
list = list.add_item(no_measures);
return list;
}
for (idx, (measure, extra)) in measures.iter().enumerate() {
list = list.add_item(measure.render(idx + 1, *extra, en));
}
list
}
fn module_label_accessibility(i18n: &I18n) -> String {
let translated = i18n.t("module-accessibility");
if translated == "module-accessibility" {
"Accessibility".to_string()
} else {
translated
}
}
pub(super) fn audit_scope_line(en: bool) -> String {
if en {
"Audited: 1 URL · Desktop and Mobile · no website crawl".to_string()
} else {
"Geprüft: 1 URL · Desktop und Mobile · kein Website-Crawl".to_string()
}
}
pub(super) fn render_management_page(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let mgt_title = if en {
"Management Summary"
} else {
"Management-Sicht"
};
let mgt_subtitle = if en {
"Overall status, key risks, prioritized actions, and leverage at a glance."
} else {
"Gesamtstatus, Hauptrisiken, wichtigste Maßnahmen und Hebel auf einen Blick."
};
builder = builder.add_component(
SectionHeaderSplit::new(mgt_title, mgt_subtitle)
.with_eyebrow(if en {
"MANAGEMENT SUMMARY"
} else {
"MANAGEMENT-SUMMARY"
})
.with_level(1),
);
builder = builder.add_component(
Label::new(audit_scope_line(en))
.with_size("10.5pt")
.bold()
.with_color(design::tokens::NEUTRAL),
);
builder = builder.add_component(build_severity_counter_strip(vm, i18n));
builder = builder.add_component(
Label::new(if en {
"Occurrences = individual affected elements, not the number of distinct rules — see the appendix for the full breakdown."
} else {
"Vorkommen = einzelne betroffene Elemente, nicht die Anzahl unterschiedlicher Regeln — vollständige Aufschlüsselung im Anhang."
})
.with_size("8.5pt")
.with_color(design::tokens::MUTED),
);
let evidence = &vm.findings.evidence;
if evidence.warnings > 0 || evidence.manual_checks > 0 {
builder = builder.add_component(
Label::new(if en {
format!(
"Counted here are confirmed WCAG violations only. In addition: {} heuristic accessibility warning(s) and {} criterion/criteria requiring manual review — documented, but not scored.",
evidence.warnings, evidence.manual_checks
)
} else {
format!(
"Gezählt sind hier ausschließlich bestätigte WCAG-Verstöße. Hinzu kommen {} heuristische Barrierefreiheits-Warnung(en) und {} manuell zu prüfende(s) Kriterium/Kriterien — dokumentiert, aber nicht in den Score eingerechnet.",
evidence.warnings, evidence.manual_checks
)
})
.with_size("8.5pt")
.with_color(design::tokens::MUTED),
);
}
builder = builder.add_component(Callout::info(&vm.summary.verdict).with_title(if en {
"Overall Verdict"
} else {
"Gesamturteil"
}));
builder = render_problem_profile(builder, vm, i18n);
if let Some(note) = &vm.summary.audit_quality_note {
let callout = if vm.summary.audit_quality_severe {
Callout::warning(note).with_title(if en {
"Audit Quality"
} else {
"Audit-Qualität"
})
} else {
Callout::info(note).with_title(if en { "Coverage" } else { "Prüfabdeckung" })
};
builder = builder.add_component(callout);
}
if !vm.severity.has_issues {
let (automated, total) = crate::wcag::coverage::coverage_stats();
builder = builder.add_component(
Label::new(if en {
format!(
"This result applies to the automated audit scope only ({automated} of ~{total} testable WCAG 2.1 AA criteria); criteria requiring manual review are listed in the appendix."
)
} else {
format!(
"Diese Einschätzung bezieht sich ausschließlich auf den automatisierten Prüfumfang ({automated} von ca. {total} testbaren WCAG-2.1-AA-Kriterien); Kriterien mit manuellem Prüfbedarf sind im Anhang aufgeführt."
)
})
.with_size("9pt")
.with_color(design::tokens::MUTED),
);
}
let radar_data: Vec<(String, f64)> = vm
.modules
.dashboard
.iter()
.map(|m| {
let short = m
.name
.split([' ', '&'])
.next()
.unwrap_or(m.name.as_str())
.trim()
.to_string();
(short, m.score as f64)
})
.collect();
if radar_data.len() >= 3 {
builder = builder.add_component(
Chart::new(
if en {
"Quality profile"
} else {
"Qualitätsprofil"
},
ChartType::Radar,
)
.add_series("scores", radar_data),
);
}
builder = render_score_driver_table(builder, vm, i18n);
if let Some(note) = build_problem_concentration_note(vm, i18n) {
builder = builder.add_component(Label::new(note).with_size("10.5pt"));
}
builder = render_additional_score_drivers(builder, vm, i18n);
let en_label = if en {
"Accessibility"
} else {
"Barrierefreiheit"
};
let accessibility_entries: Vec<(String, u32)> = vm
.modules
.accessibility_subcategory_scores
.iter()
.map(|s| (s.subcategory_kind.label(en).to_string(), s.score))
.collect();
builder = render_subcategory_breakdown(builder, en_label, &accessibility_entries, i18n);
let security_label = if en { "Security" } else { "Sicherheit" };
let security_entries: Vec<(String, u32)> = vm
.modules
.security_category_scores
.iter()
.map(|s| (s.category_kind.label(en).to_string(), s.score))
.collect();
builder = render_subcategory_breakdown(builder, security_label, &security_entries, i18n);
builder = render_module_split_dashboards(builder, vm, i18n);
builder = render_risks_and_strengths(builder, vm, i18n);
builder = builder.add_component(build_top_measures_list(vm, i18n));
builder
}
const PROBLEM_CONCENTRATION_TOP_N: usize = 4;
const PROBLEM_CONCENTRATION_MIN_OCCURRENCES: usize = 3;
struct ProblemConcentration {
total_occurrences: usize,
top_n: usize,
concentration_pct: i64,
top1_title: String,
}
fn compute_problem_concentration(vm: &ReportViewModel) -> Option<ProblemConcentration> {
let mut findings: Vec<&FindingGroup> = vm
.findings
.all_findings
.iter()
.filter(|f| f.occurrence_count > 0 && !f.wcag_criterion.is_empty())
.collect();
if findings.is_empty() {
return None;
}
findings.sort_by_key(|f| std::cmp::Reverse(f.occurrence_count));
let total_occurrences: usize = findings.iter().map(|f| f.occurrence_count).sum();
let top_n = findings.len().min(PROBLEM_CONCENTRATION_TOP_N);
let top_occurrences: usize = findings[..top_n].iter().map(|f| f.occurrence_count).sum();
let concentration_pct = if total_occurrences > 0 {
(top_occurrences as f64 * 100.0 / total_occurrences as f64).round() as i64
} else {
0
};
Some(ProblemConcentration {
total_occurrences,
top_n,
concentration_pct,
top1_title: findings[0].title.clone(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ProblemProfileKind {
Punktuell,
KonzentriertSystemisch,
BreitSystemisch,
}
impl ProblemProfileKind {
pub(super) fn label(&self, en: bool) -> &'static str {
match self {
Self::Punktuell => {
if en {
"isolated"
} else {
"punktuell"
}
}
Self::KonzentriertSystemisch => {
if en {
"concentrated & systemic"
} else {
"konzentriert & systemisch"
}
}
Self::BreitSystemisch => {
if en {
"broad & systemic"
} else {
"breit & systemisch"
}
}
}
}
}
fn weak_contributing_modules(
vm: &ReportViewModel,
) -> Vec<&crate::audit::normalized::ModuleScoreEntry> {
let mut weak: Vec<&crate::audit::normalized::ModuleScoreEntry> = vm
.modules
.module_scores
.iter()
.filter(|m| m.contributes_to_overall && m.score < 75)
.collect();
weak.sort_by_key(|m| std::cmp::Reverse((100 - m.score) * m.weight_pct));
weak
}
fn critical_contributing_modules(
vm: &ReportViewModel,
) -> Vec<&crate::audit::normalized::ModuleScoreEntry> {
let mut critical: Vec<&crate::audit::normalized::ModuleScoreEntry> = vm
.modules
.module_scores
.iter()
.filter(|m| m.contributes_to_overall && m.score < 40)
.collect();
critical.sort_by_key(|m| std::cmp::Reverse((100 - m.score) * m.weight_pct));
critical
}
pub(super) fn classify_problem_profile(vm: &ReportViewModel) -> ProblemProfileKind {
let critical = critical_contributing_modules(vm);
let critical_count = critical.len();
let concentration = compute_problem_concentration(vm);
let has_systemic_wcag = match &concentration {
Some(c) => {
c.total_occurrences > PROBLEM_CONCENTRATION_MIN_OCCURRENCES && c.concentration_pct >= 50
}
None => false,
};
if critical_count >= 3 {
ProblemProfileKind::BreitSystemisch
} else if critical_count == 2 || (critical_count == 1 && has_systemic_wcag) {
ProblemProfileKind::KonzentriertSystemisch
} else {
ProblemProfileKind::Punktuell
}
}
fn format_module_names(
modules: &[&crate::audit::normalized::ModuleScoreEntry],
i18n: &I18n,
) -> String {
let en = i18n.locale() == "en";
let names: Vec<String> = modules
.iter()
.map(|m| {
let key = format!("module-{}", m.name.to_lowercase().replace(' ', "-"));
let translated = i18n.t(&key);
if translated == key {
m.name.clone()
} else {
translated
}
})
.collect();
match names.len() {
0 => String::new(),
1 => names[0].clone(),
2 => {
if en {
format!("{} and {}", names[0], names[1])
} else {
format!("{} und {}", names[0], names[1])
}
}
_ => {
let last = &names[names.len() - 1];
let initial = names[..names.len() - 1].join(", ");
if en {
format!("{}, and {}", initial, last)
} else {
format!("{} und {}", initial, last)
}
}
}
}
fn build_problem_profile_description(
vm: &ReportViewModel,
kind: ProblemProfileKind,
i18n: &I18n,
) -> String {
let en = i18n.locale() == "en";
let critical_modules = critical_contributing_modules(vm);
let concentration = compute_problem_concentration(vm);
let has_systemic_wcag = match &concentration {
Some(c) => {
c.total_occurrences > PROBLEM_CONCENTRATION_MIN_OCCURRENCES && c.concentration_pct >= 50
}
None => false,
};
match kind {
ProblemProfileKind::Punktuell => {
if critical_modules.is_empty() {
if en {
"Very good technical condition. Isolated local optimizations without structural problems.".to_string()
} else {
"Sehr guter technischer Zustand. Einzelne lokale Optimierungen ohne strukturelles Problem.".to_string()
}
} else {
let name = format_module_names(&critical_modules, i18n);
if en {
format!("{name} determines the primary need for action. Isolated local optimizations without structural problems.")
} else {
format!("{name} bestimmt den Handlungsbedarf. Einzelne lokale Optimierungen ohne strukturelles Problem.")
}
}
}
ProblemProfileKind::KonzentriertSystemisch => {
let names = format_module_names(&critical_modules, i18n);
let verb_plural = critical_modules.len() > 1;
if has_systemic_wcag {
if en {
let verb = if verb_plural {
"determine"
} else {
"determines"
};
format!("{names} {verb} the need for action. A large portion of the accessibility findings is concentrated in a few recurring patterns.")
} else {
let verb = if verb_plural { "bestimmen" } else { "bestimmt" };
format!("{names} {verb} den Handlungsbedarf. Ein großer Teil der Accessibility-Befunde konzentriert sich auf wenige wiederkehrende Muster.")
}
} else if en {
let verb = if verb_plural {
"determine"
} else {
"determines"
};
format!(
"{names} {verb} the need for action while other technical areas remain stable."
)
} else {
let verb = if verb_plural { "bestimmen" } else { "bestimmt" };
format!("{names} {verb} den Handlungsbedarf bei ansonsten stabilen technischen Bereichen.")
}
}
ProblemProfileKind::BreitSystemisch => {
let names = format_module_names(&critical_modules, i18n);
if names.is_empty() {
if en {
"Multiple technical areas are affected simultaneously and require structural corrections.".to_string()
} else {
"Mehrere technische Bereiche sind gleichzeitig betroffen und benötigen strukturelle Korrekturen.".to_string()
}
} else if en {
format!("Multiple technical areas are affected simultaneously. In particular, {names} require structural corrections.")
} else {
format!("Mehrere technische Bereiche sind gleichzeitig betroffen. Besonders {names} benötigen strukturelle Korrekturen.")
}
}
}
}
fn render_problem_profile(
builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let kind = classify_problem_profile(vm);
let title_prefix = if en {
"Problem profile"
} else {
"Problemprofil"
};
let title = format!("{}: {}", title_prefix, kind.label(en));
let desc = build_problem_profile_description(vm, kind, i18n);
let callout = match kind {
ProblemProfileKind::Punktuell => {
if weak_contributing_modules(vm).is_empty() {
Callout::success(desc).with_title(title)
} else {
Callout::info(desc).with_title(title)
}
}
ProblemProfileKind::KonzentriertSystemisch => Callout::info(desc).with_title(title),
ProblemProfileKind::BreitSystemisch => Callout::warning(desc).with_title(title),
};
builder.add_component(callout)
}
fn build_problem_concentration_note(vm: &ReportViewModel, i18n: &I18n) -> Option<String> {
let en = i18n.locale() == "en";
let c = compute_problem_concentration(vm)?;
if c.total_occurrences <= PROBLEM_CONCENTRATION_MIN_OCCURRENCES {
return Some(if en {
format!(
"{} occurrence(s) were detected — too few to identify a recurring pattern.",
c.total_occurrences
)
} else {
format!(
"Es wurden {} Vorkommen erkannt — zu wenige, um ein wiederkehrendes Muster abzuleiten.",
c.total_occurrences
)
});
}
Some(if en {
format!(
"{} occurrences were detected. About {}% of them trace back to just {} recurring causes. Most affected: {}.",
c.total_occurrences, c.concentration_pct, c.top_n, c.top1_title
)
} else {
format!(
"{} Vorkommen wurden erkannt. Rund {} % davon lassen sich auf {} wiederkehrende Ursachen zurückführen. Besonders betroffen: {}.",
c.total_occurrences, c.concentration_pct, c.top_n, c.top1_title
)
})
}
fn build_overall_leverage_note(vm: &ReportViewModel, i18n: &I18n) -> Option<String> {
let en = i18n.locale() == "en";
let c = compute_problem_concentration(vm)?;
if c.total_occurrences <= PROBLEM_CONCENTRATION_MIN_OCCURRENCES {
return None;
}
Some(if en {
format!(
"The {} most significant causes account for about {}% of the {} occurrences detected — addressing those first has the biggest leverage on the list below.",
c.top_n, c.concentration_pct, c.total_occurrences
)
} else {
format!(
"Die {} wichtigsten Ursachen erklären rund {} % der {} erkannten Vorkommen — ihre Behebung hat die größte Hebelwirkung auf die folgende Liste.",
c.top_n, c.concentration_pct, c.total_occurrences
)
})
}
fn render_score_driver_table(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let contributing: Vec<&crate::audit::normalized::ModuleScoreEntry> = vm
.modules
.module_scores
.iter()
.filter(|m| m.contributes_to_overall)
.collect();
if contributing.len() < 2 {
return builder;
}
let weakest_name = contributing
.iter()
.filter(|m| m.score < 75)
.max_by_key(|m| (100 - m.score) * m.weight_pct)
.map(|m| m.name.clone());
let classify = |m: &crate::audit::normalized::ModuleScoreEntry| -> &'static str {
if Some(&m.name) == weakest_name.as_ref() {
if en {
"Biggest weak point"
} else {
"Größter Schwachpunkt"
}
} else if m.score < 75 {
if en {
"Significant risk driver"
} else {
"Erheblicher Risikotreiber"
}
} else if en {
"Stabilizes the overall score"
} else {
"Stabilisiert Gesamtwert"
}
};
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Module" } else { "Modul" }).with_width("28%"),
TableColumn::new("Score").with_width("14%"),
TableColumn::new(if en { "Weight" } else { "Gewichtung" }).with_width("16%"),
TableColumn::new(if en { "Assessment" } else { "Einordnung" }).with_width("42%"),
])
.with_title(if en {
"Why is the overall score what it is"
} else {
"Warum ist der Gesamtwert, was er ist"
});
for m in &contributing {
let key = format!("module-{}", m.name.to_lowercase().replace(' ', "-"));
let translated = i18n.t(&key);
let display_name = if translated == key {
m.name.clone()
} else {
translated
};
table = table.add_row(vec![
display_name,
format!("{} ({})", m.score, score_band_label(m.score, i18n)),
format!("{}%", m.weight_pct),
classify(m).to_string(),
]);
}
builder = builder.add_component(table);
builder
}
fn render_additional_score_drivers(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if vm.summary.technical_overview.len() < 4 {
return builder;
}
let mut list = List::new().with_title(if en {
"Additional score drivers"
} else {
"Weitere Score-Treiber"
});
for item in &vm.summary.technical_overview[1..4] {
list = list.add_item(item);
}
builder = builder.add_component(list);
builder
}
fn render_subcategory_breakdown(
mut builder: renderreport::engine::ReportBuilder,
title: &str,
entries: &[(String, u32)],
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if entries.is_empty() {
return builder;
}
const STABLE_CUTOFF: f32 = 60.0;
let mut weak: Vec<&(String, u32)> = entries
.iter()
.filter(|(_, score)| (*score as f32) < STABLE_CUTOFF)
.collect();
let mut stable: Vec<&(String, u32)> = entries
.iter()
.filter(|(_, score)| (*score as f32) >= STABLE_CUTOFF)
.collect();
if weak.is_empty() && stable.is_empty() {
return builder;
}
weak.sort_by_key(|(_, score)| *score);
stable.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
let render_side = |items: &[&(String, u32)]| -> String {
items
.iter()
.map(|(name, score)| format!("{name} {score}"))
.collect::<Vec<_>>()
.join(" · ")
};
let body = if !weak.is_empty() && !stable.is_empty() {
if en {
format!(
"Critical weaknesses: {}\nStronger sub-areas within {title}: {}",
render_side(&weak),
render_side(&stable)
)
} else {
format!(
"Kritische Schwächen: {}\nStärkere Teilbereiche innerhalb der {title}: {}",
render_side(&weak),
render_side(&stable)
)
}
} else if !weak.is_empty() {
if en {
format!("Critical weaknesses: {}", render_side(&weak))
} else {
format!("Kritische Schwächen: {}", render_side(&weak))
}
} else if en {
format!(
"Stronger sub-areas within {title}: {}",
render_side(&stable)
)
} else {
format!(
"Stärkere Teilbereiche innerhalb der {title}: {}",
render_side(&stable)
)
};
builder = builder.add_component(Callout::info(body).with_title(title));
builder
}
pub(super) fn render_tech_details(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if !vm.modules.dashboard.is_empty() {
let diag_rows: Vec<DiagnosisRow> = vm
.modules
.dashboard
.iter()
.map(|module| {
let status = if module.score >= 80 {
"good"
} else if module.score >= 50 {
"warn"
} else {
"bad"
};
let display_name = super::helpers::module_name_with_taxonomy_suffix(
&module.name,
&module.measurement_type,
i18n,
);
DiagnosisRow::new(&display_name, format!("{}/100", module.score))
.with_status(status)
})
.collect();
builder = builder.add_component(
DiagnosisPanel::new(diag_rows).with_title(i18n.t("panel-modules-overview")),
);
}
if vm.severity.has_issues {
builder = render_diagnosis_section(builder, &vm.diagnosis, i18n);
}
let report_ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
let mut evidence_seq: usize = 0;
let mut acronyms_expanded = false;
builder = render_findings_section(
builder,
vm,
en,
i18n,
report_ts,
&mut evidence_seq,
&mut acronyms_expanded,
);
if !report.interactive_findings.is_empty() {
builder = render_a11y_journey_findings(
builder,
&report.interactive_findings,
report.accessibility_journey.as_ref(),
i18n,
);
}
if let Some(sr) = report.screen_reader_audit.as_ref() {
builder = render_screen_reader_section(builder, sr, report.patterns.as_ref(), i18n);
}
builder
}
pub(super) fn render_appendix_full(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
findings: &[crate::audit::normalized::NormalizedFinding],
config: &ReportConfig,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let (app_title, app_intro) = if en {
(
"Appendix & Methodology",
"Technical scope, methodology, WCAG coverage, and the complete violations list.",
)
} else {
(
"Anhang & Methodik",
"Prüfumfang, Methodik, WCAG-Coverage und die vollständige Fundstellenliste.",
)
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(app_title, app_intro)
.with_eyebrow(if en { "APPENDIX" } else { "ANHANG" })
.with_level(1),
);
if vm.meta.report_level != ReportLevel::Executive {
builder = render_wcag_coverage_section(builder, report, i18n);
builder = render_manual_only_criteria_note(builder, i18n);
if config.annex == Some(AnnexKind::En301549) {
builder = render_en301549_annex(
builder,
findings,
&report.accessibility.wcag_results.rule_outcomes,
i18n,
);
}
if config.annex == Some(AnnexKind::Bik) {
let design_quality_findings: &[crate::design_quality::DesignQualityFinding] = report
.experience
.design_quality
.as_ref()
.map(|m| m.findings.as_slice())
.unwrap_or(&[]);
let seo_technical_issues: Vec<crate::seo::technical::TechnicalIssue> = report
.discoverability
.seo
.as_ref()
.map(|s| crate::seo::collect_technical_issues(&s.technical, en))
.unwrap_or_default();
let easy_language_detected = report
.patterns
.as_ref()
.is_some_and(|p| p.recognized.iter().any(|r| r.pattern == "EasyLanguage"));
let screen_reader_issues: &[crate::screen_reader::SrAuditIssue] = report
.screen_reader_audit
.as_ref()
.map(|sr| sr.issues.as_slice())
.unwrap_or(&[]);
let accessibility_assessments =
crate::audit::normalized::normalize_assessments(&report.accessibility.wcag_results);
builder = render_bik_guide_annex(
builder,
findings,
&report.interactive_findings,
screen_reader_issues,
design_quality_findings,
&seo_technical_issues,
easy_language_detected,
&accessibility_assessments,
i18n,
);
}
}
builder = render_assessment_and_execution_notes(builder, report, i18n);
let limitations_title = i18n.t("callout-limitations-title");
let limitations_text = format!("{}: {}", limitations_title, vm.methodology.limitations);
builder = builder.add_component(
Label::new(&limitations_text)
.with_size("10.5pt")
.with_color("#475569"),
);
let disclaimer_title = i18n.t("callout-note-title");
let disclaimer_text = format!("{}: {}", disclaimer_title, vm.methodology.disclaimer);
builder = builder.add_component(
Label::new(&disclaimer_text)
.with_size("10.5pt")
.with_color("#475569"),
);
builder = render_appendix_section(builder, vm, i18n);
builder
}
fn render_manual_only_criteria_note(
builder: renderreport::engine::ReportBuilder,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let rows = if en {
vec![
ChecklistRow::new(
"2.4.3 · Focus Order",
"Whether the technical focus order makes sense in context depends on the page's \
content and cannot be assessed automatically.",
),
ChecklistRow::new(
"3.3.1 · Error Identification",
"Whether an error message is actually understandable to people is a content \
question, not a structural check.",
),
ChecklistRow::new(
"1.4.4 / 1.4.10 · Resize Text & Reflow",
"Whether the page stays practically usable at 400% zoom goes beyond the \
technical reflow structure and requires manual review.",
),
ChecklistRow::new(
"1.1.1 · Non-text Content",
"Whether alt text correctly describes the image content can only be judged by \
a person; automated checks can only confirm that alt text is present at all.",
),
ChecklistRow::new(
"1.2.1 / 1.2.2 · Time-based Media",
"Whether a caption or transcript summary correctly reflects a video's content \
cannot be checked automatically.",
),
]
} else {
vec![
ChecklistRow::new(
"2.4.3 · Fokusreihenfolge",
"Ob die technische Fokusreihenfolge inhaltlich sinnvoll ist, hängt vom \
Seitenaufbau ab und lässt sich nicht automatisiert bewerten.",
),
ChecklistRow::new(
"3.3.1 · Fehlerkennzeichnung",
"Ob eine Fehlermeldung für Menschen tatsächlich verständlich ist, ist eine \
inhaltliche Frage und kein struktureller Check.",
),
ChecklistRow::new(
"1.4.4 / 1.4.10 · Textgröße & Reflow",
"Ob die Seite bei 400 % Zoom praktisch bedienbar bleibt, geht über die \
technische Reflow-Struktur hinaus und erfordert eine manuelle Prüfung.",
),
ChecklistRow::new(
"1.1.1 · Nicht-Text-Inhalte",
"Ob ein Alternativtext den Bildinhalt korrekt beschreibt, kann nur ein Mensch \
beurteilen; automatisiert prüfbar ist nur, ob überhaupt ein Alternativtext \
vorhanden ist.",
),
ChecklistRow::new(
"1.2.1 / 1.2.2 · Zeitbasierte Medien",
"Ob eine Untertitel- oder Transkript-Zusammenfassung den Videoinhalt inhaltlich \
korrekt wiedergibt, lässt sich nicht automatisiert prüfen.",
),
]
};
builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Structurally manual-only criteria"
} else {
"Strukturell nur manuell prüfbare Kriterien"
}))
}
fn render_assessment_and_execution_notes(
mut builder: renderreport::engine::ReportBuilder,
report: &AuditReport,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let wcag = &report.accessibility.wcag_results;
if !wcag.warnings.is_empty() || !wcag.not_testables.is_empty() {
let mut rows = Vec::new();
for finding in wcag.not_testables.iter().take(20) {
let mut recommendation = finding
.rule_id
.as_deref()
.and_then(crate::output::explanations::get_explanation)
.or_else(|| crate::output::explanations::get_explanation(&finding.rule))
.map(|explanation| explanation.recommendation_for(i18n.locale()).to_string())
.unwrap_or_else(|| {
if en {
"Verify this criterion manually on the rendered page.".to_string()
} else {
"Dieses Kriterium manuell an der gerenderten Seite prüfen.".to_string()
}
});
if let Some(instruction) = manual_recheck_instruction(&finding.rule, en) {
recommendation.push(' ');
recommendation.push_str(instruction);
}
rows.push(
ChecklistRow::new(
format!(
"{} · {}",
finding.rule,
if en {
"Manual review"
} else {
"Manuelle Prüfung"
}
),
recommendation,
)
.with_status("warn"),
);
}
for finding in wcag
.warnings
.iter()
.take(20usize.saturating_sub(rows.len()))
{
let mut text = finding
.rule_id
.as_deref()
.and_then(crate::output::explanations::get_explanation)
.or_else(|| crate::output::explanations::get_explanation(&finding.rule))
.map(|explanation| explanation.recommendation_for(i18n.locale()).to_string())
.unwrap_or_else(|| {
if en {
"Confirm this heuristic signal manually.".to_string()
} else {
"Dieses heuristische Signal manuell bestätigen.".to_string()
}
});
if let Some(instruction) = manual_recheck_instruction(&finding.rule, en) {
text.push(' ');
text.push_str(instruction);
}
rows.push(
ChecklistRow::new(
format!(
"{} · {}",
finding.rule,
if en { "Warning" } else { "Hinweis" }
),
text,
)
.with_status("warn"),
);
}
builder = builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Manual review and heuristic signals"
} else {
"Manuelle Prüfpunkte und heuristische Hinweise"
}));
}
if let Some(journey) = report.accessibility_journey.as_ref() {
let execution = &journey.execution;
let text = if en {
format!(
"Interactive coverage: {} of {} attempted journeys completed; {} failed, {} skipped{}.",
execution.completed,
execution.attempted,
execution.failed,
execution.skipped,
if execution.budget_exhausted { "; budget exhausted" } else { "" }
)
} else {
format!(
"Interaktive Abdeckung: {} von {} versuchten Journeys abgeschlossen; {} fehlgeschlagen, {} übersprungen{}.",
execution.completed,
execution.attempted,
execution.failed,
execution.skipped,
if execution.budget_exhausted { "; Budget ausgeschöpft" } else { "" }
)
};
builder = builder.add_component(Label::new(text).with_size("10.5pt").with_color("#475569"));
}
builder
}
fn render_findings_section(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
en: bool,
i18n: &I18n,
report_ts: i64,
evidence_seq: &mut usize,
acronyms_expanded: &mut bool,
) -> renderreport::engine::ReportBuilder {
if vm.severity.has_issues {
let (findings_title, findings_intro) = if en {
(
"Classification of Findings",
"All technical findings, categorized by systemic template relevance and individual page occurrences.",
)
} else {
(
"Klassifizierung der Befunde",
"Alle technischen Befunde, getrennt nach systemischen Komponentenfehlern und Einzelfällen.",
)
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(findings_title, findings_intro)
.with_eyebrow(if en { "FINDINGS" } else { "BEFUNDE" })
.with_level(2),
);
let all_findings = &vm.findings.all_findings;
let systemic_mandatory: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| f.is_component_issue && f.criticality_tier == CriticalityTier::Mandatory)
.collect();
let systemic_optimization: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| f.is_component_issue && f.criticality_tier == CriticalityTier::Optimization)
.collect();
let local_mandatory: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| !f.is_component_issue && f.criticality_tier == CriticalityTier::Mandatory)
.collect();
let local_optimization: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| {
!f.is_component_issue && f.criticality_tier == CriticalityTier::Optimization
})
.collect();
builder = builder.add_component(
MetricStrip::new(vec![
MetricStripItem::new(
if en {
"Systemic (mandatory)"
} else {
"Systemisch (Pflicht)"
},
systemic_mandatory.len().to_string(),
)
.with_accent(if !systemic_mandatory.is_empty() {
design::tokens::INFO
} else {
design::tokens::SUCCESS
}),
MetricStripItem::new(
if en {
"Systemic (optimization)"
} else {
"Systemisch (Optimierung)"
},
systemic_optimization.len().to_string(),
)
.with_accent(design::tokens::NEUTRAL),
MetricStripItem::new(
if en {
"Local (mandatory)"
} else {
"Lokal (Pflicht)"
},
local_mandatory.len().to_string(),
)
.with_accent(if !local_mandatory.is_empty() {
design::tokens::INFO
} else {
design::tokens::SUCCESS
}),
MetricStripItem::new(
if en {
"Local (optimization)"
} else {
"Lokal (Optimierung)"
},
local_optimization.len().to_string(),
)
.with_accent(design::tokens::NEUTRAL),
])
.compact(),
);
const FINDINGS_MATRIX_MAX_ROWS: usize = 30;
let mut matrix_findings: Vec<&FindingGroup> = all_findings.iter().collect();
matrix_findings.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| b.occurrence_count.cmp(&a.occurrence_count))
});
if !matrix_findings.is_empty() {
let (matrix_title, matrix_intro) = if en {
(
"Findings Matrix",
"One row per finding group, sorted by priority — an overview to prioritize by before the detail cards below expand on each row.",
)
} else {
(
"Befundmatrix",
"Eine Zeile je Befundgruppe, sortiert nach Priorität – als Überblick zur Priorisierung, bevor die Detailkarten unten jede Zeile vertiefen.",
)
};
builder = builder.add_component(Label::new(matrix_intro).with_size("10.5pt"));
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Priority" } else { "Priorität" }).with_width("10%"),
TableColumn::new("WCAG").with_width("10%"),
TableColumn::new(if en { "Finding" } else { "Befund" }).with_width("28%"),
TableColumn::new(if en { "Scope" } else { "Umfang" }).with_width("14%"),
TableColumn::new(if en { "Responsible" } else { "Zuständig" }).with_width("14%"),
TableColumn::new(if en { "Action" } else { "Maßnahme" }).with_width("24%"),
])
.with_title(matrix_title);
for group in matrix_findings.iter().take(FINDINGS_MATRIX_MAX_ROWS) {
let wcag = if group.wcag_criterion.is_empty() {
"n/a".to_string()
} else if group.wcag_level.is_empty() {
group.wcag_criterion.clone()
} else {
format!("{} ({})", group.wcag_criterion, group.wcag_level)
};
let scope = if group.is_component_issue {
if en {
format!("Systemic · {}", group.occurrence_count)
} else {
format!("Systemisch · {}", group.occurrence_count)
}
} else if en {
format!("Local · {}", group.occurrence_count)
} else {
format!("Lokal · {}", group.occurrence_count)
};
table = table.add_row(vec![
priority_label_i18n(group.priority, i18n),
wcag,
truncate_title(&group.title, 48),
scope,
role_label_i18n(group.responsible_role, i18n),
truncate_title(&group.recommendation, 72),
]);
}
builder = builder.add_component(table);
let remaining = matrix_findings
.len()
.saturating_sub(FINDINGS_MATRIX_MAX_ROWS);
if remaining > 0 {
let note = if en {
format!("{remaining} further findings are listed in the detail cards below.")
} else {
format!(
"{remaining} weitere Befunde sind in den Detailkarten unten aufgeführt."
)
};
builder = builder.add_component(
Label::new(note)
.with_size("9pt")
.with_color(design::tokens::NEUTRAL),
);
}
}
let mut rendered_categories = 0usize;
if !systemic_mandatory.is_empty() {
let title = if en {
"Systemic Template & Component Issues (WCAG A/AA)"
} else {
"Systemische Template- & Komponentenfehler (WCAG A/AA)"
};
let desc = if en {
"This pattern suggests a reused component or template — confirming that requires evidence from additional pages. A central fix would likely also resolve the cause on other pages sharing the same pattern."
} else {
"Dieses Muster deutet auf eine wiederverwendete Komponente oder Vorlage hin — bestätigt ist das erst mit Belegen von weiteren Seiten. Eine zentrale Behebung würde die Ursache voraussichtlich auch auf anderen Seiten mit demselben Muster beheben."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"SYSTEMIC COMPLIANCE"
} else {
"SYSTEMISCHE PFLICHT"
})
.with_level(2),
);
for group in systemic_mandatory {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
if !systemic_optimization.is_empty() {
let title = if en {
"Systemic Quality & SEO Optimizations"
} else {
"Systemische Qualitäts- & SEO-Optimierungen"
};
let desc = if en {
"These recommendations recur within this single page, which suggests a shared template — confirming that would require checking additional pages."
} else {
"Diese Empfehlungen wiederholen sich innerhalb dieser einen Seite, was auf eine gemeinsame Vorlage hindeutet — bestätigt ist das erst nach Prüfung weiterer Seiten."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"SYSTEMIC OPTIMIZATION"
} else {
"SYSTEMISCHE OPTIMIERUNG"
})
.with_level(2),
);
for group in systemic_optimization {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
if !local_mandatory.is_empty() {
let title = if en {
"Local & Editorial Findings (WCAG A/AA)"
} else {
"Einzelfälle & Redaktionelle Befunde (WCAG A/AA)"
};
let desc = if en {
"Single instances of accessibility barriers affecting specific pages, images, or editorial content, usually requiring individual resolution."
} else {
"Punktuelle Barrieren, die nur einzelne Seiten, spezifische Bilder oder redaktionelle Texte betreffen und meist individuell behoben werden müssen."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"LOCAL COMPLIANCE"
} else {
"LOKALE PFLICHT"
})
.with_level(2),
);
for group in local_mandatory {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
if !local_optimization.is_empty() {
let title = if en {
"Additional Quality & SEO Recommendations"
} else {
"Ergänzende Qualitäts- & SEO-Empfehlungen"
};
let desc = if en {
"Usability, performance, or SEO recommendations for specific pages."
} else {
"Ergänzende Empfehlungen zur Verbesserung der Ladezeiten, Suchmaschinenoptimierung und Benutzerfreundlichkeit auf bestimmten Seiten."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"LOCAL OPTIMIZATION"
} else {
"LOKALE OPTIMIERUNG"
})
.with_level(2),
);
for group in local_optimization {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
let _ = rendered_categories; }
builder
}
fn render_appendix_section(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if vm.appendix.has_violations {
let (appendix_title, appendix_intro) = if en {
(
"Complete Findings List",
"Raw audit data and the full list of detected violations.",
)
} else {
(
"Vollständige Fundstellen",
"Rohdaten des Audits und die vollständige Liste aller erkannten Verstöße.",
)
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(appendix_title, appendix_intro)
.with_eyebrow(if en { "APPENDIX" } else { "ANHANG" })
.with_level(2),
);
builder = builder.add_component(build_cli_snapshot_table(vm, i18n));
let json_note = if en {
"The accompanying JSON report contains the complete machine-readable issue list with selectors, occurrences, and all detail data for automated processing."
} else {
"Der begleitende JSON-Report enthält die vollständige maschinenlesbare Fehlerliste mit Selektoren, Vorkommen und allen Detaildaten für automatisierte Weiterverarbeitung."
};
builder = builder.add_component(Callout::info(json_note).with_title(if en {
"Raw data & processing"
} else {
"Rohdaten & Weiterverarbeitung"
}));
if vm.meta.report_level == ReportLevel::Technical {
for v in &vm.appendix.violations {
let mut desc = v.message.clone();
if let Some(ref fix) = v.fix_suggestion {
desc.push_str(&format!("\n\nFix: {}", fix));
}
desc.push_str(&format!(
"\n\n{} Elemente betroffen",
v.affected_elements.len()
));
let useful_selectors: Vec<&str> = v
.affected_elements
.iter()
.map(|e| e.selector.as_str())
.filter(|s| {
s.contains('.')
|| s.contains('#')
|| s.contains('[')
|| s.contains('>')
|| s.contains(' ')
})
.collect();
if !useful_selectors.is_empty() {
desc.push_str(&format!("\nSelektoren: {}", useful_selectors.join(", ")));
}
builder = builder.add_component(Finding::new(
format!("{} — {}", v.rule, v.rule_name),
map_severity(&v.severity),
&desc,
));
}
} else {
let rows: Vec<ChecklistRow> = vm
.appendix
.violations
.iter()
.map(|v| {
let status = match v.severity {
crate::wcag::Severity::Critical => "bad",
crate::wcag::Severity::High => "warn",
_ => "neutral",
};
ChecklistRow::new(format!("{} — {}", v.rule, v.rule_name), v.message.clone())
.with_status(status)
})
.collect();
builder = builder.add_component(
ChecklistPanel::new(rows).with_title(i18n.t("section-all-violations")),
);
}
}
builder
}
fn render_positive_signals_section(
mut builder: renderreport::engine::ReportBuilder,
signals: &[PositiveSignal],
is_first: bool,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
if signals.is_empty() {
return builder;
}
if !is_first {
builder = builder.add_component(PageBreak::new());
}
let en = i18n.locale() == "en";
let mut rows = Vec::new();
for signal in signals {
let status = if signal.strong {
if en {
"Strong"
} else {
"Stark"
}
} else if en {
"Present"
} else {
"Vorhanden"
};
rows.push(
ChecklistRow::new(&signal.title, format!("{}: {}", status, signal.description))
.with_status(if signal.strong { "good" } else { "info" }),
);
}
builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Recognized structural patterns"
} else {
"Erkannte Strukturmuster"
}))
}
fn render_dual_viewport_summary_section(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
is_first: bool,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let has_scores = vm.cover.desktop_score.is_some() && vm.cover.mobile_score.is_some();
let has_screenshot_status = report.page_screenshots.is_some()
|| matches!(
report.screenshot_status,
crate::audit::ScreenshotStatus::Failed(_)
);
if !has_scores && !has_screenshot_status {
return builder;
}
if !is_first {
builder = builder.add_component(PageBreak::new());
}
let en = i18n.locale() == "en";
if report.page_screenshots.is_some() {
builder = builder.add_component(
DevicePreview::new(
super::PAGE_DESKTOP_SCREENSHOT_ASSET,
super::PAGE_MOBILE_SCREENSHOT_ASSET,
)
.with_height(210.0),
);
}
builder = builder.add_component(
Label::new(if en {
"Dual viewport summary"
} else {
"Dual-Viewport-Zusammenfassung"
})
.bold()
.with_size("10.5pt"),
);
let mut items = Vec::new();
if let (Some(desktop), Some(mobile)) = (vm.cover.desktop_score, vm.cover.mobile_score) {
items.push(
MetricStripItem::new(
if en {
"Accessibility - Desktop"
} else {
"Barrierefreiheit - Desktop"
},
format!("{desktop} / 100"),
)
.with_unit(score_range_label(desktop, en))
.with_accent(design::score_color(desktop as u8)),
);
items.push(
MetricStripItem::new(
if en {
"Accessibility - Mobile"
} else {
"Barrierefreiheit - Mobile"
},
format!("{mobile} / 100"),
)
.with_unit(score_range_label(mobile, en))
.with_accent(design::score_color(mobile as u8)),
);
items.push(
MetricStripItem::new(
if en {
"Accessibility - overall"
} else {
"Barrierefreiheit - Gesamt"
},
format!("{} / 100", vm.summary.score),
)
.with_unit(if en {
"70/30 weighted"
} else {
"70/30 gewichtet"
})
.with_accent(design::score_color(vm.summary.score as u8)),
);
}
if !has_scores
&& matches!(
report.screenshot_status,
crate::audit::ScreenshotStatus::Captured
)
{
items.push(
MetricStripItem::new(
if en { "Preview" } else { "Vorschau" },
if en { "Captured" } else { "Erfasst" },
)
.with_accent(design::tokens::SUCCESS),
);
}
if !items.is_empty() {
builder = builder.add_component(MetricStrip::new(items).compact());
}
if let crate::audit::ScreenshotStatus::Failed(reason) = &report.screenshot_status {
builder = builder.add_component(Callout::warning(if en {
format!("Screenshot capture failed: {reason}")
} else {
format!("Screenshot-Erfassung fehlgeschlagen: {reason}")
}));
}
let occurrence_context = report
.dual_viewport
.as_ref()
.map(|dual| {
let desktop = dual.desktop.wcag_results.violations.len();
let mobile = dual.mobile.wcag_results.violations.len();
let combined = vm.severity.total;
if en {
format!("WCAG occurrences before cross-viewport consolidation: {desktop} on desktop and {mobile} on mobile. The normalized combined finding list contains {combined} occurrences; this count is used for evidence and remediation, not as a third score. ")
} else {
format!("WCAG-Vorkommen vor der ansichtsübergreifenden Zusammenführung: {desktop} auf Desktop und {mobile} auf Mobile. Die normalisierte gemeinsame Befundliste enthält {combined} Vorkommen; diese Anzahl dient Evidenz und Maßnahmenplanung, nicht als dritter Score. ")
}
})
.unwrap_or_default();
let explanation = if en {
format!(
"Each viewport accessibility score reflects the severity and variety of automatically detected WCAG findings; 100 means no issue was detected within the automated scope. Lower values mean more remediation pressure. {occurrence_context}The displayed accessibility overall score is calculated from mobile at 70% and desktop at 30%."
)
} else {
format!(
"Jeder Barrierefreiheits-Score bewertet Schwere und Vielfalt der automatisiert erkannten WCAG-Befunde seiner Ansicht; 100 bedeutet, dass im automatisierten Prüfumfang kein Problem erkannt wurde. Niedrigere Werte bedeuten höheren Handlungsdruck. {occurrence_context}Der ausgewiesene Barrierefreiheits-Gesamtwert wird mit 70 % Mobile und 30 % Desktop berechnet."
)
};
builder.add_component(
Label::new(explanation)
.with_size("9pt")
.with_color(design::tokens::MUTED),
)
}
fn score_range_label(score: u32, en: bool) -> &'static str {
crate::registry::SCORE_RANGE.label(score as f32, en)
}
pub(super) fn render_module_sections(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let mut is_first = true;
let has_dual_viewport_scores =
vm.cover.desktop_score.is_some() && vm.cover.mobile_score.is_some();
let has_screenshot_status = report.page_screenshots.is_some()
|| matches!(
report.screenshot_status,
crate::audit::ScreenshotStatus::Failed(_)
);
if has_dual_viewport_scores || has_screenshot_status {
builder = render_dual_viewport_summary_section(builder, vm, report, is_first, i18n);
is_first = false;
}
if let Some(ref sx) = vm.module_details.search_experience {
builder = render_search_experience(builder, sx, is_first, i18n);
is_first = false;
}
let en = i18n.locale() == "en";
let mut ki_opened = false;
let mut in_ki_chapter = false;
for module in active_report_modules(report) {
let key = module.module_key();
let is_trust = matches!(
key,
"source_quality" | "ai_visibility" | "content_visibility"
);
if is_trust && !ki_opened {
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(
if en { "AI & Trust" } else { "KI & Vertrauen" },
if en {
"Source quality, AI readability and content visibility — trust and discoverability indicators."
} else {
"Quellenqualität, KI-Lesbarkeit und Content-Sichtbarkeit — Vertrauens- und Auffindbarkeits-Indikatoren."
},
)
.with_eyebrow(if en { "TRUST" } else { "VERTRAUEN" })
.with_level(2),
);
ki_opened = true;
in_ki_chapter = true;
is_first = true; } else if !is_trust && in_ki_chapter {
builder = builder.add_component(PageBreak::new());
in_ki_chapter = false;
is_first = true;
}
let (next_builder, rendered) =
render_active_module_section(builder, key, vm, report, is_first, i18n);
builder = next_builder;
if rendered {
is_first = false;
}
}
builder
}
fn render_active_module_section(
mut builder: renderreport::engine::ReportBuilder,
module_key: &str,
vm: &ReportViewModel,
report: &AuditReport,
is_first: bool,
i18n: &I18n,
) -> (renderreport::engine::ReportBuilder, bool) {
match module_key {
"performance" => {
if let Some(ref perf) = vm.module_details.performance {
builder = render_performance(builder, perf, is_first, i18n);
if !report.experience.budget_violations.is_empty() {
builder = render_budget_violations(
builder,
&report.experience.budget_violations,
i18n,
);
}
return (builder, true);
}
}
"seo" => {
if let Some(ref seo) = vm.module_details.seo {
return (render_seo(builder, seo, is_first, i18n), true);
}
}
"security" => {
if let Some(ref sec) = vm.module_details.security {
return (render_security(builder, sec, is_first, i18n), true);
}
}
"html_conform" => {
if let Some(ref hc) = vm.module_details.html_conform {
return (render_html_conform(builder, hc, is_first, i18n), true);
}
}
"commerce" => {
if let Some(ref c) = vm.module_details.commerce {
return (render_commerce(builder, c, is_first, i18n), true);
}
}
"mobile" => {
if let Some(ref mobile) = vm.module_details.mobile {
return (render_mobile(builder, mobile, is_first, i18n), true);
}
}
"ux" => {
if let Some(ref ux) = vm.module_details.ux {
return (render_ux(builder, ux, is_first, i18n), true);
}
}
"journey" => {
if let Some(ref journey) = vm.module_details.journey {
return (render_journey(builder, journey, is_first, i18n), true);
}
}
"dark_mode" => {
if let Some(ref dm) = vm.module_details.dark_mode {
return (render_dark_mode(builder, dm, is_first, i18n), true);
}
}
"design_quality" => {
if let Some(ref dq) = vm.module_details.design_quality {
return (render_design_quality(builder, dq, is_first, i18n), true);
}
}
"ai_transparency" => {
if let Some(ref at) = vm.module_details.ai_transparency {
return (render_ai_transparency(builder, at, is_first, i18n), true);
}
}
"network_dns" => {
if let Some(ref dns) = vm.module_details.network_dns {
return (render_network_dns(builder, dns, is_first, i18n), true);
}
}
"source_quality" => {
if let Some(ref sq) = vm.module_details.source_quality {
return (render_source_quality(builder, sq, is_first, i18n), true);
}
}
"ai_visibility" => {
if let Some(ref av) = vm.module_details.ai_visibility {
return (render_ai_visibility(builder, av, is_first, i18n), true);
}
}
"content_visibility" => {
if let Some(ref cv) = vm.module_details.content_visibility {
return (render_content_visibility(builder, cv, is_first, i18n), true);
}
}
"best_practices" => {
if let Some(ref bp) = vm.module_details.best_practices {
return (render_best_practices(builder, bp, is_first, i18n), true);
}
}
"tech_stack" => {
if let Some(ref ts) = vm.module_details.tech_stack {
return (render_tech_stack(builder, ts, is_first, i18n), true);
}
}
"patterns" if !vm.positive_signals.is_empty() => {
return (
render_positive_signals_section(builder, &vm.positive_signals, is_first, i18n),
true,
);
}
_ => {}
}
(builder, false)
}
const ROOT_CAUSE_SHOWN: usize = 6;
fn mandatory_root_causes(vm: &ReportViewModel) -> Vec<&FindingGroup> {
let mut findings: Vec<&FindingGroup> = vm
.findings
.all_findings
.iter()
.filter(|f| f.occurrence_count > 0 && f.criticality_tier == CriticalityTier::Mandatory)
.collect();
findings.sort_by_key(|f| std::cmp::Reverse(f.occurrence_count));
findings
}
fn truncate_title(value: &str, max_chars: usize) -> String {
let count = value.chars().count();
if count <= max_chars {
return value.to_string();
}
value
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>()
+ "…"
}
fn root_cause_lookup(
findings: &[&FindingGroup],
) -> std::collections::HashMap<String, (char, usize, i64)> {
let total_occurrences: usize = findings.iter().map(|f| f.occurrence_count).sum();
findings
.iter()
.enumerate()
.take(ROOT_CAUSE_SHOWN)
.map(|(idx, f)| {
let letter = (b'A' + idx as u8) as char;
let share_pct = if total_occurrences > 0 {
(f.occurrence_count as f64 * 100.0 / total_occurrences as f64).round() as i64
} else {
0
};
(f.rule_id.clone(), (letter, f.occurrence_count, share_pct))
})
.collect()
}
pub(super) fn render_root_cause_analysis(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let title = if en {
"Root Cause Analysis"
} else {
"Ursachenanalyse"
};
let subtitle = if en {
"Many individual findings trace back to a few recurring causes — fixing those has a compounding effect."
} else {
"Viele Einzelbefunde gehen auf wenige wiederkehrende Ursachen zurück – deren Behebung wirkt gebündelt."
};
builder = builder.add_component(
SectionHeaderSplit::new(title, subtitle)
.with_eyebrow(if en { "ROOT CAUSE" } else { "URSACHEN" })
.with_level(2),
);
let findings = mandatory_root_causes(vm);
if findings.is_empty() {
let evidence = &vm.findings.evidence;
let msg = if evidence.is_empty() {
if en {
"No accessibility findings were detected, so no root cause analysis is necessary."
.to_string()
} else {
"Es wurden keine Barrierefreiheits-Befunde erkannt, daher ist keine Ursachenanalyse erforderlich.".to_string()
}
} else if en {
format!(
"No confirmed WCAG violations, so no root cause analysis is necessary. {} accessibility warning(s) and {} manual check(s) remain — they are documented in their own sections and are not scored.",
evidence.warnings, evidence.manual_checks
)
} else {
format!(
"Keine bestätigten WCAG-Verstöße, daher ist keine Ursachenanalyse erforderlich. Es bleiben {} Barrierefreiheits-Warnung(en) und {} manuelle(r) Prüfhinweis(e) — sie stehen in den eigenen Abschnitten und fließen nicht in den Score ein.",
evidence.warnings, evidence.manual_checks
)
};
builder = builder.add_component(if evidence.is_empty() {
Callout::success(&msg)
} else {
Callout::info(&msg)
});
return builder;
}
let lead_in = if en {
"In practice: fixing the right root cause — a shared component or template — often resolves several findings at once, instead of fixing each one individually."
} else {
"Das bedeutet konkret: Ein Fix an der richtigen Stelle – etwa einer wiederverwendeten Komponente oder einem Template – behebt oft mehrere Befunde gleichzeitig, statt jeden einzeln reparieren zu müssen."
};
builder = builder.add_component(Label::new(lead_in).with_size("10.5pt"));
let mut list = List::new().with_title(if en {
"Systemic Root Causes"
} else {
"Erkannte Kernursachen"
});
let mut table_rows = Vec::new();
let mut chart_data: Vec<(String, f64)> = Vec::new();
let total_occurrences: usize = findings.iter().map(|f| f.occurrence_count).sum();
let share_pct = |occurrences: usize| -> i64 {
if total_occurrences > 0 {
(occurrences as f64 * 100.0 / total_occurrences as f64).round() as i64
} else {
0
}
};
let mut shown_occurrences = 0usize;
for (idx, finding) in findings.iter().enumerate().take(ROOT_CAUSE_SHOWN) {
let letter = (b'A' + idx as u8) as char;
let item_title = format!(
"{} {}: {} {} — {}",
if en { "Root Cause" } else { "Ursache" },
letter,
finding.occurrence_count,
if en { "occurrences" } else { "Vorkommen" },
finding.title
);
list = list.add_item(&item_title);
shown_occurrences += finding.occurrence_count;
let short_title = truncate_title(&finding.title, 45);
table_rows.push(vec![
format!("{letter} — {short_title}"),
finding.occurrence_count.to_string(),
format!("{} %", share_pct(finding.occurrence_count)),
]);
chart_data.push((
format!("{letter} — {short_title}"),
finding.occurrence_count as f64,
));
}
if findings.len() > ROOT_CAUSE_SHOWN {
let remaining_causes = findings.len() - ROOT_CAUSE_SHOWN;
let remaining_occurrences = total_occurrences - shown_occurrences;
let label = if en {
format!("Other ({remaining_causes} further causes)")
} else {
format!("Sonstige ({remaining_causes} weitere Ursachen)")
};
table_rows.push(vec![
label.clone(),
remaining_occurrences.to_string(),
format!("{} %", share_pct(remaining_occurrences)),
]);
chart_data.push((label, remaining_occurrences as f64));
}
builder = builder.add_component(list);
if chart_data.len() >= 2 {
builder = builder.add_component(
Chart::bar(if en {
"Occurrence distribution by cause"
} else {
"Verteilung der Vorkommen nach Ursache"
})
.add_series("occurrences", chart_data)
.horizontal(),
);
}
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Root Cause" } else { "Ursache" }).with_width("40%"),
TableColumn::new(if en { "Occurrences" } else { "Vorkommen" }).with_width("30%"),
TableColumn::new(if en { "Share" } else { "Anteil" }).with_width("30%"),
])
.with_title(if en {
"Distribution of Issues by Root Cause"
} else {
"Verteilung der Mängel nach Ursache"
});
for row in table_rows {
table = table.add_row(row);
}
builder = builder.add_component(table);
builder
}
pub(super) fn render_timeframe_roadmap(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let title = if en { "Action Plan" } else { "Maßnahmenplan" };
let subtitle = if en {
"Recommended actions grouped by where the problem lives — including supplementary SEO and quality recommendations without legal relevance."
} else {
"Empfohlene Maßnahmen, gruppiert nach Ebene des Problems — inklusive ergänzender SEO- und Qualitätsempfehlungen ohne Rechtsbezug."
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(title, subtitle)
.with_eyebrow(if en { "ROADMAP" } else { "MASSNAHMEN" })
.with_level(2),
);
if let Some(note) = build_overall_leverage_note(vm, i18n) {
builder = builder.add_component(Label::new(note).with_size("10.5pt"));
}
let columns = &vm.actions.roadmap_columns;
if columns.is_empty() {
let module_measure_count = cross_module_measures(vm, i18n).len();
let empty_msg = if module_measure_count > 0 {
if en {
format!(
"No prioritized accessibility actions. {module_measure_count} recommendation(s) from other modules (performance, SEO, security, mobile) are listed in their module sections and in the key measures above."
)
} else {
format!(
"Keine priorisierten Barrierefreiheits-Maßnahmen. {module_measure_count} Empfehlung(en) aus anderen Modulen (Performance, SEO, Sicherheit, Mobile) stehen in den jeweiligen Modulabschnitten und in den wichtigsten Maßnahmen oben."
)
}
} else if en {
"No prioritized actions — no findings require remediation.".to_string()
} else {
"Keine priorisierten Maßnahmen — keine Befunde mit Handlungsbedarf.".to_string()
};
builder = builder.add_component(Label::new(empty_msg).with_color(design::tokens::NEUTRAL));
return builder;
}
let root_causes = mandatory_root_causes(vm);
let root_cause_by_rule = root_cause_lookup(&root_causes);
for col in columns {
builder = builder.add_component(
SectionHeaderSplit::new(col.title.clone(), col.description.clone()).with_level(3),
);
for item in &col.items {
let mut why = if !item.benefit.is_empty() {
item.benefit.clone()
} else {
item.risk_effect.clone()
};
let leverage_tag = if en {
format!(
"Remediation leverage: {} — {} occurrence(s) affected · effort: {}",
item.leverage, item.occurrence_count, item.effort
)
} else {
format!(
"Reparaturhebel: {} — {} Vorkommen betroffen · Aufwand: {}",
item.leverage, item.occurrence_count, item.effort
)
};
why = format!("{why}\n\n{leverage_tag}");
if let Some((letter, occurrence_count, share_pct)) =
root_cause_by_rule.get(&item.rule_id)
{
let tag = if en {
format!("→ Root Cause {letter}, {occurrence_count} occurrences ({share_pct}%)")
} else {
format!("→ Ursache {letter}, {occurrence_count} Vorkommen ({share_pct} %)")
};
why = format!("{why}\n\n{tag}");
}
builder = builder.add_component(RecommendationCard::new(item.action.clone(), why));
}
}
builder
}
#[cfg(test)]
mod top_measure_tests {
use super::*;
fn measure(module: &str, title: &str, detail: &str) -> TopMeasure {
TopMeasure {
module: module.to_string(),
title: title.to_string(),
detail: detail.to_string(),
}
}
#[test]
fn collapsed_measure_reports_how_many_it_stands_for() {
let rendered =
measure("Sicherheit", "Content-Security-Policy", "unsafe-inline").render(1, 2, false);
assert_eq!(
rendered,
"1. Sicherheit — Content-Security-Policy: unsafe-inline (+2 weitere gleicher Art)"
);
}
#[test]
fn single_measure_has_no_collapse_suffix() {
let rendered =
measure("Security", "Content-Security-Policy", "unsafe-inline").render(3, 0, true);
assert_eq!(
rendered,
"3. Security — Content-Security-Policy: unsafe-inline"
);
}
#[test]
fn titleless_measure_renders_without_separator() {
let rendered = measure("Performance", "", "DOM-Struktur verschlanken.").render(2, 0, false);
assert_eq!(rendered, "2. Performance — DOM-Struktur verschlanken.");
}
}
#[cfg(test)]
mod risk_and_strength_tests {
use super::*;
#[test]
fn mixed_report_buckets_bad_dimensions_as_risks_and_good_ones_as_strengths() {
let dimensions = compute_dimension_rows(
2, 1,
2, 45,
95, 95, 95,
false,
);
assert_eq!(dimensions.len(), 5);
let risks: Vec<&DimensionRow> = dimensions.iter().filter(|d| d.status != "good").collect();
let strengths: Vec<&DimensionRow> =
dimensions.iter().filter(|d| d.status == "good").collect();
assert_eq!(
risks.len(),
3,
"usability, legal and business should be risks"
);
assert!(risks.iter().all(|d| d.status == "bad"));
assert!(
risks.iter().all(|d| d.label != "SEO & KI-Sichtbarkeit"),
"SEO must never appear in the risks bucket when its status is good"
);
assert_eq!(
strengths.len(),
2,
"SEO and performance/mobile should be strengths"
);
let seo_strength = strengths
.iter()
.find(|d| d.label == "SEO & KI-Sichtbarkeit")
.expect("SEO dimension should be a strength");
assert!(
seo_strength
.description
.contains("Sehr gute Auffindbarkeit"),
"the exact bug cited in #576: positive SEO text must land in strengths, not risks"
);
}
#[test]
fn clean_report_puts_all_five_dimensions_in_strengths() {
let dimensions = compute_dimension_rows(
0, 0,
0, 95,
95, 95, 95,
false,
);
assert_eq!(dimensions.len(), 5);
assert!(
dimensions.iter().all(|d| d.status == "good"),
"all dimensions should be good, got: {:?}",
dimensions
.iter()
.map(|d| (&d.label, d.status))
.collect::<Vec<_>>()
);
}
#[test]
fn risks_partition_sorts_bad_before_warn_stably() {
let dimensions = compute_dimension_rows(
0, 1, 1, 70, 50, 95, 95, true,
);
let (mut risks, _strengths): (Vec<DimensionRow>, Vec<DimensionRow>) =
dimensions.into_iter().partition(|d| d.status != "good");
risks.sort_by_key(|d| if d.status == "bad" { 0u8 } else { 1u8 });
let statuses: Vec<&str> = risks.iter().map(|d| d.status).collect();
assert_eq!(statuses, vec!["bad", "bad", "warn", "warn"]);
assert_eq!(risks[0].label, "BFSG-relevant technical criteria");
assert_eq!(risks[1].label, "SEO & AI Visibility");
}
fn test_report_view_model() -> ReportViewModel {
let mut results = crate::wcag::WcagResults::new();
results.nodes_checked = 50;
results.passes = 10;
results.add_violation(
crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
crate::cli::WcagLevel::A,
crate::wcag::Severity::High,
"Image missing alt",
"node-img",
)
.with_selector("img.hero")
.with_html_snippet("<img class=\"hero\">")
.with_fix("Add alt"),
);
let report = AuditReport::new(
"https://example.com".into(),
crate::cli::WcagLevel::AA,
results,
100,
);
let normalized = crate::audit::normalized::normalize(&report);
let config = ReportConfig::default();
crate::output::builder::build_view_model(&normalized, &config)
}
#[test]
fn appendix_table_does_not_duplicate_interpretation_when_card_context_matches() {
let mut vm = test_report_view_model();
vm.modules.dashboard = vec![crate::output::report_model::ModuleScore {
name: "Sichtbarkeit & Nutzerverständnis".into(),
score: 56,
measurement_type: "composite".into(),
interpretation:
"Eingeschränkte Search Experience: Die Seite ist nicht ausreichend verständlich."
.into(),
card_context:
"Eingeschränkte Search Experience: Die Seite ist nicht ausreichend verständlich."
.into(),
score_context: String::new(),
key_lever: String::new(),
good_threshold: 75,
warn_threshold: 50,
}];
let i18n = I18n::new("de").unwrap();
let table = build_cli_snapshot_table(&vm, &i18n);
let row = table
.rows
.iter()
.find(|r| r.get(1).and_then(|v| v.as_str()) == Some("Sichtbarkeit & Nutzerverständnis"))
.expect("search experience row must be present");
let value = row[2].as_str().unwrap();
assert_eq!(
value.matches("Eingeschränkte Search Experience").count(),
1,
"interpretation sentence must appear exactly once, got: {value}"
);
}
#[test]
fn problem_profile_casoon_scenario_is_punktuell() {
let mut vm = test_report_view_model();
let i18n_de = I18n::new("de").unwrap();
let i18n_en = I18n::new("en").unwrap();
vm.modules.module_scores = vec![
crate::audit::normalized::ModuleScoreEntry {
name: "Accessibility".into(),
score: 99,
grade: "Sehr gut".into(),
weight_pct: 40,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Performance".into(),
score: 75,
grade: "Gut".into(),
weight_pct: 20,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "SEO".into(),
score: 100,
grade: "Sehr gut".into(),
weight_pct: 20,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Security".into(),
score: 95,
grade: "Sehr gut".into(),
weight_pct: 10,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Mobile".into(),
score: 90,
grade: "Sehr gut".into(),
weight_pct: 10,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
];
let mut finding = vm.findings.all_findings[0].clone();
finding.rule_id = "1.4.12".into();
finding.title = "Inhalt wird abgeschnitten".into();
finding.occurrence_count = 1;
finding.wcag_criterion = "1.4.12".into();
vm.findings.all_findings = vec![finding];
let kind = classify_problem_profile(&vm);
assert_eq!(kind, ProblemProfileKind::Punktuell);
assert_eq!(kind.label(false), "punktuell");
assert_eq!(kind.label(true), "isolated");
let desc_de = build_problem_profile_description(&vm, kind, &i18n_de);
assert!(desc_de.contains("Sehr guter technischer Zustand"));
let desc_en = build_problem_profile_description(&vm, kind, &i18n_en);
assert!(desc_en.contains("Very good technical condition"));
}
#[test]
fn problem_profile_inros_lackner_scenario_is_konzentriert_systemisch() {
let mut vm = test_report_view_model();
let i18n_de = I18n::new("de").unwrap();
let i18n_en = I18n::new("en").unwrap();
vm.modules.module_scores = vec![
crate::audit::normalized::ModuleScoreEntry {
name: "Accessibility".into(),
score: 20,
grade: "Kritisch".into(),
weight_pct: 40,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Performance".into(),
score: 64,
grade: "Verbesserungswürdig".into(),
weight_pct: 20,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "SEO".into(),
score: 65,
grade: "Verbesserungswürdig".into(),
weight_pct: 20,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Security".into(),
score: 30,
grade: "Kritisch".into(),
weight_pct: 10,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Mobile".into(),
score: 80,
grade: "Gut".into(),
weight_pct: 10,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
];
let mut f1 = vm.findings.all_findings[0].clone();
f1.rule_id = "1.4.3".into();
f1.title = "Kontrast unzureichend".into();
f1.occurrence_count = 60;
f1.wcag_criterion = "1.4.3".into();
let mut f2 = vm.findings.all_findings[0].clone();
f2.rule_id = "4.1.2".into();
f2.title = "Name/Rolle fehlt".into();
f2.occurrence_count = 30;
f2.wcag_criterion = "4.1.2".into();
vm.findings.all_findings = vec![f1, f2];
let kind = classify_problem_profile(&vm);
assert_eq!(kind, ProblemProfileKind::KonzentriertSystemisch);
assert_eq!(kind.label(false), "konzentriert & systemisch");
assert_eq!(kind.label(true), "concentrated & systemic");
let desc_de = build_problem_profile_description(&vm, kind, &i18n_de);
assert!(desc_de.contains("Barrierefreiheit und Sicherheit bestimmen den Handlungsbedarf"));
assert!(desc_de.contains("konzentriert sich auf wenige wiederkehrende Muster"));
let desc_en = build_problem_profile_description(&vm, kind, &i18n_en);
assert!(desc_en.contains("Accessibility and Security determine the need for action"));
assert!(desc_en.contains("concentrated in a few recurring patterns"));
}
#[test]
fn problem_profile_auto_birne_scenario_is_breit_systemisch() {
let mut vm = test_report_view_model();
let i18n_de = I18n::new("de").unwrap();
let i18n_en = I18n::new("en").unwrap();
vm.modules.module_scores = vec![
crate::audit::normalized::ModuleScoreEntry {
name: "Accessibility".into(),
score: 12,
grade: "Kritisch".into(),
weight_pct: 40,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Performance".into(),
score: 34,
grade: "Kritisch".into(),
weight_pct: 20,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Security".into(),
score: 22,
grade: "Kritisch".into(),
weight_pct: 10,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "SEO".into(),
score: 82,
grade: "Gut".into(),
weight_pct: 20,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
crate::audit::normalized::ModuleScoreEntry {
name: "Mobile".into(),
score: 70,
grade: "Verbesserungswürdig".into(),
weight_pct: 10,
contributes_to_overall: true,
measurement_type: "measured".into(),
},
];
let kind = classify_problem_profile(&vm);
assert_eq!(kind, ProblemProfileKind::BreitSystemisch);
assert_eq!(kind.label(false), "breit & systemisch");
assert_eq!(kind.label(true), "broad & systemic");
let desc_de = build_problem_profile_description(&vm, kind, &i18n_de);
assert!(desc_de.contains("Mehrere technische Bereiche sind gleichzeitig betroffen"));
assert!(desc_de.contains("Barrierefreiheit, Performance und Sicherheit"));
let desc_en = build_problem_profile_description(&vm, kind, &i18n_en);
assert!(desc_en.contains("Multiple technical areas are affected simultaneously"));
assert!(desc_en.contains("Accessibility, Performance, and Security"));
}
#[test]
fn subcategory_breakdown_handles_all_weak_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries = vec![
("ARIA".to_string(), 10),
("Landmarks".to_string(), 20),
("Semantik".to_string(), 50),
];
let builder = render_subcategory_breakdown(builder, "Accessibility", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 1);
let json = serde_json::to_string(&report.components[0]).unwrap();
assert!(json.contains("Kritische Schwächen:"));
assert!(!json.contains("Stärkere Teilbereiche"));
assert!(json.contains("ARIA 10"));
}
#[test]
fn subcategory_breakdown_handles_all_stable_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries = vec![
("Formulare".to_string(), 100),
("Alternativtexte".to_string(), 90),
];
let builder = render_subcategory_breakdown(builder, "Barrierefreiheit", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 1);
let json = serde_json::to_string(&report.components[0]).unwrap();
assert!(!json.contains("Kritische Schwächen"));
assert!(json.contains("Stärkere Teilbereiche innerhalb der Barrierefreiheit:"));
assert!(json.contains("Formulare 100"));
}
#[test]
fn subcategory_breakdown_handles_contrast_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries = vec![("ARIA".to_string(), 10), ("Formulare".to_string(), 100)];
let builder = render_subcategory_breakdown(builder, "Barrierefreiheit", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 1);
let json = serde_json::to_string(&report.components[0]).unwrap();
assert!(json.contains("Kritische Schwächen:"));
assert!(json.contains("Stärkere Teilbereiche innerhalb der Barrierefreiheit:"));
}
#[test]
fn subcategory_breakdown_handles_empty_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries: Vec<(String, u32)> = vec![];
let builder = render_subcategory_breakdown(builder, "Accessibility", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 0);
}
#[test]
fn part_divider_part_1_and_2_do_not_have_trailing_break() {
let i18n = I18n::new("de").unwrap();
let b1 = renderreport::engine::ReportBuilder::new("single");
let r1 = render_part_divider(b1, 1, "T1", "I1", "Z1", "B1", &i18n).build();
assert_eq!(r1.components.len(), 3);
let b2 = renderreport::engine::ReportBuilder::new("single");
let r2 = render_part_divider(b2, 2, "T2", "I2", "Z2", "B2", &i18n).build();
assert_eq!(r2.components.len(), 4);
}
#[test]
fn part_divider_part_3_acts_as_standalone_trennseite() {
let i18n = I18n::new("de").unwrap();
let b3 = renderreport::engine::ReportBuilder::new("single");
let r3 = render_part_divider(b3, 3, "T3", "I3", "Z3", "B3", &i18n).build();
assert_eq!(r3.components.len(), 6);
let json = serde_json::to_string(&r3.components).unwrap();
assert!(json.contains("TECHNISCHER ANHANG"));
assert!(json.contains("divider"));
}
#[test]
fn part_divider_part_3_english_eyebrow() {
let i18n = I18n::new("en").unwrap();
let b3 = renderreport::engine::ReportBuilder::new("single");
let r3 = render_part_divider(b3, 3, "T3", "I3", "Z3", "B3", &i18n).build();
let json = serde_json::to_string(&r3.components).unwrap();
assert!(json.contains("TECHNICAL APPENDIX"));
}
}