Skip to main content

sbom_tools/reports/
html.rs

1//! HTML report generator.
2
3use super::escape::{escape_html, escape_html_opt};
4use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
5use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
6use crate::model::NormalizedSbom;
7use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
8use std::fmt::Write;
9
10/// HTML report generator
11pub struct HtmlReporter {
12    /// Include inline CSS
13    include_styles: bool,
14}
15
16impl HtmlReporter {
17    /// Create a new HTML reporter
18    #[must_use]
19    pub const fn new() -> Self {
20        Self {
21            include_styles: true,
22        }
23    }
24}
25
26impl Default for HtmlReporter {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32// ============================================================================
33// HTML building helpers
34// ============================================================================
35
36/// Write the HTML document head (doctype through opening body/container).
37fn write_html_head(html: &mut String, title: &str, include_styles: bool) -> std::fmt::Result {
38    writeln!(html, "<!DOCTYPE html>")?;
39    writeln!(html, "<html lang=\"en\">")?;
40    writeln!(html, "<head>")?;
41    writeln!(html, "    <meta charset=\"UTF-8\">")?;
42    writeln!(
43        html,
44        "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
45    )?;
46    writeln!(html, "    <title>{}</title>", escape_html(title))?;
47    if include_styles {
48        writeln!(html, "{HTML_STYLES}")?;
49    }
50    writeln!(html, "</head>")?;
51    writeln!(html, "<body>")?;
52    writeln!(html, "<div class=\"container\">")
53}
54
55/// Write the page header with title and generation info.
56fn write_page_header(html: &mut String, title: &str, subtitle: Option<&str>) -> std::fmt::Result {
57    writeln!(html, "<div class=\"header\" id=\"top\">")?;
58    writeln!(html, "    <h1>{}</h1>", escape_html(title))?;
59    if let Some(sub) = subtitle {
60        writeln!(html, "    <p>{}</p>", escape_html(sub))?;
61    }
62    writeln!(
63        html,
64        "    <p>Generated by sbom-tools v{} on {}</p>",
65        env!("CARGO_PKG_VERSION"),
66        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
67    )?;
68    writeln!(html, "</div>")
69}
70
71/// Write a table of contents navigation.
72fn write_toc(html: &mut String, sections: &[(&str, &str)]) -> std::fmt::Result {
73    writeln!(html, "<nav class=\"toc\">")?;
74    writeln!(html, "    <strong>Contents:</strong>")?;
75    for (id, label) in sections {
76        write!(html, "    <a href=\"#{id}\">{label}</a>")?;
77    }
78    writeln!(html)?;
79    writeln!(html, "</nav>")
80}
81
82/// Write a single summary card.
83fn write_card(html: &mut String, title: &str, value: &str, css_class: &str) -> std::fmt::Result {
84    writeln!(html, "    <div class=\"card\">")?;
85    writeln!(html, "        <div class=\"card-title\">{title}</div>")?;
86    writeln!(
87        html,
88        "        <div class=\"card-value {css_class}\">{value}</div>"
89    )?;
90    writeln!(html, "    </div>")
91}
92
93/// Write the HTML document footer and closing tags.
94fn write_html_footer(html: &mut String) -> std::fmt::Result {
95    writeln!(html, "<div class=\"footer\">")?;
96    writeln!(
97        html,
98        "    <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>"
99    )?;
100    writeln!(html, "</div>")?;
101    writeln!(html, "</div>")?;
102    writeln!(html, "</body>")?;
103    writeln!(html, "</html>")
104}
105
106/// Write an EOL components section if any components have EOL data.
107fn write_eol_section(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
108    use crate::model::EolStatus;
109
110    let eol_components: Vec<_> = sbom
111        .components
112        .values()
113        .filter(|c| {
114            c.eol.as_ref().is_some_and(|e| {
115                matches!(
116                    e.status,
117                    EolStatus::EndOfLife | EolStatus::ApproachingEol | EolStatus::SecurityOnly
118                )
119            })
120        })
121        .collect();
122
123    if eol_components.is_empty() {
124        return Ok(());
125    }
126
127    writeln!(html, "<div class=\"section\" id=\"eol\">")?;
128    writeln!(html, "    <h2>End-of-Life Components</h2>")?;
129    writeln!(html, "    <table>")?;
130    writeln!(html, "        <thead>")?;
131    writeln!(
132        html,
133        "            <tr><th>Component</th><th>Version</th><th>Status</th><th>Product</th><th>EOL Date</th></tr>"
134    )?;
135    writeln!(html, "        </thead>")?;
136    writeln!(html, "        <tbody>")?;
137
138    for comp in &eol_components {
139        let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
140        let badge_class = match eol.status {
141            EolStatus::EndOfLife => "badge-critical",
142            EolStatus::ApproachingEol => "badge-warning",
143            EolStatus::SecurityOnly => "badge-info",
144            _ => "",
145        };
146        let eol_date = eol
147            .eol_date
148            .map_or_else(|| "-".to_string(), |d| d.to_string());
149
150        writeln!(
151            html,
152            "            <tr><td>{}</td><td>{}</td><td><span class=\"badge {}\">{}</span></td><td>{}</td><td>{}</td></tr>",
153            escape_html(&comp.name),
154            escape_html(comp.version.as_deref().unwrap_or("-")),
155            badge_class,
156            escape_html(eol.status.label()),
157            escape_html(&eol.product),
158            escape_html(&eol_date),
159        )?;
160    }
161
162    writeln!(html, "        </tbody>")?;
163    writeln!(html, "    </table>")?;
164    writeln!(html, "</div>")
165}
166
167/// Write the document-metadata changes table for diff reports.
168fn write_diff_metadata_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
169    writeln!(html, "<div class=\"section\" id=\"metadata-changes\">")?;
170    writeln!(html, "    <h2>Metadata Changes</h2>")?;
171    writeln!(html, "    <table>")?;
172    writeln!(html, "        <thead>")?;
173    writeln!(html, "            <tr>")?;
174    writeln!(html, "                <th>Field</th>")?;
175    writeln!(html, "                <th>Old</th>")?;
176    writeln!(html, "                <th>New</th>")?;
177    writeln!(html, "            </tr>")?;
178    writeln!(html, "        </thead>")?;
179    writeln!(html, "        <tbody>")?;
180
181    for change in &result.metadata_changes {
182        writeln!(html, "            <tr>")?;
183        writeln!(
184            html,
185            "                <td>{}</td>",
186            escape_html(&change.field)
187        )?;
188        writeln!(
189            html,
190            "                <td>{}</td>",
191            escape_html_opt(change.old_value.as_deref())
192        )?;
193        writeln!(
194            html,
195            "                <td>{}</td>",
196            escape_html_opt(change.new_value.as_deref())
197        )?;
198        writeln!(html, "            </tr>")?;
199    }
200
201    writeln!(html, "        </tbody>")?;
202    writeln!(html, "    </table>")?;
203    writeln!(html, "</div>")
204}
205
206/// Write the component changes table for diff reports.
207fn write_diff_component_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
208    writeln!(html, "<div class=\"section\" id=\"component-changes\">")?;
209    writeln!(html, "    <h2>Component Changes</h2>")?;
210    writeln!(html, "    <table>")?;
211    writeln!(html, "        <thead>")?;
212    writeln!(html, "            <tr>")?;
213    writeln!(html, "                <th>Status</th>")?;
214    writeln!(html, "                <th>Name</th>")?;
215    writeln!(html, "                <th>Old Version</th>")?;
216    writeln!(html, "                <th>New Version</th>")?;
217    writeln!(html, "                <th>Ecosystem</th>")?;
218    writeln!(html, "            </tr>")?;
219    writeln!(html, "        </thead>")?;
220    writeln!(html, "        <tbody>")?;
221
222    for comp in &result.components.added {
223        writeln!(html, "            <tr>")?;
224        writeln!(
225            html,
226            "                <td><span class=\"badge badge-added\">Added</span></td>"
227        )?;
228        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
229        writeln!(html, "                <td>-</td>")?;
230        writeln!(
231            html,
232            "                <td>{}</td>",
233            escape_html_opt(comp.new_version.as_deref())
234        )?;
235        writeln!(
236            html,
237            "                <td>{}</td>",
238            escape_html_opt(comp.ecosystem.as_deref())
239        )?;
240        writeln!(html, "            </tr>")?;
241    }
242
243    for comp in &result.components.removed {
244        writeln!(html, "            <tr>")?;
245        writeln!(
246            html,
247            "                <td><span class=\"badge badge-removed\">Removed</span></td>"
248        )?;
249        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
250        writeln!(
251            html,
252            "                <td>{}</td>",
253            escape_html_opt(comp.old_version.as_deref())
254        )?;
255        writeln!(html, "                <td>-</td>")?;
256        writeln!(
257            html,
258            "                <td>{}</td>",
259            escape_html_opt(comp.ecosystem.as_deref())
260        )?;
261        writeln!(html, "            </tr>")?;
262    }
263
264    for comp in &result.components.modified {
265        writeln!(html, "            <tr>")?;
266        writeln!(
267            html,
268            "                <td><span class=\"badge badge-modified\">Modified</span></td>"
269        )?;
270        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
271        writeln!(
272            html,
273            "                <td>{}</td>",
274            escape_html_opt(comp.old_version.as_deref())
275        )?;
276        writeln!(
277            html,
278            "                <td>{}</td>",
279            escape_html_opt(comp.new_version.as_deref())
280        )?;
281        writeln!(
282            html,
283            "                <td>{}</td>",
284            escape_html_opt(comp.ecosystem.as_deref())
285        )?;
286        writeln!(html, "            </tr>")?;
287    }
288
289    writeln!(html, "        </tbody>")?;
290    writeln!(html, "    </table>")?;
291    writeln!(html, "</div>")
292}
293
294/// Write the introduced vulnerabilities table for diff reports.
295fn write_diff_vuln_table(html: &mut String, result: &DiffResult) -> std::fmt::Result {
296    writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
297    writeln!(html, "    <h2>Introduced Vulnerabilities</h2>")?;
298    writeln!(html, "    <table>")?;
299    writeln!(html, "        <thead>")?;
300    writeln!(html, "            <tr>")?;
301    writeln!(html, "                <th>ID</th>")?;
302    writeln!(html, "                <th>Severity</th>")?;
303    writeln!(html, "                <th>CVSS</th>")?;
304    writeln!(html, "                <th>SLA</th>")?;
305    writeln!(html, "                <th>Type</th>")?;
306    writeln!(html, "                <th>Component</th>")?;
307    writeln!(html, "                <th>Version</th>")?;
308    writeln!(html, "                <th>VEX</th>")?;
309    writeln!(html, "            </tr>")?;
310    writeln!(html, "        </thead>")?;
311    writeln!(html, "        <tbody>")?;
312
313    for vuln in &result.vulnerabilities.introduced {
314        let badge_class = match vuln.severity.to_lowercase().as_str() {
315            "critical" => "badge-critical",
316            "high" => "badge-high",
317            "medium" => "badge-medium",
318            _ => "badge-low",
319        };
320        let (depth_label, depth_class) = match vuln.component_depth {
321            Some(1) => ("Direct", "badge-direct"),
322            Some(_) => ("Transitive", "badge-transitive"),
323            None => ("-", ""),
324        };
325        writeln!(html, "            <tr>")?;
326        writeln!(html, "                <td>{}</td>", escape_html(&vuln.id))?;
327        writeln!(
328            html,
329            "                <td><span class=\"badge {}\">{}</span></td>",
330            badge_class,
331            escape_html(&vuln.severity)
332        )?;
333        writeln!(
334            html,
335            "                <td>{}</td>",
336            vuln.cvss_score
337                .map(|s| format!("{s:.1}"))
338                .as_deref()
339                .unwrap_or("-")
340        )?;
341        // SLA cell
342        let (sla_text, sla_class) = format_sla_html(vuln);
343        if sla_class.is_empty() {
344            writeln!(html, "                <td>{sla_text}</td>")?;
345        } else {
346            writeln!(
347                html,
348                "                <td><span class=\"{sla_class}\">{sla_text}</span></td>"
349            )?;
350        }
351        if depth_class.is_empty() {
352            writeln!(html, "                <td>{depth_label}</td>")?;
353        } else {
354            writeln!(
355                html,
356                "                <td><span class=\"badge {depth_class}\">{depth_label}</span></td>"
357            )?;
358        }
359        writeln!(
360            html,
361            "                <td>{}</td>",
362            escape_html(&vuln.component_name)
363        )?;
364        writeln!(
365            html,
366            "                <td>{}</td>",
367            escape_html_opt(vuln.version.as_deref())
368        )?;
369        // VEX status
370        let vex_display = format_vex_html(vuln.vex_state.as_ref());
371        writeln!(html, "                <td>{vex_display}</td>")?;
372        writeln!(html, "            </tr>")?;
373    }
374
375    writeln!(html, "        </tbody>")?;
376    writeln!(html, "    </table>")?;
377    writeln!(html, "</div>")
378}
379
380/// Write the components table for view reports.
381fn write_view_component_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
382    writeln!(html, "<div class=\"section\" id=\"components\">")?;
383    writeln!(html, "    <h2>Components</h2>")?;
384    writeln!(html, "    <table>")?;
385    writeln!(html, "        <thead>")?;
386    writeln!(html, "            <tr>")?;
387    writeln!(html, "                <th>Name</th>")?;
388    writeln!(html, "                <th>Version</th>")?;
389    writeln!(html, "                <th>Ecosystem</th>")?;
390    writeln!(html, "                <th>License</th>")?;
391    writeln!(html, "                <th>Vulnerabilities</th>")?;
392    writeln!(html, "            </tr>")?;
393    writeln!(html, "        </thead>")?;
394    writeln!(html, "        <tbody>")?;
395
396    // Sort components by name for consistent output
397    let mut components: Vec<_> = sbom.components.values().collect();
398    components.sort_by(|a, b| a.name.cmp(&b.name));
399
400    for comp in components {
401        let license_str = comp
402            .licenses
403            .declared
404            .first()
405            .map_or("-", |l| l.expression.as_str());
406        let vuln_count = comp.vulnerabilities.len();
407        let vuln_badge = if vuln_count > 0 {
408            format!("<span class=\"badge badge-critical\">{vuln_count}</span>")
409        } else {
410            "0".to_string()
411        };
412
413        writeln!(html, "            <tr>")?;
414        writeln!(html, "                <td>{}</td>", escape_html(&comp.name))?;
415        writeln!(
416            html,
417            "                <td>{}</td>",
418            escape_html_opt(comp.version.as_deref())
419        )?;
420        writeln!(
421            html,
422            "                <td>{}</td>",
423            comp.ecosystem
424                .as_ref()
425                .map(|e| escape_html(&format!("{e:?}")))
426                .as_deref()
427                .unwrap_or("-")
428        )?;
429        writeln!(
430            html,
431            "                <td>{}</td>",
432            escape_html(license_str)
433        )?;
434        writeln!(html, "                <td>{vuln_badge}</td>")?;
435        writeln!(html, "            </tr>")?;
436    }
437
438    writeln!(html, "        </tbody>")?;
439    writeln!(html, "    </table>")?;
440    writeln!(html, "</div>")
441}
442
443/// A view vulnerability row with SLA info.
444type ViewVulnRow<'a> = (
445    &'a str,
446    &'a Option<crate::model::Severity>,
447    Option<f32>,
448    &'a str,
449    Option<&'a str>,
450    Option<&'a crate::model::VulnerabilityRef>,
451);
452
453/// Write the vulnerabilities table for view reports (with SLA columns).
454fn write_view_vuln_table(html: &mut String, sbom: &NormalizedSbom) -> std::fmt::Result {
455    writeln!(html, "<div class=\"section\" id=\"vulnerabilities\">")?;
456    writeln!(html, "    <h2>Vulnerabilities</h2>")?;
457    writeln!(html, "    <table>")?;
458    writeln!(html, "        <thead>")?;
459    writeln!(html, "            <tr>")?;
460    writeln!(html, "                <th>ID</th>")?;
461    writeln!(html, "                <th>Severity</th>")?;
462    writeln!(html, "                <th>CVSS</th>")?;
463    writeln!(html, "                <th>SLA</th>")?;
464    writeln!(html, "                <th>Component</th>")?;
465    writeln!(html, "                <th>Version</th>")?;
466    writeln!(html, "                <th>VEX</th>")?;
467    writeln!(html, "            </tr>")?;
468    writeln!(html, "        </thead>")?;
469    writeln!(html, "        <tbody>")?;
470
471    // Collect all vulnerabilities with their component info
472    let mut all_vulns: Vec<ViewVulnRow<'_>> = sbom
473        .components
474        .values()
475        .flat_map(|comp| {
476            comp.vulnerabilities.iter().map(move |v| {
477                (
478                    v.id.as_str(),
479                    &v.severity,
480                    v.cvss.first().map(|c| c.base_score),
481                    comp.name.as_str(),
482                    comp.version.as_deref(),
483                    Some(v),
484                )
485            })
486        })
487        .collect();
488
489    // Sort by severity (critical first)
490    all_vulns.sort_by(|a, b| {
491        let sev_order = |s: &Option<crate::model::Severity>| match s {
492            Some(crate::model::Severity::Critical) => 0,
493            Some(crate::model::Severity::High) => 1,
494            Some(crate::model::Severity::Medium) => 2,
495            Some(crate::model::Severity::Low) => 3,
496            Some(crate::model::Severity::Info) => 4,
497            _ => 5,
498        };
499        sev_order(a.1).cmp(&sev_order(b.1))
500    });
501
502    for &(id, severity, cvss, comp_name, version, vuln) in &all_vulns {
503        let (badge_class, sev_str) = match severity {
504            Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
505            Some(crate::model::Severity::High) => ("badge-high", "High"),
506            Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
507            Some(crate::model::Severity::Low) => ("badge-low", "Low"),
508            Some(crate::model::Severity::Info) => ("badge-low", "Info"),
509            _ => ("badge-low", "Unknown"),
510        };
511
512        // Compute SLA from published date if available
513        let (sla_text, sla_class) = if let Some(v) = vuln {
514            compute_view_sla(v)
515        } else {
516            ("-".to_string(), "sla-unknown")
517        };
518
519        writeln!(html, "            <tr>")?;
520        writeln!(html, "                <td>{}</td>", escape_html(id))?;
521        writeln!(
522            html,
523            "                <td><span class=\"badge {badge_class}\">{sev_str}</span></td>"
524        )?;
525        writeln!(
526            html,
527            "                <td>{}</td>",
528            cvss.map(|s| format!("{s:.1}")).as_deref().unwrap_or("-")
529        )?;
530        if sla_class.is_empty() {
531            writeln!(html, "                <td>{sla_text}</td>")?;
532        } else {
533            writeln!(
534                html,
535                "                <td><span class=\"{sla_class}\">{sla_text}</span></td>"
536            )?;
537        }
538        writeln!(html, "                <td>{}</td>", escape_html(comp_name))?;
539        writeln!(
540            html,
541            "                <td>{}</td>",
542            escape_html_opt(version)
543        )?;
544        // VEX status from per-vuln
545        let vex_state = vuln.and_then(|v| v.vex_status.as_ref().map(|vs| &vs.status));
546        let vex_display = format_vex_html(vex_state);
547        writeln!(html, "                <td>{vex_display}</td>")?;
548        writeln!(html, "            </tr>")?;
549    }
550
551    writeln!(html, "        </tbody>")?;
552    writeln!(html, "    </table>")?;
553    writeln!(html, "</div>")
554}
555
556/// Compute SLA display for a vulnerability in view mode (from published date).
557fn compute_view_sla(vuln: &crate::model::VulnerabilityRef) -> (String, &'static str) {
558    if let Some(published) = vuln.published {
559        let delta: chrono::TimeDelta = chrono::Utc::now() - published;
560        let days = delta.num_days();
561        if days < 0 {
562            return ("-".to_string(), "sla-unknown");
563        }
564        let days = days as u64;
565        // Use standard SLA thresholds based on severity
566        let sla_days: Option<u64> = match &vuln.severity {
567            Some(crate::model::Severity::Critical) => Some(15),
568            Some(crate::model::Severity::High) => Some(30),
569            Some(crate::model::Severity::Medium) => Some(90),
570            Some(crate::model::Severity::Low) => Some(180),
571            _ => None,
572        };
573        if let Some(sla) = sla_days {
574            if days > sla {
575                (format!("{}d late", days - sla), "sla-overdue")
576            } else if sla - days <= 7 {
577                (format!("{}d left", sla - days), "sla-due-soon")
578            } else {
579                (format!("{}d left", sla - days), "sla-on-track")
580            }
581        } else {
582            (format!("{days}d old"), "sla-unknown")
583        }
584    } else {
585        ("-".to_string(), "sla-unknown")
586    }
587}
588
589/// Format SLA status for HTML display.
590fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
591    match vuln.sla_status() {
592        SlaStatus::Overdue(days) => (format!("{days}d late"), "sla-overdue"),
593        SlaStatus::DueSoon(days) => (format!("{days}d left"), "sla-due-soon"),
594        SlaStatus::OnTrack(days) => (format!("{days}d left"), "sla-on-track"),
595        SlaStatus::NoDueDate => {
596            let text = vuln
597                .days_since_published
598                .map_or_else(|| "-".to_string(), |d| format!("{d}d old"));
599            (text, "sla-unknown")
600        }
601    }
602}
603
604/// Format VEX state as an HTML badge string.
605fn format_vex_html(vex_state: Option<&crate::model::VexState>) -> String {
606    match vex_state {
607        Some(crate::model::VexState::NotAffected) => {
608            "<span class=\"badge badge-added\">Not Affected</span>".to_string()
609        }
610        Some(crate::model::VexState::Fixed) => {
611            "<span class=\"badge badge-added\">Fixed</span>".to_string()
612        }
613        Some(crate::model::VexState::Affected) => {
614            "<span class=\"badge badge-removed\">Affected</span>".to_string()
615        }
616        Some(crate::model::VexState::UnderInvestigation) => {
617            "<span class=\"badge badge-medium\">Under Investigation</span>".to_string()
618        }
619        None => "-".to_string(),
620    }
621}
622
623/// Compute compliance score as percentage (0-100)
624fn compliance_score_html(result: &ComplianceResult) -> u8 {
625    let total = result.violations.len() + 1;
626    let issues = result.error_count + result.warning_count;
627    let score = if issues >= total {
628        0
629    } else {
630        ((total - issues) * 100) / total
631    };
632    score.min(100) as u8
633}
634
635/// Generate an HTML trend badge for numeric delta
636fn trend_badge(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
637    if old_val == new_val {
638        ""
639    } else if (new_val < old_val) == lower_is_better {
640        " <span class=\"badge badge-added\">improved</span>"
641    } else {
642        " <span class=\"badge badge-removed\">regressed</span>"
643    }
644}
645
646/// Write a CRA compliance comparison section for diff reports.
647fn write_cra_compliance_diff_html(
648    html: &mut String,
649    old: &ComplianceResult,
650    new: &ComplianceResult,
651) -> std::fmt::Result {
652    writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
653    writeln!(html, "    <h2>CRA Compliance</h2>")?;
654    writeln!(html, "    <table>")?;
655    writeln!(html, "        <thead>")?;
656    writeln!(
657        html,
658        "            <tr><th></th><th>Old SBOM</th><th>New SBOM</th><th>Trend</th></tr>"
659    )?;
660    writeln!(html, "        </thead>")?;
661    writeln!(html, "        <tbody>")?;
662
663    let old_badge = compliance_status_badge(old.is_compliant);
664    let new_badge = compliance_status_badge(new.is_compliant);
665    let old_score = compliance_score_html(old);
666    let new_score = compliance_score_html(new);
667    let err_trend = trend_badge(old.error_count, new.error_count, true);
668    let warn_trend = trend_badge(old.warning_count, new.warning_count, true);
669    let score_trend = trend_badge(old_score.into(), new_score.into(), false);
670
671    writeln!(
672        html,
673        "            <tr><td><strong>Status</strong></td><td>{old_badge}</td><td>{new_badge}</td><td></td></tr>"
674    )?;
675    writeln!(
676        html,
677        "            <tr><td><strong>Score</strong></td><td>{old_score}%</td><td>{new_score}%</td><td>{score_trend}</td></tr>"
678    )?;
679    writeln!(
680        html,
681        "            <tr><td><strong>Level</strong></td><td>{}</td><td>{}</td><td></td></tr>",
682        escape_html(old.level.name()),
683        escape_html(new.level.name())
684    )?;
685    writeln!(
686        html,
687        "            <tr><td><strong>Errors</strong></td><td>{}</td><td>{}</td><td>{err_trend}</td></tr>",
688        old.error_count, new.error_count
689    )?;
690    writeln!(
691        html,
692        "            <tr><td><strong>Warnings</strong></td><td>{}</td><td>{}</td><td>{warn_trend}</td></tr>",
693        old.warning_count, new.warning_count
694    )?;
695
696    writeln!(html, "        </tbody>")?;
697    writeln!(html, "    </table>")?;
698
699    write_conformity_assessment_html(html, new)?;
700    write_reporting_channels_html(html, new)?;
701
702    write_compact_diff_violation_summary_html(html, new)?;
703
704    writeln!(html, "</div>")?;
705    writeln!(
706        html,
707        "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
708    )
709}
710
711fn write_compact_diff_violation_summary_html(
712    html: &mut String,
713    result: &ComplianceResult,
714) -> std::fmt::Result {
715    if result.violations.is_empty() {
716        return Ok(());
717    }
718
719    let group_count = count_violation_groups_html(&result.violations);
720    writeln!(html, "    <h3>Violation Summary (New SBOM)</h3>")?;
721    writeln!(
722        html,
723        "    <p>{} total findings across {group_count} distinct requirement groups.</p>",
724        result.violations.len(),
725    )?;
726    writeln!(
727        html,
728        "    <p><em>Re-run with <code>sbom-tools diff ... -o json</code> or <code>-o sarif</code> for the full CRA violation detail.</em></p>"
729    )?;
730
731    Ok(())
732}
733
734/// Write a CRA compliance section for view reports.
735fn write_cra_compliance_view_html(
736    html: &mut String,
737    result: &ComplianceResult,
738) -> std::fmt::Result {
739    writeln!(html, "<div class=\"section\" id=\"cra-compliance\">")?;
740    writeln!(html, "    <h2>CRA Compliance</h2>")?;
741
742    let badge = compliance_status_badge(result.is_compliant);
743    let score = compliance_score_html(result);
744    writeln!(html, "    <p><strong>Status:</strong> {badge} &nbsp; ")?;
745    writeln!(html, "    <strong>Score:</strong> {score}% &nbsp; ")?;
746    writeln!(
747        html,
748        "    <strong>Level:</strong> {} &nbsp; ",
749        escape_html(result.level.name())
750    )?;
751    writeln!(
752        html,
753        "    <strong>Issues:</strong> {} errors, {} warnings</p>",
754        result.error_count, result.warning_count
755    )?;
756
757    write_conformity_assessment_html(html, result)?;
758    write_reporting_channels_html(html, result)?;
759
760    if !result.violations.is_empty() {
761        write_violation_table_html(html, &result.violations)?;
762    }
763
764    writeln!(html, "</div>")?;
765    writeln!(
766        html,
767        "<a href=\"#top\" class=\"back-to-top\">Back to top</a>"
768    )
769}
770
771/// Render a "Conformity assessment" subsection (CRA-P4.3) when a product
772/// class has been pinned. Surfaces the resolved Annex VIII route and a
773/// per-route evidence checklist. Mirrors the Markdown variant.
774fn write_conformity_assessment_html(
775    html: &mut String,
776    result: &ComplianceResult,
777) -> std::fmt::Result {
778    let Some(summary) = result.conformity_summary.as_ref() else {
779        return Ok(());
780    };
781    writeln!(html, "    <h3>Conformity Assessment (CRA Annex VIII)</h3>")?;
782    writeln!(
783        html,
784        "    <p><strong>Product class:</strong> {}<br><strong>Conformity route:</strong> {}</p>",
785        crate::reports::escape::escape_html(summary.product_class.name()),
786        crate::reports::escape::escape_html(summary.route.name())
787    )?;
788    writeln!(html, "    <table>")?;
789    writeln!(
790        html,
791        "        <thead><tr><th>Evidence</th><th>Status</th><th>Detail</th></tr></thead>"
792    )?;
793    writeln!(html, "        <tbody>")?;
794    for ev in &summary.evidence {
795        let status = if ev.satisfied {
796            "<span class=\"present\">Present</span>"
797        } else {
798            "<span class=\"missing\">Missing</span>"
799        };
800        writeln!(
801            html,
802            "            <tr><td>{}</td><td>{}</td><td>{}</td></tr>",
803            crate::reports::escape::escape_html(&ev.label),
804            status,
805            crate::reports::escape::escape_html(&ev.detail)
806        )?;
807    }
808    writeln!(html, "        </tbody>")?;
809    writeln!(html, "    </table>")?;
810    Ok(())
811}
812
813/// Render a "Reporting channels" subsection (CRA Art. 14 readiness).
814/// Same logic as the Markdown variant — see `markdown.rs::write_reporting_channels_md`.
815fn write_reporting_channels_html(html: &mut String, result: &ComplianceResult) -> std::fmt::Result {
816    if !result.level.is_cra() {
817        return Ok(());
818    }
819
820    let psirt = channel_status_html(result, "Art. 14: PSIRT");
821    let early = channel_status_html(result, "Art. 14(1)");
822    let incident = channel_status_html(result, "Art. 14(2)");
823    let enisa = channel_status_html(result, "Art. 14(7)");
824
825    writeln!(html, "    <h3>Reporting Channels (CRA Art. 14)</h3>")?;
826    writeln!(html, "    <table>")?;
827    writeln!(
828        html,
829        "        <thead><tr><th>Channel</th><th>Status</th></tr></thead>"
830    )?;
831    writeln!(html, "        <tbody>")?;
832    writeln!(
833        html,
834        "            <tr><td>PSIRT contact</td><td>{}</td></tr>",
835        psirt.html()
836    )?;
837    writeln!(
838        html,
839        "            <tr><td>24-hour early warning (Art. 14(1))</td><td>{}</td></tr>",
840        early.html()
841    )?;
842    writeln!(
843        html,
844        "            <tr><td>72-hour incident report (Art. 14(2))</td><td>{}</td></tr>",
845        incident.html()
846    )?;
847    writeln!(
848        html,
849        "            <tr><td>ENISA single reporting platform (Art. 14(7))</td><td>{}</td></tr>",
850        enisa.html()
851    )?;
852    writeln!(html, "        </tbody>")?;
853    writeln!(html, "    </table>")?;
854    writeln!(
855        html,
856        "    <p><em>Article 14 reporting obligations apply from 11 September 2026. \
857         Channels marked 'Missing (pre-deadline)' surface as Info; \
858         after the deadline they become Warnings.</em></p>"
859    )?;
860    Ok(())
861}
862
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
864enum ChannelStatusHtml {
865    Documented,
866    MissingPreDeadline,
867    MissingPostDeadline,
868}
869
870impl ChannelStatusHtml {
871    fn html(self) -> &'static str {
872        match self {
873            Self::Documented => "<span class=\"badge badge-low\">Documented</span>",
874            Self::MissingPreDeadline => {
875                "<span class=\"badge badge-medium\">Missing (pre-deadline 2026-09-11)</span>"
876            }
877            Self::MissingPostDeadline => "<span class=\"badge badge-critical\">Missing</span>",
878        }
879    }
880}
881
882fn channel_status_html(result: &ComplianceResult, needle: &str) -> ChannelStatusHtml {
883    match result
884        .violations
885        .iter()
886        .find(|v| v.requirement.contains(needle))
887    {
888        None => ChannelStatusHtml::Documented,
889        Some(v) => match v.severity {
890            ViolationSeverity::Warning | ViolationSeverity::Error => {
891                ChannelStatusHtml::MissingPostDeadline
892            }
893            ViolationSeverity::Info => ChannelStatusHtml::MissingPreDeadline,
894        },
895    }
896}
897
898/// Count distinct `(severity, category, requirement)` violation groups without
899/// allocating the full aggregated representation.
900fn count_violation_groups_html(violations: &[crate::quality::Violation]) -> usize {
901    use std::collections::HashSet;
902    let mut groups: HashSet<(u8, &str, &str)> = HashSet::new();
903    for v in violations {
904        let sev_ord = match v.severity {
905            ViolationSeverity::Error => 0,
906            ViolationSeverity::Warning => 1,
907            ViolationSeverity::Info => 2,
908        };
909        groups.insert((sev_ord, v.category.name(), v.requirement.as_str()));
910    }
911    groups.len()
912}
913
914/// Aggregate violations by (severity, category, requirement) to reduce noise.
915fn aggregate_violations_html(
916    violations: &[crate::quality::Violation],
917) -> Vec<AggregatedViolationHtml<'_>> {
918    use std::collections::BTreeMap;
919
920    let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
921    for v in violations {
922        let sev_ord = match v.severity {
923            ViolationSeverity::Error => 0,
924            ViolationSeverity::Warning => 1,
925            ViolationSeverity::Info => 2,
926        };
927        groups
928            .entry((sev_ord, v.category.name(), v.requirement.as_str()))
929            .or_default()
930            .push(v);
931    }
932
933    groups
934        .into_values()
935        .map(|group| {
936            let message = if group.len() == 1 {
937                group[0].message.clone()
938            } else {
939                let elements: Vec<&str> =
940                    group.iter().filter_map(|v| v.element.as_deref()).collect();
941                if elements.is_empty() {
942                    group[0].message.clone()
943                } else {
944                    let preview: Vec<&str> = elements.iter().take(5).copied().collect();
945                    let suffix = if elements.len() > 5 {
946                        format!(", ... +{} more", elements.len() - 5)
947                    } else {
948                        String::new()
949                    };
950                    format!(
951                        "{} components affected ({}{})",
952                        elements.len(),
953                        preview.join(", "),
954                        suffix
955                    )
956                }
957            };
958            let standard_refs = format_standard_refs_html(&group[0].standard_refs);
959            AggregatedViolationHtml {
960                severity: group[0].severity,
961                category: group[0].category.name(),
962                requirement: &group[0].requirement,
963                message,
964                remediation: group[0].remediation_guidance(),
965                count: group.len(),
966                standard_refs,
967            }
968        })
969        .collect()
970}
971
972/// Render `standard_refs` as a comma-separated cell of `<kind>: <id>` tokens.
973fn format_standard_refs_html(refs: &[crate::quality::StandardRef]) -> String {
974    use std::fmt::Write;
975    let mut out = String::new();
976    for (i, r) in refs.iter().enumerate() {
977        if i > 0 {
978            out.push_str(", ");
979        }
980        let _ = write!(out, "{}: {}", r.standard.label(), r.id);
981    }
982    out
983}
984
985struct AggregatedViolationHtml<'a> {
986    severity: ViolationSeverity,
987    category: &'a str,
988    requirement: &'a str,
989    message: String,
990    remediation: &'static str,
991    count: usize,
992    standard_refs: String,
993}
994
995/// Write an HTML table of compliance violations (aggregated, with collapsible remediation).
996fn write_violation_table_html(
997    html: &mut String,
998    violations: &[crate::quality::Violation],
999) -> std::fmt::Result {
1000    let aggregated = aggregate_violations_html(violations);
1001    writeln!(html, "    <table>")?;
1002    writeln!(html, "        <thead>")?;
1003    writeln!(html, "            <tr>")?;
1004    writeln!(html, "                <th>Severity</th>")?;
1005    writeln!(html, "                <th>Category</th>")?;
1006    writeln!(html, "                <th>Standard refs</th>")?;
1007    writeln!(html, "                <th>Requirement</th>")?;
1008    writeln!(html, "                <th>Message</th>")?;
1009    writeln!(html, "                <th>Remediation</th>")?;
1010    writeln!(html, "            </tr>")?;
1011    writeln!(html, "        </thead>")?;
1012    writeln!(html, "        <tbody>")?;
1013
1014    for v in &aggregated {
1015        let (badge_class, label) = match v.severity {
1016            ViolationSeverity::Error => ("badge-critical", "Error"),
1017            ViolationSeverity::Warning => ("badge-medium", "Warning"),
1018            ViolationSeverity::Info => ("badge-low", "Info"),
1019        };
1020        let count_suffix = if v.count > 1 {
1021            format!(
1022                " <span class=\"badge badge-transitive\">x{}</span>",
1023                v.count
1024            )
1025        } else {
1026            String::new()
1027        };
1028        writeln!(html, "            <tr>")?;
1029        writeln!(
1030            html,
1031            "                <td><span class=\"badge {badge_class}\">{label}</span>{count_suffix}</td>"
1032        )?;
1033        writeln!(html, "                <td>{}</td>", escape_html(v.category))?;
1034        writeln!(
1035            html,
1036            "                <td>{}</td>",
1037            escape_html(&v.standard_refs)
1038        )?;
1039        writeln!(
1040            html,
1041            "                <td>{}</td>",
1042            escape_html(v.requirement)
1043        )?;
1044        writeln!(html, "                <td>{}</td>", escape_html(&v.message))?;
1045        writeln!(
1046            html,
1047            "                <td><details><summary>View</summary>{}</details></td>",
1048            escape_html(v.remediation)
1049        )?;
1050        writeln!(html, "            </tr>")?;
1051    }
1052
1053    writeln!(html, "        </tbody>")?;
1054    writeln!(html, "    </table>")
1055}
1056
1057/// Generate an HTML badge for compliance status.
1058fn compliance_status_badge(is_compliant: bool) -> &'static str {
1059    if is_compliant {
1060        "<span class=\"badge badge-added\">Compliant</span>"
1061    } else {
1062        "<span class=\"badge badge-removed\">Non-compliant</span>"
1063    }
1064}
1065
1066// ============================================================================
1067// Report generation (using helpers above)
1068// ============================================================================
1069
1070impl ReportGenerator for HtmlReporter {
1071    fn generate_diff_report(
1072        &self,
1073        result: &DiffResult,
1074        old_sbom: &NormalizedSbom,
1075        new_sbom: &NormalizedSbom,
1076        config: &ReportConfig,
1077    ) -> Result<String, ReportError> {
1078        let mut html = String::new();
1079        let title = config
1080            .title
1081            .clone()
1082            .unwrap_or_else(|| "SBOM Diff Report".to_string());
1083
1084        write_html_head(&mut html, &title, self.include_styles)?;
1085        write_page_header(&mut html, &title, None)?;
1086
1087        // Build TOC entries based on what will render
1088        let has_components =
1089            config.includes(ReportType::Components) && !result.components.is_empty();
1090        let has_vulns = config.includes(ReportType::Vulnerabilities)
1091            && !result.vulnerabilities.introduced.is_empty();
1092        let has_metadata = !result.metadata_changes.is_empty();
1093        let mut toc_entries: Vec<(&str, &str)> = Vec::new();
1094        if has_metadata {
1095            toc_entries.push(("metadata-changes", "Metadata"));
1096        }
1097        if has_components {
1098            toc_entries.push(("component-changes", "Components"));
1099        }
1100        if has_vulns {
1101            toc_entries.push(("vulnerabilities", "Vulnerabilities"));
1102        }
1103        toc_entries.push(("cra-compliance", "CRA Compliance"));
1104        write_toc(&mut html, &toc_entries)?;
1105
1106        // Summary cards
1107        writeln!(html, "<div class=\"summary-cards\">")?;
1108        write_card(
1109            &mut html,
1110            "Components Added",
1111            &format!("+{}", result.summary.components_added),
1112            "added",
1113        )?;
1114        write_card(
1115            &mut html,
1116            "Components Removed",
1117            &format!("-{}", result.summary.components_removed),
1118            "removed",
1119        )?;
1120        write_card(
1121            &mut html,
1122            "Components Modified",
1123            &format!("~{}", result.summary.components_modified),
1124            "modified",
1125        )?;
1126        write_card(
1127            &mut html,
1128            "Vulns Introduced",
1129            &result.summary.vulnerabilities_introduced.to_string(),
1130            "critical",
1131        )?;
1132        write_card(
1133            &mut html,
1134            "Semantic Score",
1135            &format!("{:.1}", result.semantic_score),
1136            "",
1137        )?;
1138        writeln!(html, "</div>")?;
1139
1140        // Document-metadata changes
1141        if has_metadata {
1142            write_diff_metadata_table(&mut html, result)?;
1143        }
1144
1145        // Component changes
1146        if has_components {
1147            write_diff_component_table(&mut html, result)?;
1148        }
1149
1150        // Vulnerability changes
1151        if has_vulns {
1152            write_diff_vuln_table(&mut html, result)?;
1153        }
1154
1155        // End-of-Life section
1156        write_eol_section(&mut html, new_sbom)?;
1157
1158        // CRA Compliance
1159        {
1160            let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
1161                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
1162            });
1163            let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
1164                ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
1165            });
1166            write_cra_compliance_diff_html(&mut html, &old_cra, &new_cra)?;
1167        }
1168
1169        write_html_footer(&mut html)?;
1170        Ok(html)
1171    }
1172
1173    fn generate_view_report(
1174        &self,
1175        sbom: &NormalizedSbom,
1176        config: &ReportConfig,
1177    ) -> Result<String, ReportError> {
1178        use std::collections::HashSet;
1179
1180        let mut html = String::new();
1181        let title = config
1182            .title
1183            .clone()
1184            .unwrap_or_else(|| "SBOM Report".to_string());
1185
1186        // Compute statistics
1187        let total_components = sbom.component_count();
1188        let vuln_component_count = sbom
1189            .components
1190            .values()
1191            .filter(|c| !c.vulnerabilities.is_empty())
1192            .count();
1193        let total_vulns: usize = sbom
1194            .components
1195            .values()
1196            .map(|c| c.vulnerabilities.len())
1197            .sum();
1198        let ecosystems: HashSet<_> = sbom
1199            .components
1200            .values()
1201            .filter_map(|c| c.ecosystem.as_ref())
1202            .collect();
1203        let licenses: HashSet<String> = sbom
1204            .components
1205            .values()
1206            .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
1207            .collect();
1208
1209        let subtitle = sbom
1210            .document
1211            .name
1212            .as_deref()
1213            .map(|n| format!("Document: {n}"));
1214        write_html_head(&mut html, &title, self.include_styles)?;
1215        write_page_header(&mut html, &title, subtitle.as_deref())?;
1216
1217        // Build TOC
1218        let has_components = config.includes(ReportType::Components) && total_components > 0;
1219        let has_vulns = config.includes(ReportType::Vulnerabilities) && total_vulns > 0;
1220        let mut toc_entries: Vec<(&str, &str)> = Vec::new();
1221        if has_components {
1222            toc_entries.push(("components", "Components"));
1223        }
1224        if has_vulns {
1225            toc_entries.push(("vulnerabilities", "Vulnerabilities"));
1226        }
1227        toc_entries.push(("cra-compliance", "CRA Compliance"));
1228        write_toc(&mut html, &toc_entries)?;
1229
1230        // Summary cards
1231        writeln!(html, "<div class=\"summary-cards\">")?;
1232        write_card(
1233            &mut html,
1234            "Total Components",
1235            &total_components.to_string(),
1236            "",
1237        )?;
1238        let vuln_class = if vuln_component_count > 0 {
1239            "critical"
1240        } else {
1241            ""
1242        };
1243        write_card(
1244            &mut html,
1245            "Vulnerable Components",
1246            &vuln_component_count.to_string(),
1247            vuln_class,
1248        )?;
1249        let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
1250        write_card(
1251            &mut html,
1252            "Total Vulnerabilities",
1253            &total_vulns.to_string(),
1254            total_vuln_class,
1255        )?;
1256        write_card(&mut html, "Ecosystems", &ecosystems.len().to_string(), "")?;
1257        write_card(
1258            &mut html,
1259            "Unique Licenses",
1260            &licenses.len().to_string(),
1261            "",
1262        )?;
1263        writeln!(html, "</div>")?;
1264
1265        // Components table
1266        if has_components {
1267            write_view_component_table(&mut html, sbom)?;
1268        }
1269
1270        // Vulnerabilities table
1271        if has_vulns {
1272            write_view_vuln_table(&mut html, sbom)?;
1273        }
1274
1275        // CRA Compliance
1276        {
1277            let cra = config
1278                .view_cra_compliance
1279                .clone()
1280                .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
1281            write_cra_compliance_view_html(&mut html, &cra)?;
1282        }
1283
1284        write_html_footer(&mut html)?;
1285        Ok(html)
1286    }
1287
1288    fn format(&self) -> ReportFormat {
1289        ReportFormat::Html
1290    }
1291}
1292
1293// ============================================================================
1294// Inline CSS styles
1295// ============================================================================
1296
1297const HTML_STYLES: &str = r"
1298        <style>
1299            :root {
1300                --bg-color: #1e1e2e;
1301                --text-color: #cdd6f4;
1302                --accent-color: #89b4fa;
1303                --success-color: #a6e3a1;
1304                --warning-color: #f9e2af;
1305                --error-color: #f38ba8;
1306                --border-color: #45475a;
1307                --card-bg: #313244;
1308            }
1309
1310            body {
1311                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1312                background-color: var(--bg-color);
1313                color: var(--text-color);
1314                margin: 0;
1315                padding: 20px;
1316                line-height: 1.6;
1317            }
1318
1319            .container {
1320                max-width: 1200px;
1321                margin: 0 auto;
1322            }
1323
1324            h1, h2, h3 {
1325                color: var(--accent-color);
1326            }
1327
1328            .header {
1329                border-bottom: 2px solid var(--border-color);
1330                padding-bottom: 20px;
1331                margin-bottom: 30px;
1332            }
1333
1334            .summary-cards {
1335                display: grid;
1336                grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1337                gap: 20px;
1338                margin-bottom: 30px;
1339            }
1340
1341            .card {
1342                background-color: var(--card-bg);
1343                border-radius: 8px;
1344                padding: 20px;
1345                border: 1px solid var(--border-color);
1346            }
1347
1348            .card-title {
1349                font-size: 0.9em;
1350                color: #a6adc8;
1351                margin-bottom: 10px;
1352            }
1353
1354            .card-value {
1355                font-size: 2em;
1356                font-weight: bold;
1357            }
1358
1359            .card-value.added { color: var(--success-color); }
1360            .card-value.removed { color: var(--error-color); }
1361            .card-value.modified { color: var(--warning-color); }
1362            .card-value.critical { color: var(--error-color); }
1363
1364            table {
1365                width: 100%;
1366                border-collapse: collapse;
1367                margin-bottom: 30px;
1368                background-color: var(--card-bg);
1369                border-radius: 8px;
1370                overflow: hidden;
1371            }
1372
1373            th, td {
1374                padding: 12px 15px;
1375                text-align: left;
1376                border-bottom: 1px solid var(--border-color);
1377            }
1378
1379            th {
1380                background-color: #45475a;
1381                font-weight: 600;
1382            }
1383
1384            tr:hover {
1385                background-color: #3b3d4d;
1386            }
1387
1388            .badge {
1389                display: inline-block;
1390                padding: 2px 8px;
1391                border-radius: 4px;
1392                font-size: 0.85em;
1393                font-weight: 500;
1394            }
1395
1396            .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
1397            .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
1398            .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
1399            .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
1400            .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
1401            .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
1402            .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
1403            .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
1404            .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
1405            .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
1406            .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
1407            .sla-on-track { color: #8b949e; }
1408            .sla-unknown { color: #8b949e; }
1409
1410            .section {
1411                margin-bottom: 40px;
1412            }
1413
1414            .tabs {
1415                display: flex;
1416                border-bottom: 2px solid var(--border-color);
1417                margin-bottom: 20px;
1418            }
1419
1420            .tab {
1421                padding: 10px 20px;
1422                cursor: pointer;
1423                border-bottom: 2px solid transparent;
1424                margin-bottom: -2px;
1425            }
1426
1427            .tab:hover {
1428                color: var(--accent-color);
1429            }
1430
1431            .tab.active {
1432                border-bottom-color: var(--accent-color);
1433                color: var(--accent-color);
1434            }
1435
1436            .footer {
1437                margin-top: 40px;
1438                padding-top: 20px;
1439                border-top: 1px solid var(--border-color);
1440                font-size: 0.9em;
1441                color: #a6adc8;
1442            }
1443
1444            .toc {
1445                background-color: var(--card-bg);
1446                border: 1px solid var(--border-color);
1447                border-radius: 8px;
1448                padding: 12px 20px;
1449                margin-bottom: 30px;
1450                display: flex;
1451                gap: 16px;
1452                align-items: center;
1453                flex-wrap: wrap;
1454            }
1455
1456            .toc a {
1457                color: var(--accent-color);
1458                text-decoration: none;
1459                padding: 4px 8px;
1460                border-radius: 4px;
1461            }
1462
1463            .toc a:hover {
1464                background-color: rgba(137, 180, 250, 0.1);
1465            }
1466
1467            .back-to-top {
1468                display: inline-block;
1469                color: #a6adc8;
1470                text-decoration: none;
1471                font-size: 0.85em;
1472                margin-bottom: 20px;
1473            }
1474
1475            .back-to-top:hover {
1476                color: var(--accent-color);
1477            }
1478
1479            details summary {
1480                cursor: pointer;
1481                color: var(--accent-color);
1482                font-size: 0.85em;
1483            }
1484
1485            details summary:hover {
1486                text-decoration: underline;
1487            }
1488
1489            details[open] summary {
1490                margin-bottom: 6px;
1491            }
1492        </style>
1493";