auditmysite 1.5.1

Resource-efficient WCAG 2.1 AA (+ select 2.2 criteria) Accessibility Checker in Rust
Documentation
use super::*;

pub(in crate::output::pdf) fn render_a11y_journey_findings(
    mut builder: renderreport::engine::ReportBuilder,
    findings: &[crate::audit::normalized::InteractiveFinding],
    journey: Option<&crate::audit::normalized::AccessibilityJourney>,
    i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
    let en = i18n.locale() == "en";

    builder = builder.add_component(PageBreak::new()).add_component(
        Section::new(if en {
            "Keyboard Accessibility Journey"
        } else {
            "Tastatur-Accessibility-Journey"
        })
        .with_level(2),
    );

    // Collapse identical journey findings (same journey, category, message and
    // severity) into a single row with an occurrence count. Repeated identical
    // entries otherwise read like a render-loop bug (#358). Distinct elements
    // carry distinct messages and are never merged.
    let mut deduped: Vec<(&crate::audit::normalized::InteractiveFinding, usize)> = Vec::new();
    for f in findings {
        if let Some(entry) = deduped.iter_mut().find(|(g, _)| {
            g.journey == f.journey
                && g.category == f.category
                && g.message == f.message
                && g.severity == f.severity
        }) {
            entry.1 += 1;
        } else {
            deduped.push((f, 1));
        }
    }

    let critical_count = deduped
        .iter()
        .filter(|(f, _)| f.severity == crate::taxonomy::Severity::Critical)
        .count();
    let high_count = deduped
        .iter()
        .filter(|(f, _)| f.severity == crate::taxonomy::Severity::High)
        .count();
    let overview = if critical_count > 0 {
        if en {
            format!(
                "{} critical and {} high issues detected during keyboard and state-transition tests.",
                critical_count, high_count
            )
        } else {
            format!(
                "{} kritische und {} hohe Befunde aus Tastatur- und Zustandswechsel-Tests.",
                critical_count, high_count
            )
        }
    } else if high_count > 0 {
        if en {
            format!("{} high-severity interactive issues found.", high_count)
        } else {
            format!(
                "{} interaktive Befunde mit hoher Schwere gefunden.",
                high_count
            )
        }
    } else if en {
        format!(
            "{} minor interactive {} — no critical or high barriers detected.",
            deduped.len(),
            if deduped.len() == 1 {
                "issue"
            } else {
                "issues"
            }
        )
    } else {
        format!(
            "{} {} interaktive{} Befund{} — keine kritischen oder hohen Barrieren erkannt.",
            deduped.len(),
            if deduped.len() == 1 {
                "kleinerer"
            } else {
                "kleinere"
            },
            if deduped.len() == 1 { "r" } else { "" },
            if deduped.len() == 1 { "" } else { "e" }
        )
    };
    // Never a success callout: this section only renders when there *are*
    // interactive findings, so green read as "all clear" directly above the
    // findings it was summarizing.
    builder = builder.add_component(if critical_count > 0 {
        Callout::warning(&overview)
    } else {
        Callout::info(&overview)
    });

    let shown = deduped.len().min(10);
    for (finding, count) in &deduped[..shown] {
        let sev = map_severity(&finding.severity);
        // Re-derive message/fix_suggestion in the run language. The stored
        // `finding.message`/`finding.fix_suggestion` are baked in canonical
        // English (#406); the PDF localizes them from `kind` + `values`.
        let (message, fix_suggestion) =
            crate::audit::normalized::interactive_finding_text(finding.kind, &finding.values, en);
        let body = if let Some(fix) = fix_suggestion {
            format!("{} — {}", message, fix)
        } else {
            message
        };
        let label = journey_category_label(&finding.category, i18n);
        let title = if *count > 1 {
            format!("{label} (×{count})")
        } else {
            label
        };
        builder = builder.add_component(Finding::new(&title, sev, &body));
    }
    if deduped.len() > 10 {
        let more = if en {
            format!(
                "{} additional interactive findings in the JSON report.",
                deduped.len() - 10
            )
        } else {
            format!(
                "{} weitere interaktive Befunde im JSON-Report.",
                deduped.len() - 10
            )
        };
        builder = builder.add_component(Callout::info(&more));
    }

    if let Some(journey_data) = journey {
        if !journey_data.traces.is_empty() {
            let mut kv = KeyValueList::new().with_title(if en {
                "Tested Journey Sequences"
            } else {
                "Geprüfte Journey-Sequenzen"
            });
            for trace in &journey_data.traces {
                let step_count = trace.steps.len();
                let summary = format!("{} {}", step_count, if en { "steps" } else { "Schritte" });
                kv = kv.add(journey_label(&trace.journey, en), summary);
            }
            builder = builder.add_component(kv);
        }
    }

    let disclaimer = if en {
        "These tests check whether browser, DOM, focus, and accessibility tree provide a robust foundation for screen reader use. They do not simulate the exact output of NVDA, JAWS, or VoiceOver."
    } else {
        "Diese Tests prüfen, ob Browser, DOM, Fokus und Accessibility Tree eine robuste Grundlage für Screenreader-Nutzung liefern. Sie simulieren nicht den exakten Output von NVDA, JAWS oder VoiceOver."
    };
    builder = builder.add_component(Callout::info(disclaimer));

    builder
}

/// Screen-reader reading-order audit section (#411). Renders the quality scores,
/// the BFSG verdict and the detected issues. The full reading sequence stays in
/// the sidecar JSON; this section surfaces the actionable summary in the report.
pub(in crate::output::pdf) fn render_screen_reader_section(
    mut builder: renderreport::engine::ReportBuilder,
    sr: &crate::screen_reader::SrAuditReport,
    patterns: Option<&crate::patterns::PatternAnalysis>,
    i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
    use crate::screen_reader::BfsgVerdict;
    let en = i18n.locale() == "en";

    builder = builder.add_component(PageBreak::new()).add_component(
        Section::new(if en {
            "Screen Reader Reading Order"
        } else {
            "Screenreader-Lesereihenfolge"
        })
        .with_level(2),
    );

    let intro = if en {
        "How the page is announced to screen reader users: reading order, landmark and heading quality, and accessible names. These are structural checks on the accessibility tree, not a simulation of NVDA, JAWS or VoiceOver."
    } else {
        "Wie die Seite Screenreader-Nutzern angekündigt wird: Lesereihenfolge, Landmark- und Heading-Qualität sowie zugängliche Namen. Strukturelle Prüfungen am Accessibility Tree, keine Simulation von NVDA, JAWS oder VoiceOver."
    };
    builder = builder.add_component(
        Label::new(intro)
            .with_size("10.5pt")
            .with_color(crate::output::pdf::design::tokens::NEUTRAL),
    );

    let s = &sr.summary;
    builder = builder.add_component(
        MetricStrip::new(vec![
            MetricStripItem::new(
                if en {
                    "Heading quality"
                } else {
                    "Heading-Qualität"
                },
                format!("{} / 100", s.heading_quality_score),
            )
            .with_accent(crate::output::pdf::design::score_color(
                s.heading_quality_score as u8,
            )),
            MetricStripItem::new(
                if en {
                    "Landmark quality"
                } else {
                    "Landmark-Qualität"
                },
                format!("{} / 100", s.landmark_quality_score),
            )
            .with_accent(crate::output::pdf::design::score_color(
                s.landmark_quality_score as u8,
            )),
            MetricStripItem::new(
                if en {
                    "Name quality"
                } else {
                    "Namens-Qualität"
                },
                format!("{} / 100", s.name_quality_score),
            )
            .with_accent(crate::output::pdf::design::score_color(
                s.name_quality_score as u8,
            )),
            MetricStripItem::new(
                if en {
                    "Announced nodes"
                } else {
                    "Angekündigte Knoten"
                },
                s.total_announced_nodes.to_string(),
            ),
            MetricStripItem::new(
                if en { "Tab stops" } else { "Tab-Stopps" },
                s.tab_stops.to_string(),
            ),
        ])
        .compact(),
    );
    builder = builder.add_component(
        Label::new(if en {
            "The three quality scores use a 0–100 scale (higher is better) and summarize the detected heading hierarchy, landmark structure, and accessible names. Announced nodes and tab stops are simple counts, not quality scores."
        } else {
            "Die drei Qualitätswerte nutzen eine Skala von 0–100 (höher ist besser) und verdichten erkannte Überschriftenhierarchie, Landmark-Struktur und zugängliche Namen. Angekündigte Knoten und Tab-Stopps sind reine Anzahlen, keine Qualitätswerte."
        })
        .with_size("9pt")
        .with_color(crate::output::pdf::design::tokens::NEUTRAL),
    );

    // Deliberately never phrased as a conformance verdict: this check compares
    // technical criteria, it cannot establish whether a service falls under
    // the BFSG at all or whether every legal requirement is met. "No
    // violations" would read as a legal clearance the audit cannot give.
    let bfsg_note = match sr.bfsg_compliance.verdict {
        BfsgVerdict::Compliant => Callout::info(if en {
            "BFSG-relevant technical criteria: no violations detected within the automated scope. This is not a legal conformance assessment."
        } else {
            "BFSG-relevante technische Kriterien: im automatisierten Prüfumfang keine Verstöße erkannt. Dies ist keine rechtliche Konformitätsprüfung."
        }),
        BfsgVerdict::NonCompliant => Callout::warning(if en {
            "BFSG-relevant technical criteria: violations detected within the automated scope (see issues below). This is not a legal conformance assessment."
        } else {
            "BFSG-relevante technische Kriterien: im automatisierten Prüfumfang Verstöße festgestellt (siehe Befunde unten). Dies ist keine rechtliche Konformitätsprüfung."
        }),
        BfsgVerdict::NotEvaluated => Callout::info(if en {
            "BFSG conformance was not evaluated for this page."
        } else {
            "BFSG-Konformität wurde für diese Seite nicht bewertet."
        }),
    };
    builder = builder.add_component(bfsg_note);

    // Re-derive issues in the run language. The stored `sr.issues` are baked in
    // canonical English (#406); the PDF localizes them by recomputing from the
    // reading sequence and navigation views.
    let items: Vec<crate::screen_reader::ReadingItem> =
        sr.reading_sequence.iter().map(|a| a.item.clone()).collect();
    let has_disclosure_menu_pattern =
        patterns.is_some_and(|patterns| patterns.has_recognized("DisclosureMenu"));
    let localized_issues = crate::screen_reader::analyze_reading_sequence(
        &items,
        &sr.navigation_views,
        i18n.locale(),
        i18n.locale() == "en",
        has_disclosure_menu_pattern,
    );

    if !localized_issues.is_empty() {
        // Collapse identical messages into one row with an occurrence count.
        let mut deduped: Vec<(&crate::screen_reader::SrAuditIssue, usize)> = Vec::new();
        for issue in &localized_issues {
            if let Some(entry) = deduped
                .iter_mut()
                .find(|(g, _)| g.message == issue.message && g.severity == issue.severity)
            {
                entry.1 += 1;
            } else {
                deduped.push((issue, 1));
            }
        }

        let sev_label = |sev: &str| -> &'static str {
            match (sev, en) {
                ("high", true) => "High",
                ("high", false) => "Hoch",
                ("medium", true) => "Medium",
                ("medium", false) => "Mittel",
                (_, true) => "Low",
                (_, false) => "Niedrig",
            }
        };

        let mut table = AuditTable::new(vec![
            TableColumn::new(i18n.t("label-issue")).with_width("75%"),
            TableColumn::new(i18n.t("label-severity")).with_width("25%"),
        ])
        .with_title(if en {
            "Screen reader findings"
        } else {
            "Screenreader-Befunde"
        });
        for (issue, count) in deduped {
            let mut msg = if count > 1 {
                format!("{} ({}×)", issue.message, count)
            } else {
                issue.message.clone()
            };
            if let Some(instruction) = issue
                .wcag_criterion
                .as_deref()
                .and_then(|c| manual_recheck_instruction(c, en))
            {
                msg.push(' ');
                msg.push_str(instruction);
            }
            table = table.add_row(vec![msg, sev_label(&issue.severity).to_string()]);
        }
        builder = builder.add_component(table);
    }

    builder
}

/// Human label for an internal journey id such as `tab_walk` or
/// `disclosure_0`. The ids are analysis-internal and were previously printed
/// verbatim into the customer-facing PDF. Unknown ids fall back to the raw id
/// rather than inventing a name for them.
pub(super) fn journey_label(journey: &str, en: bool) -> String {
    let (base, index) = match journey.rsplit_once('_') {
        Some((base, tail)) if tail.chars().all(|c| c.is_ascii_digit()) && !tail.is_empty() => (
            base,
            tail.parse::<usize>()
                .ok()
                .map(|index| index.saturating_add(1)),
        ),
        _ => (journey, None),
    };

    let label = match base {
        "tab_walk" => Some(if en { "Tab walk" } else { "Tab-Durchlauf" }),
        "spa_navigation" => Some(if en {
            "SPA navigation"
        } else {
            "SPA-Navigation"
        }),
        "skip_link" => Some(if en { "Skip link" } else { "Sprunglink" }),
        "disclosure" => Some(if en {
            "Disclosure menu"
        } else {
            "Aufklapp-Menü"
        }),
        "menu" => Some(if en { "Menu" } else { "Menü" }),
        "modal" => Some(if en { "Modal dialog" } else { "Dialog" }),
        "tabs" => Some(if en { "Tab panel" } else { "Tab-Panel" }),
        "form_error" => Some(if en { "Form error" } else { "Formularfehler" }),
        "add_to_cart" => Some(if en {
            "Add to cart"
        } else {
            "In den Warenkorb"
        }),
        "quantity_stepper" => Some(if en {
            "Quantity stepper"
        } else {
            "Mengenauswahl"
        }),
        _ => None,
    };

    match (label, index) {
        (Some(label), Some(index)) => format!("{label} {index}"),
        (Some(label), None) => label.to_string(),
        (None, _) => journey.to_string(),
    }
}