Skip to main content

sloc_report/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3#![allow(clippy::multiple_crate_versions)]
4
5mod pdf_compat;
6
7use std::collections::BTreeMap;
8use std::fmt::Write as FmtWrite;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use askama::Template;
14use chrono::{DateTime, FixedOffset, Utc};
15use sloc_core::{AnalysisRun, Author, CocomoMode, FileRecord, StyleSummary, SummaryTotals};
16
17// Embed logo images at compile time so every generated HTML report is fully
18// self-contained.  Server-relative paths like /images/logo/... break when the
19// HTML is rendered by Chrome via file:// (PDF export) or opened from disk.
20static LOGO_TEXT_PNG: &[u8] = include_bytes!("../assets/logo/logo-text.png");
21static SMALL_LOGO_PNG: &[u8] = include_bytes!("../assets/logo/small-logo.png");
22static CHART_JS: &str = include_str!("../assets/chart.min.js");
23
24fn png_data_uri(bytes: &[u8]) -> String {
25    format!("data:image/png;base64,{}", base64_encode(bytes))
26}
27
28fn base64_encode(data: &[u8]) -> String {
29    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
30    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
31    for chunk in data.chunks(3) {
32        let b0 = u32::from(chunk[0]);
33        let b1 = if chunk.len() > 1 {
34            u32::from(chunk[1])
35        } else {
36            0
37        };
38        let b2 = if chunk.len() > 2 {
39            u32::from(chunk[2])
40        } else {
41            0
42        };
43        let n = (b0 << 16) | (b1 << 8) | b2;
44        out.push(CHARS[((n >> 18) & 63) as usize] as char);
45        out.push(CHARS[((n >> 12) & 63) as usize] as char);
46        out.push(if chunk.len() > 1 {
47            CHARS[((n >> 6) & 63) as usize] as char
48        } else {
49            '='
50        });
51        out.push(if chunk.len() > 2 {
52            CHARS[(n & 63) as usize] as char
53        } else {
54            '='
55        });
56    }
57    out
58}
59
60/// Convert an SSH or HTTPS remote URL to a plain HTTPS base URL.
61/// `git@github.com:owner/repo.git` → `https://github.com/owner/repo`
62/// `https://github.com/owner/repo.git` → `https://github.com/owner/repo`
63fn normalize_remote_url(remote_url: &str) -> Option<String> {
64    let url = remote_url.trim();
65    if let Some(rest) = url.strip_prefix("git@") {
66        let (host, path) = rest.split_once(':')?;
67        let path = path.trim_end_matches(".git");
68        return Some(format!("https://{host}/{path}"));
69    }
70    if url.starts_with("https://") || url.starts_with("http://") {
71        return Some(url.trim_end_matches(".git").to_string());
72    }
73    None
74}
75
76/// Derive a direct link to the given commit SHA on the hosting forge.
77pub(crate) fn derive_commit_url(remote_url: &str, sha: &str) -> Option<String> {
78    let base = normalize_remote_url(remote_url)?;
79    let lower = base.to_lowercase();
80    if lower.contains("bitbucket.org") {
81        Some(format!("{base}/commits/{sha}"))
82    } else if lower.contains("gitlab.") {
83        Some(format!("{base}/-/commit/{sha}"))
84    } else {
85        Some(format!("{base}/commit/{sha}"))
86    }
87}
88
89/// Derive a direct link to the given branch on the hosting forge.
90pub(crate) fn derive_branch_url(remote_url: &str, branch: &str) -> Option<String> {
91    let base = normalize_remote_url(remote_url)?;
92    let lower = base.to_lowercase();
93    if lower.contains("bitbucket.org") {
94        Some(format!("{base}/branch/{branch}"))
95    } else if lower.contains("gitlab.") {
96        Some(format!("{base}/-/tree/{branch}"))
97    } else {
98        Some(format!("{base}/tree/{branch}"))
99    }
100}
101
102/// Optional delta context for embedding a "Changes vs. Previous Scan" panel
103/// in the HTML report. Pass `None` to omit the panel (CLI, sub-reports).
104pub struct ReportDeltaContext {
105    /// Net code lines added (new + grown files).
106    pub delta_code_added: i64,
107    /// Net code lines removed (deleted + shrunk files).
108    pub delta_code_removed: i64,
109    /// Code lines present in both scans without change.
110    pub delta_unmodified_lines: i64,
111    /// Number of files added since the previous scan.
112    pub delta_files_added: usize,
113    /// Number of files removed since the previous scan.
114    pub delta_files_removed: usize,
115    /// Number of files modified since the previous scan.
116    pub delta_files_modified: usize,
117    /// Number of files unchanged since the previous scan.
118    pub delta_files_unchanged: usize,
119    /// Code lines in the previous scan (for the "Code before: X" display).
120    pub prev_code_lines: u64,
121    /// Total number of scans on record for this project (including current).
122    pub prev_scan_count: usize,
123    /// Human-readable label for the previous scan (timestamp or run label).
124    pub prev_scan_label: String,
125    /// Run ID of the previous scan, used to generate navigation links.
126    pub prev_run_id: Option<String>,
127    /// Run ID of the current scan, used to generate the compare-scans link.
128    pub current_run_id: Option<String>,
129}
130
131/// Render a full standalone HTML report for the given analysis run.
132///
133/// # Errors
134///
135/// Returns an error if template rendering or configuration serialization fails.
136pub fn render_html(run: &AnalysisRun) -> Result<String> {
137    render_html_inner(run, false, None, None)
138}
139
140/// Render a full standalone HTML report with an optional delta panel.
141///
142/// When `delta` is `Some`, a "Changes vs. Previous Scan" section is embedded
143/// near the top of the report so the artifact is self-contained for external
144/// stakeholders who have no access to the web server.
145///
146/// # Errors
147///
148/// Returns an error if template rendering or configuration serialization fails.
149pub fn render_html_with_delta(
150    run: &AnalysisRun,
151    delta: Option<&ReportDeltaContext>,
152) -> Result<String> {
153    render_html_inner(run, false, None, delta)
154}
155
156/// Render an embedded sub-report HTML fragment for the given analysis run.
157///
158/// # Errors
159///
160/// Returns an error if template rendering or configuration serialization fails.
161pub fn render_sub_report_html(run: &AnalysisRun, pdf_url: Option<&str>) -> Result<String> {
162    render_html_inner(run, true, pdf_url, None)
163}
164
165fn load_custom_logo(path: &std::path::Path) -> Option<String> {
166    let bytes = std::fs::read(path).ok()?;
167    let ext = path
168        .extension()
169        .and_then(|e| e.to_str())
170        .unwrap_or("")
171        .to_ascii_lowercase();
172    let mime = if ext == "svg" {
173        "image/svg+xml"
174    } else {
175        "image/png"
176    };
177    Some(format!("data:{mime};base64,{}", base64_encode(&bytes)))
178}
179
180// ── Chart JSON builders ───────────────────────────────────────────────────────
181
182/// Escape a string for safe embedding inside a JSON string literal.
183fn json_escape(s: &str) -> String {
184    s.replace('\\', "\\\\").replace('"', "\\\"")
185}
186
187fn build_lang_chart_json(run: &AnalysisRun) -> String {
188    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
189    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
190    let entries: Vec<String> = langs
191        .into_iter()
192        .take(12)
193        .map(|l| {
194            format!(
195                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"tests":{},"files":{}}}"#,
196                json_escape(l.language.display_name()),
197                l.code_lines, l.comment_lines, l.blank_lines, l.total_physical_lines,
198                l.functions, l.classes, l.variables, l.imports,
199                l.test_count, l.files,
200            )
201        })
202        .collect();
203    format!("[{}]", entries.join(","))
204}
205
206fn build_submodule_chart_json(run: &AnalysisRun) -> String {
207    let entries: Vec<String> = run
208        .submodule_summaries
209        .iter()
210        .map(|s| {
211            format!(
212                r#"{{"name":"{}","path":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
213                json_escape(&s.name), json_escape(&s.relative_path),
214                s.code_lines, s.comment_lines, s.blank_lines,
215                s.total_physical_lines, s.files_analyzed,
216            )
217        })
218        .collect();
219    format!("[{}]", entries.join(","))
220}
221
222fn build_scatter_chart_json(run: &AnalysisRun) -> String {
223    let entries: Vec<String> = run
224        .totals_by_language
225        .iter()
226        .map(|l| {
227            format!(
228                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
229                json_escape(l.language.display_name()),
230                l.files,
231                l.code_lines,
232                l.total_physical_lines,
233            )
234        })
235        .collect();
236    format!("[{}]", entries.join(","))
237}
238
239fn build_semantic_chart_json(run: &AnalysisRun) -> String {
240    let entries: Vec<String> = run
241        .totals_by_language
242        .iter()
243        .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0)
244        .map(|l| {
245            format!(
246                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
247                json_escape(l.language.display_name()),
248                l.functions, l.classes, l.variables, l.imports, l.test_count,
249            )
250        })
251        .collect();
252    format!("[{}]", entries.join(","))
253}
254
255fn build_file_size_histogram_json(run: &AnalysisRun) -> String {
256    // Buckets: Tiny <50, Small 50-199, Medium 200-499, Large 500-999, Huge >=1000
257    let labels = [
258        ("Tiny (<50)", 0u64, 49u64),
259        ("Small (50-199)", 50, 199),
260        ("Medium (200-499)", 200, 499),
261        ("Large (500-999)", 500, 999),
262        ("Huge (>=1000)", 1000, u64::MAX),
263    ];
264    let mut counts = [0u64; 5];
265    for f in &run.per_file_records {
266        let cl = f.effective_counts.code_lines;
267        for (i, &(_, lo, hi)) in labels.iter().enumerate() {
268            if cl >= lo && cl <= hi {
269                counts[i] += 1;
270                break;
271            }
272        }
273    }
274    let entries: Vec<String> = labels
275        .iter()
276        .zip(counts.iter())
277        .map(|((label, _, _), count)| {
278            format!(r#"{{"label":"{}","count":{}}}"#, json_escape(label), count)
279        })
280        .collect();
281    format!("[{}]", entries.join(","))
282}
283
284/// Build JSON for the multi-language style-guide adherence chart.
285/// Returns a per-language-family array, each with its sorted guide scores.
286fn build_style_chart_json(summary: &StyleSummary) -> String {
287    let groups: Vec<String> = summary
288        .by_language
289        .iter()
290        .map(|grp| {
291            let guides: Vec<String> = grp
292                .guide_avg_scores
293                .iter()
294                .map(|(name, score)| {
295                    format!(r#"{{"guide":"{}","score":{}}}"#, json_escape(name), score)
296                })
297                .collect();
298            format!(
299                r#"{{"family":"{}","files":{},"indent":"{}","dominant":"{}","score":{},"guides":[{}]}}"#,
300                json_escape(&grp.language_family),
301                grp.files_count,
302                json_escape(&grp.common_indent_style),
303                json_escape(&grp.dominant_guide),
304                grp.dominant_score_pct,
305                guides.join(","),
306            )
307        })
308        .collect();
309    format!("[{}]", groups.join(","))
310}
311
312/// Build JSON for the per-file style breakdown table (up to 500 rows).
313fn build_style_file_json(run: &AnalysisRun) -> String {
314    let entries: Vec<String> = run
315        .per_file_records
316        .iter()
317        .filter_map(|f| {
318            let s = f.style_analysis.as_ref()?;
319            // Collect key signals for display (up to 3).
320            let sigs: Vec<String> = s
321                .signals
322                .iter()
323                .take(3)
324                .map(|sig| {
325                    format!(
326                        r#"{{"k":"{}","v":"{}"}}"#,
327                        json_escape(&sig.name),
328                        json_escape(&sig.value),
329                    )
330                })
331                .collect();
332            Some(format!(
333                r#"{{"path":"{}","lang":"{}","family":"{}","indent":"{}","guide":"{}","score":{},"signals":[{}]}}"#,
334                json_escape(&f.relative_path),
335                json_escape(f.language.map_or("\u{2014}", |l| l.display_name())),
336                json_escape(&s.language_family),
337                json_escape(s.indent_style.display()),
338                json_escape(&s.dominant_guide),
339                s.dominant_score_pct,
340                sigs.join(","),
341            ))
342        })
343        .take(500)
344        .collect();
345    format!("[{}]", entries.join(","))
346}
347
348// ── Coverage / density helpers ────────────────────────────────────────────────
349
350// ratio/percentage display, precision loss acceptable
351#[allow(clippy::cast_precision_loss)]
352fn coverage_pct_str(hit: u64, found: u64) -> String {
353    if found > 0 {
354        format!("{:.1}", hit as f64 / found as f64 * 100.0)
355    } else {
356        String::new()
357    }
358}
359
360// ratio/percentage display, precision loss acceptable
361#[allow(clippy::cast_precision_loss)]
362fn coverage_class(hit: u64, found: u64) -> String {
363    if found > 0 {
364        let pct = hit as f64 / found as f64 * 100.0;
365        if pct >= 80.0 {
366            "good"
367        } else if pct >= 60.0 {
368            "warn"
369        } else {
370            "danger"
371        }
372    } else {
373        "muted"
374    }
375    .to_string()
376}
377
378// ratio display, precision loss acceptable
379#[allow(clippy::cast_precision_loss)]
380fn format_test_density(code_lines: u64, test_count: u64) -> String {
381    if code_lines > 0 && test_count > 0 {
382        format!("{:.1}", test_count as f64 / code_lines as f64 * 1000.0)
383    } else {
384        String::from("0.0")
385    }
386}
387
388/// Insert thousands separators into the integer portion of a number's textual form.
389///
390/// Works for plain integers (`"50789"` → `"50,789"`), signed values, and
391/// pre-formatted decimal strings (`"11.2"` → `"11.2"`). Input whose integer part
392/// is not all ASCII digits (e.g. `"—"`) is returned unchanged.
393fn group_thousands(s: &str) -> String {
394    let (sign, rest) = match s.as_bytes().first() {
395        Some(b'-') => ("-", &s[1..]),
396        Some(b'+') => ("+", &s[1..]),
397        _ => ("", s),
398    };
399    let (int_part, frac_part) = match rest.split_once('.') {
400        Some((i, f)) => (i, Some(f)),
401        None => (rest, None),
402    };
403    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
404        return s.to_string();
405    }
406    let bytes = int_part.as_bytes();
407    let len = bytes.len();
408    let mut grouped = String::with_capacity(len + len / 3);
409    for (i, &b) in bytes.iter().enumerate() {
410        if i > 0 && (len - i).is_multiple_of(3) {
411            grouped.push(',');
412        }
413        grouped.push(b as char);
414    }
415    frac_part.map_or_else(
416        || format!("{sign}{grouped}"),
417        |f| format!("{sign}{grouped}.{f}"),
418    )
419}
420
421/// Custom Askama filters available to templates in this crate.
422mod filters {
423    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
424    // (a `&self` `execute` method returning `Result`), not on our own source.
425    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
426    use askama::{Result, Values};
427
428    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
429    #[askama::filter_fn]
430    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
431        Ok(super::group_thousands(&value.to_string()))
432    }
433}
434
435// ── Main renderer ─────────────────────────────────────────────────────────────
436
437#[allow(clippy::too_many_lines)] // large HTML renderer; splitting would obscure the template structure
438fn render_html_inner(
439    run: &AnalysisRun,
440    is_sub_report: bool,
441    pdf_url: Option<&str>,
442    delta_ctx: Option<&ReportDeltaContext>,
443) -> Result<String> {
444    let config_json = serde_json::to_string_pretty(&run.effective_configuration)
445        .context("failed to serialize effective configuration")?;
446
447    let warning_summary_rows = summarize_warnings(&run.warnings);
448    let warning_opportunity_rows = build_support_opportunities(&run.warnings);
449
450    let logo_text_uri = png_data_uri(LOGO_TEXT_PNG);
451    let small_logo_uri = png_data_uri(SMALL_LOGO_PNG);
452
453    let rep = &run.effective_configuration.reporting;
454    let custom_logo_uri = rep.logo_path.as_deref().and_then(load_custom_logo);
455    let company_name = rep.company_name.clone();
456    let accent_hex = rep.accent_color.clone();
457    let report_header_footer = rep.report_header_footer.clone();
458
459    let totals = &run.summary_totals;
460
461    // The HTML report paginates client-side, so surface a deeper ranking (up to 200 files)
462    // than the 15-row PDF page.
463    let hotspot_rows = build_hotspot_rows(run, 200);
464    let author_rows = build_author_rows(run);
465    let own_summary = build_ownership_summary(run);
466
467    let template = ReportTemplate {
468        // Empty nonce for disk-saved reports; patch_html_nonce replaces it
469        // with the request nonce when serving from the web server.
470        nonce: String::new(),
471        title: rep.report_title.clone(),
472        browser_title: format!("Oxide-SLOC | {}", rep.report_title),
473        scan_performed_by: run.environment.ci_name.clone().unwrap_or_else(|| {
474            format!(
475                "{} / {}",
476                run.environment.initiator_username, run.environment.initiator_hostname
477            )
478        }),
479        scan_time_pst: to_pst_display(run.tool.timestamp_utc),
480        tool_version: run.tool.version.clone(),
481        is_sub_report,
482        run,
483        language_rows: run
484            .totals_by_language
485            .iter()
486            .map(|row| LanguageRow {
487                language: row.language.display_name().to_string(),
488                files: row.files,
489                total_physical_lines: row.total_physical_lines,
490                code_lines: row.code_lines,
491                comment_lines: row.comment_lines,
492                blank_lines: row.blank_lines,
493                mixed_lines_separate: row.mixed_lines_separate,
494                functions: row.functions,
495                classes: row.classes,
496                variables: row.variables,
497                imports: row.imports,
498                test_count: row.test_count,
499                test_assertion_count: row.test_assertion_count,
500                test_suite_count: row.test_suite_count,
501                test_density_str: if row.code_lines > 0 {
502                    // ratio display, precision loss acceptable
503                    #[allow(clippy::cast_precision_loss)]
504                    let density = row.test_count as f64 / row.code_lines as f64 * 1000.0;
505                    format!("{density:.1}")
506                } else {
507                    "—".to_string()
508                },
509            })
510            .collect(),
511        file_rows: run.per_file_records.iter().map(file_row_view).collect(),
512        skipped_rows: run.skipped_file_records.iter().map(file_row_view).collect(),
513        config_json,
514        lang_chart_json: build_lang_chart_json(run),
515        submodule_chart_json: build_submodule_chart_json(run),
516        scatter_chart_json: build_scatter_chart_json(run),
517        semantic_chart_json: build_semantic_chart_json(run),
518        file_size_histogram_json: build_file_size_histogram_json(run),
519        has_submodule_data: !run.submodule_summaries.is_empty(),
520        has_semantic_data: run
521            .totals_by_language
522            .iter()
523            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
524        has_coverage_data: run.per_file_records.iter().any(|f| f.coverage.is_some()),
525        has_fn_coverage: totals.coverage_functions_found > 0,
526        has_branch_coverage: totals.coverage_branches_found > 0,
527        test_files_count: run
528            .per_file_records
529            .iter()
530            .filter(|f| f.raw_line_categories.test_count > 0)
531            .count() as u64,
532        test_assertion_count: totals.test_assertion_count,
533        test_suite_count: totals.test_suite_count,
534        test_density: format_test_density(totals.code_lines, totals.test_count),
535        most_tested_lang: run
536            .totals_by_language
537            .iter()
538            .filter(|l| l.test_count > 0)
539            .max_by_key(|l| l.test_count)
540            .map_or_else(
541                || "\u{2014}".to_string(),
542                |l| l.language.display_name().to_string(),
543            ),
544        langs_with_tests: run
545            .totals_by_language
546            .iter()
547            .filter(|l| l.test_count > 0)
548            .count(),
549        cov_line_pct: coverage_pct_str(totals.coverage_lines_hit, totals.coverage_lines_found),
550        cov_fn_pct: coverage_pct_str(
551            totals.coverage_functions_hit,
552            totals.coverage_functions_found,
553        ),
554        cov_branch_pct: coverage_pct_str(
555            totals.coverage_branches_hit,
556            totals.coverage_branches_found,
557        ),
558        cov_line_class: coverage_class(totals.coverage_lines_hit, totals.coverage_lines_found),
559        cov_fn_class: coverage_class(
560            totals.coverage_functions_hit,
561            totals.coverage_functions_found,
562        ),
563        cov_branch_class: coverage_class(
564            totals.coverage_branches_hit,
565            totals.coverage_branches_found,
566        ),
567        has_run_warnings: !run.warnings.is_empty(),
568        warning_count: run.warnings.len(),
569        warning_summary_rows,
570        warning_opportunity_rows,
571        warning_console_full: build_warning_console(&run.warnings),
572        logo_text_uri,
573        small_logo_uri,
574        custom_logo_uri,
575        company_name,
576        accent_hex,
577        report_header_footer,
578        chart_js: CHART_JS,
579        run_id_short: run
580            .tool
581            .run_id
582            .split('-')
583            .next_back()
584            .unwrap_or(&run.tool.run_id)
585            .chars()
586            .take(7)
587            .collect(),
588        standalone_pdf_url: pdf_url.map(str::to_string),
589        has_style_data: run.style_summary.is_some(),
590        style_lang_count: run
591            .style_summary
592            .as_ref()
593            .map_or(0, |ss| ss.by_language.len()),
594        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
595        style_chart_json: run
596            .style_summary
597            .as_ref()
598            .map(build_style_chart_json)
599            .unwrap_or_default(),
600        style_file_json: if run.style_summary.is_some() {
601            build_style_file_json(run)
602        } else {
603            String::new()
604        },
605        style_summary: run.style_summary.clone(),
606        has_delta: delta_ctx.is_some(),
607        delta_code_added: delta_ctx.map_or(0, |d| d.delta_code_added),
608        delta_code_removed: delta_ctx.map_or(0, |d| d.delta_code_removed),
609        delta_unmodified_lines: delta_ctx.map_or(0, |d| d.delta_unmodified_lines),
610        delta_files_added: delta_ctx.map_or(0, |d| d.delta_files_added),
611        delta_files_removed: delta_ctx.map_or(0, |d| d.delta_files_removed),
612        delta_files_modified: delta_ctx.map_or(0, |d| d.delta_files_modified),
613        delta_files_unchanged: delta_ctx.map_or(0, |d| d.delta_files_unchanged),
614        delta_files_total: delta_ctx.map_or(0, |d| {
615            d.delta_files_added
616                + d.delta_files_removed
617                + d.delta_files_modified
618                + d.delta_files_unchanged
619        }),
620        prev_code_lines: delta_ctx.map_or(0, |d| d.prev_code_lines),
621        prev_scan_count: delta_ctx.map_or(0, |d| d.prev_scan_count),
622        prev_scan_label: delta_ctx
623            .map(|d| d.prev_scan_label.clone())
624            .unwrap_or_default(),
625        prev_run_id: delta_ctx
626            .and_then(|d| d.prev_run_id.clone())
627            .unwrap_or_default(),
628        git_commit_url: run
629            .git_remote_url
630            .as_deref()
631            .zip(run.git_commit_long.as_deref())
632            .and_then(|(remote, sha)| derive_commit_url(remote, sha)),
633        git_branch_url: run
634            .git_remote_url
635            .as_deref()
636            .zip(run.git_branch.as_deref())
637            .and_then(|(remote, branch)| derive_branch_url(remote, branch)),
638        has_cocomo: run.cocomo.is_some(),
639        cocomo_effort_str: run
640            .cocomo
641            .as_ref()
642            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
643        cocomo_duration_str: run
644            .cocomo
645            .as_ref()
646            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
647        cocomo_staff_str: run
648            .cocomo
649            .as_ref()
650            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
651        cocomo_ksloc_str: run
652            .cocomo
653            .as_ref()
654            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
655        cocomo_mode_label: run
656            .cocomo
657            .as_ref()
658            .map_or_else(|| "Organic".to_string(), |c| {
659                match c.mode {
660                    CocomoMode::Organic => "Organic",
661                    CocomoMode::SemiDetached => "Semi-detached",
662                    CocomoMode::Embedded => "Embedded",
663                }
664                .to_string()
665            }),
666        cocomo_mode_tooltip: run
667            .cocomo
668            .as_ref()
669            .map_or(String::new(), |c| match c.mode {
670                CocomoMode::Organic => "Organic: A small team working on a well-understood \
671                    project in a familiar environment with minimal external constraints. \
672                    Suited for internal tools, utilities, and projects with stable requirements. \
673                    Effort = 2.4 \u{00D7} KSLOC^1.05.",
674                CocomoMode::SemiDetached => "Semi-detached: A mixed team with varying levels of \
675                    experience tackling a project with moderate novelty and some rigid constraints. \
676                    Typical for compilers, transaction systems, and batch processors. \
677                    Effort = 3.0 \u{00D7} KSLOC^1.12.",
678                CocomoMode::Embedded => "Embedded: Tight hardware, software, or operational \
679                    constraints requiring significant innovation and deep integration work. \
680                    Typical for real-time control systems and safety-critical software. \
681                    Effort = 3.6 \u{00D7} KSLOC^1.20.",
682            }.to_string()),
683        uloc: run.uloc,
684        dryness_pct_str: run
685            .dryness_pct
686            .map_or(String::new(), |d| format!("{d:.1}")),
687        duplicate_group_count: run.duplicate_groups.len(),
688        has_hotspots: !hotspot_rows.is_empty(),
689        hotspot_rows,
690        has_ownership: !author_rows.is_empty(),
691        ownership_rows: author_rows,
692        own_contributors: own_summary.contributors,
693        own_top_name: own_summary.top_name,
694        own_top_pct_str: own_summary.top_pct_str,
695        own_bus_factor: own_summary.bus_factor,
696        own_total_code: own_summary.total_code,
697        own_dev_code: own_summary.dev_code,
698        own_test_code: own_summary.test_code,
699        own_test_pct_str: own_summary.test_pct_str,
700        own_total_comment: own_summary.total_comment,
701    };
702
703    template.render().context("failed to render HTML report")
704}
705
706/// One row of the Git Hotspots table: a file ranked by `code_lines × recent commits`.
707struct HotspotRow {
708    path: String,
709    code_lines: u64,
710    commit_count: u32,
711    last_commit_date: String,
712    score: u64,
713    /// Primary owner of the file (top blame owner). Empty unless attribution ran.
714    owner: String,
715    /// Best-effort profile URL for the primary owner (GitHub/GHE no-reply email → profile),
716    /// `None` when the owner's email carries no derivable platform handle.
717    owner_profile_url: Option<String>,
718}
719
720/// Best-effort profile URL for a contributor, derived from a GitHub / GitHub-Enterprise-style
721/// no-reply commit email of the form `<id>+<handle>@users.noreply.<host>` or
722/// `<handle>@users.noreply.<host>`. Returns `https://<host>/<handle>` — e.g.
723/// `12345+octocat@users.noreply.github.com` → `https://github.com/octocat`, and the same shape
724/// works for a GitHub Enterprise host. Returns `None` for plain corporate/personal emails, which
725/// carry no reliable platform handle (linking those would fabricate dead URLs).
726fn author_profile_url(email: &str) -> Option<String> {
727    let (local, domain) = email.trim().split_once('@')?;
728    let host = domain.strip_prefix("users.noreply.")?;
729    let handle = local.rsplit_once('+').map_or(local, |(_, h)| h);
730    if handle.is_empty() || host.is_empty() {
731        return None;
732    }
733    Some(format!("https://{host}/{handle}"))
734}
735
736/// Best-effort profile URL for a resolved [`Author`]: tries the canonical email first, then any
737/// folded alias email, so a contributor whose canonical identity is a personal email still links
738/// when one of their merged identities is a platform no-reply address.
739fn author_profile_url_for(author: &Author) -> Option<String> {
740    author_profile_url(&author.canonical_email).or_else(|| {
741        author
742            .aliases
743            .iter()
744            .find_map(|al| author_profile_url(&al.email))
745    })
746}
747
748/// Build the git hotspots from per-file activity (only files that carry a
749/// `commit_count` from an `--activity-window` scan), ranked by `code_lines × commits`
750/// and capped at `limit` rows. The interactive HTML report requests a larger cap (so its
751/// client-side pagination has something to page through); the fixed-height PDF page keeps
752/// the original top-15.
753fn build_hotspot_rows(run: &AnalysisRun, limit: usize) -> Vec<HotspotRow> {
754    // Map author id → display name so each hotspot file can show its primary owner.
755    let author_names: std::collections::HashMap<u32, &str> = run
756        .authors
757        .iter()
758        .map(|a| (a.id, a.canonical_name.as_str()))
759        .collect();
760    // Parallel map: author id → best-effort profile URL, so each hotspot owner can link out.
761    let author_urls: std::collections::HashMap<u32, String> = run
762        .authors
763        .iter()
764        .filter_map(|a| author_profile_url_for(a).map(|url| (a.id, url)))
765        .collect();
766    let mut rows: Vec<HotspotRow> = run
767        .per_file_records
768        .iter()
769        .filter_map(|r| {
770            let commits = r.commit_count?;
771            let code = r.effective_counts.code_lines;
772            let top_owner_id = r
773                .ownership
774                .as_ref()
775                .and_then(|o| o.first())
776                .map(|top| top.author_id);
777            let owner = top_owner_id
778                .and_then(|id| author_names.get(&id))
779                .map_or_else(String::new, |name| (*name).to_string());
780            let owner_profile_url = top_owner_id.and_then(|id| author_urls.get(&id).cloned());
781            Some(HotspotRow {
782                path: r.relative_path.clone(),
783                code_lines: code,
784                commit_count: commits,
785                // Show the calendar date only (strip the time component of the ISO date).
786                last_commit_date: r.last_commit_date.as_deref().map_or_else(String::new, |d| {
787                    d.split('T').next().unwrap_or(d).to_string()
788                }),
789                score: code.saturating_mul(u64::from(commits)),
790                owner,
791                owner_profile_url,
792            })
793        })
794        .collect();
795    rows.sort_by(|a, b| {
796        b.score
797            .cmp(&a.score)
798            .then(b.commit_count.cmp(&a.commit_count))
799    });
800    rows.truncate(limit);
801    rows
802}
803
804/// One row of the Code Ownership table: a contributor's blame-based line tallies.
805struct AuthorReportRow {
806    name: String,
807    email: String,
808    code: u64,
809    comment: u64,
810    blank: u64,
811    total: u64,
812    /// Share of total code lines owned, pre-formatted to one decimal (e.g. "42.7").
813    code_pct_str: String,
814    /// Files where this author is the single largest owner.
815    files_owned: u64,
816    /// The files this author owns (top owner of), for the per-author drill-down. Ordered by
817    /// owned code lines descending.
818    files: Vec<OwnedFileRow>,
819    /// Leaderboard rank among contributors who own at least one file (1 = most code owned).
820    /// `0` for contributors who own no files (they don't appear on the leaderboard).
821    rank: usize,
822    /// CSS medal class for the top three ranks (`lb-r1`/`lb-r2`/`lb-r3`), else empty.
823    rank_class: &'static str,
824    /// Up-to-two-letter initials for the leaderboard avatar (e.g. "NS").
825    initials: String,
826    /// Accent colour for this contributor's avatar/bar, from the canonical palette by rank.
827    color: &'static str,
828    /// Best-effort profile URL (GitHub/GHE no-reply email → profile); `None` when not derivable.
829    profile_url: Option<String>,
830}
831
832/// One file a contributor owns, for the Code Ownership per-author drill-down: the lines *this*
833/// author owns in the file (not the file's totals) plus its last-changed date.
834struct OwnedFileRow {
835    path: String,
836    code: u64,
837    comment: u64,
838    blank: u64,
839    total: u64,
840    last_changed: String,
841}
842
843/// Canonical categorical palette (shared with the web charts) used to tint leaderboard avatars.
844const REPORT_PALETTE: &[&str] = &[
845    "#C45C10", "#2A6846", "#4472C4", "#805099", "#D4A017", "#B23030", "#2E75B6", "#70AD47",
846    "#FF9900", "#9E480E", "#156082", "#5BA8A0",
847];
848
849/// Up-to-two-letter uppercase initials for a contributor's leaderboard avatar. Uses the first
850/// letter of the first and last whitespace-separated name parts (or the first two letters of a
851/// single-word name).
852fn author_initials(name: &str) -> String {
853    let parts: Vec<&str> = name.split_whitespace().collect();
854    match parts.as_slice() {
855        [] => "?".to_string(),
856        [one] => one.chars().take(2).collect::<String>().to_uppercase(),
857        [first, .., last] => {
858            let a = first.chars().next().unwrap_or('?');
859            let b = last.chars().next().unwrap_or('?');
860            format!("{a}{b}").to_uppercase()
861        }
862    }
863}
864
865/// Build the per-author ownership rows from `run.authors` (populated when an attribution scan
866/// ran on a git repo), already ordered by code lines owned. Empty when attribution did not run.
867fn build_author_rows(run: &AnalysisRun) -> Vec<AuthorReportRow> {
868    if run.authors.is_empty() {
869        return Vec::new();
870    }
871    let total_code: u64 = run.authors.iter().map(|a| a.counts.code_lines).sum();
872    // Collect, per author, the files they are the single largest owner of.
873    let mut owned_files: std::collections::HashMap<u32, Vec<OwnedFileRow>> =
874        std::collections::HashMap::new();
875    for rec in &run.per_file_records {
876        if let Some(top) = rec.ownership.as_ref().and_then(|o| o.first()) {
877            owned_files
878                .entry(top.author_id)
879                .or_default()
880                .push(OwnedFileRow {
881                    path: rec.relative_path.clone(),
882                    code: top.counts.code_lines,
883                    comment: top.counts.comment_lines,
884                    blank: top.counts.blank_lines,
885                    total: top.counts.total_lines,
886                    last_changed: rec
887                        .last_commit_date
888                        .as_deref()
889                        .map_or_else(String::new, |d| {
890                            d.split('T').next().unwrap_or(d).to_string()
891                        }),
892                });
893        }
894    }
895    let mut rows: Vec<AuthorReportRow> = run
896        .authors
897        .iter()
898        .map(|a| {
899            let pct = if total_code > 0 {
900                a.counts.code_lines as f64 / total_code as f64 * 100.0
901            } else {
902                0.0
903            };
904            let mut files = owned_files.remove(&a.id).unwrap_or_default();
905            files.sort_by_key(|f| std::cmp::Reverse(f.code));
906            AuthorReportRow {
907                name: a.canonical_name.clone(),
908                email: a.canonical_email.clone(),
909                code: a.counts.code_lines,
910                comment: a.counts.comment_lines,
911                blank: a.counts.blank_lines,
912                total: a.counts.total_lines,
913                code_pct_str: format!("{pct:.1}"),
914                files_owned: files.len() as u64,
915                files,
916                rank: 0,
917                rank_class: "",
918                initials: author_initials(&a.canonical_name),
919                color: REPORT_PALETTE[0],
920                profile_url: author_profile_url_for(a),
921            }
922        })
923        .collect();
924    // Assign leaderboard rank + medal class + accent colour among file-owning contributors
925    // (already ordered by code lines owned).
926    let mut rank = 0usize;
927    for row in &mut rows {
928        if row.files.is_empty() {
929            continue;
930        }
931        rank += 1;
932        row.rank = rank;
933        row.rank_class = match rank {
934            1 => "lb-r1",
935            2 => "lb-r2",
936            3 => "lb-r3",
937            _ => "",
938        };
939        row.color = REPORT_PALETTE[(rank - 1) % REPORT_PALETTE.len()];
940    }
941    rows
942}
943
944/// Headline code-ownership statistics surfaced above the ownership table in the report (mirrors the
945/// web `/code-ownership` summary chips). All zero / empty when attribution did not run.
946#[derive(Default)]
947struct OwnershipSummary {
948    contributors: usize,
949    top_name: String,
950    top_pct_str: String,
951    bus_factor: usize,
952    total_code: u64,
953    dev_code: u64,
954    test_code: u64,
955    test_pct_str: String,
956    total_comment: u64,
957}
958
959/// Compute the ownership summary stats (contributors, top owner, bus factor, dev/test split,
960/// comment lines) from a completed run. Empty when attribution did not populate `run.authors`.
961fn build_ownership_summary(run: &AnalysisRun) -> OwnershipSummary {
962    if run.authors.is_empty() {
963        return OwnershipSummary::default();
964    }
965    let total_code: u64 = run.authors.iter().map(|a| a.counts.code_lines).sum();
966    let total_comment: u64 = run.authors.iter().map(|a| a.counts.comment_lines).sum();
967    // Bus factor: fewest top contributors (already ordered by code owned) covering >= 50% of code.
968    let mut acc = 0u64;
969    let mut bus_factor = 0usize;
970    for a in &run.authors {
971        acc += a.counts.code_lines;
972        bus_factor += 1;
973        if total_code > 0 && acc * 2 >= total_code {
974            break;
975        }
976    }
977    // Test code = code lines owned in files classified as tests.
978    let test_code: u64 = run
979        .per_file_records
980        .iter()
981        .filter(|rec| rec.is_test_file())
982        .filter_map(|rec| rec.ownership.as_ref())
983        .flat_map(|own| own.iter().map(|o| o.counts.code_lines))
984        .sum();
985    let top = &run.authors[0];
986    let top_pct = if total_code > 0 {
987        top.counts.code_lines as f64 / total_code as f64 * 100.0
988    } else {
989        0.0
990    };
991    let test_pct = if total_code > 0 {
992        test_code as f64 / total_code as f64 * 100.0
993    } else {
994        0.0
995    };
996    OwnershipSummary {
997        contributors: run.authors.len(),
998        top_name: top.canonical_name.clone(),
999        top_pct_str: format!("{top_pct:.0}"),
1000        bus_factor,
1001        total_code,
1002        dev_code: total_code.saturating_sub(test_code),
1003        test_code,
1004        test_pct_str: format!("{test_pct:.0}"),
1005        total_comment,
1006    }
1007}
1008
1009/// Render an HTML report and write it to `output_path`.
1010///
1011/// # Errors
1012///
1013/// Returns an error if rendering fails or the file cannot be written.
1014pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
1015    let html = render_html_inner(run, false, None, None)?;
1016    fs::write(output_path, html)
1017        .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
1018}
1019
1020/// Write an HTML report that embeds a relative link to a pre-generated PDF.
1021///
1022/// When `pdf_path` is in the same directory as `output_path`, the "View PDF"
1023/// button in the report opens the PDF directly (e.g. from a Jenkins HTML
1024/// Publisher artifact directory) instead of calling the oxide-sloc server route.
1025/// Pass `pdf_path = None` to get the same behaviour as [`write_html`].
1026///
1027/// # Errors
1028/// Returns an error if HTML rendering or file I/O fails.
1029pub fn write_html_with_pdf_link(
1030    run: &AnalysisRun,
1031    output_path: &Path,
1032    pdf_path: Option<&Path>,
1033) -> Result<()> {
1034    let pdf_relative = pdf_path.and_then(|pdf| {
1035        let html_dir = output_path.parent()?;
1036        let pdf_dir = pdf.parent()?;
1037        if html_dir == pdf_dir {
1038            pdf.file_name().map(|n| n.to_string_lossy().into_owned())
1039        } else {
1040            None
1041        }
1042    });
1043    let html = render_html_inner(run, false, pdf_relative.as_deref(), None)?;
1044    fs::write(output_path, html)
1045        .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
1046}
1047
1048/// Launch a headless Chromium browser.
1049/// When `no_sandbox` is true (set via `SLOC_BROWSER_NOSANDBOX=1`) the browser
1050/// runs without the namespace sandbox — required in containers that drop `SYS_ADMIN`.
1051/// Otherwise the sandbox is always enabled with no automatic fallback, so failures
1052/// surface as clear errors rather than silently removing a security boundary.
1053fn launch_cdp_browser(
1054    browser_path: std::path::PathBuf,
1055    no_sandbox: bool,
1056) -> Result<headless_chrome::Browser> {
1057    use headless_chrome::{Browser, LaunchOptions};
1058
1059    if no_sandbox {
1060        return Browser::new(LaunchOptions {
1061            headless: true,
1062            path: Some(browser_path),
1063            window_size: Some((1122, 794)),
1064            sandbox: false,
1065            ..Default::default()
1066        })
1067        .context("failed to launch browser via CDP (no-sandbox)");
1068    }
1069
1070    // Sandboxed only — no automatic fallback to --no-sandbox.
1071    // If this fails in a container, set SLOC_BROWSER_NOSANDBOX=1 to opt in explicitly.
1072    Browser::new(LaunchOptions {
1073        headless: true,
1074        path: Some(browser_path),
1075        window_size: Some((1122, 794)),
1076        sandbox: true,
1077        ..Default::default()
1078    })
1079    .map_err(|e| {
1080        anyhow::anyhow!(
1081            "Browser launch failed with sandbox enabled: {e:#}\n\
1082             If running in a container without user namespaces (e.g. Docker with cap_drop:ALL), \
1083             set SLOC_BROWSER_NOSANDBOX=1 to opt into --no-sandbox mode."
1084        )
1085    })
1086}
1087
1088/// If a JS chart error was recorded on the page, print it to stderr.
1089fn report_chart_error_if_any(tab: &headless_chrome::Tab) {
1090    let Ok(e) = tab.evaluate("window.oxSlocChartError||''", false) else {
1091        return;
1092    };
1093    let Some(serde_json::Value::String(msg)) = e.value else {
1094        return;
1095    };
1096    if !msg.is_empty() {
1097        eprintln!("[oxide-sloc][pdf] chart JS error (charts may be missing): {msg}");
1098    }
1099}
1100
1101/// Poll `window.oxSlocChartsReady` for up to 15 s so Chart.js canvases finish rendering.
1102fn wait_for_charts_ready(tab: &headless_chrome::Tab) {
1103    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
1104    let mut last_cdp_err: Option<String> = None;
1105    loop {
1106        match tab.evaluate("!!window.oxSlocChartsReady", false) {
1107            Ok(r) => {
1108                last_cdp_err = None;
1109                if matches!(r.value, Some(serde_json::Value::Bool(true))) {
1110                    report_chart_error_if_any(tab);
1111                    return;
1112                }
1113            }
1114            Err(e) => {
1115                let msg = format!("{e:#}");
1116                if last_cdp_err.as_deref() != Some(&msg) {
1117                    eprintln!("[oxide-sloc][pdf] CDP evaluate error (will retry): {msg}");
1118                    last_cdp_err = Some(msg);
1119                }
1120            }
1121        }
1122        if std::time::Instant::now() >= deadline {
1123            report_chart_error_if_any(tab);
1124            break;
1125        }
1126        std::thread::sleep(std::time::Duration::from_millis(250));
1127    }
1128}
1129
1130/// Read the `.report-id-banner` text from the loaded page, if present and non-empty.
1131fn extract_banner_text(tab: &headless_chrome::Tab) -> Option<String> {
1132    let result = tab
1133        .evaluate(
1134            "(function(){\
1135               var el=document.querySelector('.report-id-banner');\
1136               return el?el.textContent.trim():null;\
1137             })()",
1138            false,
1139        )
1140        .ok()?;
1141    match result.value? {
1142        serde_json::Value::String(s) if !s.is_empty() => Some(s),
1143        _ => None,
1144    }
1145}
1146
1147/// Read the `innerHTML` of an optional `#<id>` element supplied by the document to act as
1148/// a Chrome print header/footer template. Chrome renders these in the page margin on every
1149/// printed page (including a short final page), which in-flow or `position:fixed` markup
1150/// cannot do reliably. Returns `None` when the element is absent or empty.
1151fn extract_pdf_template(tab: &headless_chrome::Tab, id: &str) -> Option<String> {
1152    // `id` is always a hard-coded constant below — no script-injection surface.
1153    let js = format!(
1154        "(function(){{var el=document.getElementById('{id}');return el?el.innerHTML:null;}})()"
1155    );
1156    let result = tab.evaluate(&js, false).ok()?;
1157    match result.value? {
1158        serde_json::Value::String(s) if !s.trim().is_empty() => Some(s),
1159        _ => None,
1160    }
1161}
1162
1163/// Use Chrome `DevTools` Protocol to render `html_path` as a PDF at `output_path`.
1164///
1165/// Launches a headless Chromium-based browser at A4-landscape viewport (1122 × 794 px),
1166/// waits up to 15 s for all Chart.js canvases to signal readiness via
1167/// `window.oxSlocChartsReady`, then captures the page using `Page.printToPDF` via CDP.
1168fn write_pdf_via_cdp(html_path: &Path, output_path: &Path) -> Result<()> {
1169    use headless_chrome::types::PrintToPdfOptions;
1170
1171    let browser_path = discover_browser().context(
1172        "no supported Chromium-based browser found; \
1173         set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
1174    )?;
1175    eprintln!("[oxide-sloc][pdf] browser = {}", browser_path.display());
1176
1177    let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
1178    if no_sandbox {
1179        eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
1180    }
1181
1182    let browser = launch_cdp_browser(browser_path, no_sandbox)?;
1183    let tab = browser.new_tab().context("failed to open browser tab")?;
1184    // Raise the per-call CDP timeout well above the 20 s default. On a loaded host
1185    // (e.g. the user's own Chromium already eating several GB) just launching a second
1186    // headless instance and navigating a trivial page can take 15-30 s; the old default
1187    // made navigation/print time out and fall back to wkhtmltopdf, failing the export.
1188    tab.set_default_timeout(std::time::Duration::from_secs(90));
1189
1190    let html_for_url = PathBuf::from(
1191        html_path
1192            .to_string_lossy()
1193            .trim_start_matches(r"\\?\")
1194            .to_string(),
1195    );
1196    let url = file_url(&html_for_url);
1197    eprintln!("[oxide-sloc][pdf] url = {url}");
1198
1199    tab.navigate_to(&url)
1200        .context("failed to navigate browser to HTML file")?;
1201    tab.wait_until_navigated()
1202        .context("browser navigation did not complete")?;
1203
1204    wait_for_charts_ready(&tab);
1205
1206    // Resolve the per-page header/footer chrome (banner or per-document native templates)
1207    // and the margins those require. Kept in a helper so this function stays flat.
1208    let chrome = build_pdf_chrome(&tab);
1209
1210    let pdf_bytes = tab
1211        .print_to_pdf(Some(PrintToPdfOptions {
1212            landscape: Some(true),
1213            print_background: Some(true),
1214            scale: Some(0.97),
1215            paper_width: Some(11.69), // A4 landscape width (inches)
1216            paper_height: Some(8.27), // A4 landscape height (inches)
1217            margin_top: Some(chrome.margin_top),
1218            margin_bottom: Some(chrome.margin_bottom),
1219            margin_left: Some(0.0),
1220            margin_right: Some(0.0),
1221            prefer_css_page_size: Some(false),
1222            display_header_footer: if chrome.display_header_footer {
1223                Some(true)
1224            } else {
1225                None
1226            },
1227            header_template: chrome.header_template,
1228            footer_template: chrome.footer_template,
1229            ..Default::default()
1230        }))
1231        .context("browser failed to generate PDF")?;
1232
1233    fs::write(output_path, &pdf_bytes)
1234        .with_context(|| format!("failed to write PDF to {}", output_path.display()))?;
1235
1236    eprintln!("[oxide-sloc][pdf] wrote {} bytes", pdf_bytes.len());
1237    Ok(())
1238}
1239
1240/// Resolved per-page print chrome for the CDP PDF export.
1241struct PdfChrome {
1242    header_template: Option<String>,
1243    footer_template: Option<String>,
1244    display_header_footer: bool,
1245    margin_top: f64,
1246    margin_bottom: f64,
1247}
1248
1249/// HTML template for a centred identification banner rendered in the PDF page margin.
1250/// The template renders in the margin area, so `font-size` must be set explicitly.
1251fn pdf_banner_template(text: &str) -> String {
1252    let escaped = text
1253        .replace('&', "&amp;")
1254        .replace('<', "&lt;")
1255        .replace('>', "&gt;")
1256        .replace('"', "&quot;");
1257    format!(
1258        r#"<div style="font-size:10px;width:100%;text-align:center;\
1259color:#fff;background:#b35428;padding:5px 0;\
1260font-family:sans-serif;font-weight:700;letter-spacing:0.05em;\
1261-webkit-print-color-adjust:exact;print-color-adjust:exact;">{escaped}</div>"#
1262    )
1263}
1264
1265/// Reserve top/bottom margins only on the side(s) that actually carry chrome. A banner keeps
1266/// its historical top/bottom reserve.
1267const fn pdf_margins(
1268    has_banner: bool,
1269    has_native_header: bool,
1270    has_native_footer: bool,
1271) -> (f64, f64) {
1272    let top = if has_banner {
1273        0.35
1274    } else if has_native_header {
1275        0.55
1276    } else {
1277        0.0
1278    };
1279    let bottom = if has_banner {
1280        0.25
1281    } else if has_native_footer {
1282        0.42
1283    } else {
1284        0.0
1285    };
1286    (top, bottom)
1287}
1288
1289/// Choose the Chrome header/footer templates. A banner wins; otherwise per-document native
1290/// chrome is used. Chrome prints both a header and footer template whenever they are supplied,
1291/// so an empty `<span>` suppresses default chrome on the unused side.
1292fn pdf_header_footer_templates(
1293    banner_text: Option<&str>,
1294    native_header: Option<String>,
1295    native_footer: Option<String>,
1296) -> (Option<String>, Option<String>) {
1297    if let Some(t) = banner_text {
1298        let tmpl = pdf_banner_template(t);
1299        return (Some(tmpl.clone()), Some(tmpl));
1300    }
1301    if native_header.is_some() || native_footer.is_some() {
1302        let empty = || "<span></span>".to_string();
1303        return (
1304            Some(native_header.unwrap_or_else(empty)),
1305            Some(native_footer.unwrap_or_else(empty)),
1306        );
1307    }
1308    (None, None)
1309}
1310
1311/// Determine the header/footer templates and reserved margins for the PDF.
1312///
1313/// Priority: a report identification banner (set in step 3 of the scan configuration as
1314/// `report_header_footer`) wins; otherwise per-document hidden `#pdf-native-header` /
1315/// `#pdf-native-footer` elements are used (the Scan Delta report uses these for its per-page
1316/// footer bar).
1317fn build_pdf_chrome(tab: &headless_chrome::Tab) -> PdfChrome {
1318    let banner_text = extract_banner_text(tab);
1319    if let Some(ref t) = banner_text {
1320        eprintln!("[oxide-sloc][pdf] report banner detected: {t}");
1321    }
1322    let has_banner = banner_text.is_some();
1323
1324    let native_header = if has_banner {
1325        None
1326    } else {
1327        extract_pdf_template(tab, "pdf-native-header")
1328    };
1329    let native_footer = if has_banner {
1330        None
1331    } else {
1332        extract_pdf_template(tab, "pdf-native-footer")
1333    };
1334    let has_native_header = native_header.is_some();
1335    let has_native_footer = native_footer.is_some();
1336
1337    let (header_template, footer_template) =
1338        pdf_header_footer_templates(banner_text.as_deref(), native_header, native_footer);
1339    let (margin_top, margin_bottom) = pdf_margins(has_banner, has_native_header, has_native_footer);
1340
1341    PdfChrome {
1342        header_template,
1343        footer_template,
1344        display_header_footer: has_banner || has_native_header || has_native_footer,
1345        margin_top,
1346        margin_bottom,
1347    }
1348}
1349
1350/// Locate the `wkhtmltopdf` binary on Linux and Windows.
1351///
1352/// Search order:
1353/// 1. `wkhtmltopdf` / `wkhtmltopdf.exe` anywhere in `$PATH` (covers Linux packages and
1354///    Windows installs that add the bin dir to the system PATH).
1355/// 2. Windows-only: standard MSI install locations under `Program Files` and
1356///    `Program Files (x86)`.
1357/// 3. Linux-only: absolute paths that package managers commonly use but that may not be
1358///    on the service account's `$PATH`.
1359fn discover_wkhtmltopdf() -> Option<PathBuf> {
1360    if let Some(p) = which_in_path("wkhtmltopdf") {
1361        return Some(p);
1362    }
1363
1364    #[cfg(windows)]
1365    {
1366        for var in ["ProgramFiles", "ProgramFiles(x86)"] {
1367            if let Ok(base) = std::env::var(var) {
1368                let candidate = PathBuf::from(base)
1369                    .join("wkhtmltopdf")
1370                    .join("bin")
1371                    .join("wkhtmltopdf.exe");
1372                if candidate.is_file() {
1373                    return Some(candidate);
1374                }
1375            }
1376        }
1377    }
1378
1379    #[cfg(not(windows))]
1380    for p in [
1381        "/usr/bin/wkhtmltopdf",
1382        "/usr/local/bin/wkhtmltopdf",
1383        "/opt/wkhtmltopdf/bin/wkhtmltopdf",
1384        "/snap/bin/wkhtmltopdf",
1385    ] {
1386        let candidate = PathBuf::from(p);
1387        if candidate.is_file() {
1388            return Some(candidate);
1389        }
1390    }
1391
1392    None
1393}
1394
1395/// Generate a PDF using `wkhtmltopdf` when no Chromium-based browser is available.
1396///
1397/// Works on both Linux and Windows:
1398/// - Linux: install via `dnf install wkhtmltopdf` (RHEL/CentOS) or `apt install wkhtmltopdf`
1399/// - Windows: install the MSI from <https://wkhtmltopdf.org/downloads.html>; the installer
1400///   adds `wkhtmltopdf.exe` to `Program Files\wkhtmltopdf\bin\` which is checked automatically.
1401fn write_pdf_via_wkhtmltopdf(html_path: &Path, pdf_path: &Path) -> Result<()> {
1402    eprintln!("[oxide-sloc][pdf] trying wkhtmltopdf fallback");
1403
1404    let exe = discover_wkhtmltopdf().context(
1405        "wkhtmltopdf not found. \
1406         Linux: install via 'dnf install wkhtmltopdf' or 'apt install wkhtmltopdf'. \
1407         Windows: install the MSI from https://wkhtmltopdf.org/downloads.html. \
1408         Alternatively, set SLOC_BROWSER to a Chromium-based browser executable.",
1409    )?;
1410    eprintln!("[oxide-sloc][pdf] wkhtmltopdf = {}", exe.display());
1411
1412    // Strip the extended-length prefix on Windows (\\?\) so wkhtmltopdf can parse the path.
1413    let html_normalized = PathBuf::from(
1414        html_path
1415            .to_string_lossy()
1416            .trim_start_matches(r"\\?\")
1417            .to_string(),
1418    );
1419    // file_url() handles Windows drive letters (C:\ → /C:/) and encodes special chars.
1420    let html_url = file_url(&html_normalized);
1421    eprintln!("[oxide-sloc][pdf] wkhtmltopdf url = {html_url}");
1422
1423    let pdf_str = pdf_path
1424        .to_str()
1425        .context("PDF output path contains non-UTF-8 characters")?;
1426
1427    let output = std::process::Command::new(&exe)
1428        .args([
1429            "--enable-javascript",
1430            "--javascript-delay",
1431            "2000",
1432            "--quiet",
1433            "--orientation",
1434            "Landscape",
1435            "--page-size",
1436            "A4",
1437            "--margin-top",
1438            "9",
1439            "--margin-bottom",
1440            "9",
1441            "--margin-left",
1442            "13",
1443            "--margin-right",
1444            "13",
1445            "--print-media-type",
1446            &html_url,
1447            pdf_str,
1448        ])
1449        .output()
1450        .with_context(|| format!("failed to launch wkhtmltopdf at {}", exe.display()))?;
1451
1452    if !output.status.success() {
1453        let stderr = String::from_utf8_lossy(&output.stderr);
1454        anyhow::bail!("wkhtmltopdf exited with {}: {stderr}", output.status);
1455    }
1456
1457    if !pdf_path.exists() {
1458        anyhow::bail!(
1459            "wkhtmltopdf exited successfully but {} was not created",
1460            pdf_path.display()
1461        );
1462    }
1463
1464    eprintln!("[oxide-sloc][pdf] wkhtmltopdf wrote {}", pdf_path.display());
1465    Ok(())
1466}
1467
1468struct PdfCtx<'a> {
1469    layer: &'a crate::pdf_compat::PdfLayerReference,
1470    font_reg: crate::pdf_compat::IndirectFontRef,
1471    font_bold: crate::pdf_compat::IndirectFontRef,
1472    w: f32,
1473    margin: f32,
1474    row_h: f32,
1475    tbl_hdr_h: f32,
1476}
1477
1478/// Fixed page geometry (landscape A4 in mm) threaded through the PDF page builders.
1479/// Bundled into one struct so the page helpers stay under the argument-count lint.
1480#[derive(Clone, Copy)]
1481struct PdfPageDims {
1482    w: f32,
1483    h: f32,
1484    margin: f32,
1485    footer_h: f32,
1486    row_h: f32,
1487    tbl_hdr_h: f32,
1488}
1489
1490#[allow(
1491    clippy::cast_precision_loss,
1492    clippy::suboptimal_flops,
1493    clippy::too_many_lines
1494)]
1495fn runtime_mode_display(mode: &str) -> &str {
1496    match mode {
1497        "serve" => "Web UI",
1498        "analyze" => "CLI",
1499        "git-scan" => "Git Scan",
1500        "git-compare" => "Git Compare",
1501        "watch" => "Watch",
1502        other => other,
1503    }
1504}
1505
1506fn pdf_render_page1_header(
1507    ctx: &PdfCtx<'_>,
1508    run: &AnalysisRun,
1509    ts: &str,
1510    title: &str,
1511    h: f32,
1512    hdr_h: f32,
1513    banner: Option<&str>,
1514) -> f32 {
1515    use crate::pdf_compat::{Color, Mm, Rgb};
1516    let hdr_y = h - hdr_h;
1517    pdf_fill_rect(
1518        ctx.layer,
1519        0.0,
1520        hdr_y,
1521        ctx.w,
1522        hdr_h,
1523        Rgb::new(0.098, 0.11, 0.15, None),
1524    );
1525    ctx.layer
1526        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1527    ctx.layer.use_text(
1528        "oxide-sloc",
1529        13.0,
1530        Mm(ctx.margin),
1531        Mm(hdr_y + 4.5),
1532        ctx.font_bold,
1533    );
1534    ctx.layer
1535        .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
1536    ctx.layer.use_text(
1537        "Code Metrics Report",
1538        9.5,
1539        Mm(54.0),
1540        Mm(hdr_y + 5.0),
1541        ctx.font_reg,
1542    );
1543    ctx.layer.use_text(
1544        pdf_safe_str(ts),
1545        8.0,
1546        Mm(ctx.w - 70.0),
1547        Mm(hdr_y + 5.0),
1548        ctx.font_reg,
1549    );
1550    // Report identification banner — white bold, centered between the two header items.
1551    if let Some(text) = banner {
1552        let safe = pdf_trunc(&pdf_safe_str(text), 40);
1553        // Approximate half-width at 9pt bold Helvetica (~0.97 mm per char) for centering.
1554        // `safe` is truncated to 40 chars, so the count is tiny; the f32 cast is exact here
1555        // and only ever feeds a millimetre layout coordinate.
1556        #[allow(
1557            clippy::cast_precision_loss,
1558            reason = "small bounded char count; sub-mm layout offset"
1559        )]
1560        let text_x = (safe.len() as f32).mul_add(-0.97, ctx.w / 2.0).max(95.0);
1561        ctx.layer
1562            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1563        ctx.layer
1564            .use_text(safe, 9.0, Mm(text_x), Mm(hdr_y + 4.5), ctx.font_bold);
1565    }
1566    let title_text_y = hdr_y - 5.5;
1567    ctx.layer
1568        .set_fill_color(Color::Rgb(Rgb::new(0.098, 0.11, 0.15, None)));
1569    ctx.layer.use_text(
1570        pdf_trunc(&pdf_safe_str(title), 55),
1571        9.5,
1572        Mm(ctx.margin),
1573        Mm(title_text_y),
1574        ctx.font_bold,
1575    );
1576    let roots_text_y = title_text_y - 5.0;
1577    // ── Left side: project path ──────────────────────────────────────────────
1578    let roots: String = run
1579        .input_roots
1580        .iter()
1581        .map(|r| pdf_safe_str(r))
1582        .collect::<Vec<_>>()
1583        .join("  ");
1584    ctx.layer
1585        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
1586    ctx.layer.use_text(
1587        pdf_trunc(&roots, 85),
1588        6.5,
1589        Mm(ctx.margin),
1590        Mm(roots_text_y),
1591        ctx.font_reg,
1592    );
1593    // ── Right side: git + environment metadata in a grouped box ─────────────
1594    pdf_render_page1_gitbox(ctx, run, title_text_y, roots_text_y);
1595    roots_text_y
1596}
1597
1598/// Render the right-side git + environment metadata box of the page-1 header.
1599fn pdf_render_page1_gitbox(
1600    ctx: &PdfCtx<'_>,
1601    run: &AnalysisRun,
1602    title_text_y: f32,
1603    roots_text_y: f32,
1604) {
1605    use crate::pdf_compat::{Color, Mm, Rgb};
1606    let mut git_parts: Vec<String> = vec![];
1607    if let Some(ref b) = run.git_branch {
1608        git_parts.push(format!("Branch: {}", pdf_safe_str(b)));
1609    }
1610    if let Some(ref c) = run.git_commit_short {
1611        git_parts.push(format!("Commit: {}", pdf_safe_str(c)));
1612    }
1613    if let Some(ref t) = run.git_nearest_tag {
1614        git_parts.push(format!("Tag: {}", pdf_safe_str(t)));
1615    }
1616    let git_str = pdf_trunc(&git_parts.join("  \u{00B7}  "), 70);
1617
1618    let initiator = run
1619        .environment
1620        .ci_name
1621        .as_deref()
1622        .unwrap_or(run.environment.initiator_username.as_str());
1623    let mode_label = runtime_mode_display(&run.environment.runtime_mode);
1624    let env_str = format!(
1625        "OS: {} / {}  \u{00B7}  User: {}  \u{00B7}  Host: {}  \u{00B7}  Source: {}",
1626        pdf_safe_str(&run.environment.operating_system),
1627        pdf_safe_str(&run.environment.architecture),
1628        pdf_safe_str(initiator),
1629        pdf_safe_str(&run.environment.initiator_hostname),
1630        mode_label,
1631    );
1632    let env_trunc = pdf_trunc(&env_str, 100);
1633
1634    // Shared right anchor — text right-edges land here; box extends pad_h mm beyond.
1635    let right_anchor = ctx.w - ctx.margin - 6.0;
1636    // Accurate widths using exact PDF Helvetica advance tables (PDF spec Appendix D).
1637    // Character-count estimates are unreliable for proportional fonts — actual per-glyph widths vary 4×.
1638    let git_w = helvetica_width_mm(&git_str, 7.5, true);
1639    let env_w = helvetica_width_mm(&env_trunc, 6.5, false);
1640    let max_w = git_w.max(env_w);
1641
1642    // Background pill with 0.6 mm simulated border for visual grouping.
1643    let pad_h: f32 = 3.5;
1644    let pad_v: f32 = 1.8;
1645    let box_left = (right_anchor - max_w - pad_h).max(ctx.w / 2.0 - pad_h);
1646    let box_right = right_anchor + pad_h;
1647    let box_width = box_right - box_left;
1648    let box_bot = roots_text_y - pad_v;
1649    let box_top = title_text_y + pad_v + 1.5;
1650    let box_height = box_top - box_bot;
1651    pdf_fill_rect(
1652        ctx.layer,
1653        box_left - 0.6,
1654        box_bot - 0.6,
1655        box_width + 1.2,
1656        box_height + 1.2,
1657        Rgb::new(0.80, 0.75, 0.68, None),
1658    );
1659    pdf_fill_rect(
1660        ctx.layer,
1661        box_left,
1662        box_bot,
1663        box_width,
1664        box_height,
1665        Rgb::new(0.97, 0.95, 0.92, None),
1666    );
1667
1668    // Git line — right-aligned to shared anchor, dark-green bold
1669    if !git_str.is_empty() {
1670        let git_x = (right_anchor - git_w).max(box_left + 2.0);
1671        ctx.layer
1672            .set_fill_color(Color::Rgb(Rgb::new(0.25, 0.42, 0.25, None)));
1673        ctx.layer.use_text(
1674            git_str.as_str(),
1675            7.5,
1676            Mm(git_x),
1677            Mm(title_text_y),
1678            ctx.font_bold,
1679        );
1680    }
1681    // Env line — same right anchor so "Source: …" right-edge aligns with "Tag: …" above
1682    let env_x = (right_anchor - env_w).max(box_left + 2.0);
1683    ctx.layer
1684        .set_fill_color(Color::Rgb(Rgb::new(0.38, 0.38, 0.38, None)));
1685    ctx.layer.use_text(
1686        env_trunc.as_str(),
1687        6.5,
1688        Mm(env_x),
1689        Mm(roots_text_y),
1690        ctx.font_reg,
1691    );
1692}
1693
1694#[allow(clippy::cast_precision_loss)]
1695fn pdf_render_summary_chips(ctx: &PdfCtx<'_>, run: &AnalysisRun, roots_text_y: f32) -> f32 {
1696    use crate::pdf_compat::{Color, Mm, Rgb};
1697    let tot = &run.summary_totals;
1698    let chip_gap: f32 = 5.0;
1699    let chip_w = 3.0f32.mul_add(-chip_gap, 2.0f32.mul_add(-ctx.margin, ctx.w)) / 4.0;
1700    let chip_h: f32 = 17.0;
1701    let row1_bot = roots_text_y - 4.0 - chip_h;
1702    let row1: [(&str, u64); 4] = [
1703        ("Code Lines", tot.code_lines),
1704        ("Comment Lines", tot.comment_lines),
1705        ("Blank Lines", tot.blank_lines),
1706        ("Physical Lines", tot.total_physical_lines),
1707    ];
1708    for (i, (label, value)) in row1.iter().enumerate() {
1709        let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1710        pdf_fill_rect(
1711            ctx.layer,
1712            cx,
1713            row1_bot,
1714            chip_w,
1715            chip_h,
1716            Rgb::new(0.945, 0.925, 0.90, None),
1717        );
1718        ctx.layer
1719            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1720        // Show the full comma-separated number (no K/M rounding) on the PDF stat cards.
1721        ctx.layer.use_text(
1722            pdf_fmt_full(*value),
1723            13.0,
1724            Mm(cx + 4.0),
1725            Mm(row1_bot + 9.0),
1726            ctx.font_bold,
1727        );
1728        ctx.layer
1729            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
1730        ctx.layer.use_text(
1731            pdf_safe_str(label),
1732            6.5,
1733            Mm(cx + 4.0),
1734            Mm(row1_bot + 3.0),
1735            ctx.font_reg,
1736        );
1737    }
1738    let row2_bot = row1_bot - 3.0 - chip_h;
1739    let row2_4th = if tot.test_count > 0 {
1740        ("Test Methods", tot.test_count)
1741    } else if tot.classes > 0 {
1742        ("Classes", tot.classes)
1743    } else {
1744        ("Mixed Lines", tot.mixed_lines_separate)
1745    };
1746    let row2: [(&str, u64); 4] = [
1747        ("Files Analyzed", tot.files_analyzed),
1748        ("Files Skipped", tot.files_skipped),
1749        ("Functions", tot.functions),
1750        row2_4th,
1751    ];
1752    for (i, (label, value)) in row2.iter().enumerate() {
1753        let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1754        pdf_fill_rect(
1755            ctx.layer,
1756            cx,
1757            row2_bot,
1758            chip_w,
1759            chip_h,
1760            Rgb::new(0.91, 0.92, 0.96, None),
1761        );
1762        ctx.layer
1763            .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.25, 0.55, None)));
1764        ctx.layer.use_text(
1765            pdf_fmt_full(*value),
1766            13.0,
1767            Mm(cx + 4.0),
1768            Mm(row2_bot + 9.0),
1769            ctx.font_bold,
1770        );
1771        ctx.layer
1772            .set_fill_color(Color::Rgb(Rgb::new(0.35, 0.35, 0.45, None)));
1773        ctx.layer.use_text(
1774            pdf_safe_str(label),
1775            6.5,
1776            Mm(cx + 4.0),
1777            Mm(row2_bot + 3.0),
1778            ctx.font_reg,
1779        );
1780    }
1781    row2_bot
1782}
1783
1784#[allow(clippy::cast_precision_loss)]
1785fn pdf_info_parts_stats(tot: &SummaryTotals) -> Vec<String> {
1786    let total = tot.total_physical_lines.max(1) as f64;
1787    let code_pct = tot.code_lines as f64 / total * 100.0;
1788    let cmt_pct = tot.comment_lines as f64 / total * 100.0;
1789    let blank_pct = tot.blank_lines as f64 / total * 100.0;
1790    let mixed_pct = tot.mixed_lines_separate as f64 / total * 100.0;
1791    let mut parts = vec![
1792        format!(
1793            "Code: {code_pct:.1}% ({} lines)",
1794            pdf_fmt_full(tot.code_lines)
1795        ),
1796        format!(
1797            "Comments: {cmt_pct:.1}% ({} lines)",
1798            pdf_fmt_full(tot.comment_lines)
1799        ),
1800        format!(
1801            "Blank: {blank_pct:.1}% ({} lines)",
1802            pdf_fmt_full(tot.blank_lines)
1803        ),
1804    ];
1805    if tot.functions > 0 {
1806        parts.push(format!("Functions: {}", pdf_fmt_full(tot.functions)));
1807    }
1808    if tot.mixed_lines_separate > 0 {
1809        parts.push(format!(
1810            "Mixed: {mixed_pct:.1}% ({} lines)",
1811            pdf_fmt_full(tot.mixed_lines_separate)
1812        ));
1813    }
1814    if tot.imports > 0 {
1815        parts.push(format!("Imports: {}", pdf_fmt_full(tot.imports)));
1816    }
1817    if tot.variables > 0 {
1818        parts.push(format!("Variables: {}", pdf_fmt_full(tot.variables)));
1819    }
1820    if tot.classes > 0 {
1821        parts.push(format!("Classes: {}", pdf_fmt_full(tot.classes)));
1822    }
1823    parts
1824}
1825
1826fn pdf_info_parts_git(run: &AnalysisRun) -> Vec<String> {
1827    let mut parts: Vec<String> = Vec::new();
1828    if let Some(ref b) = run.git_branch {
1829        parts.push(format!("Branch: {}", pdf_safe_str(b)));
1830    }
1831    if let Some(ref c) = run.git_commit_short {
1832        parts.push(format!("Commit: {}", pdf_safe_str(c)));
1833    }
1834    if let Some(ref t) = run.git_nearest_tag {
1835        parts.push(format!("Tag: {}", pdf_safe_str(t)));
1836    }
1837    if let Some(ref a) = run.git_commit_author {
1838        parts.push(format!("Author: {}", pdf_safe_str(a)));
1839    }
1840    if let Some(ref d) = run.git_commit_date {
1841        parts.push(format!("Commit Date: {}", fmt_commit_date_pt(d)));
1842    }
1843    parts
1844}
1845
1846#[allow(clippy::cast_precision_loss)]
1847fn pdf_info_parts_tests(tot: &SummaryTotals) -> Vec<String> {
1848    let mut tc: Vec<String> = Vec::new();
1849    if tot.test_count > 0 {
1850        tc.push(format!("Tests: {}", pdf_fmt_full(tot.test_count)));
1851    }
1852    if tot.test_assertion_count > 0 {
1853        tc.push(format!(
1854            "Assertions: {}",
1855            pdf_fmt_full(tot.test_assertion_count)
1856        ));
1857    }
1858    if tot.test_suite_count > 0 {
1859        tc.push(format!("Suites: {}", pdf_fmt_full(tot.test_suite_count)));
1860    }
1861    if tot.coverage_lines_found > 0 {
1862        tc.push(format!(
1863            "Line Cov: {:.1}% ({}/{})",
1864            tot.coverage_lines_hit as f64 / tot.coverage_lines_found as f64 * 100.0,
1865            pdf_fmt_full(tot.coverage_lines_hit),
1866            pdf_fmt_full(tot.coverage_lines_found)
1867        ));
1868    }
1869    if tot.coverage_functions_found > 0 {
1870        tc.push(format!(
1871            "Func Cov: {:.1}%",
1872            tot.coverage_functions_hit as f64 / tot.coverage_functions_found as f64 * 100.0
1873        ));
1874    }
1875    if tot.coverage_branches_found > 0 {
1876        tc.push(format!(
1877            "Branch Cov: {:.1}%",
1878            tot.coverage_branches_hit as f64 / tot.coverage_branches_found as f64 * 100.0
1879        ));
1880    }
1881    tc
1882}
1883
1884/// Emit one or more info lines, packing `parts` and wrapping onto a fresh line whenever the
1885/// next part would overflow the usable page width. Each part is drawn as a **bold** key
1886/// ("Code:") followed by its regular-weight value, with a muted separator between parts, so the
1887/// dense metric strip reads cleanly. Measured with the exact Helvetica advance table so the
1888/// whole line is always shown — never truncated. Returns the y position below the last line.
1889// x/y are page coordinates and r/g/b are colour channels — the conventional
1890// single-letter names in graphics code; renaming them would hurt, not help.
1891#[allow(clippy::many_single_char_names)]
1892fn pdf_info_emit_line(
1893    ctx: &PdfCtx<'_>,
1894    mut y: f32,
1895    r: f32,
1896    g: f32,
1897    b: f32,
1898    parts: &[String],
1899) -> f32 {
1900    use crate::pdf_compat::{Color, Mm, Rgb};
1901    const SIZE: f32 = 7.0;
1902    const LINE_GAP: f32 = 6.2;
1903    const SEP: &str = "   |   ";
1904    if parts.is_empty() {
1905        return y;
1906    }
1907    let usable = ctx.margin.mul_add(-2.0, ctx.w);
1908    let sep_w = helvetica_width_mm(SEP, SIZE, false);
1909    let group = Color::Rgb(Rgb::new(r, g, b, None));
1910    let sep_color = Color::Rgb(Rgb::new(0.66, 0.63, 0.60, None));
1911    let mut x = ctx.margin;
1912    let mut first_on_line = true;
1913    for part in parts {
1914        // Split "Label: value" into a bold key (kept with its colon) and a regular value.
1915        let (key, val) = match part.split_once(": ") {
1916            Some((k, v)) => (format!("{k}: "), v.to_string()),
1917            None => (part.clone(), String::new()),
1918        };
1919        let key_w = helvetica_width_mm(&key, SIZE, true);
1920        let val_w = helvetica_width_mm(&val, SIZE, false);
1921        let advance = if first_on_line {
1922            key_w + val_w
1923        } else {
1924            sep_w + key_w + val_w
1925        };
1926        if !first_on_line && x + advance > ctx.margin + usable {
1927            y -= LINE_GAP;
1928            x = ctx.margin;
1929            first_on_line = true;
1930        }
1931        if !first_on_line {
1932            ctx.layer.set_fill_color(sep_color.clone());
1933            ctx.layer.use_text(SEP, SIZE, Mm(x), Mm(y), ctx.font_reg);
1934            x += sep_w;
1935        }
1936        ctx.layer.set_fill_color(group.clone());
1937        ctx.layer
1938            .use_text(key.as_str(), SIZE, Mm(x), Mm(y), ctx.font_bold);
1939        x += key_w;
1940        if !val.is_empty() {
1941            ctx.layer
1942                .use_text(val.as_str(), SIZE, Mm(x), Mm(y), ctx.font_reg);
1943            x += val_w;
1944        }
1945        first_on_line = false;
1946    }
1947    y - LINE_GAP
1948}
1949
1950fn pdf_render_info_lines(ctx: &PdfCtx<'_>, run: &AnalysisRun, row2_bot: f32) -> f32 {
1951    let tot = &run.summary_totals;
1952    let mut y = row2_bot - 6.5;
1953    let stats = pdf_info_parts_stats(tot);
1954    y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.15, &stats);
1955    // A little extra breathing room between the stats / git / tests groups.
1956    let git = pdf_info_parts_git(run);
1957    if !git.is_empty() {
1958        y -= 1.6;
1959        y = pdf_info_emit_line(ctx, y, 0.10, 0.35, 0.15, &git);
1960    }
1961    let tests = pdf_info_parts_tests(tot);
1962    if !tests.is_empty() {
1963        y -= 1.6;
1964        y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.50, &tests);
1965    }
1966    y
1967}
1968
1969#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1970fn pdf_table_render_section(
1971    ctx: &PdfCtx<'_>,
1972    x: f32,
1973    top: f32,
1974    w: f32,
1975    lbl_frac: f32,
1976    title: &str,
1977    rows: &[(&str, String)],
1978) {
1979    use crate::pdf_compat::{Color, Mm, Rgb};
1980    pdf_fill_rect(
1981        ctx.layer,
1982        x,
1983        top - ctx.tbl_hdr_h,
1984        w,
1985        ctx.tbl_hdr_h,
1986        Rgb::new(0.098, 0.11, 0.15, None),
1987    );
1988    ctx.layer
1989        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1990    ctx.layer.use_text(
1991        title,
1992        7.0,
1993        Mm(x + 2.0),
1994        Mm(top - ctx.tbl_hdr_h + 1.5),
1995        ctx.font_bold,
1996    );
1997    let y = top - ctx.tbl_hdr_h;
1998    for (ri, (lbl, val)) in rows.iter().enumerate() {
1999        let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, y);
2000        let bg = if ri % 2 == 0 {
2001            Rgb::new(0.975, 0.965, 0.95, None)
2002        } else {
2003            Rgb::new(1.0, 1.0, 1.0, None)
2004        };
2005        pdf_fill_rect(ctx.layer, x, ry, w, ctx.row_h, bg);
2006        ctx.layer
2007            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2008        ctx.layer
2009            .use_text(*lbl, 6.5, Mm(x + 2.0), Mm(ry + 1.5), ctx.font_reg);
2010        let is_dash = val == "--";
2011        let val_rgb = if is_dash {
2012            Rgb::new(0.55, 0.55, 0.55, None)
2013        } else {
2014            Rgb::new(0.12, 0.12, 0.12, None)
2015        };
2016        let val_font = if is_dash { ctx.font_reg } else { ctx.font_bold };
2017        ctx.layer.set_fill_color(Color::Rgb(val_rgb));
2018        ctx.layer.use_text(
2019            val.as_str(),
2020            6.5,
2021            Mm(x + w * lbl_frac + 2.0),
2022            Mm(ry + 1.5),
2023            val_font,
2024        );
2025    }
2026}
2027
2028#[allow(
2029    clippy::cast_precision_loss,
2030    clippy::suboptimal_flops,
2031    clippy::similar_names
2032)]
2033fn pdf_render_metric_tables(ctx: &PdfCtx<'_>, run: &AnalysisRun, tbl_top: f32) {
2034    let tot = &run.summary_totals;
2035    let half_w = (2.0f32.mul_add(-ctx.margin, ctx.w) - 4.0) / 2.0;
2036    let left_x = ctx.margin;
2037    let right_x = ctx.margin + half_w + 4.0;
2038    let lbl_frac: f32 = 0.68;
2039
2040    let files_rows: [(&str, String); 4] = [
2041        ("Files analyzed", pdf_fmt_full(tot.files_analyzed)),
2042        ("Files skipped", pdf_fmt_full(tot.files_skipped)),
2043        ("Files modified", "--".to_string()),
2044        ("Files unchanged", "--".to_string()),
2045    ];
2046    pdf_table_render_section(ctx, left_x, tbl_top, half_w, lbl_frac, "FILES", &files_rows);
2047
2048    let lc_rows: [(&str, String); 5] = [
2049        ("Physical lines", pdf_fmt_full(tot.total_physical_lines)),
2050        ("Code lines", pdf_fmt_full(tot.code_lines)),
2051        ("Comment lines", pdf_fmt_full(tot.comment_lines)),
2052        ("Blank lines", pdf_fmt_full(tot.blank_lines)),
2053        ("Mixed (separate)", pdf_fmt_full(tot.mixed_lines_separate)),
2054    ];
2055    let lc_top = tbl_top - ctx.tbl_hdr_h - (files_rows.len() as f32).mul_add(ctx.row_h, 3.0);
2056    pdf_table_render_section(
2057        ctx,
2058        left_x,
2059        lc_top,
2060        half_w,
2061        lbl_frac,
2062        "LINE COUNTS",
2063        &lc_rows,
2064    );
2065
2066    let cs_rows: [(&str, String); 4] = [
2067        ("Functions", pdf_fmt_full(tot.functions)),
2068        ("Classes / Types", pdf_fmt_full(tot.classes)),
2069        ("Variables", pdf_fmt_full(tot.variables)),
2070        ("Imports", pdf_fmt_full(tot.imports)),
2071    ];
2072    pdf_table_render_section(
2073        ctx,
2074        right_x,
2075        tbl_top,
2076        half_w,
2077        lbl_frac,
2078        "CODE STRUCTURE",
2079        &cs_rows,
2080    );
2081
2082    let lcs_rows: [(&str, String); 4] = [
2083        ("Lines added", "--".to_string()),
2084        ("Lines removed", "--".to_string()),
2085        ("Lines modified (net)", "--".to_string()),
2086        ("Lines unmodified", "--".to_string()),
2087    ];
2088    let lcs_top = tbl_top - ctx.tbl_hdr_h - (cs_rows.len() as f32).mul_add(ctx.row_h, 3.0);
2089    pdf_table_render_section(
2090        ctx,
2091        right_x,
2092        lcs_top,
2093        half_w,
2094        lbl_frac,
2095        "LINE CHANGE SUMMARY",
2096        &lcs_rows,
2097    );
2098}
2099
2100/// Render Tests & Coverage content **inline** on an existing page, starting at `y_start`.
2101/// Draw a full-width dark section title bar at `y` and return the Y just below it.
2102fn pdf_tc_title_bar(ctx: &PdfCtx<'_>, label: &str, y: f32) -> f32 {
2103    use crate::pdf_compat::{Color, Mm, Rgb};
2104    let tbl_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
2105    pdf_fill_rect(
2106        ctx.layer,
2107        ctx.margin,
2108        y - ctx.tbl_hdr_h,
2109        tbl_w,
2110        ctx.tbl_hdr_h,
2111        Rgb::new(0.098, 0.11, 0.15, None),
2112    );
2113    ctx.layer
2114        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2115    ctx.layer.use_text(
2116        label,
2117        7.0,
2118        Mm(ctx.margin + 2.0),
2119        Mm(y - ctx.tbl_hdr_h + 1.5),
2120        ctx.font_bold,
2121    );
2122    y - ctx.tbl_hdr_h
2123}
2124
2125/// Alternating zebra row background for PDF tables.
2126fn pdf_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2127    use crate::pdf_compat::Rgb;
2128    if ri.is_multiple_of(2) {
2129        Rgb::new(0.975, 0.965, 0.95, None)
2130    } else {
2131        Rgb::new(1.0, 1.0, 1.0, None)
2132    }
2133}
2134
2135/// Sum a per-submodule language metric via the provided accessor.
2136fn pdf_sub_sum(
2137    sub: &sloc_core::SubmoduleSummary,
2138    f: impl Fn(&sloc_core::LanguageSummary) -> u64,
2139) -> u64 {
2140    sub.language_summaries.iter().map(f).sum()
2141}
2142
2143/// Render the four summary stat boxes (test functions/assertions/suites + line coverage).
2144#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2145fn pdf_tc_stat_boxes(ctx: &PdfCtx<'_>, run: &AnalysisRun, has_cov: bool, mut y: f32) -> f32 {
2146    use crate::pdf_compat::{Color, Mm, Rgb};
2147    let gap: f32 = 4.0;
2148    let box_h: f32 = 15.0;
2149    let box_w = (ctx.w - 2.0 * ctx.margin - 3.0 * gap) / 4.0;
2150    let line_cov_str = if has_cov {
2151        let pct = run.summary_totals.coverage_lines_hit as f64
2152            / run.summary_totals.coverage_lines_found as f64
2153            * 100.0;
2154        format!("{pct:.1}%")
2155    } else {
2156        "\u{2014}".to_string()
2157    };
2158    let box_vals: [String; 4] = [
2159        pdf_fmt_full(run.summary_totals.test_count),
2160        pdf_fmt_full(run.summary_totals.test_assertion_count),
2161        pdf_fmt_full(run.summary_totals.test_suite_count),
2162        line_cov_str,
2163    ];
2164    let box_labels: [&str; 4] = [
2165        "Test Functions",
2166        "Test Assertions",
2167        "Test Suites",
2168        "Line Coverage",
2169    ];
2170    for (i, (label, val)) in box_labels.iter().zip(box_vals.iter()).enumerate() {
2171        let bx = ctx.margin + i as f32 * (box_w + gap);
2172        let by = y - box_h;
2173        pdf_fill_rect(
2174            ctx.layer,
2175            bx,
2176            by,
2177            box_w,
2178            box_h,
2179            Rgb::new(0.97, 0.96, 0.94, None),
2180        );
2181        ctx.layer
2182            .set_fill_color(Color::Rgb(Rgb::new(0.60, 0.40, 0.22, None)));
2183        ctx.layer
2184            .use_text(val.as_str(), 9.5, Mm(bx + 3.0), Mm(by + 7.5), ctx.font_bold);
2185        ctx.layer
2186            .set_fill_color(Color::Rgb(Rgb::new(0.50, 0.44, 0.40, None)));
2187        ctx.layer
2188            .use_text(*label, 5.5, Mm(bx + 3.0), Mm(by + 2.0), ctx.font_reg);
2189    }
2190    y -= box_h + 4.0;
2191    y
2192}
2193
2194/// Render the full-width SUBMODULES table when submodule summaries are present.
2195#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2196fn pdf_tc_submodules(ctx: &PdfCtx<'_>, run: &AnalysisRun, footer_h: f32, mut y: f32) -> f32 {
2197    use crate::pdf_compat::{Color, Mm, Rgb};
2198    let subs = &run.submodule_summaries;
2199    if subs.is_empty() {
2200        return y;
2201    }
2202    let margin = ctx.margin;
2203    let row_h = ctx.row_h;
2204    let tbl_w = ctx.w - 2.0 * margin;
2205
2206    let col_name = tbl_w * 0.40;
2207    let rem = tbl_w - col_name;
2208    let col_files = rem * 0.15;
2209    let col_code = rem * 0.20;
2210    let col_tests = rem * 0.20;
2211    let col_assert = rem * 0.20;
2212
2213    let cx_files = margin + col_name;
2214    let cx_code = cx_files + col_files;
2215    let cx_tests = cx_code + col_code;
2216    let cx_assert = cx_tests + col_tests;
2217    let cx_cov = cx_assert + col_assert;
2218
2219    y = pdf_tc_title_bar(ctx, "SUBMODULES", y);
2220
2221    pdf_fill_rect(
2222        ctx.layer,
2223        margin,
2224        y - row_h,
2225        tbl_w,
2226        row_h,
2227        Rgb::new(0.25, 0.27, 0.32, None),
2228    );
2229    ctx.layer
2230        .set_fill_color(Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)));
2231    for (lbl, x) in &[
2232        ("Submodule", margin + 2.0),
2233        ("Files", cx_files + 2.0),
2234        ("Code Lines", cx_code + 2.0),
2235        ("Test Functions", cx_tests + 2.0),
2236        ("Assertions", cx_assert + 2.0),
2237        ("Line Coverage %", cx_cov + 2.0),
2238    ] {
2239        ctx.layer
2240            .use_text(*lbl, 5.5, Mm(*x), Mm(y - row_h + 1.5), ctx.font_bold);
2241    }
2242    y -= row_h;
2243
2244    for (ri, sub) in subs.iter().enumerate() {
2245        if y < footer_h + row_h {
2246            break;
2247        }
2248        let sub_tests = pdf_sub_sum(sub, |l| l.test_count);
2249        let sub_assert = pdf_sub_sum(sub, |l| l.test_assertion_count);
2250        let sub_cov_hit = pdf_sub_sum(sub, |l| l.coverage_lines_hit);
2251        let sub_cov_found = pdf_sub_sum(sub, |l| l.coverage_lines_found);
2252        let sub_cov_str = if sub_cov_found > 0 {
2253            format!("{:.1}%", sub_cov_hit as f64 / sub_cov_found as f64 * 100.0)
2254        } else {
2255            "\u{2014}".to_string()
2256        };
2257
2258        let ry = y - row_h;
2259        pdf_fill_rect(ctx.layer, margin, ry, tbl_w, row_h, pdf_row_bg(ri));
2260        ctx.layer
2261            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2262        ctx.layer.use_text(
2263            pdf_trunc(&pdf_safe_str(&sub.name), 40),
2264            5.5,
2265            Mm(margin + 2.0),
2266            Mm(ry + 1.5),
2267            ctx.font_bold,
2268        );
2269        for (val, x) in &[
2270            (pdf_fmt_full(sub.files_analyzed), cx_files + 2.0),
2271            (pdf_fmt_full(sub.code_lines), cx_code + 2.0),
2272            (pdf_fmt_full(sub_tests), cx_tests + 2.0),
2273            (pdf_fmt_full(sub_assert), cx_assert + 2.0),
2274            (sub_cov_str, cx_cov + 2.0),
2275        ] {
2276            ctx.layer
2277                .use_text(val.as_str(), 5.5, Mm(*x), Mm(ry + 1.5), ctx.font_reg);
2278        }
2279        y -= row_h;
2280    }
2281    y - 3.0
2282}
2283
2284/// Render the line/function/branch coverage gauges across the page width.
2285#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2286fn pdf_tc_gauges(ctx: &PdfCtx<'_>, run: &AnalysisRun, mut y: f32) -> f32 {
2287    use crate::pdf_compat::{Color, Mm, Rgb};
2288    let margin = ctx.margin;
2289    let gap: f32 = 4.0;
2290    let gauges: &[(&str, u64, u64)] = &[
2291        (
2292            "Line Coverage",
2293            run.summary_totals.coverage_lines_hit,
2294            run.summary_totals.coverage_lines_found,
2295        ),
2296        (
2297            "Function Coverage",
2298            run.summary_totals.coverage_functions_hit,
2299            run.summary_totals.coverage_functions_found,
2300        ),
2301        (
2302            "Branch Coverage",
2303            run.summary_totals.coverage_branches_hit,
2304            run.summary_totals.coverage_branches_found,
2305        ),
2306    ];
2307    let visible: Vec<_> = gauges.iter().filter(|(_, _, found)| *found > 0).collect();
2308    if visible.is_empty() {
2309        return y;
2310    }
2311    let count = visible.len() as f32;
2312    let gauge_h: f32 = 16.0;
2313    let pad: f32 = 4.0;
2314    let bar_h: f32 = 3.0;
2315    let gauge_w = (ctx.w - 2.0 * margin - (count - 1.0) * gap) / count;
2316    let bar_w = gauge_w - 2.0 * pad;
2317    for (gi, (label, hit, found)) in visible.iter().enumerate() {
2318        let gx = margin + gi as f32 * (gauge_w + gap);
2319        let pct = *hit as f64 / *found as f64 * 100.0;
2320        let pct_str = format!("{pct:.1}%");
2321        // `pct` is a 0..=100 percentage; narrowing to f32 for a bar-width coordinate is exact
2322        // to well within sub-pixel rendering tolerance.
2323        #[allow(
2324            clippy::cast_possible_truncation,
2325            reason = "0..=100 percentage to f32 bar width"
2326        )]
2327        let bar_fill = bar_w * (pct as f32 / 100.0);
2328        let gy = y - gauge_h;
2329        // Simulated 0.5 mm border (outer rect) behind a lighter card fill, matching the meta box.
2330        pdf_fill_rect(
2331            ctx.layer,
2332            gx - 0.5,
2333            gy - 0.5,
2334            gauge_w + 1.0,
2335            gauge_h + 1.0,
2336            Rgb::new(0.80, 0.75, 0.68, None),
2337        );
2338        pdf_fill_rect(
2339            ctx.layer,
2340            gx,
2341            gy,
2342            gauge_w,
2343            gauge_h,
2344            Rgb::new(0.98, 0.97, 0.95, None),
2345        );
2346        // Label (top), percentage (middle), progress bar (bottom) — evenly padded.
2347        ctx.layer
2348            .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.15, 0.15, None)));
2349        ctx.layer.use_text(
2350            *label,
2351            6.0,
2352            Mm(gx + pad),
2353            Mm(gy + gauge_h - 4.5),
2354            ctx.font_bold,
2355        );
2356        ctx.layer
2357            .set_fill_color(Color::Rgb(Rgb::new(0.20, 0.55, 0.35, None)));
2358        ctx.layer.use_text(
2359            &pct_str,
2360            8.5,
2361            Mm(gx + pad),
2362            Mm(gy + bar_h + 3.0),
2363            ctx.font_bold,
2364        );
2365        pdf_fill_rect(
2366            ctx.layer,
2367            gx + pad,
2368            gy + pad * 0.5,
2369            bar_w,
2370            bar_h,
2371            Rgb::new(0.86, 0.84, 0.80, None),
2372        );
2373        if bar_fill > 0.0 {
2374            pdf_fill_rect(
2375                ctx.layer,
2376                gx + pad,
2377                gy + pad * 0.5,
2378                bar_fill,
2379                bar_h,
2380                Rgb::new(0.20, 0.55, 0.35, None),
2381            );
2382        }
2383    }
2384    y -= gauge_h + 5.0;
2385    y
2386}
2387
2388/// Column layout for the per-file coverage table, shared by the header and row renderers.
2389struct CovCols {
2390    has_fn_cov: bool,
2391    has_br_cov: bool,
2392    col_fn_w: f32,
2393    hdr_x2: f32,
2394}
2395
2396/// Draw the PER-FILE COVERAGE title + column header bar; return `(rows_start_y, cols)`.
2397fn pdf_tc_per_file_header(
2398    ctx: &PdfCtx<'_>,
2399    has_fn_cov: bool,
2400    has_br_cov: bool,
2401    col_fn_w: f32,
2402    y: f32,
2403) -> (f32, CovCols) {
2404    use crate::pdf_compat::{Color, Mm, Rgb};
2405    let margin = ctx.margin;
2406    let col_br_w: f32 = if has_br_cov { 22.0 } else { 0.0 };
2407    let col_file_w = 2.0_f32.mul_add(-margin, ctx.w) - 22.0 - col_fn_w - col_br_w;
2408    let hdr_x2 = margin + col_file_w;
2409
2410    let y = pdf_tc_title_bar(ctx, "PER-FILE COVERAGE", y - 3.0);
2411    ctx.layer
2412        .set_fill_color(Color::Rgb(Rgb::new(0.55, 0.55, 0.55, None)));
2413    ctx.layer
2414        .use_text("Line%", 5.5, Mm(hdr_x2 + 2.0), Mm(y - 3.5), ctx.font_bold);
2415    if has_fn_cov {
2416        ctx.layer
2417            .use_text("Fn%", 5.5, Mm(hdr_x2 + 24.0), Mm(y - 3.5), ctx.font_bold);
2418    }
2419    if has_br_cov {
2420        ctx.layer.use_text(
2421            "Br%",
2422            5.5,
2423            Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2424            Mm(y - 3.5),
2425            ctx.font_bold,
2426        );
2427    }
2428    (
2429        y - ctx.row_h,
2430        CovCols {
2431            has_fn_cov,
2432            has_br_cov,
2433            col_fn_w,
2434            hdr_x2,
2435        },
2436    )
2437}
2438
2439/// Render one per-file coverage row at vertical position `ry`.
2440fn pdf_tc_per_file_row(ctx: &PdfCtx<'_>, file: &FileRecord, ri: usize, cols: &CovCols, ry: f32) {
2441    use crate::pdf_compat::{Color, Mm, Rgb};
2442    let (has_fn_cov, has_br_cov, col_fn_w, hdr_x2) =
2443        (cols.has_fn_cov, cols.has_br_cov, cols.col_fn_w, cols.hdr_x2);
2444    let Some(cov) = file.coverage.as_ref() else {
2445        return;
2446    };
2447    pdf_fill_rect(
2448        ctx.layer,
2449        ctx.margin,
2450        ry,
2451        2.0_f32.mul_add(-ctx.margin, ctx.w),
2452        ctx.row_h,
2453        pdf_row_bg(ri),
2454    );
2455    ctx.layer
2456        .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2457    let fname = pdf_trunc(
2458        &pdf_safe_str(
2459            std::path::Path::new(&file.relative_path)
2460                .file_name()
2461                .and_then(|n| n.to_str())
2462                .unwrap_or(&file.relative_path),
2463        ),
2464        52,
2465    );
2466    ctx.layer.use_text(
2467        &fname,
2468        5.5,
2469        Mm(ctx.margin + 2.0),
2470        Mm(ry + 1.5),
2471        ctx.font_reg,
2472    );
2473    ctx.layer
2474        .set_fill_color(Color::Rgb(Rgb::new(0.10, 0.42, 0.25, None)));
2475    ctx.layer.use_text(
2476        format!("{:.1}%", cov.line_pct()),
2477        5.5,
2478        Mm(hdr_x2 + 2.0),
2479        Mm(ry + 1.5),
2480        ctx.font_bold,
2481    );
2482    if has_fn_cov && cov.functions_found > 0 {
2483        ctx.layer.use_text(
2484            format!("{:.1}%", cov.function_pct()),
2485            5.5,
2486            Mm(hdr_x2 + 24.0),
2487            Mm(ry + 1.5),
2488            ctx.font_bold,
2489        );
2490    }
2491    if has_br_cov && cov.branches_found > 0 {
2492        ctx.layer.use_text(
2493            format!("{:.1}%", cov.branch_pct()),
2494            5.5,
2495            Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2496            Mm(ry + 1.5),
2497            ctx.font_bold,
2498        );
2499    }
2500}
2501
2502/// Render the PER-FILE COVERAGE table (header + rows) when coverage records exist.
2503fn pdf_tc_per_file(
2504    ctx: &PdfCtx<'_>,
2505    run: &AnalysisRun,
2506    footer_h: f32,
2507    has_fn_cov: bool,
2508    has_br_cov: bool,
2509    mut y: f32,
2510) -> f32 {
2511    let cov_files: Vec<_> = run
2512        .per_file_records
2513        .iter()
2514        .filter(|r| r.coverage.is_some())
2515        .collect();
2516    if cov_files.is_empty() {
2517        return y;
2518    }
2519    let col_fn_w: f32 = if has_fn_cov { 22.0 } else { 0.0 };
2520    let (rows_start, cols) = pdf_tc_per_file_header(ctx, has_fn_cov, has_br_cov, col_fn_w, y);
2521    y = rows_start;
2522    for (ri, file) in cov_files.iter().enumerate() {
2523        if y < footer_h + ctx.row_h {
2524            break;
2525        }
2526        let ry = y - ctx.row_h;
2527        pdf_tc_per_file_row(ctx, file, ri, &cols, ry);
2528        y -= ctx.row_h;
2529    }
2530    y
2531}
2532
2533/// Render the "no coverage data" note when no coverage is present.
2534fn pdf_tc_no_coverage_note(ctx: &PdfCtx<'_>, mut y: f32) -> f32 {
2535    use crate::pdf_compat::{Color, Mm, Rgb};
2536    let margin = ctx.margin;
2537    let note_h: f32 = 12.0;
2538    pdf_fill_rect(
2539        ctx.layer,
2540        margin,
2541        y - note_h,
2542        2.0_f32.mul_add(-margin, ctx.w),
2543        note_h,
2544        Rgb::new(0.96, 0.95, 0.93, None),
2545    );
2546    ctx.layer
2547        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.40, 0.37, None)));
2548    ctx.layer.use_text(
2549        "No code coverage data detected.",
2550        7.0,
2551        Mm(margin + 4.0),
2552        Mm(y - note_h + 7.0),
2553        ctx.font_bold,
2554    );
2555    ctx.layer.use_text(
2556        "Re-run with --lcov-path <file.info> to see per-file line, function, and branch coverage.",
2557        6.0,
2558        Mm(margin + 4.0),
2559        Mm(y - note_h + 2.5),
2560        ctx.font_reg,
2561    );
2562    y -= note_h;
2563    y
2564}
2565
2566/// Does NOT create a new page, draw a mini-header, or draw a footer — those are the caller's
2567/// responsibility. Returns the Y position immediately below the last rendered element.
2568fn pdf_render_tc_inline(ctx: &PdfCtx<'_>, run: &AnalysisRun, y_start: f32, footer_h: f32) -> f32 {
2569    let has_cov = run.summary_totals.coverage_lines_found > 0;
2570    let has_fn_cov = run.summary_totals.coverage_functions_found > 0;
2571    let has_br_cov = run.summary_totals.coverage_branches_found > 0;
2572
2573    let mut y = pdf_tc_title_bar(ctx, "TESTS & COVERAGE", y_start) - 4.0;
2574    y = pdf_tc_stat_boxes(ctx, run, has_cov, y);
2575    y = pdf_tc_submodules(ctx, run, footer_h, y);
2576
2577    if has_cov {
2578        y = pdf_tc_gauges(ctx, run, y);
2579        y = pdf_tc_per_file(ctx, run, footer_h, has_fn_cov, has_br_cov, y);
2580    } else {
2581        y = pdf_tc_no_coverage_note(ctx, y);
2582    }
2583    y
2584}
2585
2586/// Build the right-aligned per-page header metadata string shown on every continuation
2587/// page so each printed sheet is self-identifying: Run ID, git commit, and scan time.
2588fn pdf_page_header_meta(run: &AnalysisRun) -> String {
2589    let mut parts = vec![format!(
2590        "Run ID: {}",
2591        pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2592    )];
2593    if let Some(ref c) = run.git_commit_short {
2594        parts.push(format!("Commit: {}", pdf_safe_str(c)));
2595    }
2596    parts.push(to_pt_hhmm(run.tool.timestamp_utc));
2597    parts.join("  \u{00B7}  ")
2598}
2599
2600/// Draw `text` right-aligned (gray, 6.5 pt) inside a navy page-header bar whose text
2601/// baseline sits at `baseline_y`. Uses exact Helvetica advance widths for precise
2602/// right-edge alignment against the page margin.
2603fn pdf_draw_header_meta(
2604    layer: &crate::pdf_compat::PdfLayerReference,
2605    font: crate::pdf_compat::IndirectFontRef,
2606    w: f32,
2607    margin: f32,
2608    baseline_y: f32,
2609    text: &str,
2610) {
2611    use crate::pdf_compat::{Color, Mm, Rgb};
2612    let tw = helvetica_width_mm(text, 6.5, false);
2613    let x = (w - margin - tw).max(margin + 60.0);
2614    layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2615    layer.use_text(text, 6.5, Mm(x), Mm(baseline_y), font);
2616}
2617
2618/// Draw the per-page mini header band (dark bar with "oxide-sloc", the truncated report `title`,
2619/// and right-aligned run metadata) shared by the dedicated T&C and Git Hotspots pages. `h` is the
2620/// page height and `hdr_h` the band height.
2621fn pdf_page_mini_header(ctx: &PdfCtx<'_>, h: f32, hdr_h: f32, title: &str, run: &AnalysisRun) {
2622    use crate::pdf_compat::{Color, Mm, Rgb};
2623    pdf_fill_rect(
2624        ctx.layer,
2625        0.0,
2626        h - hdr_h,
2627        ctx.w,
2628        hdr_h,
2629        Rgb::new(0.098, 0.11, 0.15, None),
2630    );
2631    ctx.layer
2632        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2633    ctx.layer.use_text(
2634        "oxide-sloc",
2635        9.0,
2636        Mm(ctx.margin),
2637        Mm(h - 5.5),
2638        ctx.font_bold,
2639    );
2640    ctx.layer
2641        .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2642    ctx.layer.use_text(
2643        pdf_trunc(&pdf_safe_str(title), 45),
2644        7.5,
2645        Mm(46.0),
2646        Mm(h - 5.5),
2647        ctx.font_reg,
2648    );
2649    pdf_draw_header_meta(
2650        ctx.layer,
2651        ctx.font_reg,
2652        ctx.w,
2653        ctx.margin,
2654        h - 5.5,
2655        &pdf_page_header_meta(run),
2656    );
2657}
2658
2659/// Draw the standard page footer band (light bar with the version/licence line) shared by the
2660/// dedicated T&C and Git Hotspots pages.
2661fn pdf_page_footer_band(ctx: &PdfCtx<'_>, footer_h: f32, version: &str) {
2662    use crate::pdf_compat::{Color, Mm, Rgb};
2663    pdf_fill_rect(
2664        ctx.layer,
2665        0.0,
2666        0.0,
2667        ctx.w,
2668        footer_h,
2669        Rgb::new(0.93, 0.91, 0.87, None),
2670    );
2671    ctx.layer
2672        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2673    ctx.layer.use_text(
2674        format!("oxide-sloc v{version}  \u{00b7}  AGPL-3.0-or-later"),
2675        6.5,
2676        Mm(ctx.margin),
2677        Mm(3.0),
2678        ctx.font_reg,
2679    );
2680}
2681
2682/// Create a dedicated "Tests & Coverage" page, render its content inline, and return the
2683/// `(page, layer, y_bottom)` tuple so `pdf_render_per_file_pages` can continue on this page.
2684#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
2685fn pdf_render_tests_coverage_page(
2686    doc: &crate::pdf_compat::PdfDocumentReference,
2687    font_reg: crate::pdf_compat::IndirectFontRef,
2688    font_bold: crate::pdf_compat::IndirectFontRef,
2689    run: &AnalysisRun,
2690    w: f32,
2691    h: f32,
2692    margin: f32,
2693    footer_h: f32,
2694    title: &str,
2695    version: &str,
2696) -> (
2697    crate::pdf_compat::PdfPageIndex,
2698    crate::pdf_compat::PdfLayerIndex,
2699    f32,
2700) {
2701    use crate::pdf_compat::Mm;
2702    const HDR_H: f32 = 8.0;
2703
2704    let (tc_page, tc_layer_idx) = doc.add_page(Mm(w), Mm(h), "Tests & Coverage");
2705    let layer = doc.get_page(tc_page).get_layer(tc_layer_idx);
2706    let ctx = PdfCtx {
2707        layer: &layer,
2708        font_reg,
2709        font_bold,
2710        w,
2711        margin,
2712        row_h: 5.5,
2713        tbl_hdr_h: 6.0,
2714    };
2715
2716    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
2717
2718    // T&C content inline
2719    let tc_bottom = pdf_render_tc_inline(&ctx, run, h - HDR_H - 4.0, footer_h);
2720
2721    pdf_page_footer_band(&ctx, footer_h, version);
2722
2723    (tc_page, tc_layer_idx, tc_bottom - 3.0)
2724}
2725
2726#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2727fn pdf_render_page1_footer(
2728    ctx: &PdfCtx<'_>,
2729    run: &AnalysisRun,
2730    footer_h: f32,
2731    version: &str,
2732    banner: Option<&str>,
2733) {
2734    use crate::pdf_compat::{Color, Mm, Rgb};
2735    pdf_fill_rect(
2736        ctx.layer,
2737        0.0,
2738        0.0,
2739        ctx.w,
2740        footer_h,
2741        Rgb::new(0.93, 0.91, 0.87, None),
2742    );
2743    ctx.layer
2744        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2745    // Left section.
2746    ctx.layer.use_text(
2747        format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
2748        6.5,
2749        Mm(ctx.margin),
2750        Mm(3.0),
2751        ctx.font_reg,
2752    );
2753    // Right section — github.com and Run ID, right-aligned (~1.27 mm per char at 6.5 pt).
2754    let right_text = format!(
2755        "github.com/oxide-sloc/oxide-sloc  |  Run ID: {}",
2756        pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2757    );
2758    let right_x = (ctx.w - ctx.margin - right_text.len() as f32 * 1.27).max(ctx.margin + 80.0);
2759    ctx.layer
2760        .use_text(right_text, 6.5, Mm(right_x), Mm(3.0), ctx.font_reg);
2761    // Center section — banner text, no background, oxide brand color, bold.
2762    if let Some(text) = banner {
2763        let safe = pdf_trunc(&pdf_safe_str(text), 40);
2764        // Same per-char width as the header banner (0.97 mm at 9pt bold Helvetica).
2765        let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(ctx.margin + 50.0);
2766        ctx.layer
2767            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2768        ctx.layer
2769            .use_text(safe, 9.0, Mm(text_x), Mm(2.6), ctx.font_bold);
2770    }
2771}
2772
2773fn per_file_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2774    if ri.is_multiple_of(2) {
2775        crate::pdf_compat::Rgb::new(0.975, 0.965, 0.95, None)
2776    } else {
2777        crate::pdf_compat::Rgb::new(1.0, 1.0, 1.0, None)
2778    }
2779}
2780
2781// ── Per-file page layout constants shared by the helpers below ─────────────────
2782const PDF_PERFILE_HDR_H: f32 = 8.0;
2783const PDF_PERFILE_SUB_H: f32 = 5.5;
2784// Gap between the PER-FILE DETAIL sub-bar and the column-header row.
2785// Applied on standalone per-file pages (not when sharing a page with COCOMO/T&C).
2786const PDF_PERFILE_TABLE_GAP: f32 = 3.0;
2787
2788/// Doc/font/dims context for per-file page helpers; carries `doc` instead of `layer`
2789/// because the page layer is created inside `pdf_draw_perfile_header`.
2790struct PdfPerFileCtx<'a> {
2791    doc: &'a crate::pdf_compat::PdfDocumentReference,
2792    font_reg: crate::pdf_compat::IndirectFontRef,
2793    font_bold: crate::pdf_compat::IndirectFontRef,
2794    w: f32,
2795    h: f32,
2796    margin: f32,
2797}
2798
2799/// Compute the `[start, end)` record slice displayed on one per-file page.
2800fn pdf_perfile_page_slice(
2801    page_idx: usize,
2802    use_continuation: bool,
2803    has_first_page: bool,
2804    fp_rows: usize,
2805    rows_per_page: usize,
2806    total_files: usize,
2807) -> (usize, usize) {
2808    if use_continuation {
2809        (0, fp_rows.min(total_files))
2810    } else if has_first_page {
2811        let s = fp_rows + (page_idx - 1) * rows_per_page;
2812        (s, (s + rows_per_page).min(total_files))
2813    } else {
2814        let s = page_idx * rows_per_page;
2815        (s, (s + rows_per_page).min(total_files))
2816    }
2817}
2818
2819/// Obtain (or create) the PDF layer for one per-file page and render its page header.
2820///
2821/// Returns `(layer, sub_top)` where `sub_top` is the y-coordinate at the bottom of the
2822/// header bar. When `use_continuation` is true the layer is taken from `first_page` and
2823/// no new header is drawn — the COCOMO page already has one.
2824#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2825fn pdf_draw_perfile_header(
2826    ctx: &PdfPerFileCtx<'_>,
2827    use_continuation: bool,
2828    first_page: Option<(
2829        crate::pdf_compat::PdfPageIndex,
2830        crate::pdf_compat::PdfLayerIndex,
2831        f32,
2832    )>,
2833    page_idx: usize,
2834    page_count: usize,
2835    banner: Option<&str>,
2836    meta: &str,
2837) -> (crate::pdf_compat::PdfLayerReference, f32) {
2838    use crate::pdf_compat::{Color, Mm, Rgb};
2839    if use_continuation {
2840        let (fp_page, fp_layer_idx, fp_top) = first_page.unwrap();
2841        let layer = ctx.doc.get_page(fp_page).get_layer(fp_layer_idx);
2842        (layer, fp_top - PDF_PERFILE_SUB_H)
2843    } else {
2844        let (pf_page, pf_layer_idx) = ctx.doc.add_page(Mm(ctx.w), Mm(ctx.h), "Content");
2845        let layer = ctx.doc.get_page(pf_page).get_layer(pf_layer_idx);
2846        let hdr_top = ctx.h - PDF_PERFILE_HDR_H;
2847        pdf_fill_rect(
2848            &layer,
2849            0.0,
2850            hdr_top,
2851            ctx.w,
2852            PDF_PERFILE_HDR_H,
2853            Rgb::new(0.098, 0.11, 0.15, None),
2854        );
2855        layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2856        layer.use_text(
2857            "oxide-sloc",
2858            9.0,
2859            Mm(ctx.margin),
2860            Mm(hdr_top + 2.5),
2861            ctx.font_bold,
2862        );
2863        layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2864        layer.use_text(
2865            "Per-File Detail",
2866            8.0,
2867            Mm(46.0),
2868            Mm(hdr_top + 2.5),
2869            ctx.font_reg,
2870        );
2871        // Right-aligned: Run ID / commit / scan time, then the page counter.
2872        let right = format!(
2873            "{meta}  \u{00B7}  Page {} of {}",
2874            page_idx + 2,
2875            page_count + 1
2876        );
2877        let right_w = helvetica_width_mm(&right, 6.5, false);
2878        let right_x = (ctx.w - ctx.margin - right_w).max(ctx.margin + 60.0);
2879        layer.use_text(right, 6.5, Mm(right_x), Mm(hdr_top + 2.5), ctx.font_reg);
2880        if let Some(text) = banner {
2881            let safe = pdf_trunc(&pdf_safe_str(text), 40);
2882            let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(80.0);
2883            layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2884            layer.use_text(safe, 9.0, Mm(text_x), Mm(hdr_top + 2.5), ctx.font_bold);
2885        }
2886        // Leave a gap between the top header bar and the PER-FILE DETAIL sub-bar.
2887        (layer, hdr_top - PDF_PERFILE_TABLE_GAP - PDF_PERFILE_SUB_H)
2888    }
2889}
2890
2891/// Render per-file data rows onto an existing PDF layer.
2892#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2893fn pdf_draw_perfile_rows(
2894    ctx: &PdfCtx<'_>,
2895    records: &[FileRecord],
2896    col_x: &[f32; 13],
2897    pf_tbl_top: f32,
2898) {
2899    use crate::pdf_compat::{Color, Mm, Rgb};
2900    for (ri, rec) in records.iter().enumerate() {
2901        let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, pf_tbl_top - ctx.tbl_hdr_h);
2902        let bg = per_file_row_bg(ri);
2903        pdf_fill_rect(
2904            ctx.layer,
2905            ctx.margin,
2906            ry,
2907            2.0f32.mul_add(-ctx.margin, ctx.w),
2908            ctx.row_h,
2909            bg,
2910        );
2911        ctx.layer
2912            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2913        let file_str = pdf_safe_str(&rec.relative_path);
2914        let lang_str = rec
2915            .language
2916            .as_ref()
2917            .map_or_else(|| "--".to_string(), |l| l.display_name().to_string());
2918        let raw = &rec.raw_line_categories;
2919        let eff = &rec.effective_counts;
2920        let cells = [
2921            pdf_trunc_end(&file_str, 110),
2922            lang_str,
2923            pdf_fmt_full(raw.total_physical_lines),
2924            pdf_fmt_full(eff.code_lines),
2925            pdf_fmt_full(eff.comment_lines),
2926            pdf_fmt_full(eff.blank_lines),
2927            pdf_fmt_full(eff.mixed_lines_separate),
2928            pdf_fmt_full(raw.functions),
2929            pdf_fmt_full(raw.classes),
2930            pdf_fmt_full(raw.variables),
2931            pdf_fmt_full(raw.imports),
2932            pdf_fmt_full(raw.test_count),
2933            pdf_fmt_full(raw.test_assertion_count),
2934        ];
2935        for (ci, cell) in cells.iter().enumerate() {
2936            ctx.layer.use_text(
2937                cell.clone(),
2938                5.5,
2939                Mm(col_x[ci] + 0.5),
2940                Mm(ry + 1.0),
2941                ctx.font_reg,
2942            );
2943        }
2944    }
2945}
2946
2947// PDF per-file page renderer — layout params are distinct; see PdfPerFileCtx for bundling.
2948#[allow(
2949    clippy::cast_precision_loss,
2950    clippy::cast_possible_truncation,
2951    clippy::cast_sign_loss,
2952    clippy::too_many_arguments,
2953    clippy::too_many_lines,
2954    clippy::suboptimal_flops
2955)]
2956fn pdf_render_per_file_pages(
2957    doc: &crate::pdf_compat::PdfDocumentReference,
2958    font_reg: crate::pdf_compat::IndirectFontRef,
2959    font_bold: crate::pdf_compat::IndirectFontRef,
2960    run: &AnalysisRun,
2961    w: f32,
2962    h: f32,
2963    margin: f32,
2964    footer_h: f32,
2965    row_h: f32,
2966    tbl_hdr_h: f32,
2967    title: &str,
2968    ts: &str,
2969    version: &str,
2970    banner: Option<&str>,
2971    // When COCOMO is rendered on its own page, continue the per-file table on that same page
2972    // rather than starting a new one.  Tuple: (page index, layer index, available top y-coord).
2973    first_page: Option<(
2974        crate::pdf_compat::PdfPageIndex,
2975        crate::pdf_compat::PdfLayerIndex,
2976        f32,
2977    )>,
2978) {
2979    use crate::pdf_compat::{Color, Mm, Rgb};
2980    // File column gets ~136 mm; numeric columns compressed to minimum readable width.
2981    // Column widths: File=136, Lang=14, Phys=12, Code=10, Comments=13, Blank=10, Mixed=10,
2982    //   Functions=13, Classes=11, Variables=13, Imports=11, Tests=10, Assertions=14  → total 277 mm
2983    let col_x: [f32; 13] = [
2984        10.0, 146.0, 160.0, 172.0, 182.0, 195.0, 205.0, 215.0, 228.0, 239.0, 252.0, 263.0, 273.0,
2985    ];
2986    let col_labels: [&str; 13] = [
2987        "File",
2988        "Language",
2989        "Physical",
2990        "Code",
2991        "Comments",
2992        "Blank",
2993        "Mixed",
2994        "Functions",
2995        "Classes",
2996        "Variables",
2997        "Imports",
2998        "Tests",
2999        "Assertions",
3000    ];
3001    let rows_per_page =
3002        ((h - PDF_PERFILE_HDR_H - PDF_PERFILE_SUB_H - PDF_PERFILE_TABLE_GAP - tbl_hdr_h - footer_h)
3003            / row_h)
3004            .floor() as usize;
3005    let total_files = run.per_file_records.len();
3006
3007    // Rows that fit on the continuation page (COCOMO already occupies the top portion).
3008    let fp_rows = match first_page {
3009        Some((_, _, fp_top)) => ((fp_top - PDF_PERFILE_SUB_H - tbl_hdr_h - footer_h) / row_h)
3010            .floor()
3011            .max(0.0) as usize,
3012        None => rows_per_page,
3013    };
3014    let page_count = if first_page.is_some() {
3015        1 + total_files.saturating_sub(fp_rows).div_ceil(rows_per_page)
3016    } else {
3017        total_files.div_ceil(rows_per_page)
3018    };
3019    let pf_ctx = PdfPerFileCtx {
3020        doc,
3021        font_reg,
3022        font_bold,
3023        w,
3024        h,
3025        margin,
3026    };
3027    let header_meta = pdf_page_header_meta(run);
3028
3029    for page_idx in 0..page_count {
3030        let use_continuation = page_idx == 0 && first_page.is_some();
3031        let (pf_layer, sub_top) = pdf_draw_perfile_header(
3032            &pf_ctx,
3033            use_continuation,
3034            first_page,
3035            page_idx,
3036            page_count,
3037            banner,
3038            &header_meta,
3039        );
3040
3041        // Sub-bar — dark navy, matching TESTS & COVERAGE / SUBMODULES section headers.
3042        pdf_fill_rect(
3043            &pf_layer,
3044            margin,
3045            sub_top,
3046            w - 2.0 * margin,
3047            PDF_PERFILE_SUB_H,
3048            Rgb::new(0.098, 0.11, 0.15, None),
3049        );
3050        pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3051        pf_layer.use_text(
3052            "PER-FILE DETAIL",
3053            7.0,
3054            Mm(margin + 2.0),
3055            Mm(sub_top + 1.5),
3056            font_bold,
3057        );
3058        if use_continuation {
3059            // On the continuation page show the project context on the right.
3060            let right = format!(
3061                "{}  |  {} files  |  {ts}",
3062                pdf_trunc(title, 30),
3063                total_files
3064            );
3065            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3066            let right_x = (w - margin - right.len() as f32 * 1.05).max(margin + 80.0);
3067            pf_layer.use_text(right, 5.5, Mm(right_x), Mm(sub_top + 1.5), font_reg);
3068        } else {
3069            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3070            pf_layer.use_text(
3071                pdf_trunc(title, 45),
3072                5.5,
3073                Mm(margin + 60.0),
3074                Mm(sub_top + 1.5),
3075                font_reg,
3076            );
3077            pf_layer.use_text(
3078                format!("{total_files} files  |  {ts}"),
3079                5.5,
3080                Mm(w - margin - 55.0),
3081                Mm(sub_top + 1.5),
3082                font_reg,
3083            );
3084        }
3085
3086        let pf_tbl_top = sub_top;
3087        pdf_fill_rect(
3088            &pf_layer,
3089            margin,
3090            pf_tbl_top - tbl_hdr_h,
3091            2.0f32.mul_add(-margin, w),
3092            tbl_hdr_h,
3093            Rgb::new(0.098, 0.11, 0.15, None),
3094        );
3095        pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3096        for (i, lbl) in col_labels.iter().enumerate() {
3097            pf_layer.use_text(
3098                *lbl,
3099                5.0,
3100                Mm(col_x[i] + 0.5),
3101                Mm(pf_tbl_top - tbl_hdr_h + 1.5),
3102                font_bold,
3103            );
3104        }
3105
3106        let (start, end) = pdf_perfile_page_slice(
3107            page_idx,
3108            use_continuation,
3109            first_page.is_some(),
3110            fp_rows,
3111            rows_per_page,
3112            total_files,
3113        );
3114        let row_ctx = PdfCtx {
3115            layer: &pf_layer,
3116            font_reg,
3117            font_bold,
3118            w,
3119            margin,
3120            row_h,
3121            tbl_hdr_h,
3122        };
3123        pdf_draw_perfile_rows(
3124            &row_ctx,
3125            &run.per_file_records[start..end],
3126            &col_x,
3127            pf_tbl_top,
3128        );
3129
3130        // Footer
3131        pdf_fill_rect(
3132            &pf_layer,
3133            0.0,
3134            0.0,
3135            w,
3136            footer_h,
3137            Rgb::new(0.93, 0.91, 0.87, None),
3138        );
3139        pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3140        pf_layer.use_text(
3141            format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
3142            6.5,
3143            Mm(margin),
3144            Mm(3.0),
3145            font_reg,
3146        );
3147        let right_text = format!(
3148            "github.com/oxide-sloc/oxide-sloc  |  Run ID: {}",
3149            pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
3150        );
3151        let right_x = (w - margin - right_text.len() as f32 * 1.27).max(margin + 80.0);
3152        pf_layer.use_text(right_text, 6.5, Mm(right_x), Mm(3.0), font_reg);
3153        // Center section — banner, oxide brand color, bold.
3154        if let Some(text) = banner {
3155            let safe = pdf_trunc(&pdf_safe_str(text), 40);
3156            let text_x = (w / 2.0 - safe.len() as f32 * 0.97).max(margin + 50.0);
3157            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3158            pf_layer.use_text(safe, 9.0, Mm(text_x), Mm(2.6), font_bold);
3159        }
3160    }
3161}
3162
3163/// Draw the dark section-header bar (full usable width, `hdr_h` tall, top edge at `section_top`)
3164/// with `title` rendered in white bold at the left. Shared by every PDF report section so the
3165/// header styling stays identical across them.
3166fn pdf_section_header_bar(
3167    ctx: &PdfCtx<'_>,
3168    usable_w: f32,
3169    section_top: f32,
3170    hdr_h: f32,
3171    title: &str,
3172) {
3173    use crate::pdf_compat::{Color, Mm, Rgb};
3174    pdf_fill_rect(
3175        ctx.layer,
3176        ctx.margin,
3177        section_top - hdr_h,
3178        usable_w,
3179        hdr_h,
3180        Rgb::new(0.098, 0.11, 0.15, None),
3181    );
3182    ctx.layer
3183        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3184    ctx.layer.use_text(
3185        title,
3186        7.0,
3187        Mm(ctx.margin + 2.0),
3188        Mm(section_top - hdr_h + 1.5),
3189        ctx.font_bold,
3190    );
3191}
3192
3193/// Render the Code Style Analysis section onto page 1 of the printpdf PDF.
3194///
3195/// Draws below the metric tables: a section header, four summary chips, and a
3196/// per-language mini-table showing the top style guide and N-col compliance.
3197/// Returns the y coordinate of the bottom of the rendered section.
3198#[allow(
3199    clippy::cast_precision_loss,
3200    clippy::cast_possible_truncation,
3201    clippy::too_many_lines,
3202    clippy::suboptimal_flops
3203)]
3204fn pdf_render_style_section(ctx: &PdfCtx<'_>, ss: &StyleSummary, section_top: f32) -> f32 {
3205    use crate::pdf_compat::{Color, Mm, Rgb};
3206    const HDR_H: f32 = 5.5;
3207    const CHIP_H: f32 = 11.0;
3208    const CHIP_GAP: f32 = 4.0;
3209    const ROW_H: f32 = 5.0;
3210    const TBL_HDR_H: f32 = 5.0;
3211    const GAP: f32 = 2.5;
3212
3213    let usable_w = ctx.w - 2.0 * ctx.margin;
3214    let chip_w = (usable_w - 3.0 * CHIP_GAP) / 4.0;
3215
3216    // ── section header bar ────────────────────────────────────────────────────
3217    pdf_section_header_bar(ctx, usable_w, section_top, HDR_H, "CODE STYLE ANALYSIS");
3218    let col_label = format!("{}-Col", ss.col_threshold);
3219    ctx.layer
3220        .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3221    ctx.layer.use_text(
3222        "Lexical heuristics",
3223        5.5,
3224        Mm(ctx.w - ctx.margin - 26.0),
3225        Mm(section_top - HDR_H + 1.5),
3226        ctx.font_reg,
3227    );
3228
3229    // ── summary chips ─────────────────────────────────────────────────────────
3230    let chips_bot = section_top - HDR_H - GAP - CHIP_H;
3231    let chip_data: [(&str, String); 4] = [
3232        ("Files Analyzed", ss.files_analyzed.to_string()),
3233        ("Language Groups", ss.by_language.len().to_string()),
3234        ("Common Indent", ss.common_indent_style.clone()),
3235        (&col_label, format!("{}%", ss.line_col_compliant_pct)),
3236    ];
3237    for (i, (label, value)) in chip_data.iter().enumerate() {
3238        let cx = (i as f32).mul_add(chip_w + CHIP_GAP, ctx.margin);
3239        pdf_fill_rect(
3240            ctx.layer,
3241            cx,
3242            chips_bot,
3243            chip_w,
3244            CHIP_H,
3245            Rgb::new(0.945, 0.925, 0.90, None),
3246        );
3247        ctx.layer
3248            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3249        ctx.layer.use_text(
3250            pdf_trunc(value, 16),
3251            10.0,
3252            Mm(cx + 3.0),
3253            Mm(chips_bot + 5.5),
3254            ctx.font_bold,
3255        );
3256        ctx.layer
3257            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3258        ctx.layer.use_text(
3259            pdf_safe_str(label),
3260            5.5,
3261            Mm(cx + 3.0),
3262            Mm(chips_bot + 1.5),
3263            ctx.font_reg,
3264        );
3265    }
3266
3267    // ── per-language mini-table ───────────────────────────────────────────────
3268    if ss.by_language.is_empty() {
3269        return chips_bot;
3270    }
3271    let tbl_top = chips_bot - GAP;
3272
3273    // Column widths (fractions of usable_w): Family | Files | Top Guide | Score | N-Col
3274    let col_w = [0.28_f32, 0.08, 0.36, 0.14, 0.14];
3275    let col_x: Vec<f32> = col_w
3276        .iter()
3277        .scan(ctx.margin, |acc, &w| {
3278            let x = *acc;
3279            *acc += w * usable_w;
3280            Some(x)
3281        })
3282        .collect();
3283    let headers = ["Language Family", "Files", "Top Guide", "Score", &col_label];
3284
3285    pdf_fill_rect(
3286        ctx.layer,
3287        ctx.margin,
3288        tbl_top - TBL_HDR_H,
3289        usable_w,
3290        TBL_HDR_H,
3291        Rgb::new(0.098, 0.11, 0.15, None),
3292    );
3293    ctx.layer
3294        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3295    for (hi, hdr) in headers.iter().enumerate() {
3296        ctx.layer.use_text(
3297            pdf_safe_str(hdr),
3298            5.5,
3299            Mm(col_x[hi] + 2.0),
3300            Mm(tbl_top - TBL_HDR_H + 1.5),
3301            ctx.font_bold,
3302        );
3303    }
3304
3305    let mut row_y = tbl_top - TBL_HDR_H;
3306    for (ri, grp) in ss.by_language.iter().take(5).enumerate() {
3307        let ry = row_y - ROW_H;
3308        let bg = if ri % 2 == 0 {
3309            Rgb::new(0.975, 0.965, 0.95, None)
3310        } else {
3311            Rgb::new(1.0, 1.0, 1.0, None)
3312        };
3313        pdf_fill_rect(ctx.layer, ctx.margin, ry, usable_w, ROW_H, bg);
3314        ctx.layer
3315            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3316        let cells = [
3317            pdf_trunc(&grp.language_family, 26),
3318            grp.files_count.to_string(),
3319            pdf_trunc(&grp.dominant_guide, 28),
3320            format!("{}%", grp.dominant_score_pct),
3321            format!("{}%", grp.line_col_compliant_pct),
3322        ];
3323        for (ci, cell) in cells.iter().enumerate() {
3324            let is_score = ci == 3 || ci == 4;
3325            if is_score && cell != "--" {
3326                ctx.layer
3327                    .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3328            } else {
3329                ctx.layer
3330                    .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3331            }
3332            ctx.layer.use_text(
3333                pdf_safe_str(cell),
3334                6.0,
3335                Mm(col_x[ci] + 2.0),
3336                Mm(ry + 1.5),
3337                ctx.font_reg,
3338            );
3339        }
3340        row_y = ry;
3341    }
3342
3343    row_y
3344}
3345
3346/// Render the COCOMO I estimate section as a compact table on the PDF page.
3347/// Returns the bottom y-coordinate of the rendered section.
3348#[allow(clippy::cast_precision_loss)]
3349fn pdf_render_cocomo_section(ctx: &PdfCtx<'_>, run: &AnalysisRun, section_top: f32) -> f32 {
3350    use crate::pdf_compat::{Color, Mm, Rgb};
3351    const HDR_H: f32 = 5.5;
3352    const ROW_H: f32 = 13.0; // tall enough for label + value with comfortable padding
3353    const NOTE_H: f32 = 2.0; // just enough clearance for 5.5 pt descenders below the baseline
3354    const GAP: f32 = 5.0; // breathing room between data row and footnote
3355
3356    let Some(ref c) = run.cocomo else {
3357        return section_top;
3358    };
3359
3360    let mode_label = match c.mode {
3361        CocomoMode::Organic => "Organic",
3362        CocomoMode::SemiDetached => "Semi-detached",
3363        CocomoMode::Embedded => "Embedded",
3364    };
3365    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3366
3367    // Section header bar
3368    pdf_section_header_bar(
3369        ctx,
3370        usable_w,
3371        section_top,
3372        HDR_H,
3373        "CONSTRUCTIVE COST MODEL (COCOMO I) ESTIMATE",
3374    );
3375    ctx.layer
3376        .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3377    let mode_display = format!("{mode_label} mode");
3378    ctx.layer.use_text(
3379        mode_display.as_str(),
3380        5.5,
3381        Mm(ctx.w - ctx.margin - 28.0),
3382        Mm(section_top - HDR_H + 1.5),
3383        ctx.font_reg,
3384    );
3385
3386    // 4-column data row (full width, single row)
3387    let col_w = usable_w / 4.0;
3388    let row_y = section_top - HDR_H - ROW_H;
3389    let data: [(&str, String); 4] = [
3390        ("Person-months", format!("{:.2}", c.effort_person_months)),
3391        ("Schedule (months)", format!("{:.2}", c.duration_months)),
3392        ("Avg. Team Size", format!("{:.2}", c.avg_staff)),
3393        ("Input KSLOC", format!("{:.2}K", c.ksloc)),
3394    ];
3395    for (i, (label, value)) in data.iter().enumerate() {
3396        let cx = (i as f32).mul_add(col_w, ctx.margin);
3397        let bg = if i % 2 == 0 {
3398            Rgb::new(0.975, 0.965, 0.95, None)
3399        } else {
3400            Rgb::new(1.0, 1.0, 1.0, None)
3401        };
3402        pdf_fill_rect(ctx.layer, cx, row_y, col_w, ROW_H, bg);
3403        ctx.layer
3404            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3405        ctx.layer
3406            .use_text(*label, 5.5, Mm(cx + 2.0), Mm(row_y + 9.0), ctx.font_reg);
3407        ctx.layer
3408            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3409        ctx.layer.use_text(
3410            value.as_str(),
3411            10.0,
3412            Mm(cx + 2.0),
3413            Mm(row_y + 2.5),
3414            ctx.font_bold,
3415        );
3416    }
3417
3418    // Footnote
3419    let note_y = row_y - GAP;
3420    ctx.layer
3421        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3422    ctx.layer.use_text(
3423        "COCOMO I (Boehm, 1981): algorithmic model converting SLOC into effort, schedule, and team-size estimates. \
3424         Ballpark figures only - actual outcomes vary with team experience and domain complexity.",
3425        5.5,
3426        Mm(ctx.margin),
3427        Mm(note_y),
3428        ctx.font_reg,
3429    );
3430
3431    note_y - NOTE_H
3432}
3433
3434/// Draw `text` so its right edge sits at `x_right` mm, at vertical `y` mm. The caller sets the
3435/// fill colour beforehand. Uses the Helvetica advance-width table for alignment.
3436fn pdf_text_right(ctx: &PdfCtx<'_>, text: &str, pt: f32, x_right: f32, y: f32, bold: bool) {
3437    use crate::pdf_compat::Mm;
3438    let font = if bold { ctx.font_bold } else { ctx.font_reg };
3439    let w = helvetica_width_mm(text, pt, bold);
3440    ctx.layer.use_text(text, pt, Mm(x_right - w), Mm(y), font);
3441}
3442
3443/// Front-truncate `path` with a leading "..." so it fits within `budget_mm` at `pt`, keeping the
3444/// most informative tail (the filename). Returns the path unchanged when it already fits.
3445fn pdf_fit_path(path: &str, budget_mm: f32, pt: f32) -> String {
3446    if helvetica_width_mm(path, pt, false) <= budget_mm {
3447        return path.to_string();
3448    }
3449    let mut chars: Vec<char> = path.chars().collect();
3450    while !chars.is_empty() {
3451        chars.remove(0);
3452        let candidate: String = format!("...{}", chars.iter().collect::<String>());
3453        if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3454            return candidate;
3455        }
3456    }
3457    "...".to_string()
3458}
3459
3460/// Fit free text (e.g. an author name) to `budget_mm`, truncating from the END with an ellipsis.
3461/// Unlike `pdf_fit_path` (which keeps the tail of a path), this keeps the leading characters.
3462fn pdf_fit_text(text: &str, budget_mm: f32, pt: f32) -> String {
3463    if helvetica_width_mm(text, pt, false) <= budget_mm {
3464        return text.to_string();
3465    }
3466    let mut chars: Vec<char> = text.chars().collect();
3467    while !chars.is_empty() {
3468        chars.pop();
3469        let candidate: String = format!("{}...", chars.iter().collect::<String>());
3470        if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3471            return candidate;
3472        }
3473    }
3474    "...".to_string()
3475}
3476
3477/// Render the Git Hotspots table (files ranked by code lines x recent commits) starting at
3478/// `section_top`. Returns the Y coordinate below the rendered content. Mirrors the COCOMO
3479/// section's dark header bar and the per-file table's right-aligned numeric columns.
3480fn pdf_render_hotspots_section(ctx: &PdfCtx<'_>, rows: &[HotspotRow], section_top: f32) -> f32 {
3481    use crate::pdf_compat::{Color, Mm, Rgb};
3482    const HDR_H: f32 = 5.5;
3483    const COLHDR_H: f32 = 5.0;
3484    const ROW_H: f32 = 5.2;
3485    const NOTE_GAP: f32 = 4.0;
3486
3487    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3488
3489    // Section header bar.
3490    pdf_section_header_bar(
3491        ctx,
3492        usable_w,
3493        section_top,
3494        HDR_H,
3495        "GIT HOTSPOTS (CODE LINES x RECENT COMMITS)",
3496    );
3497
3498    // Column right edges (numeric columns are right-aligned); File fills the remaining left space.
3499    let col_last_r = ctx.w - ctx.margin;
3500    let col_score_r = col_last_r - 32.0;
3501    let col_commits_r = col_score_r - 33.0;
3502    let col_code_r = col_commits_r - 32.0;
3503    let file_x = ctx.margin + 2.0;
3504    let file_budget = (col_code_r - 26.0) - file_x;
3505
3506    // Column-header row.
3507    let chdr_y = section_top - HDR_H - COLHDR_H;
3508    pdf_fill_rect(
3509        ctx.layer,
3510        ctx.margin,
3511        chdr_y,
3512        usable_w,
3513        COLHDR_H,
3514        Rgb::new(0.90, 0.88, 0.84, None),
3515    );
3516    ctx.layer
3517        .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3518    ctx.layer
3519        .use_text("File", 6.0, Mm(file_x), Mm(chdr_y + 1.4), ctx.font_bold);
3520    pdf_text_right(ctx, "Code lines", 6.0, col_code_r, chdr_y + 1.4, true);
3521    pdf_text_right(ctx, "Commits", 6.0, col_commits_r, chdr_y + 1.4, true);
3522    pdf_text_right(ctx, "Hotspot score", 6.0, col_score_r, chdr_y + 1.4, true);
3523    pdf_text_right(ctx, "Last changed", 6.0, col_last_r, chdr_y + 1.4, true);
3524
3525    // Data rows (zebra background).
3526    let mut y = chdr_y;
3527    for (ri, hrow) in rows.iter().enumerate() {
3528        y -= ROW_H;
3529        let bg = if ri.is_multiple_of(2) {
3530            Rgb::new(0.975, 0.965, 0.95, None)
3531        } else {
3532            Rgb::new(1.0, 1.0, 1.0, None)
3533        };
3534        pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3535        // File path (front-truncated to its width budget).
3536        ctx.layer
3537            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3538        let path = pdf_fit_path(&pdf_safe_str(&hrow.path), file_budget, 6.0);
3539        ctx.layer
3540            .use_text(path, 6.0, Mm(file_x), Mm(y + 1.4), ctx.font_reg);
3541        // Numeric columns.
3542        ctx.layer
3543            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3544        pdf_text_right(
3545            ctx,
3546            &group_thousands(&hrow.code_lines.to_string()),
3547            6.0,
3548            col_code_r,
3549            y + 1.4,
3550            false,
3551        );
3552        pdf_text_right(
3553            ctx,
3554            &hrow.commit_count.to_string(),
3555            6.0,
3556            col_commits_r,
3557            y + 1.4,
3558            false,
3559        );
3560        // Hotspot score — emphasised in the oxide accent colour.
3561        ctx.layer
3562            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3563        pdf_text_right(
3564            ctx,
3565            &group_thousands(&hrow.score.to_string()),
3566            6.0,
3567            col_score_r,
3568            y + 1.4,
3569            true,
3570        );
3571        ctx.layer
3572            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3573        pdf_text_right(ctx, &hrow.last_commit_date, 6.0, col_last_r, y + 1.4, false);
3574    }
3575
3576    // Footnote.
3577    let note_y = y - NOTE_GAP;
3578    ctx.layer
3579        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3580    ctx.layer.use_text(
3581        "Files ranked by code lines x commits over the configured git activity window. \
3582         Distinct from the Compare page's scan-to-scan churn rate.",
3583        5.5,
3584        Mm(ctx.margin),
3585        Mm(note_y + 1.0),
3586        ctx.font_reg,
3587    );
3588
3589    note_y
3590}
3591
3592/// Render a dedicated "Git Hotspots" page and return its `(page, layer, y_below)` so the per-file
3593/// table can continue on the same page (mirrors `pdf_render_tests_coverage_page`).
3594#[allow(clippy::too_many_arguments)]
3595fn pdf_render_hotspots_page(
3596    doc: &crate::pdf_compat::PdfDocumentReference,
3597    font_reg: crate::pdf_compat::IndirectFontRef,
3598    font_bold: crate::pdf_compat::IndirectFontRef,
3599    run: &AnalysisRun,
3600    rows: &[HotspotRow],
3601    w: f32,
3602    h: f32,
3603    margin: f32,
3604    footer_h: f32,
3605    title: &str,
3606    version: &str,
3607) -> (
3608    crate::pdf_compat::PdfPageIndex,
3609    crate::pdf_compat::PdfLayerIndex,
3610    f32,
3611) {
3612    use crate::pdf_compat::Mm;
3613    const HDR_H: f32 = 8.0;
3614
3615    let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Git Hotspots");
3616    let layer = doc.get_page(page).get_layer(layer_idx);
3617    let ctx = PdfCtx {
3618        layer: &layer,
3619        font_reg,
3620        font_bold,
3621        w,
3622        margin,
3623        row_h: 5.5,
3624        tbl_hdr_h: 6.0,
3625    };
3626
3627    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3628
3629    let bottom = pdf_render_hotspots_section(&ctx, rows, h - HDR_H - 4.0);
3630
3631    pdf_page_footer_band(&ctx, footer_h, version);
3632
3633    (page, layer_idx, bottom - 3.0)
3634}
3635
3636/// Render the Code Ownership table (per-author blame tallies) starting at `section_top`.
3637/// Columns: Author (left, truncated) then right-aligned Code / Comment / Blank / Total /
3638/// Code % / Files. Returns the Y just below the last row.
3639fn pdf_render_ownership_section(
3640    ctx: &PdfCtx<'_>,
3641    rows: &[AuthorReportRow],
3642    section_top: f32,
3643) -> f32 {
3644    use crate::pdf_compat::{Color, Mm, Rgb};
3645    const HDR_H: f32 = 5.5;
3646    const COLHDR_H: f32 = 5.0;
3647    const ROW_H: f32 = 5.2;
3648    const NOTE_GAP: f32 = 4.0;
3649
3650    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3651
3652    pdf_section_header_bar(
3653        ctx,
3654        usable_w,
3655        section_top,
3656        HDR_H,
3657        "CODE OWNERSHIP (LINES PER CONTRIBUTOR, GIT BLAME)",
3658    );
3659
3660    // Right edges for the numeric columns; Author fills the remaining left space.
3661    let col_files_r = ctx.w - ctx.margin;
3662    let col_pct_r = col_files_r - 22.0;
3663    let col_total_r = col_pct_r - 28.0;
3664    let col_blank_r = col_total_r - 26.0;
3665    let col_comment_r = col_blank_r - 28.0;
3666    let col_code_r = col_comment_r - 26.0;
3667    let name_x = ctx.margin + 2.0;
3668    let name_budget = (col_code_r - 26.0) - name_x;
3669
3670    let chdr_y = section_top - HDR_H - COLHDR_H;
3671    pdf_fill_rect(
3672        ctx.layer,
3673        ctx.margin,
3674        chdr_y,
3675        usable_w,
3676        COLHDR_H,
3677        Rgb::new(0.90, 0.88, 0.84, None),
3678    );
3679    ctx.layer
3680        .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3681    ctx.layer
3682        .use_text("Author", 6.0, Mm(name_x), Mm(chdr_y + 1.4), ctx.font_bold);
3683    pdf_text_right(ctx, "Code", 6.0, col_code_r, chdr_y + 1.4, true);
3684    pdf_text_right(ctx, "Comment", 6.0, col_comment_r, chdr_y + 1.4, true);
3685    pdf_text_right(ctx, "Blank", 6.0, col_blank_r, chdr_y + 1.4, true);
3686    pdf_text_right(ctx, "Total", 6.0, col_total_r, chdr_y + 1.4, true);
3687    pdf_text_right(ctx, "Code %", 6.0, col_pct_r, chdr_y + 1.4, true);
3688    pdf_text_right(ctx, "Files", 6.0, col_files_r, chdr_y + 1.4, true);
3689
3690    let mut y = chdr_y;
3691    for (ri, a) in rows.iter().enumerate() {
3692        y -= ROW_H;
3693        let bg = if ri.is_multiple_of(2) {
3694            Rgb::new(0.975, 0.965, 0.95, None)
3695        } else {
3696            Rgb::new(1.0, 1.0, 1.0, None)
3697        };
3698        pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3699        ctx.layer
3700            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3701        let name = pdf_fit_text(&pdf_safe_str(&a.name), name_budget, 6.0);
3702        ctx.layer
3703            .use_text(name, 6.0, Mm(name_x), Mm(y + 1.4), ctx.font_reg);
3704        pdf_text_right(
3705            ctx,
3706            &group_thousands(&a.code.to_string()),
3707            6.0,
3708            col_code_r,
3709            y + 1.4,
3710            false,
3711        );
3712        pdf_text_right(
3713            ctx,
3714            &group_thousands(&a.comment.to_string()),
3715            6.0,
3716            col_comment_r,
3717            y + 1.4,
3718            false,
3719        );
3720        pdf_text_right(
3721            ctx,
3722            &group_thousands(&a.blank.to_string()),
3723            6.0,
3724            col_blank_r,
3725            y + 1.4,
3726            false,
3727        );
3728        pdf_text_right(
3729            ctx,
3730            &group_thousands(&a.total.to_string()),
3731            6.0,
3732            col_total_r,
3733            y + 1.4,
3734            false,
3735        );
3736        ctx.layer
3737            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3738        pdf_text_right(
3739            ctx,
3740            &format!("{}%", a.code_pct_str),
3741            6.0,
3742            col_pct_r,
3743            y + 1.4,
3744            true,
3745        );
3746        ctx.layer
3747            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3748        pdf_text_right(
3749            ctx,
3750            &a.files_owned.to_string(),
3751            6.0,
3752            col_files_r,
3753            y + 1.4,
3754            false,
3755        );
3756    }
3757
3758    let note_y = y - NOTE_GAP;
3759    ctx.layer
3760        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3761    ctx.layer.use_text(
3762        "Physical lines attributed by git blame (-w -M -C, .mailmap honoured). Same-email \
3763         identities are merged; cross-account merging is a later step.",
3764        5.5,
3765        Mm(ctx.margin),
3766        Mm(note_y + 1.0),
3767        ctx.font_reg,
3768    );
3769
3770    note_y
3771}
3772
3773/// Render a dedicated terminal "Code Ownership" page. Appended after the per-file pages so it
3774/// never participates in the per-file continuation threading. Caps rows to a single page.
3775#[allow(clippy::too_many_arguments)]
3776fn pdf_render_ownership_page(
3777    doc: &crate::pdf_compat::PdfDocumentReference,
3778    font_reg: crate::pdf_compat::IndirectFontRef,
3779    font_bold: crate::pdf_compat::IndirectFontRef,
3780    run: &AnalysisRun,
3781    rows: &[AuthorReportRow],
3782    w: f32,
3783    h: f32,
3784    margin: f32,
3785    footer_h: f32,
3786    title: &str,
3787    version: &str,
3788) {
3789    use crate::pdf_compat::Mm;
3790    const HDR_H: f32 = 8.0;
3791
3792    let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Code Ownership");
3793    let layer = doc.get_page(page).get_layer(layer_idx);
3794    let ctx = PdfCtx {
3795        layer: &layer,
3796        font_reg,
3797        font_bold,
3798        w,
3799        margin,
3800        row_h: 5.2,
3801        tbl_hdr_h: 5.0,
3802    };
3803
3804    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3805    pdf_render_ownership_section(&ctx, rows, h - HDR_H - 4.0);
3806    pdf_page_footer_band(&ctx, footer_h, version);
3807}
3808
3809/// Measure how tall the COCOMO + Tests & Coverage page needs to be, so a terminal
3810/// (last) page can be trimmed to its content instead of left at full landscape height
3811/// with a large empty gap below the last section.
3812///
3813/// Renders the same sections onto a throwaway, never-saved document of height `h_full`
3814/// and reads where the content ends. Layout is vertically translation-invariant, so the
3815/// trimmed height is `h_full - content_bottom + footer_h + pad`. Falls back to `h_full`
3816/// on any error so the report is always produced.
3817#[allow(clippy::too_many_arguments)]
3818fn measure_terminal_tc_page_height(
3819    run: &AnalysisRun,
3820    w: f32,
3821    h_full: f32,
3822    margin: f32,
3823    footer_h: f32,
3824    row_h: f32,
3825    tbl_hdr_h: f32,
3826    with_cocomo: bool,
3827) -> f32 {
3828    use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3829    let measure = || -> Option<f32> {
3830        let (doc, page, layer_idx) = PdfDocument::new("measure", Mm(w), Mm(h_full), "m");
3831        let font_reg = doc.add_builtin_font(BuiltinFont::Helvetica).ok()?;
3832        let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).ok()?;
3833        let layer = doc.get_page(page).get_layer(layer_idx);
3834        let ctx = PdfCtx {
3835            layer: &layer,
3836            font_reg,
3837            font_bold,
3838            w,
3839            margin,
3840            row_h,
3841            tbl_hdr_h,
3842        };
3843        // Mirror the real render's starting offsets exactly (see the cocomo/T&C branches
3844        // in `write_pdf_from_run`): an 8 mm header band, then the first section below it.
3845        let content_bottom = if with_cocomo {
3846            let cocomo_bottom = pdf_render_cocomo_section(&ctx, run, h_full - 8.0 - 6.0);
3847            pdf_render_tc_inline(&ctx, run, cocomo_bottom - 2.0, footer_h)
3848        } else {
3849            pdf_render_tc_inline(&ctx, run, h_full - 8.0 - 4.0, footer_h)
3850        };
3851        // 4 mm bottom padding below the last element, mirroring the top-of-content gap.
3852        let pad = 4.0;
3853        Some((h_full - content_bottom + footer_h + pad).clamp(60.0, h_full))
3854    };
3855    measure().unwrap_or(h_full)
3856}
3857
3858/// Render the dedicated COCOMO + Tests & Coverage page (page 2) when COCOMO did not fit
3859/// on page 1, or a standalone Tests & Coverage page otherwise. Returns the page/layer and
3860/// the Y below the last section so the per-file table can continue on the same page with
3861/// no blank-page gap. Extracted from `write_pdf_from_run` to keep that function's cognitive
3862/// complexity low; layout and output are unchanged.
3863#[allow(
3864    clippy::cast_precision_loss,
3865    clippy::cast_possible_truncation,
3866    clippy::cast_sign_loss,
3867    clippy::too_many_arguments
3868)]
3869fn pdf_render_cocomo_or_tc_page(
3870    doc: &crate::pdf_compat::PdfDocumentReference,
3871    font_reg: crate::pdf_compat::IndirectFontRef,
3872    font_bold: crate::pdf_compat::IndirectFontRef,
3873    run: &AnalysisRun,
3874    dims: PdfPageDims,
3875    title: &str,
3876    version: &str,
3877    cocomo_fits_page1: bool,
3878    trim_page: bool,
3879) -> (
3880    crate::pdf_compat::PdfPageIndex,
3881    crate::pdf_compat::PdfLayerIndex,
3882    f32,
3883) {
3884    use crate::pdf_compat::{Color, Mm, Mm as PdfMm, Rgb};
3885    let PdfPageDims {
3886        w,
3887        h,
3888        margin,
3889        footer_h,
3890        row_h,
3891        tbl_hdr_h,
3892    } = dims;
3893
3894    // No COCOMO on its own page — create a dedicated T&C page and start per-file from it.
3895    if run.cocomo.is_none() || cocomo_fits_page1 {
3896        let page_h = if trim_page {
3897            measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, false)
3898        } else {
3899            h
3900        };
3901        return pdf_render_tests_coverage_page(
3902            doc, font_reg, font_bold, run, w, page_h, margin, footer_h, title, version,
3903        );
3904    }
3905
3906    let page_h = if trim_page {
3907        measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, true)
3908    } else {
3909        h
3910    };
3911    let (c2_page, c2_layer_idx) = doc.add_page(Mm(w), Mm(page_h), "Content");
3912    let c2_layer = doc.get_page(c2_page).get_layer(c2_layer_idx);
3913    let c2_ctx = PdfCtx {
3914        layer: &c2_layer,
3915        font_reg,
3916        font_bold,
3917        w,
3918        margin,
3919        row_h,
3920        tbl_hdr_h,
3921    };
3922    // Small page header so the reader knows which report this is.
3923    pdf_fill_rect(
3924        &c2_layer,
3925        0.0,
3926        page_h - 8.0,
3927        w,
3928        8.0,
3929        Rgb::new(0.098, 0.11, 0.15, None),
3930    );
3931    c2_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3932    c2_layer.use_text(
3933        "oxide-sloc",
3934        9.0,
3935        PdfMm(margin),
3936        PdfMm(page_h - 5.5),
3937        font_bold,
3938    );
3939    c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3940    c2_layer.use_text(
3941        pdf_trunc(&pdf_safe_str(title), 45),
3942        7.5,
3943        PdfMm(46.0),
3944        PdfMm(page_h - 5.5),
3945        font_reg,
3946    );
3947    pdf_draw_header_meta(
3948        &c2_layer,
3949        font_reg,
3950        w,
3951        margin,
3952        page_h - 5.5,
3953        &pdf_page_header_meta(run),
3954    );
3955    let cocomo_bottom = pdf_render_cocomo_section(&c2_ctx, run, page_h - 8.0 - 6.0);
3956    // Render T&C inline on the same page immediately after COCOMO — no blank gap.
3957    let tc_bottom = pdf_render_tc_inline(&c2_ctx, run, cocomo_bottom - 2.0, footer_h);
3958    // Footer (per-file renderer will overdraw with its richer version if it starts here).
3959    pdf_fill_rect(
3960        &c2_layer,
3961        0.0,
3962        0.0,
3963        w,
3964        footer_h,
3965        Rgb::new(0.93, 0.91, 0.87, None),
3966    );
3967    c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3968    c2_layer.use_text(
3969        format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
3970        6.5,
3971        PdfMm(margin),
3972        PdfMm(3.0),
3973        font_reg,
3974    );
3975    // Pass the Y below T&C content so per-file can continue on this page without a gap.
3976    (c2_page, c2_layer_idx, tc_bottom - 3.0)
3977}
3978
3979/// Generate a PDF summary report from `AnalysisRun` data using the pure-Rust `printpdf` crate.
3980///
3981/// No external tools (Chrome, wkhtmltopdf) are required — this path is always available on
3982/// both Windows and Linux server deployments.
3983///
3984/// # Errors
3985///
3986/// Returns an error if the output directory cannot be created or the PDF file cannot be written.
3987// Casts throughout are for PDF layout coordinates and percentage ratios; precision loss is fine.
3988#[allow(
3989    clippy::cast_precision_loss,
3990    clippy::cast_possible_truncation,
3991    clippy::cast_sign_loss,
3992    clippy::too_many_lines
3993)]
3994pub fn write_pdf_from_run(run: &AnalysisRun, pdf_path: &Path) -> Result<()> {
3995    use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3996    use std::fs::File;
3997    use std::io::BufWriter;
3998
3999    const W: f32 = 297.0;
4000    const H: f32 = 210.0;
4001    const MARGIN: f32 = 10.0;
4002    const FOOTER_H: f32 = 10.0;
4003    const HDR_H: f32 = 13.5;
4004    const ROW_H: f32 = 5.5;
4005    const TBL_HDR_H: f32 = 6.0;
4006
4007    if let Some(parent) = pdf_path.parent() {
4008        fs::create_dir_all(parent)
4009            .with_context(|| format!("failed to create PDF directory {}", parent.display()))?;
4010    }
4011
4012    let title = pdf_safe_str(&run.effective_configuration.reporting.report_title);
4013    let ts = to_pt_hhmm(run.tool.timestamp_utc);
4014    let version = env!("CARGO_PKG_VERSION");
4015    let banner = run
4016        .effective_configuration
4017        .reporting
4018        .report_header_footer
4019        .as_deref();
4020
4021    let (doc, page1, layer1) =
4022        PdfDocument::new(format!("oxide-sloc: {title}"), Mm(W), Mm(H), "Content");
4023    let font_reg = doc
4024        .add_builtin_font(BuiltinFont::Helvetica)
4025        .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
4026    let font_bold = doc
4027        .add_builtin_font(BuiltinFont::HelveticaBold)
4028        .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
4029    let layer = doc.get_page(page1).get_layer(layer1);
4030
4031    let ctx = PdfCtx {
4032        layer: &layer,
4033        font_reg,
4034        font_bold,
4035        w: W,
4036        margin: MARGIN,
4037        row_h: ROW_H,
4038        tbl_hdr_h: TBL_HDR_H,
4039    };
4040    let roots_text_y = pdf_render_page1_header(&ctx, run, &ts, &title, H, HDR_H, banner);
4041    let row2_bot = pdf_render_summary_chips(&ctx, run, roots_text_y);
4042    let info_y = pdf_render_info_lines(&ctx, run, row2_bot);
4043    let tbl_top = info_y - 4.0;
4044    pdf_render_metric_tables(&ctx, run, tbl_top);
4045    // Style analysis section — rendered below the metric tables when data is available.
4046    // The metric tables occupy ~64.5 mm below tbl_top; leave 4 mm clearance before drawing.
4047    let after_tables_y = tbl_top - 64.5 - 4.0;
4048    let after_style_y = run.style_summary.as_ref().map_or(after_tables_y, |ss| {
4049        if after_tables_y > FOOTER_H + 12.0 {
4050            pdf_render_style_section(&ctx, ss, after_tables_y)
4051        } else {
4052            after_tables_y
4053        }
4054    });
4055    // COCOMO estimate — on page 1 if room remains, otherwise on its own page 2.
4056    // Need ~32 mm: header (5.5) + data row (13) + gap (5) + note (5) + margins (~3.5).
4057    let cocomo_fits_page1 = run.cocomo.is_some() && (after_style_y - 3.0) > FOOTER_H + 32.0;
4058    if cocomo_fits_page1 {
4059        pdf_render_cocomo_section(&ctx, run, after_style_y - 3.0);
4060    }
4061    pdf_render_page1_footer(&ctx, run, FOOTER_H, version, banner);
4062
4063    // Page-flow bookkeeping for empty-gap trimming. The per-file table continues on the
4064    // COCOMO/T&C page only when there is no Git Hotspots page in between (the Hotspots page,
4065    // when present, becomes the per-file continuation instead). A page that nothing flows
4066    // onto is trimmed to its content height to avoid a large empty gap below the last section.
4067    let hotspot_rows = build_hotspot_rows(run, 15);
4068    let has_per_file = !run.per_file_records.is_empty();
4069    let tc_page_gets_per_file = hotspot_rows.is_empty() && has_per_file;
4070    let trim_tc_page = !tc_page_gets_per_file;
4071
4072    // If COCOMO didn't fit on page 1, render it on a dedicated page 2 (with T&C inline);
4073    // otherwise render a standalone T&C page. Either way the returned page/layer/Y lets the
4074    // per-file table continue on the same page with no blank-page gap.
4075    let page_dims = PdfPageDims {
4076        w: W,
4077        h: H,
4078        margin: MARGIN,
4079        footer_h: FOOTER_H,
4080        row_h: ROW_H,
4081        tbl_hdr_h: TBL_HDR_H,
4082    };
4083    let cocomo_page_ctx = pdf_render_cocomo_or_tc_page(
4084        &doc,
4085        font_reg,
4086        font_bold,
4087        run,
4088        page_dims,
4089        &title,
4090        version,
4091        cocomo_fits_page1,
4092        trim_tc_page,
4093    );
4094
4095    // Git Hotspots — its own page after COCOMO/T&C, only when an --activity-window scan
4096    // collected per-file git activity. Threaded as the per-file continuation (like COCOMO)
4097    // so the per-file table flows on below it with no blank-page gap.
4098    // A Git Hotspots page is only emitted when per-file git activity exists, which means
4099    // `per_file_records` is non-empty and the per-file table always flows onto it — so it is
4100    // never a terminal page and needs no trimming (it stays full height for the per-file rows).
4101    let per_file_start = if hotspot_rows.is_empty() {
4102        Some(cocomo_page_ctx)
4103    } else {
4104        Some(pdf_render_hotspots_page(
4105            &doc,
4106            font_reg,
4107            font_bold,
4108            run,
4109            &hotspot_rows,
4110            W,
4111            H,
4112            MARGIN,
4113            FOOTER_H,
4114            &title,
4115            version,
4116        ))
4117    };
4118
4119    if !run.per_file_records.is_empty() {
4120        // Per-file continues on the same page as T&C / COCOMO / Hotspots — no blank page between.
4121        pdf_render_per_file_pages(
4122            &doc,
4123            font_reg,
4124            font_bold,
4125            run,
4126            W,
4127            H,
4128            MARGIN,
4129            FOOTER_H,
4130            ROW_H,
4131            TBL_HDR_H,
4132            &title,
4133            &ts,
4134            version,
4135            banner,
4136            per_file_start,
4137        );
4138    }
4139
4140    // Code Ownership — a dedicated terminal page when an attribution scan populated authors.
4141    // Capped to a single page's worth of the top contributors (by code lines owned).
4142    let author_rows = build_author_rows(run);
4143    if !author_rows.is_empty() {
4144        let capped: Vec<AuthorReportRow> = author_rows.into_iter().take(40).collect();
4145        pdf_render_ownership_page(
4146            &doc, font_reg, font_bold, run, &capped, W, H, MARGIN, FOOTER_H, &title, version,
4147        );
4148    }
4149
4150    doc.save(&mut BufWriter::new(File::create(pdf_path).with_context(
4151        || format!("cannot create PDF at {}", pdf_path.display()),
4152    )?))
4153    .map_err(|e| anyhow::anyhow!("printpdf save error: {e}"))?;
4154
4155    Ok(())
4156}
4157
4158/// Per-character advance widths for the PDF built-in Helvetica and Helvetica-Bold fonts
4159/// (1/1000 em units, PDF spec Appendix D). Used to right-align text without a layout engine.
4160///
4161/// Each row is `(glyph, bold_advance, regular_advance)`, a verbatim transcription of the PDF
4162/// spec width tables. Keeping both weights on one row per glyph preserves the spec mapping for
4163/// audit while expressing it as data rather than two parallel `match` arms. Digits (`'0'..='9'`,
4164/// 556 in both weights) are handled in `helvetica_advance` and intentionally omitted here.
4165const HELVETICA_WIDTHS: &[(char, u32, u32)] = &[
4166    (' ', 278, 278),
4167    ('!', 333, 278),
4168    ('"', 474, 355),
4169    ('#', 556, 556),
4170    ('$', 556, 556),
4171    ('%', 889, 889),
4172    ('&', 722, 667),
4173    ('\'', 278, 222),
4174    ('(', 333, 333),
4175    (')', 333, 333),
4176    ('*', 389, 389),
4177    ('+', 584, 584),
4178    (',', 278, 278),
4179    ('-', 333, 333),
4180    ('.', 278, 278),
4181    ('/', 278, 278),
4182    (':', 333, 278),
4183    (';', 333, 278),
4184    ('<', 584, 584),
4185    ('=', 584, 584),
4186    ('>', 584, 584),
4187    ('?', 556, 472),
4188    ('@', 975, 1015),
4189    ('A', 722, 667),
4190    ('B', 722, 667),
4191    ('C', 722, 722),
4192    ('D', 722, 722),
4193    ('E', 667, 667),
4194    ('F', 611, 611),
4195    ('G', 778, 778),
4196    ('H', 722, 722),
4197    ('I', 278, 278),
4198    ('J', 556, 500),
4199    ('K', 722, 667),
4200    ('L', 611, 556),
4201    ('M', 833, 833),
4202    ('N', 722, 722),
4203    ('O', 778, 778),
4204    ('P', 667, 667),
4205    ('Q', 778, 778),
4206    ('R', 722, 722),
4207    ('S', 667, 667),
4208    ('T', 611, 611),
4209    ('U', 722, 722),
4210    ('V', 667, 667),
4211    ('W', 944, 944),
4212    ('X', 667, 667),
4213    ('Y', 611, 611),
4214    ('Z', 611, 611),
4215    ('[', 333, 278),
4216    ('\\', 278, 278),
4217    (']', 333, 278),
4218    ('^', 584, 469),
4219    ('_', 556, 556),
4220    ('`', 278, 222),
4221    ('a', 556, 556),
4222    ('b', 611, 556),
4223    ('c', 556, 500),
4224    ('d', 611, 556),
4225    ('e', 556, 556),
4226    ('f', 333, 278),
4227    ('g', 611, 556),
4228    ('h', 611, 556),
4229    ('i', 278, 222),
4230    ('j', 278, 222),
4231    ('k', 556, 500),
4232    ('l', 278, 222),
4233    ('m', 889, 833),
4234    ('n', 611, 556),
4235    ('o', 611, 556),
4236    ('p', 611, 556),
4237    ('q', 611, 556),
4238    ('r', 389, 333),
4239    ('s', 556, 500),
4240    ('t', 333, 278),
4241    ('u', 611, 556),
4242    ('v', 556, 500),
4243    ('w', 778, 722),
4244    ('x', 556, 500),
4245    ('y', 556, 500),
4246    ('z', 500, 500),
4247    ('\u{00B7}', 278, 278), // middle dot (Latin-1 0xB7) — used as section separator
4248];
4249
4250/// Advance width (1/1000 em) for `ch` in Helvetica (`bold` selects the bold weight). Looks up
4251/// `HELVETICA_WIDTHS`; digits are a uniform 556, and unknown glyphs fall back to the average
4252/// advance for the weight (556 bold, 500 regular).
4253fn helvetica_advance(ch: char, bold: bool) -> u32 {
4254    if ch.is_ascii_digit() {
4255        return 556;
4256    }
4257    for &(glyph, bold_w, regular_w) in HELVETICA_WIDTHS {
4258        if glyph == ch {
4259            return if bold { bold_w } else { regular_w };
4260        }
4261    }
4262    if bold { 556 } else { 500 }
4263}
4264
4265/// Convert a string to mm given a font size (pt) and bold flag, using exact PDF Helvetica metrics.
4266//
4267// Rendered strings are length-bounded, so the glyph-unit sum is far below f32's 2^23
4268// exact-integer ceiling; the cast feeds a millimetre layout width where any rounding is
4269// sub-pixel.
4270#[allow(
4271    clippy::cast_precision_loss,
4272    reason = "bounded glyph-unit sum to mm width"
4273)]
4274fn helvetica_width_mm(text: &str, pt: f32, bold: bool) -> f32 {
4275    let units: u32 = text.chars().map(|ch| helvetica_advance(ch, bold)).sum();
4276    // 1 unit = (pt × 25.4 mm/in ÷ 72 pt/in) / 1000.
4277    units as f32 * pt * (25.4 / 72.0) / 1000.0
4278}
4279
4280fn pdf_fill_rect(
4281    layer: &crate::pdf_compat::PdfLayerReference,
4282    x: f32,
4283    y: f32,
4284    w: f32,
4285    h: f32,
4286    color: crate::pdf_compat::Rgb,
4287) {
4288    layer.fill_rect(x, y, w, h, color);
4289}
4290
4291fn pdf_safe_str(s: &str) -> String {
4292    let mut out = String::with_capacity(s.len());
4293    for c in s.chars() {
4294        match c {
4295            // Common Unicode punctuation → readable ASCII equivalents
4296            '\u{2014}' | '\u{2013}' => out.push_str(" - "), // em dash / en dash
4297            '\u{2026}' => out.push_str("..."),              // ellipsis
4298            '\u{2018}' | '\u{2019}' => out.push('\''),      // curly single quotes
4299            '\u{201C}' | '\u{201D}' => out.push('"'),       // curly double quotes
4300            '\u{00B7}' | '\u{2022}' => out.push('-'),       // middle dot / bullet
4301            '\u{00A0}' => out.push(' '),                    // non-breaking space
4302            c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
4303            _ => {} // drop truly unprintable non-ASCII rather than emitting '?'
4304        }
4305    }
4306    out
4307}
4308
4309fn pdf_trunc(s: &str, max: usize) -> String {
4310    if s.len() <= max {
4311        s.to_string()
4312    } else {
4313        format!("{}...", &s[..max.saturating_sub(3)])
4314    }
4315}
4316
4317// Show the tail of a string — prepend "..." when truncated so the meaningful end is visible.
4318// Used for file paths where the filename/leaf matters more than the leading directories.
4319fn pdf_trunc_end(s: &str, max: usize) -> String {
4320    if s.len() <= max {
4321        s.to_string()
4322    } else {
4323        format!("...{}", &s[s.len() - max.saturating_sub(3)..])
4324    }
4325}
4326
4327fn pdf_fmt_full(n: u64) -> String {
4328    // Comma-separated full number: 15319 → "15,319", 1374 → "1,374"
4329    let s = n.to_string();
4330    let mut out = String::with_capacity(s.len() + s.len() / 3);
4331    for (i, ch) in s.chars().rev().enumerate() {
4332        if i > 0 && i % 3 == 0 {
4333            out.push(',');
4334        }
4335        out.push(ch);
4336    }
4337    out.chars().rev().collect()
4338}
4339
4340/// Launch a headless Chromium-based browser to print `html_path` as a PDF to `pdf_path`.
4341///
4342/// Tries CDP (headless Chrome) first; falls back to `wkhtmltopdf` when no Chromium-based
4343/// browser is found on the server.
4344///
4345/// # Errors
4346///
4347/// Returns an error if no PDF tool (Chromium or wkhtmltopdf) is available, the tool fails
4348/// to start, or the PDF file is not produced within the timeout.
4349pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
4350    eprintln!("[oxide-sloc][pdf] starting");
4351
4352    let absolute_html = html_path
4353        .canonicalize()
4354        .with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
4355    // canonicalize() on Windows prepends \\?\ (extended-length path prefix) — strip it for display.
4356    eprintln!(
4357        "[oxide-sloc][pdf] html = {}",
4358        absolute_html.to_string_lossy().trim_start_matches(r"\\?\")
4359    );
4360
4361    let absolute_pdf = if pdf_path.is_absolute() {
4362        pdf_path.to_path_buf()
4363    } else {
4364        std::env::current_dir()
4365            .context("failed to resolve current working directory")?
4366            .join(pdf_path)
4367    };
4368    eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
4369
4370    if let Some(parent) = absolute_pdf.parent() {
4371        fs::create_dir_all(parent).with_context(|| {
4372            format!("failed to create PDF output directory {}", parent.display())
4373        })?;
4374    }
4375
4376    match write_pdf_via_cdp(&absolute_html, &absolute_pdf) {
4377        Ok(()) => {}
4378        Err(cdp_err) => {
4379            eprintln!("[oxide-sloc][pdf] CDP failed ({cdp_err:#}), trying wkhtmltopdf fallback");
4380            write_pdf_via_wkhtmltopdf(&absolute_html, &absolute_pdf).with_context(|| {
4381                format!(
4382                    "PDF generation failed via both CDP ({cdp_err:#}) and wkhtmltopdf. \
4383                     Install a Chromium-based browser (Chrome, Edge, Brave) or wkhtmltopdf \
4384                     on the server, or set SLOC_BROWSER to the browser executable path."
4385                )
4386            })?;
4387        }
4388    }
4389
4390    eprintln!("[oxide-sloc][pdf] done");
4391    Ok(())
4392}
4393
4394fn normalize_browser_env_path(raw: &str) -> PathBuf {
4395    let trimmed = raw.trim();
4396    #[cfg(windows)]
4397    {
4398        let bytes = trimmed.as_bytes();
4399        if bytes.len() >= 3
4400            && bytes[0] == b'/'
4401            && bytes[2] == b'/'
4402            && bytes[1].is_ascii_alphabetic()
4403        {
4404            let drive = (bytes[1] as char).to_ascii_uppercase();
4405            let rest = &trimmed[3..];
4406            return PathBuf::from(format!("{drive}:/{rest}"));
4407        }
4408    }
4409    PathBuf::from(trimmed)
4410}
4411
4412fn discover_browser_from_env() -> Option<PathBuf> {
4413    for var_name in ["SLOC_BROWSER", "BROWSER"] {
4414        if let Ok(path) = std::env::var(var_name) {
4415            let candidate = normalize_browser_env_path(&path);
4416            if candidate.is_file() {
4417                return Some(candidate);
4418            }
4419        }
4420    }
4421    None
4422}
4423
4424fn discover_browser() -> Option<PathBuf> {
4425    if let Some(p) = discover_browser_from_env() {
4426        return Some(p);
4427    }
4428
4429    let names = [
4430        "chromium",
4431        "chromium-browser",
4432        "google-chrome",
4433        "google-chrome-stable",
4434        "microsoft-edge",
4435        "msedge",
4436        "brave",
4437        "brave-browser",
4438        "vivaldi",
4439        "opera",
4440        "opera-stable",
4441    ];
4442
4443    for name in names {
4444        if let Some(path) = which_in_path(name) {
4445            return Some(path);
4446        }
4447    }
4448
4449    #[cfg(windows)]
4450    {
4451        for candidate in windows_browser_candidates() {
4452            if candidate.is_file() {
4453                return Some(candidate);
4454            }
4455        }
4456    }
4457
4458    // Absolute path fallbacks for Linux servers where the browser may not be
4459    // in $PATH (e.g. installed via snap, flatpak, or a minimal systemd service env).
4460    #[cfg(not(windows))]
4461    {
4462        for candidate in linux_browser_candidates() {
4463            if candidate.is_file() {
4464                return Some(candidate);
4465            }
4466        }
4467
4468        // Final fallback: ask the shell's `which` so we catch browsers installed
4469        // in non-standard locations that weren't found via PATH or static paths.
4470        if let Some(path) = which_subprocess(&[
4471            "chromium-browser",
4472            "chromium",
4473            "google-chrome",
4474            "google-chrome-stable",
4475            "microsoft-edge",
4476            "brave-browser",
4477        ]) {
4478            return Some(path);
4479        }
4480    }
4481
4482    None
4483}
4484
4485/// Push the Chrome/Edge/Brave/Vivaldi `Application`-layout executable paths under `base` onto
4486/// `paths`. These four share the same per-base directory layout; Opera differs and is added by
4487/// the caller.
4488#[cfg(windows)]
4489fn push_chromium_app_browsers(paths: &mut Vec<PathBuf>, base: &Path) {
4490    paths.push(
4491        base.join("Google")
4492            .join("Chrome")
4493            .join("Application")
4494            .join("chrome.exe"),
4495    );
4496    paths.push(
4497        base.join("Microsoft")
4498            .join("Edge")
4499            .join("Application")
4500            .join("msedge.exe"),
4501    );
4502    paths.push(
4503        base.join("BraveSoftware")
4504            .join("Brave-Browser")
4505            .join("Application")
4506            .join("brave.exe"),
4507    );
4508    paths.push(base.join("Vivaldi").join("Application").join("vivaldi.exe"));
4509}
4510
4511#[cfg(windows)]
4512fn windows_browser_candidates() -> Vec<PathBuf> {
4513    let mut paths = Vec::new();
4514
4515    let program_files = std::env::var_os("ProgramFiles");
4516    let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
4517    let local_app_data = std::env::var_os("LocalAppData");
4518
4519    for base in [program_files, program_files_x86].into_iter().flatten() {
4520        let base = PathBuf::from(base);
4521        push_chromium_app_browsers(&mut paths, &base);
4522        paths.push(base.join("Opera").join("launcher.exe"));
4523        paths.push(base.join("Opera GX").join("launcher.exe"));
4524    }
4525
4526    if let Some(base) = local_app_data {
4527        let base = PathBuf::from(base);
4528        push_chromium_app_browsers(&mut paths, &base);
4529        paths.push(base.join("Programs").join("Opera").join("launcher.exe"));
4530        paths.push(base.join("Programs").join("Opera GX").join("launcher.exe"));
4531    }
4532
4533    paths
4534}
4535
4536#[cfg(not(windows))]
4537fn linux_browser_candidates() -> Vec<PathBuf> {
4538    vec![
4539        // snap (Ubuntu, common on servers)
4540        PathBuf::from("/snap/bin/chromium"),
4541        PathBuf::from("/snap/bin/chromium-browser"),
4542        // standard apt/dnf paths
4543        PathBuf::from("/usr/bin/chromium"),
4544        PathBuf::from("/usr/bin/chromium-browser"),
4545        PathBuf::from("/usr/bin/google-chrome"),
4546        PathBuf::from("/usr/bin/google-chrome-stable"),
4547        PathBuf::from("/usr/bin/microsoft-edge"),
4548        PathBuf::from("/usr/bin/microsoft-edge-stable"),
4549        PathBuf::from("/usr/bin/brave-browser"),
4550        PathBuf::from("/usr/bin/brave-browser-stable"),
4551        // package-managed library locations (Ubuntu 20.04, Debian)
4552        PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
4553        PathBuf::from("/usr/lib/chromium/chromium"),
4554        PathBuf::from("/usr/lib/chromium/chrome"),
4555        // manual / opt installs
4556        PathBuf::from("/opt/google/chrome/google-chrome"),
4557        PathBuf::from("/opt/google/chrome-beta/google-chrome"),
4558        PathBuf::from("/opt/google/chrome-unstable/google-chrome"),
4559        // local installs
4560        PathBuf::from("/usr/local/bin/chromium"),
4561        PathBuf::from("/usr/local/bin/chromium-browser"),
4562        PathBuf::from("/usr/local/bin/google-chrome"),
4563        // flatpak wrapper scripts
4564        PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
4565        PathBuf::from("/usr/share/flatpak/exports/bin/org.chromium.Chromium"),
4566    ]
4567}
4568
4569/// Ask the shell for a browser that may be in PATH but not in the hardcoded list above.
4570/// Used as a last resort when all static-path checks have failed.
4571#[cfg(not(windows))]
4572fn which_subprocess(names: &[&str]) -> Option<PathBuf> {
4573    for name in names {
4574        if let Ok(out) = std::process::Command::new("which").arg(name).output()
4575            && out.status.success()
4576        {
4577            let s = String::from_utf8_lossy(&out.stdout);
4578            let path = PathBuf::from(s.trim());
4579            if path.is_file() {
4580                return Some(path);
4581            }
4582        }
4583    }
4584    None
4585}
4586
4587fn which_in_path(exe: &str) -> Option<PathBuf> {
4588    let path_var = std::env::var_os("PATH")?;
4589    for dir in std::env::split_paths(&path_var) {
4590        let candidate = dir.join(exe);
4591        if candidate.is_file() {
4592            return Some(candidate);
4593        }
4594        #[cfg(windows)]
4595        {
4596            let candidate = dir.join(format!("{exe}.exe"));
4597            if candidate.is_file() {
4598                return Some(candidate);
4599            }
4600        }
4601    }
4602    None
4603}
4604
4605fn file_url(path: &Path) -> String {
4606    let raw = path.to_string_lossy().replace('\\', "/");
4607    let normalized = if raw.starts_with('/') {
4608        raw
4609    } else {
4610        format!("/{raw}")
4611    };
4612
4613    let mut encoded = String::with_capacity(normalized.len() + 8);
4614    for byte in normalized.bytes() {
4615        match byte {
4616            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
4617                encoded.push(byte as char);
4618            }
4619            _ => {
4620                let _ = write!(encoded, "%{byte:02X}");
4621            }
4622        }
4623    }
4624
4625    format!("file://{encoded}")
4626}
4627
4628fn file_row_view(file: &FileRecord) -> FileRow {
4629    FileRow {
4630        relative_path: file.relative_path.clone(),
4631        language: file.language.map_or_else(
4632            || "-".into(),
4633            |language| language.display_name().to_string(),
4634        ),
4635        total_physical_lines: file.raw_line_categories.total_physical_lines,
4636        code_lines: file.effective_counts.code_lines,
4637        comment_lines: file.effective_counts.comment_lines,
4638        blank_lines: file.effective_counts.blank_lines,
4639        mixed_lines_separate: file.effective_counts.mixed_lines_separate,
4640        functions: file.raw_line_categories.functions,
4641        classes: file.raw_line_categories.classes,
4642        variables: file.raw_line_categories.variables,
4643        imports: file.raw_line_categories.imports,
4644        test_count: file.raw_line_categories.test_count,
4645        test_assertion_count: file.raw_line_categories.test_assertion_count,
4646        test_suite_count: file.raw_line_categories.test_suite_count,
4647        line_cov_pct: file
4648            .coverage
4649            .as_ref()
4650            .map(|c| format!("{:.1}", c.line_pct()))
4651            .unwrap_or_default(),
4652        fn_cov_pct: file
4653            .coverage
4654            .as_ref()
4655            .filter(|c| c.functions_found > 0)
4656            .map(|c| format!("{:.1}", c.function_pct()))
4657            .unwrap_or_default(),
4658        branch_cov_pct: file
4659            .coverage
4660            .as_ref()
4661            .filter(|c| c.branches_found > 0)
4662            .map(|c| format!("{:.1}", c.branch_pct()))
4663            .unwrap_or_default(),
4664        cov_lines_detail: file.coverage.as_ref().map_or_else(String::new, |c| {
4665            format!("{}/{}", c.lines_hit, c.lines_found)
4666        }),
4667        status: format!("{:?}", file.status),
4668        status_class: format!("{:?}", file.status).to_ascii_lowercase(),
4669        warnings: if file.warnings.is_empty() {
4670            String::new()
4671        } else {
4672            file.warnings.join("; ")
4673        },
4674    }
4675}
4676
4677fn is_pacific_dst_report(dt: DateTime<Utc>) -> bool {
4678    use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Weekday};
4679    let year = dt.year();
4680    let nth_sun = |month: u32, n: u32, hour: u32| {
4681        let mut count = 0u32;
4682        let mut day = 1u32;
4683        loop {
4684            let d = NaiveDate::from_ymd_opt(year, month, day).expect("valid");
4685            if d.weekday() == Weekday::Sun {
4686                count += 1;
4687                if count == n {
4688                    return Utc.from_utc_datetime(
4689                        &d.and_time(NaiveTime::from_hms_opt(hour, 0, 0).expect("valid")),
4690                    );
4691                }
4692            }
4693            day += 1;
4694        }
4695    };
4696    let dst_start = nth_sun(3, 2, 10);
4697    let dst_end = nth_sun(11, 1, 9);
4698    dt >= dst_start && dt < dst_end
4699}
4700
4701fn to_pst_display(dt: DateTime<Utc>) -> String {
4702    let (offset, label) = if is_pacific_dst_report(dt) {
4703        (
4704            FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4705            "PDT",
4706        )
4707    } else {
4708        (
4709            FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4710            "PST",
4711        )
4712    };
4713    format!(
4714        "{} {label}",
4715        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
4716    )
4717}
4718
4719/// Format a UTC `DateTime` as "YYYY-MM-DD HH:MM PDT/PST" (no seconds).
4720fn to_pt_hhmm(dt: DateTime<Utc>) -> String {
4721    let (offset, label) = if is_pacific_dst_report(dt) {
4722        (
4723            FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4724            "PDT",
4725        )
4726    } else {
4727        (
4728            FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4729            "PST",
4730        )
4731    };
4732    format!(
4733        "{} {label}",
4734        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M")
4735    )
4736}
4737
4738/// Parse an RFC 3339 / ISO 8601 git commit date string and reformat it as
4739/// "YYYY-MM-DD HH:MM PDT/PST", converting from the embedded offset to Pacific time.
4740fn fmt_commit_date_pt(s: &str) -> String {
4741    use chrono::DateTime as ChronoDateTime;
4742    ChronoDateTime::parse_from_rfc3339(s).map_or_else(
4743        |_| s.replace('T', " "),
4744        |dt| to_pt_hhmm(dt.with_timezone(&Utc)),
4745    )
4746}
4747
4748fn build_warning_console(warnings: &[String]) -> String {
4749    if warnings.is_empty() {
4750        return "No top-level warnings.".to_string();
4751    }
4752
4753    warnings
4754        .iter()
4755        .enumerate()
4756        .map(|(index, warning)| {
4757            format!(
4758                "[{index:03}] {warning}",
4759                index = index + 1,
4760                warning = warning
4761            )
4762        })
4763        .collect::<Vec<_>>()
4764        .join("\n")
4765}
4766
4767fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
4768    let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
4769    for warning in warnings {
4770        let key = if warning.contains("unsupported or undetected language") {
4771            "Unsupported or undetected text formats"
4772        } else if warning.contains("file exceeded max_file_size_bytes") {
4773            "Large files skipped by size limit"
4774        } else if warning.contains("binary file skipped by default") {
4775            "Binary assets skipped"
4776        } else if warning.contains("minified file skipped by policy") {
4777            "Minified files skipped by policy"
4778        } else if warning.contains("vendor file skipped by policy") {
4779            "Vendor files skipped by policy"
4780        } else if warning.contains("best effort") || warning.contains("unclosed string literal") {
4781            "Best-effort parse results"
4782        } else {
4783            "Other warnings"
4784        };
4785        *counts.entry(key).or_default() += 1;
4786    }
4787
4788    counts
4789        .into_iter()
4790        .map(|(label, count)| {
4791            let (tone_class, detail) = match label {
4792                "Unsupported or undetected text formats" => (
4793                    "tone-neutral",
4794                    "These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
4795                ),
4796                "Large files skipped by size limit" => (
4797                    "tone-warn",
4798                    "Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
4799                ),
4800                "Binary assets skipped" => (
4801                    "tone-neutral",
4802                    "Binary bundles are excluded from source counting unless you explicitly opt into them.",
4803                ),
4804                "Minified files skipped by policy" => (
4805                    "tone-warn",
4806                    "Generated and minified assets are being filtered out to avoid inflating code totals.",
4807                ),
4808                "Vendor files skipped by policy" => (
4809                    "tone-neutral",
4810                    "Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
4811                ),
4812                "Best-effort parse results" => (
4813                    "tone-danger",
4814                    "These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
4815                ),
4816                _ => (
4817                    "tone-danger",
4818                    "Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
4819                ),
4820            };
4821
4822            WarningSummaryRow {
4823                label: label.to_string(),
4824                count,
4825                tone_class: tone_class.to_string(),
4826                detail: detail.to_string(),
4827            }
4828        })
4829        .collect()
4830}
4831
4832/// Classify an unsupported-language warning path into a named bucket.
4833fn classify_unsupported_path(path: &str) -> &'static str {
4834    let ext_lc = Path::new(path)
4835        .extension()
4836        .and_then(|e| e.to_str())
4837        .map(str::to_ascii_lowercase)
4838        .unwrap_or_default();
4839
4840    if ext_lc == "md"
4841        || path.ends_with("README")
4842        || path.ends_with("README.md")
4843        || path.ends_with("LICENSE")
4844    {
4845        "Documentation / text"
4846    } else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
4847        "JSON manifests and config"
4848    } else if ext_lc == "toml"
4849        || path.ends_with("MANIFEST.in")
4850        || path.ends_with("requirements.txt")
4851    {
4852        "Project metadata and packaging"
4853    } else if ext_lc == "html" {
4854        "HTML templates"
4855    } else if ext_lc == "txt" {
4856        "Plain text assets"
4857    } else if ext_lc.is_empty() {
4858        "Extensionless or custom text files"
4859    } else {
4860        "Other unsupported text formats"
4861    }
4862}
4863
4864/// Map a bucket label to its recommendation string.
4865fn bucket_recommendation(label: &str) -> String {
4866    match label {
4867        "Documentation / text" => "These files are documentation, not source code. Add a docs/text exclude glob (e.g. **/*.md) or mark them as non-source in your config so they stop generating warnings.".to_string(),
4868        "JSON manifests and config" => "JSON config and manifest files are not source code. Exclude them with an ignore glob (e.g. **/*.json) or add them to excluded_directories so they are silently skipped.".to_string(),
4869        "Project metadata and packaging" => "TOML, requirements.txt, and MANIFEST.in files describe package metadata — not source. Add an exclude glob such as **/*.toml or list them in excluded_directories to suppress these warnings.".to_string(),
4870        "HTML templates" => "HTML is already a supported language. These files may have unusual extensions or be in a directory that is being excluded. Check that the files have a .html extension and are not inside an excluded path.".to_string(),
4871        "Plain text assets" => "Plain .txt files are not analyzed by default. If they contain source, rename them with a source extension. Otherwise add **/*.txt to your exclude globs.".to_string(),
4872        "Extensionless or custom text files" => "Files without an extension cannot be auto-detected. Add a shebang line (e.g. #!/usr/bin/env python3) so oxide-sloc can identify the language, or add an explicit language override in your config.".to_string(),
4873        _ => "Files in this group were not recognized. Check the file extensions and either add an exclude glob to silence the warning or open an issue to request analyzer support.".to_string(),
4874    }
4875}
4876
4877/// Short human-readable description of what each bucket means.
4878fn bucket_description(label: &str) -> String {
4879    match label {
4880        "Documentation / text" => {
4881            "README, LICENSE, and markdown files — not source code.".to_string()
4882        }
4883        "JSON manifests and config" => {
4884            "JSON configuration or manifest files (package.json, lockfiles, etc.).".to_string()
4885        }
4886        "Project metadata and packaging" => {
4887            "TOML, requirements.txt, and MANIFEST.in files that describe package metadata."
4888                .to_string()
4889        }
4890        "HTML templates" => {
4891            "HTML files not covered by the built-in HTML analyzer (unexpected extension or path)."
4892                .to_string()
4893        }
4894        "Plain text assets" => "Plain .txt files that have no analyzable structure.".to_string(),
4895        "Extensionless or custom text files" => {
4896            "Files with no extension that could not be language-detected.".to_string()
4897        }
4898        _ => "Files with an unrecognized extension or format.".to_string(),
4899    }
4900}
4901
4902fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
4903    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4904    let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
4905
4906    for warning in warnings {
4907        if !warning.contains("unsupported or undetected language") {
4908            continue;
4909        }
4910
4911        let path = warning
4912            .split_once(':')
4913            .map(|(path, _)| path.trim())
4914            .unwrap_or_default();
4915        if path.is_empty() {
4916            continue;
4917        }
4918
4919        let bucket = classify_unsupported_path(path);
4920        *counts.entry(bucket.to_string()).or_default() += 1;
4921
4922        let ex = examples.entry(bucket.to_string()).or_default();
4923        if ex.len() < 3 {
4924            let basename = Path::new(path)
4925                .file_name()
4926                .and_then(|n| n.to_str())
4927                .unwrap_or(path)
4928                .to_string();
4929            if !ex.contains(&basename) {
4930                ex.push(basename);
4931            }
4932        }
4933    }
4934
4935    let mut rows = counts.into_iter().collect::<Vec<_>>();
4936    rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4937
4938    rows.into_iter()
4939        .map(|(label, count)| {
4940            let recommendation = bucket_recommendation(&label);
4941            let bucket_description = bucket_description(&label);
4942            let example_files = examples.remove(&label).unwrap_or_default();
4943            WarningOpportunityRow {
4944                label,
4945                count,
4946                recommendation,
4947                example_files,
4948                bucket_description,
4949            }
4950        })
4951        .collect()
4952}
4953
4954#[derive(Debug, Clone)]
4955struct LanguageRow {
4956    language: String,
4957    files: u64,
4958    total_physical_lines: u64,
4959    code_lines: u64,
4960    comment_lines: u64,
4961    blank_lines: u64,
4962    mixed_lines_separate: u64,
4963    functions: u64,
4964    classes: u64,
4965    variables: u64,
4966    imports: u64,
4967    test_count: u64,
4968    test_assertion_count: u64,
4969    test_suite_count: u64,
4970    test_density_str: String,
4971}
4972
4973#[derive(Debug, Clone)]
4974struct FileRow {
4975    relative_path: String,
4976    language: String,
4977    total_physical_lines: u64,
4978    code_lines: u64,
4979    comment_lines: u64,
4980    blank_lines: u64,
4981    mixed_lines_separate: u64,
4982    functions: u64,
4983    classes: u64,
4984    variables: u64,
4985    imports: u64,
4986    test_count: u64,
4987    test_assertion_count: u64,
4988    test_suite_count: u64,
4989    /// Line coverage percentage, e.g. "96.7" — empty string when no coverage data.
4990    line_cov_pct: String,
4991    /// Function coverage percentage — empty string when no coverage data.
4992    fn_cov_pct: String,
4993    /// Branch coverage percentage — empty string when no branch coverage data.
4994    branch_cov_pct: String,
4995    /// Lines hit out of lines found, e.g. "142/156" — empty string when no coverage data.
4996    cov_lines_detail: String,
4997    status: String,
4998    status_class: String,
4999    warnings: String,
5000}
5001
5002#[derive(Debug, Clone)]
5003struct WarningSummaryRow {
5004    label: String,
5005    count: usize,
5006    tone_class: String,
5007    detail: String,
5008}
5009
5010#[derive(Debug, Clone)]
5011struct WarningOpportunityRow {
5012    label: String,
5013    count: usize,
5014    recommendation: String,
5015    /// Up to 3 example file names (basename only) that triggered this bucket.
5016    example_files: Vec<String>,
5017    /// Short description of what this bucket means for the user.
5018    bucket_description: String,
5019}
5020
5021#[derive(Template)]
5022#[template(
5023    source = r##"<!doctype html>
5024<html lang="en">
5025<head>
5026  <meta charset="utf-8" />
5027  <meta name="viewport" content="width=device-width, initial-scale=1" />
5028  <title>{{ browser_title }}</title>
5029  <link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
5030  <style nonce="{{ nonce }}">
5031    :root {
5032      --radius: 18px;
5033      --bg: #f5efe8;
5034      --surface: rgba(255,255,255,0.82);
5035      --surface-2: #fbf7f2;
5036      --surface-3: #efe6dc;
5037      --line: #e6d0bf;
5038      --line-strong: #dcb89f;
5039      --text: #43342d;
5040      --muted: #7b675b;
5041      --muted-2: #a08777;
5042      --nav: #b85d33;
5043      --nav-2: #7a371b;
5044      --accent: #6f9bff;
5045      --accent-2: #4a78ee;
5046      --oxide: #d37a4c;
5047      --oxide-2: #b35428;
5048      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
5049      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
5050      --good-bg: #e8f5ed;
5051      --good-text: #1a8f47;
5052      --warn-bg: #fff4dc;
5053      --warn-text: #9a6d00;
5054      --danger-bg: #fdebec;
5055      --danger-text: #cc4b4b;
5056      --info-bg: #fbf1e8;
5057      --info-text: #a5541f;
5058    }
5059    {% if let Some(hex) = accent_hex %}
5060    :root, body.dark-theme { --accent: {{ hex }}; --accent-2: {{ hex }}; }
5061    {% endif %}
5062    body.dark-theme {
5063      --bg: #1b1511;
5064      --surface: #261c17;
5065      --surface-2: #2d221d;
5066      --surface-3: #372922;
5067      --line: #524238;
5068      --line-strong: #6c5649;
5069      --text: #f5ece6;
5070      --muted: #c7b7aa;
5071      --muted-2: #aa9485;
5072      --nav: #b85d33;
5073      --nav-2: #7a371b;
5074      --accent: #6f9bff;
5075      --accent-2: #4a78ee;
5076      --oxide: #d37a4c;
5077      --oxide-2: #b35428;
5078      --shadow: 0 18px 42px rgba(0,0,0,0.28);
5079      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
5080      --good-bg: #163927;
5081      --good-text: #8fe2a8;
5082      --warn-bg: #3c2d11;
5083      --warn-text: #f3cb75;
5084      --danger-bg: #3d1f1f;
5085      --danger-text: #ff9f9f;
5086      --info-bg: #33241b;
5087      --info-text: #e6a879;
5088    }
5089    * { box-sizing: border-box; }
5090    html, body { margin: 0; min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
5091    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
5092    .top-nav { position: sticky; top: 0; z-index: 30; background: linear-gradient(180deg, var(--nav), var(--nav-2)); border-bottom: 1px solid rgba(255,255,255,0.12); box-shadow: 0 4px 14px rgba(0,0,0,0.18); }
5093    .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; }
5094    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
5095    .brand-logo { width: 42px; height: 46px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.22)); }
5096    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
5097    .background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
5098    .code-particles { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
5099    .code-particle { position: absolute; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; font-weight: 600; color: var(--oxide); opacity: 0; white-space: nowrap; user-select: none; animation: floatCode linear infinite; }
5100    @keyframes floatCode { 0% { opacity: 0; transform: translateY(0) rotate(var(--rot)); } 10% { opacity: var(--op); } 85% { opacity: var(--op); } 100% { opacity: 0; transform: translateY(-200px) rotate(var(--rot)); } }
5101    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
5102    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
5103    .brand-subtitle { color: rgba(255,255,255,0.72); font-size: 11px; line-height: 1.2; margin-top: 2px; letter-spacing: 0.01em; }
5104    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
5105    .nav-project-pill, .nav-pill, .theme-toggle, .header-button {
5106      display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.08); font-size: 12px; font-weight: 700; box-shadow: none;
5107    }
5108    .nav-project-pill { pointer-events: auto; width: 100%; max-width: 300px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
5109    .nav-project-label { color: rgba(255,255,255,0.72); text-transform: uppercase; letter-spacing: 0.09em; font-size: 10px; font-weight: 800; }
5110    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 13px; }
5111    .nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:nowrap; min-width:0; }
5112    @media (max-width: 1400px) { .nav-status { gap: 6px; } .header-button, .theme-toggle { padding: 0 10px; } }
5113    @media (max-width: 1150px) { .nav-status { gap: 4px; } .header-button, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } }
5114    .theme-toggle, .header-button { cursor:pointer; background: rgba(255,255,255,0.08); text-decoration:none; transition: background 0.15s ease, transform 0.15s ease; }
5115    .theme-toggle:hover, .header-button:hover { background: rgba(255,255,255,0.18); transform: translateY(-1px); }
5116    .theme-toggle { width: 38px; justify-content:center; padding:0; }
5117    .nav-dropdown-wrap { position: relative; }
5118    .nav-dropdown-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 6px; }
5119    .nav-dropdown-trigger { }
5120    .nav-dropdown-menu { display: none; position: absolute; top: 100%; right: 0; background: var(--nav-2); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; min-width: 140px; padding: 6px; z-index: 50; box-shadow: 0 8px 24px rgba(0,0,0,0.28); }
5121    .nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
5122    .nav-dropdown-item { display: block; width: 100%; padding: 8px 12px; border: none; border-radius: 7px; background: transparent; color: #fff; font-size: 13px; font-weight: 700; text-align: left; cursor: pointer; }
5123    .nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
5124    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
5125    .theme-toggle .icon-sun { display:none; }
5126    body.dark-theme .theme-toggle .icon-sun { display:block; }
5127    body.dark-theme .theme-toggle .icon-moon { display:none; }
5128    .settings-modal{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
5129    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
5130    .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
5131    .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
5132    .settings-close:hover{color:var(--text);background:var(--surface-2);}
5133    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
5134    .settings-modal-body{padding:14px 16px 16px;}
5135    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
5136    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
5137    .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
5138    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
5139    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
5140    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
5141    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
5142    .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
5143    .tz-select:focus{border-color:var(--oxide);}
5144    .page { max-width: 1720px; margin: 0 auto; padding: 32px 24px 40px; }
5145    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
5146    /* Uniform two-row card strip. JS pads the card count to even (revealing a
5147       reserve card when odd) and sets the column count to n/2, so the cards form
5148       exactly two full rows with every column aligned and every card the same
5149       width — no oversized card, no empty trailing cell. */
5150    .summary-grid { display:grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap:10px; align-items:stretch; }
5151    .panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
5152    .panel { padding: 20px; }
5153    .metric { padding: 11px 12px 20px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 70px; }
5154    .metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
5155    .metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
5156    .section-kicker { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
5157    .metric-value { margin-top: 6px; }
5158    .metric-big { display:block; font-size: 20px; font-weight: 900; color: var(--oxide); line-height: 1.15; letter-spacing: -0.02em; }
5159    .metric-exact { position: absolute; bottom: 6px; right: 10px; font-size: 12px; font-weight: 600; color: var(--muted); font-family: ui-monospace, monospace; }
5160    .metric-tooltip { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%) translateY(7px); background: var(--text); color: var(--bg); padding: 10px 14px; border-radius: 10px; font-size: 12px; font-weight: 500; line-height: 1.55; white-space: normal; max-width: 340px; min-width: 200px; text-align: left; pointer-events: none; opacity: 0; transition: opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index: 100; box-shadow: 0 4px 18px rgba(0,0,0,0.25); }
5161    .metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
5162    .metric:hover .metric-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); }
5163    .hero { padding: 24px 24px 20px; margin-bottom: 18px; background: linear-gradient(150deg, rgba(111,155,255,0.06) 0%, transparent 55%), var(--surface); border-top: 3px solid var(--accent); }
5164    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
5165    .hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
5166    .run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
5167    @media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
5168    @media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
5169    .run-id-chip { display:flex; flex-direction:column; gap:5px; padding:12px 14px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); border-left:3px solid var(--accent); color:var(--text); position:relative; cursor:default; transition:transform 0.18s ease, box-shadow 0.18s ease; }
5170    .run-id-chip[data-copy] { cursor:pointer; }
5171    a.run-id-chip-link { text-decoration:none; cursor:pointer; }
5172    a.run-id-chip-link:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; border-left-color:var(--oxide); }
5173    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
5174    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
5175    .run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); display:flex; align-items:center; gap:4px; }
5176    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
5177    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
5178    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
5179    .submodule-state-badge { display:inline-block; font-size:10px; font-style:italic; font-weight:600; color:var(--accent-2); background:rgba(100,130,220,0.10); border:1px solid rgba(100,130,220,0.22); border-radius:4px; padding:1px 6px; letter-spacing:0.03em; }
5180    body.dark-theme .submodule-state-badge { color:var(--accent); background:rgba(111,155,255,0.13); border-color:rgba(111,155,255,0.28); }
5181    .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
5182    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5183    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
5184    a.run-id-chip-link:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
5185    .chip-copy-icon { display:inline-block; margin-left:5px; font-size:10px; opacity:0.55; vertical-align:middle; }
5186    .chip-label-icon { display:inline-block; vertical-align:middle; margin-right:3px; margin-top:-1px; opacity:0.8; }
5187    .chip-popout-icon { display:inline-block; vertical-align:middle; margin-left:4px; opacity:0.6; flex-shrink:0; }
5188    .run-id-short-badge { font-family:ui-monospace,monospace; font-size:13px; font-weight:700; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:6px; padding:2px 8px; letter-spacing:0.04em; white-space:nowrap; vertical-align:middle; }
5189    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
5190    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
5191    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
5192    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
5193    .subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
5194    .meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 20px; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); width:100%; }
5195    .meta-chip { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 10px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
5196    .meta-chip:last-child { border-right:none; }
5197    .meta-chip b { color:var(--text); font-weight:700; }
5198    .prev-scan-banner { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; margin:0 0 20px; display:flex; flex-direction:column; gap:12px; width:100%; box-sizing:border-box; box-shadow:0 4px 16px rgba(77,44,20,0.10); }
5199    body.dark-theme .prev-scan-banner { box-shadow:0 4px 16px rgba(0,0,0,0.3); }
5200    .prev-scan-banner-empty { flex-direction:row; align-items:center; gap:8px; font-size:13px; color:var(--muted); font-style:italic; }
5201    .prev-scan-banner-top { display:flex; flex-direction:column; gap:4px; }
5202    .prev-scan-meta { display:flex; align-items:center; gap:7px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); flex-wrap:wrap; }
5203    .prev-scan-ts { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
5204    .prev-scan-count { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
5205    .prev-scan-summary { font-size:13px; font-weight:600; color:var(--text); }
5206    .prev-scan-summary b { font-weight:900; }
5207    .delta-neutral-text { color:var(--muted); }
5208    .delta-up { color:#2a6846; }
5209    .delta-down { color:#b23030; }
5210    body.dark-theme .delta-up { color:#5aba8a; }
5211    body.dark-theme .delta-down { color:#e07070; }
5212    .delta-card-row { display:grid; grid-template-columns:repeat(7,1fr); gap:12px; width:100%; }
5213    @media(max-width:1000px){ .delta-card-row { grid-template-columns:repeat(4,1fr); } }
5214    @media(max-width:540px){ .delta-card-row { grid-template-columns:repeat(2,1fr); } }
5215    .delta-card-inline { display:flex; flex-direction:column; gap:4px; padding:14px 16px; border-radius:12px; border:1px solid var(--line); background:var(--surface); position:relative; cursor:default; transition:transform .2s ease, box-shadow .2s ease; box-shadow:0 2px 8px rgba(77,44,20,0.07); }
5216    .delta-card-inline:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.22); z-index:10; }
5217    body.dark-theme .delta-card-inline { box-shadow:0 2px 8px rgba(0,0,0,0.2); }
5218    body.dark-theme .delta-card-inline:hover { box-shadow:0 12px 32px rgba(0,0,0,0.55); }
5219    .delta-card-val { font-size:20px; font-weight:900; color:var(--oxide); line-height:1.2; }
5220    .delta-card-val.pos { color:#2a6846; }
5221    .delta-card-val.neg { color:#b23030; }
5222    .delta-card-val.mod { color:#7a5a10; }
5223    body.dark-theme .delta-card-val.pos { color:#5aba8a; }
5224    body.dark-theme .delta-card-val.neg { color:#e07070; }
5225    body.dark-theme .delta-card-val.mod { color:#d4a843; }
5226    .delta-card-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
5227    .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:7px 12px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 12px rgba(0,0,0,0.18); }
5228    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5229    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
5230    .delta-panel-link { display:inline-flex; align-items:center; gap:6px; padding:8px 16px; border-radius:10px; border:1px solid var(--line); background:var(--surface); color:var(--text); font-size:12px; font-weight:600; text-decoration:none; transition:background .15s, border-color .15s, color .15s, transform .15s, box-shadow .15s; box-shadow:0 2px 6px rgba(77,44,20,0.08); }
5231    .delta-panel-link:hover { background:var(--oxide); color:#fff; border-color:var(--oxide); transform:translateY(-2px); box-shadow:0 6px 18px rgba(77,44,20,0.25); }
5232    body.dark-theme .delta-panel-link { box-shadow:0 2px 6px rgba(0,0,0,0.2); }
5233    body.dark-theme .delta-panel-link:hover { box-shadow:0 6px 18px rgba(0,0,0,0.45); }
5234    .soft-chip { display:inline-flex; align-items:center; min-height:32px; padding:0 12px; border-radius:999px; border:1px solid var(--line); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; }
5235    .toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
5236    .toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
5237    .search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
5238    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
5239    .pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
5240    .pill.good { background: var(--good-bg); color: var(--good-text); }
5241    .pill.info { background: var(--info-bg); color: var(--info-text); }
5242    .export-group { display:flex; gap:6px; align-items:center; }
5243    .export-btn { display:inline-flex; align-items:center; gap:5px; padding:6px 12px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:12px; font-weight:700; cursor:pointer; white-space:nowrap; }
5244    .export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
5245    .page-size-row { display:flex; align-items:center; gap:6px; }
5246    .page-size-label { font-size:13px; color:var(--muted); font-weight:600; }
5247    .page-size-select { padding:5px 10px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; cursor:pointer; }
5248    .page-count-label { font-size:12px; color:var(--muted); white-space:nowrap; }
5249    .pagination-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:10px 0 2px; }
5250    .pager-btn { padding:6px 16px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; cursor:pointer; transition:background .15s,color .15s; }
5251    .pager-btn:hover:not(:disabled) { background:var(--accent); color:#fff; border-color:var(--accent); }
5252    .pager-btn:disabled { opacity:.4; cursor:default; }
5253    .pager-info { font-size:13px; color:var(--muted); font-weight:600; min-width:120px; text-align:center; }
5254    .pager-edge { font-size:12px; padding:5px 10px; }
5255    .pager-jump-wrap { font-size:13px; color:var(--muted); font-weight:600; display:flex; align-items:center; gap:5px; white-space:nowrap; }
5256    .pager-jump { width:52px; padding:3px 5px; border-radius:6px; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); font-size:13px; font-weight:700; text-align:center; -moz-appearance:textfield; }
5257    .pager-jump::-webkit-inner-spin-button,.pager-jump::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
5258    .table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
5259    /* Clip wrapper: hides the scrollbar track that hangs 8px past the right edge */
5260    .table-shell-clip { overflow: hidden !important; max-height: none !important; }
5261    /* Skipped-files scroll pane: auto so no phantom space when content is short */
5262    #skipped-shell { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--surface-2); }
5263    #per-file-table tbody tr:last-child td, #skipped-table tbody tr:last-child td { border-bottom: none; }
5264    #skipped-shell::-webkit-scrollbar { width: 8px; }
5265    #skipped-shell::-webkit-scrollbar-track { background: var(--surface-2); }
5266    #skipped-shell::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
5267    table { width: 100%; border-collapse: collapse; font-size: 14px; }
5268    th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
5269    th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
5270    /* Per-file detail table — auto layout so File column sizes to content */
5271    .table-resizable { table-layout: auto; }
5272    .table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
5273    .table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5274    .table-resizable td.mono { overflow: visible; text-overflow: unset; white-space: nowrap; }
5275    #skipped-table { table-layout: fixed; width: 100%; }
5276    #skipped-table th, #skipped-table td { padding: 7px 8px; }
5277    #skipped-table td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5278    /* Hotspots table — overflow visible so header tooltips can escape the cell */
5279    #hotspots-table th { overflow: visible; }
5280    .hs-hint { color: var(--muted); font-style: italic; }
5281    .section-desc { font-size:13px; color:var(--muted); line-height:1.6; margin:0 0 4px; padding:0 4px; }
5282    .table-hint { font-size:12px; color:var(--muted); margin:0 0 12px; padding:0 4px; }
5283    .own-files-title { font-size:18px; font-weight:850; letter-spacing:-0.02em; color:var(--text); margin:26px 0 4px; }
5284    .own-files-sub { font-size:13px; color:var(--muted); line-height:1.55; margin:0 0 16px; padding:0 2px; }
5285    .own-details { border:1px solid var(--line); border-radius:14px; margin-bottom:10px; background:var(--surface-2); overflow:hidden; transition:box-shadow .2s ease, transform .2s ease; }
5286    .own-details:hover { box-shadow:0 8px 24px rgba(77,44,20,0.13); transform:translateY(-1px); }
5287    .own-details > summary { cursor:pointer; padding:14px 18px; list-style:none; display:flex; align-items:center; gap:14px; }
5288    .own-details > summary::-webkit-details-marker { display:none; }
5289    .own-details > summary::before { content:'\25B6'; color:var(--muted); font-size:10px; transition:transform .15s ease; flex:0 0 auto; }
5290    .own-details[open] > summary::before { transform:rotate(90deg); }
5291    .lb-rank { flex:0 0 auto; min-width:30px; height:30px; padding:0 6px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; font-weight:800; font-size:14px; color:var(--muted); background:var(--surface-3); border:1px solid var(--line); font-variant-numeric:tabular-nums; }
5292    .lb-r1 { background:linear-gradient(135deg,#f7d774,#e0a92e); color:#5a3d00; border-color:#e0a92e; box-shadow:0 3px 10px rgba(224,169,46,0.45); }
5293    .lb-r2 { background:linear-gradient(135deg,#e6e7ea,#b9bcc4); color:#3d4048; border-color:#b9bcc4; box-shadow:0 3px 10px rgba(150,153,160,0.35); }
5294    .lb-r3 { background:linear-gradient(135deg,#eabd92,#cd7f4c); color:#4d2a0e; border-color:#cd7f4c; box-shadow:0 3px 10px rgba(205,127,76,0.35); }
5295    .lb-avatar { flex:0 0 auto; width:36px; height:36px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:#fff; font-weight:800; font-size:14px; text-shadow:0 1px 2px rgba(0,0,0,0.25); }
5296    .lb-name { flex:0 0 210px; font-size:16px; font-weight:800; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
5297    /* Contributor profile links (ownership table, leaderboard, hotspot owner column) — inherit the
5298       surrounding weight/size, tint oxide, underline only on hover. */
5299    a.author-link { color:var(--oxide); text-decoration:none; border-bottom:1px solid transparent; transition:border-color 0.15s ease; }
5300    a.author-link:hover { border-bottom-color:var(--oxide); }
5301    .lb-name a.author-link { color:inherit; }
5302    .lb-name a.author-link:hover { color:var(--oxide); }
5303    .lb-bar-wrap { flex:1 1 auto; height:12px; min-width:60px; background:var(--surface-3); border-radius:7px; overflow:hidden; box-shadow:inset 0 1px 2px rgba(0,0,0,0.08); }
5304    .lb-bar { display:block; height:100%; border-radius:7px; transition:width .5s cubic-bezier(.16,1,.3,1); }
5305    .lb-stats { flex:0 0 auto; font-size:13px; color:var(--muted); white-space:nowrap; font-variant-numeric:tabular-nums; }
5306    .lb-stats strong { color:var(--oxide); font-size:16px; font-weight:900; }
5307    .own-files-shell { margin:2px 14px 14px; max-height:420px; }
5308    @media (max-width:760px) { .lb-name { flex-basis:120px; font-size:14px; } .lb-bar-wrap { display:none; } .lb-stats { font-size:12px; } }
5309    /* Column-header explainer tooltip (shared visual language with .stat-chip-tip) */
5310    .col-tip { position: absolute; top: calc(100% + 9px); left: 0; z-index: 60; width: max-content; max-width: 270px;
5311      background: var(--text); color: var(--bg); padding: 9px 12px; border-radius: 9px;
5312      font-size: 11.5px; font-weight: 500; line-height: 1.5; letter-spacing: normal; text-transform: none;
5313      white-space: normal; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.22);
5314      opacity: 0; pointer-events: none; transition: opacity .18s ease; }
5315    .col-tip.col-tip-r { left: auto; right: 0; }
5316    .col-tip strong { color: var(--bg); }
5317    .col-tip::after { content: ''; position: absolute; bottom: 100%; left: 16px;
5318      border: 6px solid transparent; border-bottom-color: var(--text); }
5319    .col-tip.col-tip-r::after { left: auto; right: 16px; }
5320    #hotspots-table th:hover .col-tip { opacity: 1; }
5321    /* Column resize handle */
5322    .col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
5323    .col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
5324    #per-file-table { table-layout: fixed; width: 100%; min-width: 0; }
5325    #per-file-table th, #per-file-table td { padding: 8px 6px; }
5326    /* File column: pinned, truncates long paths */
5327    #per-file-table th:first-child { position: sticky; top: 0; left: 0; z-index: 3; width: 26%; background: var(--surface-2); padding: 8px 6px; overflow: hidden; text-overflow: ellipsis; }
5328    #per-file-table td:first-child { position: sticky; left: 0; z-index: 1; background: var(--surface-2); padding: 8px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5329    #per-file-table th:nth-child(2) { width: 6%; }
5330    /* 12 numeric columns share the remaining 68%: 26+6+12×5.67≈98% total */
5331    #per-file-table th:nth-child(n+3) { width: 5.67%; }
5332    /* Override mono class overflow so file paths truncate */
5333    #per-file-table td.mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5334    #per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
5335    body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
5336    /* Language breakdown: auto layout with resizable columns — headers size to content */
5337    #lang-breakdown-table { width: 100%; min-width: 760px; }
5338    #lang-breakdown-table th, #lang-breakdown-table td { padding: 8px 6px; font-size: 13px; }
5339    /* Skipped table: extend truncation to all cells, not just the first column */
5340    #skipped-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
5341    /* Support opportunities table: fixed layout so Category/Count columns don't grow */
5342    .support-table { table-layout: fixed; width: 100%; }
5343    .support-table th:first-child { width: 20%; }
5344    .support-table th:nth-child(2) { width: 6%; }
5345    .support-table th:nth-child(3) { width: 24%; }
5346    .support-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
5347    /* Description and example columns must wrap — content can be long */
5348    .support-table td:nth-child(3) { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; line-height: 1.45; }
5349    .support-table td:last-child { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; }
5350    .support-example-file { display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; margin-bottom: 2px; word-break: break-all; }
5351    .support-recommendation { color: var(--muted); font-size: 11px; margin: 6px 0 0; line-height: 1.5; }
5352    .num-col { text-align: right !important; }
5353    /* Per-file coverage table: fixed layout keeps numeric columns compact and off the shell border */
5354    .cov-file-table { table-layout: fixed; }
5355    .cov-file-table th:not(:first-child), .cov-file-table td:not(:first-child) { width: 150px; }
5356    .cov-file-table th.num-col, .cov-file-table td.num-col { padding-right: 16px; }
5357    tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
5358    body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
5359    tr:last-child td { border-bottom: none; }
5360    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
5361    .small { color: var(--muted); font-size: 13px; }
5362    .status-tag { display:inline-flex; align-items:center; padding: 4px 8px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
5363    .status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
5364    .status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
5365    .status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
5366    .stack { display:grid; gap:22px; }
5367    .summary-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
5368    @media(max-width:800px) { .summary-strip { grid-template-columns:repeat(2,1fr) !important; } }
5369    .test-density-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; padding:10px 16px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); }
5370    .test-density-num { font-size:22px; font-weight:900; color:var(--oxide); line-height:1; }
5371    .test-density-meta { display:flex; flex-direction:column; gap:2px; }
5372    .test-density-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
5373    .test-density-sub { font-size:11px; color:var(--muted-2); }
5374    .test-density-badge { margin-left:auto; padding:4px 12px; border-radius:999px; font-size:12px; font-weight:700; }
5375    .test-density-badge.good { background:var(--good-bg); color:var(--good-text); }
5376    .test-density-badge.warn { background:var(--warn-bg); color:var(--warn-text); }
5377    .test-density-badge.danger { background:var(--danger-bg); color:var(--danger-text); }
5378    .info-callout { display:flex; align-items:flex-start; gap:10px; margin-top:14px; padding:11px 14px; border-radius:10px; background:var(--info-bg); border:1px solid rgba(196,92,16,0.20); color:var(--info-text); font-size:13px; line-height:1.5; }
5379    .info-callout-icon { flex:0 0 auto; font-size:15px; margin-top:1px; }
5380    .info-callout code { background:rgba(196,92,16,0.12); border-radius:4px; padding:1px 5px; font-size:12px; }
5381    body.dark-theme .info-callout { background:rgba(211,122,76,0.10); border-color:rgba(211,122,76,0.25); }
5382    .empty-state-row td { text-align:center; padding:20px; color:var(--muted-2); font-size:13px; font-style:italic; }
5383    /* auto-fit so a lone gauge card (line-only coverage) spans the full width instead of 1/3 */
5384    .cov-gauge-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:16px; margin-bottom:18px; }
5385    @media(max-width:700px) { .cov-gauge-row { grid-template-columns:1fr; } }
5386    .cov-gauge-card { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; display:flex; flex-direction:column; gap:8px; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); min-width:0; }
5387    .cov-gauge-card:hover { transform:translateY(-3px); box-shadow:0 10px 28px rgba(77,44,20,0.15); z-index:10; }
5388    .cov-gauge-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; font-weight:500; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
5389    .cov-gauge-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5390    .cov-gauge-card:hover .cov-gauge-tip { opacity:1; transform:translateX(-50%) translateY(0); }
5391    .cov-gauge-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
5392    .cov-gauge-val { font-size:32px; font-weight:900; line-height:1; }
5393    .cov-gauge-track { height:8px; border-radius:4px; background:var(--line); overflow:hidden; }
5394    .cov-gauge-fill { height:100%; border-radius:4px; transition:width .5s ease; }
5395    .cov-gauge-sub { font-size:11px; color:var(--muted); }
5396    .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
5397    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
5398    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
5399    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
5400    .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; font-weight:500; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
5401    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
5402    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
5403    .stat-chip-exact { position:absolute; bottom:6px; right:10px; font-size:12px; font-weight:600; color:var(--muted); font-variant-numeric:tabular-nums; line-height:1; }
5404    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
5405    .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; transform:translateY(-7px); background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
5406    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
5407    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
5408    .report-stack { display:grid; gap: 18px; align-items:start; }
5409    pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
5410    .warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
5411    .sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
5412    .warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
5413    .warning-card { padding: 10px 12px; }
5414    .warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
5415    .warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
5416    .tone-neutral .count { color: var(--text); }
5417    .tone-warn .count { color: var(--warn-text); }
5418    .tone-danger .count { color: var(--danger-text); }
5419    .tone-neutral .warning-count { color: var(--oxide); }
5420    .tone-warn .warning-count { color: var(--warn-text); }
5421    .tone-danger .warning-count { color: var(--danger-text); }
5422    .support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
5423    .support-table th { cursor: default; }
5424    details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
5425    summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
5426    details > div { padding: 0 16px 16px; }
5427    .warning-console { margin: 0; padding: 14px 16px; border-radius: 12px; border:1px solid var(--line); background: #16120f; color: #d4f0d0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; line-height: 1.55; max-height: 260px; overflow: auto; }
5428    .warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
5429    .warning-console.hidden { display:none; }
5430    @media (max-width: 1200px) {
5431      .warning-grid { grid-template-columns: 1fr 1fr; }
5432    }
5433    @media (max-width: 960px) {
5434      .top-nav-inner { grid-template-columns: 1fr; }
5435      .nav-project-slot, .nav-status { justify-content:flex-start; }
5436      .warning-grid, .report-stack { grid-template-columns: 1fr; }
5437      .hero-top { flex-direction: column; }
5438      .search { min-width: 100%; width: 100%; }
5439    }
5440    @media (max-width: 640px) {
5441      .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
5442    }
5443    /* ── Report header / footer identification banner ─────────────────── */
5444    .report-id-banner { background: var(--nav); color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; height: 27px; padding: 0 16px; position: fixed; top: 0; left: 0; right: 0; z-index: 32; }
5445    .report-id-footer-banner { background: var(--nav); color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; height: 27px; padding: 0 16px; position: fixed; bottom: 0; left: 0; right: 0; z-index: 32; }
5446    body.has-report-banner .top-nav { top: 27px; }
5447    body.has-report-banner { padding-bottom: 27px; }
5448    /* ── Print & PDF export ──────────────────────────────────────────── */
5449    @page { size: A4 landscape; margin: 0.35in 0.5in; }
5450
5451    @media print {
5452      *, *::before, *::after {
5453        -webkit-print-color-adjust: exact !important;
5454        print-color-adjust: exact !important;
5455        box-sizing: border-box !important;
5456      }
5457
5458      html, body {
5459        background: #f5efe8 !important;
5460        min-height: auto !important;
5461        width: 100% !important;
5462      }
5463
5464      /* Report id banner — fixed position repeats the banner on every printed page */
5465      .report-id-banner { display: flex !important; align-items: center !important; justify-content: center !important; position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; padding: 3px 12px !important; font-size: 10px !important; background: #3d3d3d !important; color: #fff !important; z-index: 9999 !important; }
5466      .report-id-footer-banner { display: flex !important; align-items: center !important; justify-content: center !important; position: fixed !important; bottom: 0 !important; left: 0 !important; right: 0 !important; padding: 3px 12px !important; font-size: 10px !important; margin-top: 0 !important; background: #3d3d3d !important; color: #fff !important; z-index: 9999 !important; }
5467      body.has-report-banner .top-nav { top: 0 !important; }
5468      body.has-report-banner { padding-bottom: 0 !important; }
5469      /* Hide interactive UI-chrome; keep section heading text visible */
5470      .top-nav, .hero-actions,
5471      .background-watermarks, #code-particles,
5472      .header-button, .theme-toggle,
5473      .nav-dropdown-wrap, .config-actions,
5474      .warnings-show-link, .warning-console-actions,
5475      .toolbar .pill-row, .toolbar .export-group,
5476      input[type="search"], button { display: none !important; }
5477      /* Show toolbar as a plain block so h2 headings are visible */
5478      .toolbar { display: block !important; margin-bottom: 8px !important; }
5479      .toolbar-left { display: block !important; }
5480
5481      /* Remove page-level layout constraints */
5482      .page {
5483        max-width: none !important;
5484        width: 100% !important;
5485        padding: 0 !important;
5486        margin: 0 !important;
5487      }
5488
5489      .panel, .hero, .section,
5490      .saved-report-shell, .saved-panel, .report-shell, .stack {
5491        max-width: none !important;
5492        width: 100% !important;
5493        box-shadow: none !important;
5494        border: 1px solid #ddd !important;
5495        border-radius: 10px !important;
5496        margin-bottom: 10px !important;
5497        overflow: visible !important;
5498      }
5499
5500      /* Force grids to their full-width column counts regardless of viewport */
5501      .summary-grid {
5502        display: grid !important;
5503        grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
5504        gap: 10px !important;
5505      }
5506
5507      .warning-grid {
5508        display: grid !important;
5509        grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
5510        gap: 8px !important;
5511      }
5512
5513      .report-stack {
5514        display: grid !important;
5515        gap: 12px !important;
5516        align-items: start !important;
5517      }
5518
5519      /* Metric cards */
5520      .metric {
5521        box-shadow: none !important;
5522        border: 1px solid #e0d0c0 !important;
5523        border-radius: 8px !important;
5524        break-inside: avoid !important;
5525        padding: 10px 12px 22px !important;
5526        min-height: 0 !important;
5527      }
5528
5529      .metric-big { font-size: 20px !important; }
5530      .metric-exact { font-size: 10px !important; bottom: 5px !important; right: 8px !important; }
5531      .metric-label { font-size: 10px !important; }
5532
5533      /* Page break control — small atomic cards stay together; large panels
5534         and tables flow freely so they never force blank pages. */
5535      .metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
5536      .hero, .panel, .stack { break-inside: auto !important; }
5537      section { break-inside: auto !important; }
5538      /* Keep each chart panel whole — browser moves it to the next page rather than
5539         slicing through the middle of a canvas. */
5540      .chart-section { break-inside: avoid !important; }
5541      /* Keep the summary grid on the same page as the hero header when possible */
5542      .summary-grid { break-before: avoid !important; }
5543      /* Section headings never orphan at the bottom of a page */
5544      h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
5545      /* Keep the first few rows of a table with the header */
5546      thead { break-after: avoid !important; }
5547
5548      /* Language charts — table layout is inherently side-by-side */
5549      #lang-overview-charts table { display: inline-table !important; }
5550      #lang-overview-charts td { vertical-align: top !important; }
5551
5552      /* Tables */
5553      .table-shell {
5554        max-height: none !important;
5555        overflow: visible !important;
5556        width: 100% !important;
5557        break-inside: auto !important;
5558      }
5559
5560      table {
5561        width: 100% !important;
5562        table-layout: auto !important;
5563        font-size: 10px !important;
5564        border-collapse: collapse !important;
5565        orphans: 4 !important;
5566        widows: 4 !important;
5567      }
5568
5569      /* Remove the screen-layout min-width so tables scale to page width */
5570      #per-file-table, #skipped-table { min-width: 0 !important; }
5571      /* Release sticky column positioning (not meaningful on paper) */
5572      #per-file-table th:first-child,
5573      #per-file-table td:first-child { position: static !important; }
5574      /* Show ALL rows — JS pagination hides rows via inline style; !important overrides it */
5575      #per-file-table tbody tr, #skipped-table tbody tr, #hotspots-table tbody tr { display: table-row !important; }
5576      /* Hide pagination controls — not interactive in PDF */
5577      .page-size-row, .pagination-bar { display: none !important; }
5578      /* Header tooltips and the interaction hint are screen-only */
5579      .col-tip { display: none !important; }
5580      .hs-hint { display: none !important; }
5581
5582      thead { display: table-header-group; }
5583      tr { break-inside: avoid !important; }
5584
5585      th {
5586        position: relative !important;
5587        font-size: 9px !important;
5588        font-weight: 700 !important;
5589        color: #333 !important;
5590        padding: 5px 8px !important;
5591        background: rgba(211,122,76,0.18) !important;
5592        white-space: normal !important;
5593      }
5594      /* Resize handles are screen-only — hide them in print */
5595      .col-resize-handle { display: none !important; }
5596      /* Sort indicators are redundant on paper */
5597      .sort-indicator { display: none !important; }
5598
5599      td {
5600        white-space: normal !important;
5601        overflow-wrap: anywhere !important;
5602        word-break: break-word !important;
5603        padding: 5px 8px !important;
5604        font-size: 10px !important;
5605        border-bottom: 1px solid #e8d8c8 !important;
5606      }
5607
5608      pre, code {
5609        white-space: pre-wrap !important;
5610        overflow-wrap: anywhere !important;
5611        word-break: break-word !important;
5612        font-size: 9px !important;
5613        max-height: none !important;
5614      }
5615
5616      .warning-card {
5617        box-shadow: none !important;
5618        border: 1px solid #ddd !important;
5619        break-inside: avoid !important;
5620        padding: 10px !important;
5621      }
5622
5623      .hero-top { flex-direction: row !important; }
5624
5625      .run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
5626      .run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
5627      .meta { flex-wrap: wrap !important; gap: 0 !important; padding: 4px 0 !important; border-top: 1px solid #ccc !important; border-bottom: 1px solid #ccc !important; width: 100% !important; }
5628      .meta-chip { flex: 1 !important; justify-content: center !important; font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
5629      .meta-chip:last-child { border-right: none !important; }
5630
5631      .report-footer {
5632        border-top: 1px solid #ccc !important;
5633        margin-top: 12px !important;
5634        font-size: 10px !important;
5635      }
5636
5637      /* Collapse all <details> in print except the warnings block */
5638      details { border: 1px solid #ddd !important; border-radius: 8px !important; }
5639      details > summary { display: block !important; font-size: 10px !important; }
5640      details > div { display: none !important; }
5641      .warning-console { display: none !important; }
5642      .warning-console-actions { display: none !important; }
5643      /* Always expand the run-warnings details in PDF */
5644      details.warnings-details > div { display: block !important; }
5645      details.warnings-details .warning-console {
5646        display: block !important;
5647        max-height: none !important;
5648        overflow: visible !important;
5649        font-size: 8px !important;
5650        white-space: pre-wrap !important;
5651        word-break: break-all !important;
5652      }
5653      details.warnings-details .code-block-toolbar { display: none !important; }
5654
5655      /* Pill badges */
5656      .pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
5657
5658      /* Support opportunities table */
5659      .support-table td:first-child { font-weight: 600; font-size: 10px !important; }
5660
5661      /* Hide canvas-based interactive chart sections; replaced by pre-rendered variants */
5662      .chart-section { display: none !important; }
5663      .charts-grid   { display: none !important; }
5664      /* Pre-rendered chart variants — no forced page break; flow naturally after hero section */
5665      .pdf-variants-root { display: block !important; padding: 0 !important; }
5666      .pdf-variant-group { break-inside: auto !important; margin-bottom: 8px !important; background: #faf6f0 !important; border: 1px solid #e0d0c0 !important; border-radius: 10px !important; padding: 10px 12px !important; }
5667      .pdf-variant-group-title { break-after: avoid !important; font-size: 12px !important; font-weight: 800 !important; color: #3d2d26 !important; margin: 0 0 6px !important; padding-bottom: 4px !important; border-bottom: 2px solid #d37a4c !important; }
5668      .pdf-variant-grid { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
5669      /* Single-column chart (scatter, etc.) — centre and constrain width in print */
5670      .pdf-variant-grid.single-col { grid-template-columns: 1fr !important; }
5671      .pdf-variant-grid.single-col .pdf-variant-panel { max-width: 62% !important; margin: 0 auto !important; }
5672      .pdf-variant-panel { break-inside: avoid !important; }
5673      .pdf-variant-label { font-size: 9px !important; font-weight: 700 !important; text-transform: uppercase !important; letter-spacing: .06em !important; color: #7b675b !important; margin: 0 0 2px !important; }
5674      .pdf-variant-img { width: 100% !important; height: auto !important; display: block !important; border-radius: 5px !important; border: 1px solid #ddd !important; }
5675    }
5676
5677
5678    .warnings-show-link {
5679      display: inline-flex;
5680      align-items: center;
5681      gap: 8px;
5682      padding: 8px 12px;
5683      border-radius: 10px;
5684      border: 1px solid rgba(111, 144, 255, 0.35);
5685      background: #eef3ff;
5686      color: #2f5fe3 !important;
5687      font-weight: 800;
5688      text-decoration: none;
5689      box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
5690    }
5691
5692    body.dark-theme .warnings-show-link {
5693      background: #1c2847;
5694      color: #a9c1ff !important;
5695      border-color: rgba(169, 193, 255, 0.32);
5696    }
5697
5698    .effective-config-note {
5699      margin: 8px 0 0;
5700      color: var(--muted);
5701      font-size: 14px;
5702      line-height: 1.6;
5703    }
5704    .config-actions { display: flex; gap: 8px; flex-shrink: 0; }
5705    .config-pre-wrap { position: relative; }
5706    .config-pre { margin: 0; background: #16120f; color: #d4f0d0; border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; overflow: auto; resize: vertical; max-height: 320px; min-height: 100px; white-space: pre; }
5707    body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
5708    .code-block-toolbar { display:flex; justify-content:flex-end; margin-bottom:6px; }
5709    .code-copy-btn { display:inline-flex; align-items:center; gap:5px; background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--muted); border-radius: 8px; padding: 5px 12px; font-size: 12px; font-weight: 700; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; white-space: nowrap; }
5710    .code-copy-btn:hover { background: rgba(184,93,51,0.08); color: var(--oxide-2); border-color: rgba(184,93,51,0.30); }
5711    body.dark-theme .code-copy-btn { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.18); color: rgba(255,255,255,0.75); }
5712    body.dark-theme .code-copy-btn:hover { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.40); color: var(--oxide); }
5713
5714
5715    .page {
5716      position: relative;
5717      z-index: 1;
5718    }
5719    .report-footer { margin-top: 16px; padding: 14px 24px; border-top: 1px solid var(--line); text-align: center; color: var(--muted); font-size: 12px; font-weight: 600; }
5720
5721    /* ── Chart controls & containers ───────────────────────────────────── */
5722    .chart-section { }
5723    .chart-controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
5724    .chart-controls label { font-size:13px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:6px; }
5725    .chart-select { background:var(--surface-2); border:1px solid var(--line-strong); border-radius:8px; padding:5px 10px; color:var(--text); font-size:13px; font-weight:600; cursor:pointer; outline:none; appearance:auto; }
5726    .chart-select:focus { border-color:var(--accent); }
5727    .chart-expand-btn { background:none; border:1px solid var(--line-strong); border-radius:6px; cursor:pointer; color:var(--muted); padding:4px 10px; font-size:13px; line-height:1; transition:background .13s,color .13s; }
5728    .chart-expand-btn:hover { background:var(--surface-2); color:var(--text); }
5729    .chart-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; box-sizing:border-box; }
5730    .chart-modal { background:var(--bg); border-radius:16px; padding:24px 28px; max-width:1000px; width:100%; max-height:88vh; overflow-y:auto; position:relative; box-shadow:0 24px 80px rgba(0,0,0,0.3); }
5731    .chart-modal-title { font-size:15px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--text); margin:0 0 2px; display:block; }
5732    .chart-modal-subtitle { font-size:13px; font-weight:600; color:var(--muted); margin:0 0 16px; display:block; letter-spacing:.02em; }
5733    .chart-modal-close { position:absolute; top:14px; right:18px; background:none; border:none; font-size:22px; cursor:pointer; color:var(--text); line-height:1; padding:0; }
5734    .chart-modal-close:hover { opacity:.7; }
5735    .chart-modal-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin:0 0 16px; padding-right:44px; }
5736    .chart-modal-header .chart-modal-title { flex:1 1 auto; margin:0; min-width:0; }
5737    body.dark-theme .chart-modal { background:var(--surface); }
5738    .chart-container { width:100%; overflow:visible; }
5739    .charts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
5740    .charts-grid > .panel { margin:0; min-width:0; display:flex; flex-direction:column; }
5741    .charts-grid .chart-section > div { display:flex; flex-direction:column; flex:1; }
5742    .charts-grid .chart-container { flex:1; min-height:180px; }
5743    .chart-pre { min-height:72px; }
5744    @media (max-width:820px) { .charts-grid { grid-template-columns:1fr; } }
5745    .r-lang-overview { display:flex; gap:40px; align-items:center; justify-content:center; flex-wrap:wrap; padding:8px 0 16px; }
5746    .r-lang-overview-cell { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 1 280px; max-width:480px; }
5747    .r-lang-overview-cell p { margin:0; font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); text-align:center; }
5748    .r-lang-overview svg { display:block; max-width:100%; height:auto; }
5749    .rchit { cursor:pointer; transition:opacity .17s,filter .17s,transform .17s; transform-box:fill-box; transform-origin:center center; }
5750    .rchit:hover { filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18)); transform:scale(1.05); }
5751    .lang-bar-row { cursor:pointer; transition:transform .2s cubic-bezier(.34,1.56,.64,1); }
5752    .lang-bar-row:hover { transform:translateY(-2px); }
5753    .lang-bar-row .rchit:hover { filter:none; transform:none; }
5754    .lang-bar-row:hover .rchit { filter:brightness(1.12); transform:scaleY(1.22); }
5755    #r-tt { display:none; position:fixed; background:rgba(15,10,6,.95); color:#fff; border-radius:10px; padding:8px 13px; font-size:12px; line-height:1.5; pointer-events:none; z-index:10001; box-shadow:0 4px 20px rgba(0,0,0,.32); border:1px solid rgba(255,255,255,.1); max-width:240px; white-space:nowrap; }
5756    .chart-tab-bar { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
5757    .chart-tab { padding:5px 16px; border-radius:999px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--muted); font-size:12px; font-weight:700; cursor:pointer; transition:background 0.12s,color 0.12s,border-color 0.12s; }
5758    .chart-tab:hover { background:var(--surface-3); color:var(--text); }
5759    .chart-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
5760    .chart-locked-card { display:none; padding:20px 24px; border-radius:14px; background:var(--info-bg); border:1px solid rgba(196,92,16,0.28); color:var(--info-text); font-size:14px; line-height:1.6; }
5761    .chart-locked-card a { color:var(--accent-2); font-weight:700; }
5762    .chart-locked-card h3 { margin:0 0 6px; font-size:15px; }
5763
5764    /* Print: hide interactive controls; keep SVGs; show locked card for history mode */
5765    @media print {
5766      .chart-controls, .chart-tab-bar { display:none !important; }
5767      /* Single-column grid: each chart gets full page width, renders shorter, fits on one page */
5768      .charts-grid { grid-template-columns: 1fr !important; gap: 10px !important; }
5769      /* Cap canvas height so a single chart never overflows a landscape page */
5770      canvas { max-width: 100% !important; max-height: 280px !important; }
5771      .chart-container { width: 100% !important; overflow: visible !important; }
5772      .chart-container svg { max-height:300px !important; }
5773      /* chart-locked-card: do NOT force display:block — let JS-set visibility carry
5774         into print (hidden in normal mode; visible only when history mode is active).
5775         When it does show, use readable dark text instead of accent blue. */
5776      .chart-locked-card { background:#f0f0f0 !important; border:1px solid #bbb !important; color:#333 !important; font-size:11px !important; padding:10px 14px !important; border-radius:8px !important; }
5777      .chart-locked-card h3 { color:#222 !important; font-size:13px !important; }
5778      .chart-locked-card a, .chart-locked-card strong { color:#1a4fa0 !important; }
5779      .chart-locked-card code { background:rgba(0,0,0,0.08) !important; padding:1px 4px !important; border-radius:3px !important; }
5780    }
5781
5782    /* PDF-only chart variants container — hidden on screen, rendered in print */
5783    .pdf-variants-root{display:none;}
5784    .pdf-variant-group{margin-bottom:12px;background:#faf6f0;border:1px solid #e0d0c0;border-radius:10px;padding:12px 14px;}
5785    .pdf-variant-group-title{font-size:15px;font-weight:800;color:#3d2d26;margin:0 0 8px;padding-bottom:5px;border-bottom:2px solid #d37a4c;}
5786    .pdf-variant-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
5787    /* Solo charts (one per row) — constrained width, centred */
5788    .pdf-variant-grid.single-col{grid-template-columns:1fr;}
5789    .pdf-variant-grid.single-col .pdf-variant-panel{max-width:62%;margin:0 auto;}
5790    .pdf-variant-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b675b;margin:0 0 3px;}
5791    .pdf-variant-img{width:100%;height:auto;display:block;border-radius:6px;border:1px solid #ddd;}
5792
5793    #rpt-loading-overlay{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .6s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
5794    #rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
5795    /* Drifting color blobs — transform/opacity only (GPU composited, no per-frame repaint) */
5796    .rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
5797    .rpt-blob-a{width:48vw;height:48vw;left:-10vw;top:-12vw;background:radial-gradient(circle,#e8932f,transparent 64%);animation:rpt-drift-a 17s ease-in-out infinite;}
5798    .rpt-blob-b{width:42vw;height:42vw;right:-8vw;bottom:-10vw;background:radial-gradient(circle,#d3621a,transparent 64%);animation:rpt-drift-b 21s ease-in-out infinite;}
5799    .rpt-blob-c{width:34vw;height:34vw;right:20vw;top:-8vw;background:radial-gradient(circle,#caa14f,transparent 64%);opacity:.38;animation:rpt-drift-c 25s ease-in-out infinite;}
5800    @keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
5801    @keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
5802    @keyframes rpt-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(-7vw,8vw,0) scale(1.22);}}
5803    body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
5804    body.dark-theme .rpt-bg-blob{opacity:.36;}
5805    .rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:22px;width:432px;max-width:88vw;padding:46px 54px 38px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:26px;box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 22px 64px rgba(120,64,16,.16),0 4px 16px rgba(0,0,0,.06);animation:rpt-card-in .5s cubic-bezier(.22,.68,0,1.12) both;}
5806    @keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
5807    body.dark-theme .rpt-load-card{background:linear-gradient(155deg,rgba(42,24,12,.92),rgba(28,15,6,.95));border-color:rgba(200,120,50,.16);box-shadow:0 1px 0 rgba(255,200,140,.05) inset,0 22px 64px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.35);}
5808    /* Logo is static — no bounce (kept GPU-cheap) */
5809    .rpt-load-logo{width:58px;height:58px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));animation:rpt-card-in .5s ease .05s both;}
5810    .rpt-spinner-wrap{position:relative;width:90px;height:90px;}
5811    .rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
5812    .rpt-spinner{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,rgba(196,92,16,0) 0%,rgba(196,92,16,.18) 35%,#c45c10 100%);will-change:transform;animation:rpt-spin 1s linear infinite;-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));}
5813    @keyframes rpt-spin{to{transform:rotate(360deg);}}
5814    .rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
5815    body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
5816    body.dark-theme .rpt-spinner-pct{color:#e8932f;}
5817    .rpt-load-divider{width:54px;height:1px;background:linear-gradient(90deg,transparent,rgba(196,92,16,.22),transparent);}
5818    .rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
5819    .rpt-load-word{background:linear-gradient(90deg,#9a7a64 0%,#c45c10 45%,#e08a3a 55%,#9a7a64 100%);background-size:220% auto;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;animation:rpt-text-shimmer 3.2s linear infinite;}
5820    @keyframes rpt-text-shimmer{to{background-position:-220% center;}}
5821    .rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
5822    .rpt-dot:nth-child(2){animation-delay:.28s;}
5823    .rpt-dot:nth-child(3){animation-delay:.56s;}
5824    @keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
5825    .rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
5826    .rpt-status-in{animation:rpt-status-pop .38s ease both;}
5827    @keyframes rpt-status-pop{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
5828    .rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
5829    .rpt-progress-bar{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;border-radius:99px;background:linear-gradient(90deg,#e8932f,#c45c10);transition:transform .25s cubic-bezier(.4,0,.2,1);will-change:transform;}
5830    body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
5831    .rpt-feed{width:100%;min-height:66px;display:flex;flex-direction:column;justify-content:flex-end;gap:3px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;color:rgba(138,112,96,.72);text-align:left;overflow:hidden;}
5832    .rpt-feed-line{display:flex;align-items:center;gap:6px;opacity:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;animation:rpt-feed-in .4s ease forwards;}
5833    .rpt-feed-line::before{content:'>';color:#c45c10;font-weight:700;}
5834    @keyframes rpt-feed-in{from{opacity:0;transform:translateX(-6px);}to{opacity:.82;transform:none;}}
5835    body.dark-theme .rpt-feed{color:rgba(204,172,150,.62);}
5836    body.dark-theme .rpt-load-divider{background:linear-gradient(90deg,transparent,rgba(196,92,16,.28),transparent);}
5837    @media (prefers-reduced-motion:reduce){ #rpt-loading-overlay .rpt-bg-blob,#rpt-loading-overlay .rpt-spinner,#rpt-loading-overlay .rpt-load-word,#rpt-loading-overlay .rpt-dot{animation:none!important;}}
5838    /* ── Code Style Analysis section ── */
5839    .style-guide-adherence{margin-top:26px;padding:18px 20px 20px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);}
5840    .style-guide-adherence-title{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:14px;}
5841    .style-guide-grid{display:grid;gap:14px;}
5842    .style-guide-row{display:grid;grid-template-columns:150px 1fr 52px;align-items:center;gap:14px;padding:10px 12px;border-radius:8px;cursor:default;position:relative;transition:transform .18s ease,box-shadow .18s ease,background .18s ease;}
5843    .style-guide-row:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(77,44,20,0.18);background:var(--surface);}
5844    .style-guide-label{font-size:12px;font-weight:800;color:var(--text);text-align:right;white-space:nowrap;}
5845    .style-guide-track{background:var(--surface-3);border-radius:6px;height:20px;overflow:hidden;position:relative;box-shadow:inset 0 1px 3px rgba(0,0,0,.08);}
5846    .style-guide-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));transition:width .65s cubic-bezier(.25,.46,.45,.94),filter .18s ease;position:relative;}
5847    .style-guide-fill::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.18) 0%,rgba(255,255,255,.04) 100%);border-radius:6px;}
5848    .style-guide-row:hover .style-guide-fill{filter:brightness(1.12);}
5849    .style-guide-score{font-size:12px;font-weight:800;color:var(--oxide);text-align:right;white-space:nowrap;}
5850    .style-guide-desc{font-size:10px;color:var(--muted);margin-top:2px;grid-column:2/3;}
5851    .style-bar-tip{position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 14px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:300;box-shadow:0 4px 18px rgba(0,0,0,.24);}
5852    .style-bar-tip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:var(--text);}
5853    .style-guide-row:hover .style-bar-tip{opacity:1;}
5854    .style-metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:18px 0 0;}
5855    @media(max-width:800px){.style-metrics-strip{grid-template-columns:repeat(2,1fr);}}
5856    .style-chip{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;text-align:center;cursor:default;position:relative;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
5857    .style-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
5858    .style-chip-val{font-size:18px;font-weight:900;color:var(--oxide);}
5859    .style-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:3px;}
5860    .style-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}
5861    .style-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
5862    .style-chip:hover .style-chip-tip{opacity:1;}
5863    .style-file-table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed;}
5864    .style-file-table th{background:var(--surface-3);padding:7px 10px;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);text-align:left;border-bottom:2px solid var(--line);cursor:pointer;user-select:none;white-space:nowrap;position:relative;}
5865    .style-file-table th:hover{background:var(--surface-2);color:var(--text);}
5866    .style-sort-ind{display:inline-block;margin-left:4px;font-size:9px;opacity:.4;vertical-align:middle;}
5867    .style-file-table th.sft-sort-asc .style-sort-ind,.style-file-table th.sft-sort-desc .style-sort-ind{opacity:1;color:var(--oxide);}
5868    .style-file-table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
5869    .style-file-table tr:hover td{background:var(--surface-2);}
5870    .style-score-bar{display:inline-block;width:48px;height:8px;border-radius:4px;background:var(--surface-3);vertical-align:middle;position:relative;margin-right:4px;}
5871    .style-score-fill{position:absolute;left:0;top:0;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));}
5872    .style-badge{display:inline-block;padding:2px 7px;border-radius:12px;font-size:10px;font-weight:700;background:var(--surface-3);color:var(--oxide);border:1px solid var(--line);text-decoration:none;transition:background .15s,transform .15s,box-shadow .15s;}
5873    a.style-badge:hover{background:var(--oxide);color:#fff !important;transform:translateY(-1px);box-shadow:0 3px 10px rgba(77,44,20,.24);}
5874    .style-heuristic-note{display:flex;align-items:flex-start;gap:10px;background:var(--info-bg);color:var(--info-text);border-radius:10px;padding:11px 14px;font-size:13px;line-height:1.5;margin-top:14px;border:1px solid rgba(196,92,16,0.20);}
5875    .style-lang-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
5876    .style-lang-tab{padding:4px 12px;border-radius:14px;border:1px solid var(--line);background:var(--surface);font-size:11px;font-weight:700;cursor:pointer;color:var(--text);transition:background .15s;}
5877    .style-lang-tab:hover{background:var(--surface-2);}
5878    .style-lang-tab.active{background:var(--oxide);color:#fff;border-color:var(--oxide);}
5879    .style-sig-chip{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;background:var(--surface-2);color:var(--muted);border:1px solid var(--line);margin-right:3px;cursor:default;}
5880    .style-row-warn td{background:rgba(178,48,48,0.06)!important;}
5881    .style-row-warn td:first-child{border-left:3px solid #b23030;}
5882    .style-sig-more{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;background:transparent;color:var(--oxide);border:1px solid var(--oxide);margin-right:3px;font-weight:700;cursor:pointer;transition:background .15s,color .15s;}
5883    .style-sig-more:hover{background:var(--oxide);color:#fff;}
5884    .style-sig-info-btn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:0 2px;line-height:1;vertical-align:middle;transition:color .15s;margin-left:4px;}
5885    .style-sig-info-btn:hover{color:var(--oxide);}
5886    .style-sig-pop{position:fixed;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 14px;box-shadow:0 8px 24px rgba(0,0,0,.18);z-index:9999;min-width:200px;max-width:300px;font-size:12px;line-height:1.6;}
5887    .style-sig-pop-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
5888    .style-sig-pop-row{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);}
5889    .style-sig-pop-row:last-child{border-bottom:none;}
5890    .style-sig-pop-key{color:var(--muted);font-weight:700;white-space:nowrap;flex-shrink:0;}
5891    .style-sig-pop-val{color:var(--text);}
5892    .style-sig-info-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:10000;display:flex;align-items:center;justify-content:center;}
5893    .style-sig-info-modal{background:var(--bg);border-radius:14px;padding:22px 26px;max-width:500px;width:92%;box-shadow:0 12px 40px rgba(0,0,0,.2);position:relative;max-height:80vh;overflow-y:auto;}
5894    .style-sig-info-close{position:absolute;top:12px;right:16px;background:none;border:none;cursor:pointer;font-size:20px;color:var(--muted);line-height:1;}
5895    .style-sig-info-close:hover{color:var(--text);}
5896    .style-sig-info-grid{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin-top:14px;font-size:13px;}
5897    .style-sig-info-name{color:var(--oxide);font-weight:700;padding:2px 0;}
5898    .style-sig-info-desc{color:var(--text);padding:2px 0;}
5899    body.dark-theme .style-guide-track{background:var(--surface-3);}
5900    body.dark-theme .style-chip{background:var(--surface-2);}
5901    body.dark-theme .style-file-table th{background:var(--surface-3);}
5902    body.dark-theme .style-heuristic-note{border-color:rgba(211,122,76,0.25);}
5903    body.dark-theme .style-lang-tab{background:var(--surface-2);color:var(--text);}
5904    body.dark-theme .style-lang-tab.active{background:var(--oxide);color:#fff;}
5905    body.dark-theme .style-sig-chip{background:var(--surface-3);color:var(--muted);}
5906    body.dark-theme .style-sig-pop{background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.4);}
5907    body.dark-theme .style-sig-info-modal{background:var(--surface);}
5908    .sig-tip{position:fixed;background:rgba(28,18,8,0.93);color:#f0ebe4;padding:9px 13px 15px 13px;border-radius:9px;font-size:12px;line-height:1.65;pointer-events:none;z-index:9998;opacity:0;transition:opacity .1s;box-shadow:0 4px 16px rgba(0,0,0,.35);display:none;min-width:160px;max-width:300px;}
5909    .sig-tip.visible{opacity:1;}
5910    .sig-tip::after{content:'';position:absolute;top:100%;left:var(--sig-tip-ax,50%);transform:translateX(-50%);border:7px solid transparent;border-top-color:rgba(28,18,8,0.93);}
5911    .sig-tip-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:rgba(240,235,228,.5);margin-bottom:5px;}
5912    .sig-tip-row{display:flex;gap:8px;align-items:baseline;}
5913    .sig-tip-k{color:#e07b3a;font-weight:700;white-space:nowrap;flex-shrink:0;}
5914    .sig-tip-v{color:#f0ebe4;}
5915</style>
5916<script nonce="{{ nonce }}">{{ chart_js|safe }}</script>
5917</head>
5918<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
5919  <div id="rpt-loading-overlay" aria-live="polite" aria-label="Loading report">
5920    <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
5921    <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
5922    <div class="rpt-bg-blob rpt-blob-c" aria-hidden="true"></div>
5923    <div class="rpt-load-card">
5924      <img src="{{ small_logo_uri }}" alt="oxide-sloc" class="rpt-load-logo" />
5925      <div class="rpt-spinner-wrap">
5926        <div class="rpt-spinner-track"></div>
5927        <div class="rpt-spinner"></div>
5928        <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
5929      </div>
5930      <div class="rpt-load-divider"></div>
5931      <div class="rpt-loading-text"><span class="rpt-load-word">Loading report</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
5932      <div class="rpt-status" id="rpt-status">Initializing analysis engine</div>
5933      <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
5934      <div class="rpt-feed" id="rpt-feed" aria-hidden="true"></div>
5935    </div>
5936  </div>
5937  <script nonce="{{ nonce }}">
5938  (function(){
5939    var ov=document.getElementById('rpt-loading-overlay');if(!ov)return;
5940    var statusEl=document.getElementById('rpt-status'),bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),feed=document.getElementById('rpt-feed');
5941    var msgs=['Initializing analysis engine','Discovering source files','Detecting languages','Tokenizing and counting lines','Classifying comments and docstrings','Computing complexity metrics','Aggregating per-language totals','Estimating COCOMO effort','Rendering charts','Finalizing report'];
5942    var logs=['scan: walking directory tree','lexer: state machine warm','metrics: SLOC and ULOC ready','cocomo: effort model loaded','charts: canvas contexts bound','render: assembling sections','dedup: hashing file contents','git: reading activity window'];
5943    var mi=0,li=0,prog=0,ready=false,start=Date.now(),MIN=1700;
5944    function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
5945    function nextMsg(){if(statusEl){statusEl.classList.remove('rpt-status-in');void statusEl.offsetWidth;statusEl.textContent=msgs[mi%msgs.length];statusEl.classList.add('rpt-status-in');}mi++;}
5946    function addLog(){if(!feed)return;var l=document.createElement('div');l.className='rpt-feed-line';l.textContent=logs[li%logs.length];feed.appendChild(l);li++;while(feed.childNodes.length>4)feed.removeChild(feed.firstChild);}
5947    nextMsg();addLog();setProg(6);
5948    var msgTimer=setInterval(nextMsg,900),logTimer=setInterval(addLog,640),progTimer=setInterval(function(){var cap=ready?100:90;if(prog<cap){var step=(cap-prog)*0.08+0.6;setProg(Math.min(cap,prog+step));}},80);
5949    function done(){clearInterval(msgTimer);clearInterval(logTimer);clearInterval(progTimer);setProg(100);if(statusEl){statusEl.textContent='Done';statusEl.classList.add('rpt-status-in');}setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},600);},260);}
5950    window.__rptFinish=function(){ready=true;setTimeout(done,Math.max(0,MIN-(Date.now()-start)));};
5951  })();
5952  </script>
5953  <div class="background-watermarks" aria-hidden="true">
5954    <img src="{{ logo_text_uri }}" alt="" />
5955    <img src="{{ logo_text_uri }}" alt="" />
5956    <img src="{{ logo_text_uri }}" alt="" />
5957    <img src="{{ logo_text_uri }}" alt="" />
5958    <img src="{{ logo_text_uri }}" alt="" />
5959    <img src="{{ logo_text_uri }}" alt="" />
5960    <img src="{{ logo_text_uri }}" alt="" />
5961    <img src="{{ logo_text_uri }}" alt="" />
5962    <img src="{{ logo_text_uri }}" alt="" />
5963    <img src="{{ logo_text_uri }}" alt="" />
5964    <img src="{{ logo_text_uri }}" alt="" />
5965    <img src="{{ logo_text_uri }}" alt="" />
5966  </div>
5967  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5968  {% if let Some(banner) = report_header_footer %}
5969  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
5970  {% endif %}
5971  <div class="top-nav">
5972    <div class="top-nav-inner">
5973      <a class="brand" href="/" data-local-brand="1">
5974        {% if let Some(uri) = custom_logo_uri %}
5975        <img class="brand-logo" src="{{ uri }}" alt="logo" />
5976        {% else %}
5977        <img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
5978        {% endif %}
5979        <div class="brand-copy">
5980          {% if let Some(name) = company_name %}
5981          <div class="brand-title">{{ name }}</div>
5982          {% else %}
5983          <div class="brand-title">OxideSLOC</div>
5984          {% endif %}
5985          <div class="brand-subtitle">Saved HTML report</div>
5986        </div>
5987      </a>
5988      <div class="nav-project-slot">
5989        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ title }}</span></div>
5990      </div>
5991      <div class="nav-status">
5992        <button type="button" class="header-button" data-copy-link>Copy link</button>
5993        <button type="button" class="header-button" data-share-report>Share</button>
5994        <div class="nav-dropdown-wrap">
5995          <button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
5996          <div class="nav-dropdown-menu">
5997            <button type="button" class="nav-dropdown-item" data-export-csv>Export CSV</button>
5998            <button type="button" class="nav-dropdown-item" data-export-xls>Export Excel</button>
5999          </div>
6000        </div>
6001        <a id="nav-view-pdf-btn" href="/runs/pdf/{{ run.tool.run_id }}" target="_blank" rel="noopener" class="header-button" style="text-decoration:none;"{% if let Some(purl) = standalone_pdf_url %} data-standalone-pdf="{{ purl }}"{% endif %}>View PDF</a>
6002        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
6003          <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
6004        </button>
6005        <button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
6006          <svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
6007          <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
6008        </button>
6009      </div>
6010    </div>
6011  </div>
6012
6013  <div class="page">
6014    <section class="hero panel">
6015      <div class="hero-top">
6016        <div>
6017          <div class="section-kicker">Saved report artifact</div>
6018          <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
6019            <h1>{{ title }}</h1>
6020            <span class="run-id-short-badge" title="Short run ID \u2014 matches the ID shown in View Reports">{{ run_id_short }}</span>
6021          </div>
6022        </div>
6023      </div>
6024      <div class="run-id-row">
6025            <span class="run-id-chip" data-copy="{{ run.tool.run_id }}">
6026              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>Run ID</span>
6027              <span class="run-id-chip-value">{{ run.tool.run_id }}</span>
6028              <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
6029            </span>
6030            {% if let Some(long_commit) = run.git_commit_long %}
6031            {% if let Some(commit_url) = git_commit_url %}
6032            <a class="run-id-chip run-id-chip-link" href="{{ commit_url }}" target="_blank" rel="noopener noreferrer" title="Open commit in source control">
6033              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-popout-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
6034              <span class="run-id-chip-value">{{ long_commit }}</span>
6035              <span class="chip-tooltip">Opens commit in source control — new tab</span>
6036            </a>
6037            {% else %}
6038            <span class="run-id-chip" data-copy="{{ long_commit }}">
6039              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
6040              <span class="run-id-chip-value">{{ long_commit }}</span>
6041              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
6042            </span>
6043            {% endif %}
6044            {% else %}
6045            <span class="run-id-chip muted-chip">
6046              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
6047              <span class="run-id-chip-value">Not detected</span>
6048              <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
6049            </span>
6050            {% endif %}
6051            {% if let Some(branch) = run.git_branch %}
6052            {% if let Some(branch_url) = git_branch_url %}
6053            <a class="run-id-chip run-id-chip-link" href="{{ branch_url }}" target="_blank" rel="noopener noreferrer" title="Open branch in source control">
6054              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch<svg class="chip-popout-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
6055              <span class="run-id-chip-value">{{ branch }}</span>
6056              <span class="chip-tooltip">Opens branch in source control — new tab</span>
6057            </a>
6058            {% else %}
6059            <span class="run-id-chip">
6060              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
6061              <span class="run-id-chip-value">{{ branch }}</span>
6062              <span class="chip-tooltip">Git branch scanned for this report</span>
6063            </span>
6064            {% endif %}
6065            {% else %}
6066            {% if is_sub_report %}
6067            <span class="run-id-chip">
6068              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>Submodule</span>
6069              <span class="run-id-chip-value"><span class="submodule-state-badge">detached HEAD</span></span>
6070              <span class="chip-tooltip">Submodules are pinned to a specific commit — no branch ref</span>
6071            </span>
6072            {% else %}
6073            <span class="run-id-chip muted-chip">
6074              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
6075              <span class="run-id-chip-value">Not detected</span>
6076              <span class="chip-tooltip">No Git branch was found for this scan</span>
6077            </span>
6078            {% endif %}
6079            {% endif %}
6080            {% if let Some(author) = run.git_commit_author %}
6081            <span class="run-id-chip" data-author="{{ author }}">
6082              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
6083              <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
6084              <span class="chip-tooltip">Author of the most recent commit in this repository</span>
6085            </span>
6086            {% else %}
6087            <span class="run-id-chip muted-chip">
6088              <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
6089              <span class="run-id-chip-value">Not detected</span>
6090              <span class="chip-tooltip">No commit author was found for this scan</span>
6091            </span>
6092            {% endif %}
6093      </div>
6094
6095      <div class="meta">
6096        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
6097        <span class="meta-chip">Scanned <b>{{ scan_time_pst }}</b></span>
6098        <span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
6099        <span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
6100        <span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
6101      </div>
6102
6103      {% if has_delta %}
6104      <div class="prev-scan-banner" aria-label="Changes vs. previous scan">
6105        <div class="prev-scan-banner-top">
6106          <div class="prev-scan-meta">
6107            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
6108            {% if prev_run_id != "" %}<a href="/runs/html/{{ prev_run_id }}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;font-weight:700;">PREVIOUS SCAN</a>{% else %}<strong>PREVIOUS SCAN</strong>{% endif %}
6109            <span class="prev-scan-ts">{{ prev_scan_label }}</span>
6110            {% if prev_scan_count > 0 %}
6111            <span class="prev-scan-count">&#xb7; {{ prev_scan_count }} scan{% if prev_scan_count != 1 %}s{% endif %} total</span>
6112            {% endif %}
6113          </div>
6114          <div class="prev-scan-summary">
6115            Code before: <b data-raw="{{ prev_code_lines }}">{{ prev_code_lines }}</b>
6116            &nbsp;&rarr;&nbsp;
6117            Code now: <b data-raw="{{ run.summary_totals.code_lines }}">{{ run.summary_totals.code_lines }}</b>
6118            &nbsp;&#xb7;&nbsp;
6119            <span class="{% if delta_code_added > 0 %}delta-up{% else %}delta-neutral-text{% endif %}">+<span data-raw="{{ delta_code_added }}">{{ delta_code_added }}</span> added</span>
6120            &nbsp;
6121            <span class="{% if delta_code_removed > 0 %}delta-down{% else %}delta-neutral-text{% endif %}">&minus;<span data-raw="{{ delta_code_removed }}">{{ delta_code_removed }}</span> removed</span>
6122          </div>
6123        </div>
6124        <div class="delta-card-row">
6125          <div class="delta-card-inline {% if delta_code_added > 0 %}pos{% endif %}">
6126            <div class="delta-card-val pos">+{{ delta_code_added|commas }}</div>
6127            <div class="delta-card-lbl">Lines added</div>
6128            <div class="delta-card-tip">Code lines added since {{ prev_scan_label }}</div>
6129          </div>
6130          <div class="delta-card-inline {% if delta_code_removed > 0 %}neg{% endif %}">
6131            <div class="delta-card-val neg">&minus;{{ delta_code_removed|commas }}</div>
6132            <div class="delta-card-lbl">Lines removed</div>
6133            <div class="delta-card-tip">Code lines removed since {{ prev_scan_label }}</div>
6134          </div>
6135          <div class="delta-card-inline">
6136            <div class="delta-card-val">{{ delta_unmodified_lines|commas }}</div>
6137            <div class="delta-card-lbl">Unmodified lines</div>
6138            <div class="delta-card-tip">Code lines unchanged since {{ prev_scan_label }}</div>
6139          </div>
6140          <div class="delta-card-inline {% if delta_files_modified > 0 %}mod{% endif %}">
6141            <div class="delta-card-val mod">{{ delta_files_modified|commas }}</div>
6142            <div class="delta-card-lbl">Files modified</div>
6143            <div class="delta-card-tip">Files with at least one line changed</div>
6144          </div>
6145          <div class="delta-card-inline {% if delta_files_added > 0 %}pos{% endif %}">
6146            <div class="delta-card-val pos">{{ delta_files_added|commas }}</div>
6147            <div class="delta-card-lbl">Files added</div>
6148            <div class="delta-card-tip">New files added since {{ prev_scan_label }}</div>
6149          </div>
6150          <div class="delta-card-inline {% if delta_files_removed > 0 %}neg{% endif %}">
6151            <div class="delta-card-val neg">{{ delta_files_removed|commas }}</div>
6152            <div class="delta-card-lbl">Files removed</div>
6153            <div class="delta-card-tip">Files deleted since {{ prev_scan_label }}</div>
6154          </div>
6155          <div class="delta-card-inline">
6156            <div class="delta-card-val">{{ delta_files_unchanged|commas }}</div>
6157            <div class="delta-card-lbl">Files unchanged</div>
6158            <div class="delta-card-tip">Files with no changes since {{ prev_scan_label }}</div>
6159          </div>
6160          <div class="delta-card-inline">
6161            <div class="delta-card-val">{{ delta_files_total|commas }}</div>
6162            <div class="delta-card-lbl">Files total</div>
6163            <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
6164          </div>
6165        </div>
6166      </div>
6167      {% else %}
6168      <div class="prev-scan-banner prev-scan-banner-empty" aria-label="No previous scan">
6169        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
6170        No previous scan found for this project &#x2014; this report is the baseline.
6171      </div>
6172      {% endif %}
6173
6174      <div class="summary-grid">
6175        <div class="metric" data-metric-value="{{ run.summary_totals.total_physical_lines }}"><div class="metric-tooltip">Total lines across all analyzed files, including code, comments, and blank lines.</div><div class="metric-label">Physical lines</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6176        <div class="metric" data-metric-value="{{ run.summary_totals.code_lines }}"><div class="metric-tooltip">Lines containing executable source code, excluding comments and blanks.</div><div class="metric-label">Code</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6177        <div class="metric" data-metric-value="{{ run.summary_totals.comment_lines }}"><div class="metric-tooltip">Lines consisting entirely of comments or inline documentation.</div><div class="metric-label">Comments</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6178        <div class="metric" data-metric-value="{{ run.summary_totals.blank_lines }}"><div class="metric-tooltip">Empty or whitespace-only lines used for readability and spacing.</div><div class="metric-label">Blank</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6179        <div class="metric" data-metric-value="{{ run.summary_totals.mixed_lines_separate }}"><div class="metric-tooltip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div><div class="metric-label">Mixed separate</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6180        <div class="metric" data-metric-value="{{ run.summary_totals.functions }}"><div class="metric-tooltip">Best-effort count of function/method definitions detected across all source files.</div><div class="metric-label">Functions</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6181        <div class="metric" data-metric-value="{{ run.summary_totals.classes }}"><div class="metric-tooltip">Best-effort count of class, struct, interface, and type definitions.</div><div class="metric-label">Classes / Types</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6182        <div class="metric" data-metric-value="{{ run.summary_totals.variables }}"><div class="metric-tooltip">Best-effort count of variable and constant declarations.</div><div class="metric-label">Variables</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6183        <div class="metric" data-metric-value="{{ run.summary_totals.imports }}"><div class="metric-tooltip">Best-effort count of import, include, and module-use statements.</div><div class="metric-label">Imports</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6184        <div class="metric" data-metric-value="{{ run.summary_totals.test_count }}"><div class="metric-tooltip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div><div class="metric-label">Tests</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6185        <div class="metric" data-metric-density><div class="metric-tooltip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div><div class="metric-label">Code density</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6186        <div class="metric" data-metric-value="{{ run.summary_totals.files_analyzed }}"><div class="metric-tooltip">Total number of source files included in this analysis.</div><div class="metric-label">Files analyzed</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6187        {% if run.summary_totals.cyclomatic_complexity > 0 %}<div class="metric" data-metric-value="{{ run.summary_totals.cyclomatic_complexity }}"><div class="metric-tooltip">Sum of branch decision keywords (if, for, while, ||, &amp;&amp;, …) across all code lines. Approximates total McCabe cyclomatic complexity.</div><div class="metric-label">Complexity score</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
6188        {% if let Some(lsloc) = run.summary_totals.lsloc %}<div class="metric" data-metric-value="{{ lsloc }}"><div class="metric-tooltip">Logical SLOC: count of executable statements (semicolons for C-family; non-continuation lines for Python/Ruby/Shell). Normalises across coding styles.</div><div class="metric-label">Logical SLOC</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
6189        {% if uloc > 0 %}<div class="metric" data-metric-value="{{ uloc }}"><div class="metric-tooltip">Unique Lines of Code: distinct non-blank code lines across all files. Counts each line once regardless of how many files it appears in.</div><div class="metric-label">Unique SLOC (ULOC)</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
6190        {% if uloc > 0 && dryness_pct_str != "" %}<div class="metric"><div class="metric-tooltip">ULOC &divide; Code Lines &mdash; the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div><div class="metric-label">DRYness</div><div class="metric-value"><span class="metric-big">{{ dryness_pct_str }}%</span></div></div>{% endif %}
6191        {% if duplicate_group_count > 0 %}<div class="metric" data-metric-value="{{ duplicate_group_count }}"><div class="metric-tooltip">Groups of files with identical content detected. These may inflate SLOC totals. Re-run with --no-duplicates to exclude them.</div><div class="metric-label">Duplicate groups</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
6192        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
6193             odd, so the strip always has an even number of cards that fill exactly
6194             two aligned rows (no oversized card, no empty trailing cell). -->
6195        <div class="metric metric-pad" data-metric-value="{{ run.summary_totals.test_assertion_count }}" style="display:none"><div class="metric-tooltip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div><div class="metric-label">Assertions</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
6196      </div>
6197    </section>
6198
6199    <!-- ── PDF-only pre-rendered chart variants (hidden on screen) ─────── -->
6200    <div id="pdf-variants" class="pdf-variants-root"></div>
6201
6202    <div class="report-stack">
6203      <!-- ── Chart row 1: Overview + Composition ───────────────────────── -->
6204      <div class="charts-grid">
6205        <section class="panel stack chart-section">
6206          <div>
6207            <div class="toolbar">
6208              <div class="toolbar-left"><h2>Project Overview</h2></div>
6209              <button class="chart-expand-btn" id="overview-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6210            </div>
6211            <div class="chart-pre">
6212            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;line-height:1.6;">A configurable cartesian view of your codebase. Choose what to show on each axis.</p>
6213            <div class="chart-controls">
6214              <label>Y Axis:
6215                <select class="chart-select" id="overview-y-axis">
6216                  <option value="code">Code Lines</option>
6217                  <option value="comments">Comment Lines</option>
6218                  <option value="blanks">Blank Lines</option>
6219                  <option value="physical">Total Physical Lines</option>
6220                  <option value="files">File Count</option>
6221                </select>
6222              </label>
6223              <label>X Axis / Mode:
6224                <select class="chart-select" id="overview-x-mode">
6225                  <option value="languages">Languages</option>
6226                  {% if has_submodule_data %}<option value="submodules">Submodules</option>{% endif %}
6227                  <option value="history-commits">Per Commit (Web UI)</option>
6228                  <option value="history-tags">Per Tag (Web UI)</option>
6229                  <option value="history-releases">Per Release (Web UI)</option>
6230                  <option value="history-repos">Other Repos (Web UI)</option>
6231                </select>
6232              </label>
6233            </div>
6234            </div>
6235            <div id="overview-chart" class="chart-container"><div id="canvas-proj-wrap" style="position:relative;min-height:150px;"><canvas id="canvas-proj"></canvas></div></div>
6236            <div class="chart-locked-card" id="overview-chart-locked">
6237              <h3>Historical trend requires the web UI</h3>
6238              <p style="margin:0">Run <code>oxide-sloc serve</code> and navigate to <strong>/trend-reports</strong> to view per-commit, per-tag, per-release, and cross-repo comparisons on an interactive timeline chart. The web UI stores scan history and can plot any metric over time.</p>
6239            </div>
6240          </div>
6241        </section>
6242
6243        <section class="panel stack chart-section">
6244          <div>
6245            <div class="toolbar">
6246              <div class="toolbar-left"><h2>Language Composition</h2></div>
6247              <button class="chart-expand-btn" id="comp-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6248            </div>
6249            <div class="chart-pre">
6250            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Code, comments, and blank lines as a percentage of total physical lines per language.</p>
6251            <div class="chart-tab-bar">
6252              <button type="button" class="chart-tab active" data-comp-tab="absolute">Absolute Lines</button>
6253              <button type="button" class="chart-tab" data-comp-tab="pct">Composition %</button>
6254            </div>
6255            </div>
6256            <div id="composition-chart" class="chart-container" style="overflow:hidden;display:flex;align-items:center;justify-content:center;"><div id="comp-svg-container" style="width:100%;"></div></div>
6257          </div>
6258        </section>
6259      </div>
6260
6261      <!-- ── Chart row 2: Scatter + Semantic ───────────────────────────── -->
6262      <div class="charts-grid">
6263        <section class="panel stack chart-section">
6264          <div>
6265            <div class="toolbar">
6266              <div class="toolbar-left"><h2>Files vs Code Lines</h2></div>
6267              <button class="chart-expand-btn" id="scatter-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6268            </div>
6269            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Each bubble is a language. X&nbsp;=&nbsp;files analyzed, Y&nbsp;=&nbsp;code lines, bubble size&nbsp;∝&nbsp;total physical lines.</p>
6270            <div id="scatter-chart" class="chart-container" style="position:relative;height:224px;"><canvas id="canvas-scatter"></canvas></div>
6271          </div>
6272        </section>
6273
6274        <section class="panel stack chart-section">
6275          <div>
6276            <div class="toolbar">
6277              <div class="toolbar-left"><h2>Semantic Metrics</h2></div>
6278              {% if has_semantic_data %}
6279              <button class="chart-expand-btn" id="semantic-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6280              {% endif %}
6281            </div>
6282            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Detected structural elements per language. Select a metric to explore.</p>
6283            {% if has_semantic_data %}
6284            <div class="chart-controls">
6285              <label>Metric:
6286                <select class="chart-select" id="semantic-metric">
6287                  <option value="functions">Functions</option>
6288                  <option value="classes">Classes / Types</option>
6289                  <option value="variables">Variables</option>
6290                  <option value="imports">Imports</option>
6291                  <option value="tests">Tests</option>
6292                </select>
6293              </label>
6294            </div>
6295            <div id="semantic-chart" class="chart-container" style="position:relative;height:234px;"><canvas id="canvas-semantic"></canvas></div>
6296            {% else %}
6297            <div style="display:flex;align-items:center;justify-content:center;height:200px;color:var(--muted);font-size:13px;text-align:center;line-height:1.6;">
6298              <div>No structural metrics detected for this scan.<br>Semantic analysis covers languages with function/class detection<br>(e.g., Go, Python, Rust, Java, C++).</div>
6299            </div>
6300            {% endif %}
6301          </div>
6302        </section>
6303        <section class="panel stack chart-section">
6304          <div>
6305            <div class="toolbar">
6306              <div class="toolbar-left"><h2>Comment Density</h2></div>
6307              <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6308            </div>
6309            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Comments as a percentage of significant lines (code + comments) per language — a proxy for documentation coverage.</p>
6310            <div id="density-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-density"></canvas></div>
6311          </div>
6312        </section>
6313
6314        <section class="panel stack chart-section">
6315          <div>
6316            <div class="toolbar">
6317              <div class="toolbar-left"><h2>File Size Distribution</h2></div>
6318              <button class="chart-expand-btn" id="filesize-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6319            </div>
6320            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Number of files in each SLOC bucket — a quick view of whether the codebase favours small focused modules or large files.</p>
6321            <div id="filesize-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-filesize"></canvas></div>
6322          </div>
6323        </section>
6324      </div>
6325
6326      <!-- ── Tests & Coverage ──────────────────────────────────────────── -->
6327      <section class="panel stack">
6328        <div>
6329          <div class="toolbar">
6330            <div class="toolbar-left"><h2>Tests &amp; Coverage</h2></div>
6331            {% if has_coverage_data %}<div class="pill-row"><span class="pill good">LCOV coverage data present</span></div>{% endif %}
6332          </div>
6333          <div class="summary-strip">
6334            <div class="stat-chip">
6335              <div class="stat-chip-val" data-fmt="{{ run.summary_totals.test_count }}">{{ run.summary_totals.test_count|commas }}</div>
6336              <div class="stat-chip-label">Test Functions</div>
6337              <div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div>
6338              <span class="stat-chip-exact">{{ run.summary_totals.test_count|commas }}</span>
6339            </div>
6340            <div class="stat-chip">
6341              <div class="stat-chip-val" data-fmt="{{ test_assertion_count }}">{{ test_assertion_count|commas }}</div>
6342              <div class="stat-chip-label">Assertions</div>
6343              <div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div>
6344              <span class="stat-chip-exact">{{ test_assertion_count|commas }}</span>
6345            </div>
6346            <div class="stat-chip">
6347              <div class="stat-chip-val" data-fmt="{{ test_suite_count }}">{{ test_suite_count|commas }}</div>
6348              <div class="stat-chip-label">Test Suites</div>
6349              <div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div>
6350              <span class="stat-chip-exact">{{ test_suite_count|commas }}</span>
6351            </div>
6352            <div class="stat-chip">
6353              <div class="stat-chip-val">{{ test_files_count|commas }} / {{ run.summary_totals.files_analyzed|commas }}</div>
6354              <div class="stat-chip-label">Test Files</div>
6355              <div class="stat-chip-tip">Files containing at least one detected test definition out of total analyzed files</div>
6356            </div>
6357          </div>
6358          <div class="summary-strip" style="margin-top:0;">
6359            <div class="stat-chip">
6360              <div class="stat-chip-val">{{ test_density }}</div>
6361              <div class="stat-chip-label">Tests per 1K SLOC</div>
6362              <div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div>
6363            </div>
6364            <div class="stat-chip">
6365              <div class="stat-chip-val" style="font-size:15px;word-break:break-word;line-height:1.2;">{{ most_tested_lang }}</div>
6366              <div class="stat-chip-label">Most Tested Language</div>
6367              <div class="stat-chip-tip">Language with the highest absolute test function count</div>
6368            </div>
6369            <div class="stat-chip">
6370              <div class="stat-chip-val">{{ langs_with_tests }}</div>
6371              <div class="stat-chip-label">Languages with Tests</div>
6372              <div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div>
6373            </div>
6374            <div class="stat-chip">
6375              {% if has_coverage_data %}<div class="stat-chip-val">{{ cov_line_pct }}%</div>{% else %}<div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>{% endif %}
6376              <div class="stat-chip-label">Line Coverage</div>
6377              <div class="stat-chip-tip">Overall line coverage from LCOV data — run with --lcov-path to populate</div>
6378            </div>
6379          </div>
6380          {% if has_coverage_data %}
6381          <div class="cov-gauge-row">
6382            <div class="cov-gauge-card">
6383              <div class="cov-gauge-label">Line Coverage</div>
6384              <div class="cov-gauge-val" style="color:var(--{{ cov_line_class }}-text);">{{ cov_line_pct }}%</div>
6385              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_line_pct }}%;background:var(--{{ cov_line_class }}-text);"></div></div>
6386              <div class="cov-gauge-sub">Lines hit / instrumented</div>
6387              <div class="cov-gauge-tip">Share of instrumented source lines executed at least once during the test run (from LCOV <code>DA</code> records).</div>
6388            </div>
6389            {% if has_fn_coverage %}
6390            <div class="cov-gauge-card">
6391              <div class="cov-gauge-label">Function Coverage</div>
6392              <div class="cov-gauge-val" style="color:var(--{{ cov_fn_class }}-text);">{{ cov_fn_pct }}%</div>
6393              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_fn_pct }}%;background:var(--{{ cov_fn_class }}-text);"></div></div>
6394              <div class="cov-gauge-sub">Functions hit / found</div>
6395            </div>
6396            {% endif %}
6397            {% if has_branch_coverage %}
6398            <div class="cov-gauge-card">
6399              <div class="cov-gauge-label">Branch Coverage</div>
6400              <div class="cov-gauge-val" style="color:var(--{{ cov_branch_class }}-text);">{{ cov_branch_pct }}%</div>
6401              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_branch_pct }}%;background:var(--{{ cov_branch_class }}-text);"></div></div>
6402              <div class="cov-gauge-sub">Branches hit / found</div>
6403            </div>
6404            {% endif %}
6405          </div>
6406          {% endif %}
6407          <div class="table-shell" style="margin-top:16px;">
6408            <table data-sort-table style="min-width:560px;">
6409              <thead>
6410                <tr>
6411                  <th data-sort-type="text">Language</th>
6412                  <th data-sort-type="number">Test Fns</th>
6413                  <th data-sort-type="number">Assertions</th>
6414                  <th data-sort-type="number">Suites</th>
6415                  <th data-sort-type="text">Density (per 1K SLOC)</th>
6416                </tr>
6417              </thead>
6418              <tbody>
6419                {% for row in language_rows %}
6420                {% if row.test_count > 0 || row.test_assertion_count > 0 %}
6421                <tr>
6422                  <td>{{ row.language }}</td>
6423                  <td>{{ row.test_count|commas }}</td>
6424                  <td>{{ row.test_assertion_count|commas }}</td>
6425                  <td>{{ row.test_suite_count|commas }}</td>
6426                  <td>{{ row.test_density_str }}</td>
6427                </tr>
6428                {% endif %}
6429                {% endfor %}
6430                {% if run.summary_totals.test_count == 0 && test_assertion_count == 0 %}
6431                <tr class="empty-state-row"><td colspan="5">No test functions or assertions detected in this scan</td></tr>
6432                {% endif %}
6433              </tbody>
6434            </table>
6435          </div>
6436          {% if has_coverage_data %}
6437          <div style="display:flex;align-items:center;gap:10px;margin:16px 0 8px;">
6438            <h3 style="margin:0;font-size:14px;font-weight:800;color:var(--text);">Per-File Coverage</h3>
6439            <span class="pill good" style="font-size:10px;">{{ file_rows.len() }} files with data</span>
6440          </div>
6441          <div class="table-shell">
6442            <table data-sort-table class="cov-file-table" style="min-width:560px;">
6443              <thead>
6444                <tr>
6445                  <th data-sort-type="text">File</th>
6446                  <th class="num-col" data-sort-type="number">Line Cov %</th>
6447                  <th class="num-col" data-sort-type="text">Lines Hit / Found</th>
6448                  {% if has_fn_coverage %}<th class="num-col" data-sort-type="number">Fn Cov %</th>{% endif %}
6449                  {% if has_branch_coverage %}<th class="num-col" data-sort-type="number">Branch Cov %</th>{% endif %}
6450                </tr>
6451              </thead>
6452              <tbody>
6453                {% for row in file_rows %}
6454                {% if !row.line_cov_pct.is_empty() %}
6455                <tr>
6456                  <td class="mono" style="font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6457                  <td class="num-col">{{ row.line_cov_pct }}%</td>
6458                  <td class="num-col" style="font-size:11px;color:var(--muted);">{{ row.cov_lines_detail }}</td>
6459                  {% if has_fn_coverage %}<td class="num-col">{% if !row.fn_cov_pct.is_empty() %}{{ row.fn_cov_pct }}%{% else %}&mdash;{% endif %}</td>{% endif %}
6460                  {% if has_branch_coverage %}<td class="num-col">{% if !row.branch_cov_pct.is_empty() %}{{ row.branch_cov_pct }}%{% else %}&mdash;{% endif %}</td>{% endif %}
6461                </tr>
6462                {% endif %}
6463                {% endfor %}
6464                {% if file_rows.is_empty() %}
6465                <tr class="empty-state-row"><td colspan="5">No per-file coverage data available</td></tr>
6466                {% endif %}
6467              </tbody>
6468            </table>
6469          </div>
6470          {% else %}
6471          <div class="info-callout">
6472            <span class="info-callout-icon">&#x2139;</span>
6473            <span>No code coverage detected. Re-run with <code>--lcov-path coverage.info</code> to see line, function, and branch coverage here.</span>
6474          </div>
6475          {% endif %}
6476        </div>
6477      </section>
6478
6479      <!-- ── Multi-Language Code Style Analysis ───────────────────────── -->
6480      {% if has_style_data %}
6481      {% if let Some(ss) = style_summary %}
6482      <section class="panel stack">
6483        <div>
6484          <div class="toolbar">
6485            <div class="toolbar-left"><h2>Code Style Analysis</h2></div>
6486            <div class="pill-row"><span class="pill info">{{ style_lang_count }} language group(s) &#xB7; Lexical heuristics</span></div>
6487          </div>
6488          <div class="style-heuristic-note">
6489            <span class="info-callout-icon">&#x2139;</span>
6490            <span>Scores are lexical approximations based on indentation, line length, brace placement, and language-specific signals &#x2014; not a full parse. Use as a directional signal.</span>
6491          </div>
6492          <!-- Summary chips -->
6493          <div class="style-metrics-strip">
6494            <div class="style-chip">
6495              <div class="style-chip-val">{{ ss.files_analyzed }}</div>
6496              <div class="style-chip-label">Files Analyzed</div>
6497              <div class="style-chip-tip">Total files with style data</div>
6498            </div>
6499            <div class="style-chip">
6500              <div class="style-chip-val">{{ style_lang_count }}</div>
6501              <div class="style-chip-label">Language Groups</div>
6502              <div class="style-chip-tip">Distinct language families detected</div>
6503            </div>
6504            <div class="style-chip">
6505              <div class="style-chip-val">{{ ss.common_indent_style }}</div>
6506              <div class="style-chip-label">Common Indent</div>
6507              <div class="style-chip-tip">Most prevalent indentation across all files</div>
6508            </div>
6509            <div class="style-chip">
6510              <div class="style-chip-val">{{ ss.line_col_compliant_pct }}%</div>
6511              <div class="style-chip-label">{{ ss.col_threshold }}-Col Compliant</div>
6512              <div class="style-chip-tip">Files where &le;5% of lines exceed {{ ss.col_threshold }} chars</div>
6513            </div>
6514          </div>
6515          <!-- Language selector tab strip -->
6516          <div class="style-guide-adherence">
6517            <div class="style-guide-adherence-title">Style Guide Adherence by Language</div>
6518            <div id="style-lang-tabs" class="style-lang-tabs"></div>
6519            <div class="style-guide-grid" id="style-guide-bars"></div>
6520          </div>
6521          <!-- Per-file style table -->
6522          <div style="margin-top:22px;">
6523            <div class="toolbar" style="margin-bottom:8px;">
6524              <div class="toolbar-left">
6525                <span style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);">Per-File Style Details</span>
6526                <input id="sft-search" class="search" type="search" placeholder="Filter files, languages, guides..." style="margin-left:12px;" />
6527                <div class="page-size-row"><label class="page-size-label" for="sft-page-size">Show:</label><select id="sft-page-size" class="page-size-select"><option value="20" selected>20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="sft-count-label" class="page-count-label"></span></div>
6528              </div>
6529            </div>
6530            <div class="table-scroll-wrap">
6531              <table class="style-file-table" id="style-file-table">
6532                <thead>
6533                  <tr>
6534                    <th data-sort-key="path" style="width:35%;" title="File path relative to the scanned root. Click to sort alphabetically.">File <span class="style-sort-ind">&#9662;</span></th>
6535                    <th data-sort-key="lang" style="width:10%;" title="Programming language detected for this file. Click to sort.">Language <span class="style-sort-ind">&#9662;</span></th>
6536                    <th data-sort-key="indent" style="width:12%;" title="Dominant indentation style detected: Tabs, 2-Space, 4-Space, 8-Space, Mixed, or Unknown. Click to sort.">Indent <span class="style-sort-ind">&#9662;</span></th>
6537                    <th data-sort-key="guide" style="width:20%;" title="Style guide with the highest lexical-adherence score for this file. Click a badge to open the official guide documentation. Click header to sort.">Best Match Guide <span class="style-sort-ind">&#9662;</span></th>
6538                    <th data-sort-key="score" style="width:10%;" title="Adherence score (0-100%) for the best-matching style guide. Higher = closer match to that guide's conventions. Lexical heuristic only \u2014 not a full parse. Click to sort.">Score <span class="style-sort-ind">&#9662;</span></th>
6539                    <th style="width:13%;" title="Hover a row to see all signals \u2014 signal name and detected value.">Signals</th>
6540                  </tr>
6541                </thead>
6542                <tbody id="style-file-tbody">
6543                  <tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">Loading...</td></tr>
6544                </tbody>
6545              </table>
6546            </div>
6547            <div id="sft-pagination" class="pagination-bar">
6548              <button id="sft-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6549              <button id="sft-prev" class="pager-btn" disabled>&#8592; Prev</button>
6550              <span class="pager-jump-wrap">Page <input id="sft-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="sft-page-total">&#8212;</span></span>
6551              <span id="sft-page-info" class="pager-info"></span>
6552              <button id="sft-next" class="pager-btn">Next &#8594;</button>
6553              <button id="sft-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6554            </div>
6555          </div>
6556        </div>
6557      </section>
6558      {% endif %}
6559      {% endif %}
6560
6561      <!-- ── Submodule Breakdown (2-column, conditional) ─────────────── -->
6562      {% if has_submodule_data %}
6563      <div class="charts-grid">
6564        <section class="panel stack chart-section">
6565          <div>
6566            <div class="toolbar">
6567              <div class="toolbar-left"><h2>Submodule Breakdown</h2></div>
6568              <button class="chart-expand-btn" id="sub-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6569            </div>
6570            <div class="chart-controls">
6571              <label>Y Axis:
6572                <select class="chart-select" id="sub-y-axis">
6573                  <option value="code">Code Lines</option>
6574                  <option value="comment">Comment Lines</option>
6575                  <option value="blank">Blank Lines</option>
6576                  <option value="physical">Total Physical Lines</option>
6577                  <option value="files">File Count</option>
6578                </select>
6579              </label>
6580              <label>Sort:
6581                <select class="chart-select" id="sub-sort">
6582                  <option value="desc">Value ↓</option>
6583                  <option value="asc">Value ↑</option>
6584                  <option value="name">Name A→Z</option>
6585                </select>
6586              </label>
6587            </div>
6588            <div id="submodule-chart" class="chart-container"><div id="canvas-sub-wrap" style="position:relative;min-height:150px;"><canvas id="canvas-sub"></canvas></div></div>
6589          </div>
6590        </section>
6591        <section class="panel stack chart-section">
6592          <div>
6593            <div class="toolbar">
6594              <div class="toolbar-left"><h2>Submodule Composition</h2></div>
6595              <button class="chart-expand-btn" id="sub-comp-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6596            </div>
6597            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Code vs comments vs blank lines per submodule — bar width reflects relative size.</p>
6598            <div id="submodule-donut" style="width:100%;padding:4px 0;overflow:hidden;"></div>
6599          </div>
6600        </section>
6601      </div>
6602      {% endif %}
6603
6604      {% if has_cocomo %}
6605      <section class="panel" id="cocomo-section">
6606        <div class="toolbar">
6607          <div class="toolbar-left">
6608            <h2>Constructive Cost Model &mdash; COCOMO I</h2>
6609            <span class="cocomo-mode-pill-wrap" style="margin-left:12px;">
6610              <span class="pill" style="background:var(--surface-3);color:var(--muted);border:1px solid var(--line);font-size:11px;">{{ cocomo_mode_label }} mode</span>
6611              <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
6612            </span>
6613          </div>
6614        </div>
6615        <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
6616          <div class="stat-chip">
6617            <div class="stat-chip-label">Person-months</div>
6618            <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
6619            <div class="stat-chip-tip">Total estimated developer effort to build this codebase from scratch. One person-month = one developer working full-time for one calendar month. Computed as 2.4 &times; KSLOC^1.05 (Organic mode).</div>
6620          </div>
6621          <div class="stat-chip">
6622            <div class="stat-chip-label">Schedule (months)</div>
6623            <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
6624            <div class="stat-chip-tip">Estimated calendar duration assuming an optimally sized team. Computed as 2.5 &times; effort^0.38. Adding more people beyond this optimum rarely shortens the timeline.</div>
6625          </div>
6626          <div class="stat-chip">
6627            <div class="stat-chip-label">Avg. Team Size</div>
6628            <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
6629            <div class="stat-chip-tip">Average number of engineers working in parallel, derived as effort &divide; schedule. Actual headcount may peak higher during intensive phases of the project.</div>
6630          </div>
6631          <div class="stat-chip">
6632            <div class="stat-chip-label">Input KSLOC</div>
6633            <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
6634            <div class="stat-chip-tip">KSLOC = Kilo Source Lines of Code (1 KSLOC = 1,000 lines). This is the primary input to the COCOMO model. Only executable code lines are counted &mdash; blank lines and comments are excluded. ({{ run.summary_totals.code_lines|commas }} total code lines)</div>
6635          </div>
6636        </div>
6637        <p style="font-size:13px;color:var(--muted);padding:8px 4px 0;line-height:1.6;white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures &mdash; actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</p>
6638      </section>
6639      {% endif %}
6640
6641      {% if has_hotspots %}
6642      <section class="panel" id="hotspots-section">
6643        <div class="toolbar"><div class="toolbar-left"><h2>Git Hotspots</h2><input id="hotspots-search" class="search" type="search" placeholder="Filter files..." /><div class="page-size-row"><label class="page-size-label" for="hotspots-page-size">Show:</label><select id="hotspots-page-size" class="page-size-select"><option value="15" selected>15</option><option value="25">25</option><option value="50">50</option><option value="all">All</option></select><span id="hotspots-count-label" class="page-count-label"></span></div></div></div>
6644        <p class="section-desc">Files ranked by <strong>code lines &times; recent commits</strong> over the configured git activity window. Large files that change often are the strongest refactoring candidates.{% if has_ownership %} The <strong>Author</strong> column shows each file's primary author from git blame (only shown when code-ownership attribution ran).{% endif %}</p>
6645        <p class="table-hint hs-hint">Click a column header to sort; drag its right edge to resize; hover a header for what it means.</p>
6646        <div class="table-shell">
6647          <table id="hotspots-table" data-sort-table class="table-resizable hotspots-table">
6648            <colgroup><col>{% if has_ownership %}<col>{% endif %}<col><col><col><col></colgroup>
6649            <thead><tr>
6650              <th data-sort-type="text">File<span class="col-tip">Repository-relative path of the file. Click to sort the list alphabetically by path.</span><div class="col-resize-handle"></div></th>
6651              {% if has_ownership %}<th data-sort-type="text">Author<span class="col-tip">Primary author of this file &mdash; the contributor who owns the most of its lines, per git blame.</span><div class="col-resize-handle"></div></th>{% endif %}
6652              <th data-sort-type="number" class="num-col">Code lines<span class="col-tip col-tip-r">Executable source lines in the file (blank lines and comments excluded). Bigger files are harder to change safely.</span><div class="col-resize-handle"></div></th>
6653              <th data-sort-type="number" class="num-col">Commits<span class="col-tip col-tip-r">How many times the file was committed within the git activity window. More commits = more churn.</span><div class="col-resize-handle"></div></th>
6654              <th data-sort-type="number" class="num-col">Hotspot score<span class="col-tip col-tip-r"><strong>Code lines &times; Commits.</strong> A large file that changes often scores high &mdash; it concentrates both size and churn, making it the strongest refactoring candidate. Lower is calmer.</span><div class="col-resize-handle"></div></th>
6655              <th data-sort-type="text" class="num-col">Last changed<span class="col-tip col-tip-r">Date of the most recent commit that touched this file, within the activity window.</span><div class="col-resize-handle"></div></th>
6656            </tr></thead>
6657            <tbody>
6658            {% for h in hotspot_rows %}
6659              <tr>
6660                <td class="mono" title="{{ h.path }}">{{ h.path }}</td>
6661                {% if has_ownership %}<td>{% match h.owner_profile_url %}{% when Some with (url) %}<a class="author-link" href="{{ url }}" target="_blank" rel="noopener noreferrer">{{ h.owner }}</a>{% when None %}{{ h.owner }}{% endmatch %}</td>{% endif %}
6662                <td class="num-col">{{ h.code_lines|commas }}</td>
6663                <td class="num-col">{{ h.commit_count }}</td>
6664                <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ h.score|commas }}</td>
6665                <td class="num-col" style="color:var(--muted);">{{ h.last_commit_date }}</td>
6666              </tr>
6667            {% endfor %}
6668            </tbody>
6669          </table>
6670        </div>
6671        <div id="hotspots-pagination" class="pagination-bar">
6672          <button id="hs-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6673          <button id="hs-prev" class="pager-btn" disabled>&#8592; Prev</button>
6674          <span class="pager-jump-wrap">Page <input id="hs-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="hs-page-total">&#8212;</span></span>
6675          <span id="hs-page-info" class="pager-info"></span>
6676          <button id="hs-next" class="pager-btn">Next &#8594;</button>
6677          <button id="hs-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6678        </div>
6679      </section>
6680      {% endif %}
6681
6682      {% if has_ownership %}
6683      <section class="panel" id="ownership-section">
6684        <div class="toolbar"><div class="toolbar-left"><h2>Code Ownership</h2><input id="ownership-search" class="search" type="search" placeholder="Filter authors..." /></div></div>
6685        <p class="section-desc">Per-author line ownership from <strong>git blame</strong> &mdash; each physical line is attributed to the author who last touched it (<code>-w -M -C</code>, <code>.mailmap</code> honoured), split into <strong>code / comment / blank</strong>. Same-email identities are merged automatically; cross-account merging is a later step.</p>
6686        <div class="summary-strip own-summary-strip">
6687          <div class="stat-chip"><div class="stat-chip-val">{{ own_contributors|commas }}</div><div class="stat-chip-label">Contributors</div><div class="stat-chip-tip">Distinct authors owning at least one line in this scan, after identity merges.</div></div>
6688          <div class="stat-chip"><div class="stat-chip-val">{{ own_top_name }}</div><div class="stat-chip-label">Top Owner &middot; {{ own_top_pct_str }}% of code</div><div class="stat-chip-tip">The single contributor owning the largest share of code lines.</div></div>
6689          <div class="stat-chip"><div class="stat-chip-val">{{ own_bus_factor }}</div><div class="stat-chip-label">Bus Factor</div><div class="stat-chip-tip">Fewest contributors who together own at least half of the code &mdash; a low number means knowledge is concentrated in very few people.</div></div>
6690          <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_total_code }}">{{ own_total_code|commas }}</div><div class="stat-chip-label">Total Code Lines</div><div class="stat-chip-tip">Physical code lines attributed across all contributors.</div></div>
6691          <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_dev_code }}">{{ own_dev_code|commas }}</div><div class="stat-chip-label">Development Code</div><div class="stat-chip-tip">Code lines owned in non-test files (total code minus code in files detected as tests).</div></div>
6692          <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_test_code }}">{{ own_test_code|commas }}</div><div class="stat-chip-label">Test Code &middot; {{ own_test_pct_str }}% of code</div><div class="stat-chip-tip">Code lines owned in files classified as tests (detected test functions/assertions or a test-path convention).</div></div>
6693          <div class="stat-chip"><div class="stat-chip-val" data-fmt="{{ own_total_comment }}">{{ own_total_comment|commas }}</div><div class="stat-chip-label">Total Comment Lines</div><div class="stat-chip-tip">Physical comment / documentation lines attributed across all contributors.</div></div>
6694        </div>
6695        <p class="table-hint hs-hint">Click a column header to sort; drag its right edge to resize.</p>
6696        <div class="table-shell">
6697          <table id="ownership-table" data-sort-table class="table-resizable">
6698            <colgroup><col><col><col><col><col><col><col><col></colgroup>
6699            <thead><tr>
6700              <th data-sort-type="text">Author<div class="col-resize-handle"></div></th>
6701              <th data-sort-type="text">Email<div class="col-resize-handle"></div></th>
6702              <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6703              <th data-sort-type="number" class="num-col">Comment<div class="col-resize-handle"></div></th>
6704              <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6705              <th data-sort-type="number" class="num-col">Total<div class="col-resize-handle"></div></th>
6706              <th data-sort-type="number" class="num-col">Code %<div class="col-resize-handle"></div></th>
6707              <th data-sort-type="number" class="num-col">Files owned<div class="col-resize-handle"></div></th>
6708            </tr></thead>
6709            <tbody>
6710            {% for a in ownership_rows %}
6711              <tr>
6712                <td>{% match a.profile_url %}{% when Some with (url) %}<a class="author-link" href="{{ url }}" target="_blank" rel="noopener noreferrer">{{ a.name }}</a>{% when None %}{{ a.name }}{% endmatch %}</td>
6713                <td class="mono" style="color:var(--muted);">{{ a.email }}</td>
6714                <td class="num-col">{{ a.code|commas }}</td>
6715                <td class="num-col">{{ a.comment|commas }}</td>
6716                <td class="num-col">{{ a.blank|commas }}</td>
6717                <td class="num-col">{{ a.total|commas }}</td>
6718                <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ a.code_pct_str }}%</td>
6719                <td class="num-col">{{ a.files_owned }}</td>
6720              </tr>
6721            {% endfor %}
6722            </tbody>
6723          </table>
6724        </div>
6725        <h3 class="own-files-title">Contributor Leaderboard</h3>
6726        <p class="own-files-sub">Ranked by <strong>code lines owned</strong>. Expand a contributor to see the files they own (files where they are the top blame owner), with the lines they own in each &mdash; tying ownership to the specific files, including the Git Hotspots.</p>
6727        {% for a in ownership_rows %}
6728        {% if !a.files.is_empty() %}
6729        <details class="own-details">
6730          <summary>
6731            <span class="lb-rank {{ a.rank_class }}">{{ a.rank }}</span>
6732            <span class="lb-avatar" style="background:{{ a.color }};">{{ a.initials }}</span>
6733            <span class="lb-name">{% match a.profile_url %}{% when Some with (url) %}<a class="author-link" href="{{ url }}" target="_blank" rel="noopener noreferrer">{{ a.name }}</a>{% when None %}{{ a.name }}{% endmatch %}</span>
6734            <span class="lb-bar-wrap"><span class="lb-bar" style="width:{{ a.code_pct_str }}%;background:{{ a.color }};"></span></span>
6735            <span class="lb-stats"><strong>{{ a.code|commas }}</strong> code lines &middot; {{ a.files_owned }} file{% if a.files_owned != 1 %}s{% endif %} &middot; {{ a.code_pct_str }}%</span>
6736          </summary>
6737          <div class="table-shell own-files-shell">
6738            <table class="table-resizable">
6739              <colgroup><col><col><col><col><col><col></colgroup>
6740              <thead><tr>
6741                <th data-sort-type="text">File</th>
6742                <th data-sort-type="number" class="num-col">Code</th>
6743                <th data-sort-type="number" class="num-col">Comment</th>
6744                <th data-sort-type="number" class="num-col">Blank</th>
6745                <th data-sort-type="number" class="num-col">Total</th>
6746                <th data-sort-type="text" class="num-col">Last changed</th>
6747              </tr></thead>
6748              <tbody>
6749              {% for f in a.files %}
6750                <tr>
6751                  <td class="mono" title="{{ f.path }}">{{ f.path }}</td>
6752                  <td class="num-col">{{ f.code|commas }}</td>
6753                  <td class="num-col">{{ f.comment|commas }}</td>
6754                  <td class="num-col">{{ f.blank|commas }}</td>
6755                  <td class="num-col">{{ f.total|commas }}</td>
6756                  <td class="num-col" style="color:var(--muted);">{{ f.last_changed }}</td>
6757                </tr>
6758              {% endfor %}
6759              </tbody>
6760            </table>
6761          </div>
6762        </details>
6763        {% endif %}
6764        {% endfor %}
6765      </section>
6766      {% endif %}
6767
6768      <section class="panel stack">
6769        <div>
6770          <div class="toolbar"><div class="toolbar-left"><h2>Language Breakdown</h2></div><button class="chart-expand-btn" id="lang-overview-expand-btn" title="View full chart" aria-label="Expand charts">&#x2922; Full View</button></div>
6771          <div id="report-lang-overview" style="margin:0 0 16px;"></div>
6772          <div class="table-shell">
6773            <table id="lang-breakdown-table" data-sort-table class="table-resizable">
6774              <colgroup>
6775                <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6776              </colgroup>
6777              <thead>
6778                <tr>
6779                  <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6780                  <th data-sort-type="number" class="num-col">Files<div class="col-resize-handle"></div></th>
6781                  <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6782                  <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6783                  <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6784                  <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6785                  <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6786                  <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6787                  <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6788                  <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6789                  <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6790                  <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6791                  <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6792                  <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6793                </tr>
6794              </thead>
6795              <tbody>
6796                {% for row in language_rows %}
6797                <tr>
6798                  <td title="{{ row.language }}">{{ row.language }}</td>
6799                  <td class="num-col">{{ row.files|commas }}</td>
6800                  <td class="num-col">{{ row.total_physical_lines|commas }}</td>
6801                  <td class="num-col">{{ row.code_lines|commas }}</td>
6802                  <td class="num-col">{{ row.comment_lines|commas }}</td>
6803                  <td class="num-col">{{ row.blank_lines|commas }}</td>
6804                  <td class="num-col">{{ row.mixed_lines_separate|commas }}</td>
6805                  <td class="num-col">{{ row.functions|commas }}</td>
6806                  <td class="num-col">{{ row.classes|commas }}</td>
6807                  <td class="num-col">{{ row.variables|commas }}</td>
6808                  <td class="num-col">{{ row.imports|commas }}</td>
6809                  <td class="num-col">{{ row.test_count|commas }}</td>
6810                  <td class="num-col">{{ row.test_assertion_count|commas }}</td>
6811                  <td class="num-col">{{ row.test_suite_count|commas }}</td>
6812                </tr>
6813                {% endfor %}
6814              </tbody>
6815            </table>
6816          </div>
6817        </div>
6818      </section>
6819
6820      <section class="panel stack">
6821        <div class="toolbar"><div class="toolbar-left"><h2>Per-file detail</h2><input id="per-file-search" class="search" type="search" placeholder="Filter files, languages, status, warnings..." /><div class="page-size-row"><label class="page-size-label" for="per-file-page-size">Show:</label><select id="per-file-page-size" class="page-size-select"><option value="20" selected>20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="per-file-count-label" class="page-count-label"></span></div></div><div class="pill-row"><span class="pill good">Counts shown as analyzed by the selected policy</span><div class="export-group"><button class="export-btn" data-reset-table title="Reset scroll and column layout">&#8635; Reset</button><button class="export-btn" data-export-csv>&#8595; CSV</button><button class="export-btn" data-export-xls>&#8595; Excel</button></div></div></div>
6822        <div class="table-shell table-shell-clip">
6823        <div id="per-file-shell">
6824          <table id="per-file-table" data-sort-table class="table-resizable">
6825            <colgroup>
6826              <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6827            </colgroup>
6828            <thead>
6829              <tr>
6830                <th data-sort-type="text">File<div class="col-resize-handle"></div></th>
6831                <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6832                <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6833                <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6834                <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6835                <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6836                <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6837                <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6838                <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6839                <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6840                <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6841                <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6842                <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6843                <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6844                {% if has_coverage_data %}<th data-sort-type="text" class="num-col">Line Cov %<div class="col-resize-handle"></div></th><th data-sort-type="text" class="num-col">Fn Cov %<div class="col-resize-handle"></div></th>{% endif %}
6845              </tr>
6846            </thead>
6847            <tbody>
6848              {% for row in file_rows %}
6849              <tr>
6850                <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6851                <td title="{{ row.language }}">{{ row.language }}</td>
6852                <td class="num-col">{{ row.total_physical_lines }}</td>
6853                <td class="num-col">{{ row.code_lines }}</td>
6854                <td class="num-col">{{ row.comment_lines }}</td>
6855                <td class="num-col">{{ row.blank_lines }}</td>
6856                <td class="num-col">{{ row.mixed_lines_separate }}</td>
6857                <td class="num-col">{{ row.functions }}</td>
6858                <td class="num-col">{{ row.classes }}</td>
6859                <td class="num-col">{{ row.variables }}</td>
6860                <td class="num-col">{{ row.imports }}</td>
6861                <td class="num-col">{{ row.test_count }}</td>
6862                <td class="num-col">{{ row.test_assertion_count }}</td>
6863                <td class="num-col">{{ row.test_suite_count }}</td>
6864                {% if has_coverage_data %}<td class="num-col">{{ row.line_cov_pct }}</td><td class="num-col">{{ row.fn_cov_pct }}</td>{% endif %}
6865              </tr>
6866              {% endfor %}
6867            </tbody>
6868          </table>
6869        </div>
6870        </div>
6871        <div id="per-file-pagination" class="pagination-bar">
6872          <button id="pf-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6873          <button id="pf-prev" class="pager-btn" disabled>&#8592; Prev</button>
6874          <span class="pager-jump-wrap">Page <input id="pf-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="pf-page-total">&#8212;</span></span>
6875          <span id="pf-page-info" class="pager-info"></span>
6876          <button id="pf-next" class="pager-btn">Next &#8594;</button>
6877          <button id="pf-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6878        </div>
6879      </section>
6880
6881      <section class="panel stack">
6882        <div class="toolbar"><div class="toolbar-left"><h2>Skipped files</h2><input id="skipped-search" class="search" type="search" placeholder="Filter skipped files, reasons, warnings..." /><div class="page-size-row"><label class="page-size-label" for="skipped-page-size">Show:</label><select id="skipped-page-size" class="page-size-select"><option value="10" selected>10</option><option value="20">20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="skipped-count-label" class="page-count-label"></span></div></div><div class="export-group"><button class="export-btn" id="skipped-export-csv">&#8595; CSV</button><button class="export-btn" id="skipped-export-xls">&#8595; Excel</button></div></div>
6883        <div class="table-shell table-shell-clip" style="margin-top:6px;">
6884        <div id="skipped-shell">
6885          <table id="skipped-table" data-sort-table class="table-resizable">
6886            <thead>
6887              <tr>
6888                <th data-sort-type="text" style="width:42%">File</th>
6889                <th data-sort-type="text" style="width:20%">Status</th>
6890                <th data-sort-type="text" style="width:38%">Warnings</th>
6891              </tr>
6892            </thead>
6893            <tbody>
6894              {% for row in skipped_rows %}
6895              <tr>
6896                <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6897                <td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
6898                <td class="small" title="{{ row.warnings }}">{{ row.warnings }}</td>
6899              </tr>
6900              {% endfor %}
6901            </tbody>
6902          </table>
6903        </div>
6904        </div>
6905        <div id="skipped-pagination" class="pagination-bar">
6906          <button id="sk-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6907          <button id="sk-prev" class="pager-btn" disabled>&#8592; Prev</button>
6908          <span class="pager-jump-wrap">Page <input id="sk-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="sk-page-total">&#8212;</span></span>
6909          <span id="sk-page-info" class="pager-info"></span>
6910          <button id="sk-next" class="pager-btn">Next &#8594;</button>
6911          <button id="sk-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6912        </div>
6913      </section>
6914
6915      <section class="panel stack">
6916        <div>
6917          <div class="toolbar">
6918            <div class="toolbar-left"><h2>Diagnostics &amp; Configuration</h2></div>
6919            {% if !is_sub_report && has_run_warnings %}<div class="pill-row"><span class="pill info" style="font-size:11px;min-height:26px;">{{ warning_count }} total warnings</span></div>{% endif %}
6920          </div>
6921          <p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
6922        </div>
6923
6924        {% if !is_sub_report %}
6925        <div style="margin-top:-14px;">
6926          <h3 style="margin:0 0 4px;">Warnings overview</h3>
6927          <p class="support-note">Warning categories produced during the scan. Each row shows the warning type, how many files were affected, and what it means for your results.</p>
6928          {% if !has_run_warnings %}
6929            <div class="pill good">No top-level warnings.</div>
6930          {% else %}
6931            <div class="table-shell">
6932              <table class="support-table">
6933                <thead>
6934                  <tr><th style="width:30%;">Category</th><th style="width:8%;">Count</th><th>What this means</th></tr>
6935                </thead>
6936                <tbody>
6937                  {% for row in warning_summary_rows %}
6938                  <tr class="{{ row.tone_class }}">
6939                    <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6940                    <td class="warning-count" style="font-weight:800;">{{ row.count }}</td>
6941                    <td class="small" style="color:var(--muted);">{{ row.detail }}</td>
6942                  </tr>
6943                  {% endfor %}
6944                </tbody>
6945              </table>
6946            </div>
6947          {% endif %}
6948        </div>
6949
6950        <div>
6951          <h3 style="margin:0 0 4px;">Skipped file categories</h3>
6952          <p class="support-note">Files that were not analyzed, grouped by category. Each row shows what the files are, how many were skipped, example file names, and how to silence or fix the warning.</p>
6953          {% if warning_opportunity_rows.is_empty() %}
6954            <div class="pill good">No unsupported text-format buckets detected.</div>
6955          {% else %}
6956          <div class="table-shell">
6957            <table class="support-table">
6958              <thead>
6959                <tr><th style="width:20%;">Category</th><th style="width:6%;">Count</th><th style="width:24%;">What these files are</th><th>Example files &amp; how to fix</th></tr>
6960              </thead>
6961              <tbody>
6962                {% for row in warning_opportunity_rows %}
6963                <tr>
6964                  <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6965                  <td style="font-weight:800;color:var(--oxide);">{{ row.count }}</td>
6966                  <td class="small" style="color:var(--muted);">{{ row.bucket_description }}</td>
6967                  <td>
6968                    {% if !row.example_files.is_empty() %}
6969                    <div style="margin-bottom:6px;">
6970                      {% for f in row.example_files %}<span class="support-example-file">{{ f }}</span> {% endfor %}
6971                      {% if row.count > row.example_files.len() %}<span style="font-size:11px;color:var(--muted);font-style:italic;">+{{ row.count - row.example_files.len() }} more</span>{% endif %}
6972                    </div>
6973                    {% endif %}
6974                    <p class="support-recommendation">{{ row.recommendation }}</p>
6975                  </td>
6976                </tr>
6977                {% endfor %}
6978              </tbody>
6979            </table>
6980          </div>
6981          {% endif %}
6982        </div>
6983
6984        <div>
6985          <details open class="warnings-details">
6986            <summary>Detailed run warnings ({{ warning_count }})</summary>
6987            <div>
6988              <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Raw warning messages emitted during the scan — unsupported file formats, encoding fallbacks, binary detections, and per-file parse issues. Scroll to see all warnings. High counts typically indicate many non-code assets (JSON configs, docs, lockfiles) in the scanned directory.</p>
6989              {% if !has_run_warnings %}
6990                <div class="pill good">No top-level warnings.</div>
6991              {% else %}
6992                <div class="code-block-toolbar">
6993                  <button type="button" class="code-copy-btn" id="warning-console-copy-btn" aria-label="Copy warnings">Copy</button>
6994                </div>
6995                <pre class="warning-console" id="warning-console-full" style="max-height:210px;">{{ warning_console_full }}</pre>
6996              {% endif %}
6997            </div>
6998          </details>
6999        </div>
7000        {% endif %}
7001
7002        <div>
7003          <details open>
7004            <summary>Effective configuration</summary>
7005            <div>
7006              <div style="display:flex;gap:8px;margin-bottom:10px;">
7007                <button type="button" class="export-btn" data-copy-config>Copy</button>
7008                <button type="button" class="export-btn" data-download-config>Download</button>
7009              </div>
7010              <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">The merged, fully-resolved configuration snapshot used for this scan — includes all CLI overrides applied on top of the base config file. Use this to replay the exact run or verify what settings were active.</p>
7011              <div class="config-pre-wrap">
7012                <div class="code-block-toolbar">
7013                  <button type="button" class="code-copy-btn" id="config-inline-copy-btn" aria-label="Copy configuration">Copy</button>
7014                </div>
7015                <pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
7016              </div>
7017            </div>
7018          </details>
7019        </div>
7020      </section>
7021    </div>
7022  </div>
7023
7024  <div id="r-tt" aria-hidden="true"></div>
7025  <script nonce="{{ nonce }}">
7026    // Hide "View PDF" button and block brand-link navigation when opened as a local file
7027    (function () {
7028      var pdfBtn = document.getElementById('nav-view-pdf-btn');
7029      if (pdfBtn && window.location.protocol === 'file:') {
7030        pdfBtn.style.display = 'none';
7031      }
7032      var brand = document.querySelector('a[data-local-brand]');
7033      if (brand && window.location.protocol === 'file:') {
7034        brand.addEventListener('click', function (e) { e.preventDefault(); });
7035      }
7036    })();
7037
7038    (function () {
7039      var body = document.body;
7040      var storageKey = 'oxide-sloc-theme';
7041      var themeToggle = document.querySelector('[data-theme-toggle]');
7042      var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
7043      var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
7044      var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
7045
7046      function applyTheme(theme) {
7047        body.classList.toggle('dark-theme', theme === 'dark');
7048      }
7049
7050      function currentTheme() {
7051        return body.classList.contains('dark-theme') ? 'dark' : 'light';
7052      }
7053
7054      try {
7055        var saved = localStorage.getItem(storageKey);
7056        if (saved === 'dark' || saved === 'light') {
7057          applyTheme(saved);
7058        }
7059      } catch (e) {}
7060
7061      if (themeToggle) {
7062        themeToggle.addEventListener('click', function () {
7063          var next = currentTheme() === 'dark' ? 'light' : 'dark';
7064          applyTheme(next);
7065          try { localStorage.setItem(storageKey, next); } catch (e) {}
7066        });
7067      }
7068
7069      function copyText(value) {
7070        if (!value) return;
7071        if (navigator.clipboard && navigator.clipboard.writeText) {
7072          navigator.clipboard.writeText(value).catch(function () {});
7073        }
7074      }
7075
7076      copyLinkButtons.forEach(function (button) {
7077        button.addEventListener('click', function () {
7078          copyText(window.location.href);
7079        });
7080      });
7081
7082      shareButtons.forEach(function (button) {
7083        button.addEventListener('click', function () {
7084          if (navigator.share) {
7085            navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
7086          } else {
7087            copyText(window.location.href);
7088          }
7089        });
7090      });
7091
7092      printButtons.forEach(function (button) {
7093        button.addEventListener('click', function () {
7094          window.print();
7095        });
7096      });
7097
7098      // "View PDF" nav button.
7099      // Priority order:
7100      //  1. data-standalone-pdf attr — pre-generated PDF in the same directory
7101      //     (set when oxide-sloc CLI was invoked with both --html-out and
7102      //     --pdf-out). Opens the file directly; works in Jenkins HTML Publisher.
7103      //  2. Server route (/runs/pdf/<id>) — oxide-sloc web server generates
7104      //     the PDF on demand via headless Chrome. Checked via HEAD request.
7105      //  3. Neither available — inform the user how to generate a PDF via CLI.
7106      var pdfNavBtn = document.getElementById('nav-view-pdf-btn');
7107      if (pdfNavBtn) {
7108        pdfNavBtn.addEventListener('click', function (e) {
7109          e.preventDefault();
7110          var standaloneUrl = pdfNavBtn.getAttribute('data-standalone-pdf');
7111          if (standaloneUrl) {
7112            window.open(standaloneUrl, '_blank', 'noopener');
7113            return;
7114          }
7115          var serverUrl = pdfNavBtn.getAttribute('href');
7116          var xhr = new XMLHttpRequest();
7117          xhr.open('HEAD', serverUrl, true);
7118          xhr.onreadystatechange = function () {
7119            if (xhr.readyState === 4) {
7120              if (xhr.status >= 200 && xhr.status < 300) {
7121                window.open(serverUrl, '_blank', 'noopener');
7122              } else {
7123                alert('PDF not available.\n\nTo generate one, run:\n  oxide-sloc report result.json --pdf-out report.pdf\n\nOr enable GENERATE_PDF in your Jenkins pipeline.');
7124              }
7125            }
7126          };
7127          xhr.onerror = function () {
7128            alert('PDF not available.\n\nTo generate one, run:\n  oxide-sloc report result.json --pdf-out report.pdf\n\nOr enable GENERATE_PDF in your Jenkins pipeline.');
7129          };
7130          xhr.send();
7131        });
7132      }
7133
7134      var copyConfigBtn = document.querySelector('[data-copy-config]');
7135      var downloadConfigBtn = document.querySelector('[data-download-config]');
7136      var configBlock = document.getElementById('config-json-block');
7137      var inlineCopyBtn = document.getElementById('config-inline-copy-btn');
7138      function handleConfigCopy(btn) {
7139        if (!btn || !configBlock) return;
7140        btn.addEventListener('click', function (e) {
7141          e.stopPropagation();
7142          copyText(configBlock.textContent);
7143          var orig = btn.textContent;
7144          btn.textContent = 'Copied!';
7145          setTimeout(function () { btn.textContent = orig; }, 1600);
7146        });
7147      }
7148      handleConfigCopy(copyConfigBtn);
7149      handleConfigCopy(inlineCopyBtn);
7150
7151      var warnCopyBtn = document.getElementById('warning-console-copy-btn');
7152      var warnBlock = document.getElementById('warning-console-full');
7153      if (warnCopyBtn && warnBlock) {
7154        warnCopyBtn.addEventListener('click', function () {
7155          copyText(warnBlock.textContent);
7156          var orig = warnCopyBtn.textContent;
7157          warnCopyBtn.textContent = 'Copied!';
7158          setTimeout(function () { warnCopyBtn.textContent = orig; }, 1600);
7159        });
7160      }
7161
7162      if (downloadConfigBtn && configBlock) {
7163        downloadConfigBtn.addEventListener('click', function (e) {
7164          e.stopPropagation();
7165          var blob = new Blob([configBlock.textContent], { type: 'application/json' });
7166          var url = URL.createObjectURL(blob);
7167          var a = document.createElement('a');
7168          a.href = url; a.download = 'effective-config.json';
7169          document.body.appendChild(a); a.click();
7170          document.body.removeChild(a);
7171          setTimeout(function () { URL.revokeObjectURL(url); }, 200);
7172        });
7173      }
7174
7175      function detectType(value) {
7176        // Strip thousands separators so comma-formatted numbers (e.g. "121,542")
7177        // still sort numerically rather than lexicographically.
7178        var v = value.trim().replace(/,/g, '');
7179        return /^-?\d+(?:\.\d+)?$/.test(v) ? parseFloat(v) : value.trim().toLowerCase();
7180      }
7181
7182      document.querySelectorAll('[data-sort-table]').forEach(function (table) {
7183        var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
7184        var allMarkers = [];
7185        headers.forEach(function (th, idx) {
7186          var direction = 1;
7187          var marker = document.createElement('span');
7188          marker.className = 'sort-indicator';
7189          marker.textContent = ' \u2195';
7190          th.style.cursor = 'pointer';
7191          th.appendChild(marker);
7192          allMarkers.push(marker);
7193          th.addEventListener('click', function (e) {
7194            if (e.target.closest && e.target.closest('.col-resize-handle')) return;
7195            var tbody = table.tBodies[0];
7196            var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
7197            rows.sort(function (a, b) {
7198              var av = detectType((a.children[idx].textContent || '').trim());
7199              var bv = detectType((b.children[idx].textContent || '').trim());
7200              if (av < bv) return -1 * direction;
7201              if (av > bv) return 1 * direction;
7202              return 0;
7203            });
7204            rows.forEach(function (row) { tbody.appendChild(row); });
7205            allMarkers.forEach(function(m) { m.textContent = ' \u2195'; });
7206            direction = direction * -1;
7207            marker.textContent = direction === -1 ? ' \u2191' : ' \u2193';
7208            table.dispatchEvent(new CustomEvent('sloc-sorted'));
7209          });
7210        });
7211      });
7212
7213      // ── Column resize for all table-resizable tables ──────────────────────────
7214      (function() {
7215        document.querySelectorAll('.table-resizable').forEach(function(table) {
7216          var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
7217          var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
7218          ths.forEach(function(th, i) {
7219            var handle = th.querySelector('.col-resize-handle');
7220            if (!handle || !cols[i]) return;
7221            var startX, startW;
7222            handle.addEventListener('mousedown', function(e) {
7223              e.stopPropagation(); e.preventDefault();
7224              startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
7225              handle.classList.add('dragging');
7226              function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
7227              function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
7228              document.addEventListener('mousemove', onMove);
7229              document.addEventListener('mouseup', onUp);
7230            });
7231          });
7232        });
7233      })();
7234
7235      document.querySelectorAll('[data-table-filter]').forEach(function (input) {
7236        var table = document.getElementById(input.getAttribute('data-table-filter'));
7237        if (!table) return;
7238        var filterTimer = null;
7239        var rowCache = null;
7240        input.addEventListener('input', function () {
7241          clearTimeout(filterTimer);
7242          var q = input.value.toLowerCase();
7243          filterTimer = setTimeout(function () {
7244            if (!rowCache) {
7245              rowCache = Array.prototype.map.call(table.tBodies[0].rows, function (row) {
7246                return { row: row, text: row.textContent.toLowerCase() };
7247              });
7248            }
7249            rowCache.forEach(function (item) {
7250              item.row.style.display = q === '' || item.text.indexOf(q) >= 0 ? '' : 'none';
7251            });
7252          }, 200);
7253        });
7254      });
7255
7256      // ── Per-file table pagination ────────────────────────────────────────────
7257      (function () {
7258        var table = document.getElementById('per-file-table');
7259        if (!table) return;
7260        var tbody = table.tBodies[0];
7261        var searchInput = document.getElementById('per-file-search');
7262        var pageSizeSelect = document.getElementById('per-file-page-size');
7263        var firstBtn = document.getElementById('pf-first');
7264        var prevBtn = document.getElementById('pf-prev');
7265        var nextBtn = document.getElementById('pf-next');
7266        var lastBtn = document.getElementById('pf-last');
7267        var pageInfo = document.getElementById('pf-page-info');
7268        var jumpInput = document.getElementById('pf-page-jump');
7269        var pageTotal = document.getElementById('pf-page-total');
7270        var countLabel = document.getElementById('per-file-count-label');
7271        var filteredRows = [];
7272        var currentPage = 1;
7273        var totalAll = tbody.rows.length;
7274
7275        function getPageSize() {
7276          var v = pageSizeSelect ? pageSizeSelect.value : '20';
7277          return v === 'all' ? Infinity : parseInt(v, 10);
7278        }
7279
7280        function applyFilter() {
7281          var q = searchInput ? searchInput.value.toLowerCase() : '';
7282          var rows = Array.prototype.slice.call(tbody.rows);
7283          filteredRows = q === '' ? rows : rows.filter(function (row) {
7284            return row.textContent.toLowerCase().indexOf(q) >= 0;
7285          });
7286          currentPage = 1;
7287          render();
7288        }
7289
7290        function render() {
7291          var ps = getPageSize();
7292          var total = filteredRows.length;
7293          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
7294          if (currentPage > totalPages) currentPage = totalPages;
7295          if (currentPage < 1) currentPage = 1;
7296          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
7297          var end = ps === Infinity ? total : Math.min(start + ps, total);
7298          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
7299          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
7300          if (pageInfo) {
7301            if (total === 0) {
7302              pageInfo.textContent = 'No results';
7303            } else if (ps === Infinity) {
7304              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
7305            } else {
7306              pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
7307            }
7308          }
7309          if (countLabel) {
7310            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
7311          }
7312          var edgeDisabled = ps === Infinity;
7313          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
7314          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
7315          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
7316          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
7317          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
7318          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
7319        }
7320
7321        if (searchInput) {
7322          var filterTimer = null;
7323          searchInput.addEventListener('input', function () {
7324            clearTimeout(filterTimer);
7325            filterTimer = setTimeout(applyFilter, 200);
7326          });
7327        }
7328        if (pageSizeSelect) {
7329          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
7330        }
7331        if (firstBtn) {
7332          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
7333        }
7334        if (prevBtn) {
7335          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
7336        }
7337        if (nextBtn) {
7338          nextBtn.addEventListener('click', function () {
7339            var ps = getPageSize();
7340            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
7341            if (currentPage < totalPages) { currentPage++; render(); }
7342          });
7343        }
7344        if (lastBtn) {
7345          lastBtn.addEventListener('click', function () {
7346            var ps = getPageSize();
7347            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7348            render();
7349          });
7350        }
7351        if (jumpInput) {
7352          function pfJump() {
7353            var ps = getPageSize();
7354            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7355            var v = parseInt(jumpInput.value, 10);
7356            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
7357          }
7358          jumpInput.addEventListener('change', pfJump);
7359          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') pfJump(); });
7360        }
7361        table.addEventListener('sloc-sorted', function () { applyFilter(); });
7362        window._pfPaginationReset = function () { currentPage = 1; applyFilter(); };
7363        applyFilter();
7364      })();
7365
7366      // ── Skipped-files table pagination ───────────────────────────────────────
7367      (function () {
7368        var table = document.getElementById('skipped-table');
7369        if (!table) return;
7370        var tbody = table.tBodies[0];
7371        var searchInput = document.getElementById('skipped-search');
7372        var pageSizeSelect = document.getElementById('skipped-page-size');
7373        var firstBtn = document.getElementById('sk-first');
7374        var prevBtn = document.getElementById('sk-prev');
7375        var nextBtn = document.getElementById('sk-next');
7376        var lastBtn = document.getElementById('sk-last');
7377        var pageInfo = document.getElementById('sk-page-info');
7378        var jumpInput = document.getElementById('sk-page-jump');
7379        var pageTotal = document.getElementById('sk-page-total');
7380        var countLabel = document.getElementById('skipped-count-label');
7381        var filteredRows = [];
7382        var currentPage = 1;
7383        var totalAll = tbody.rows.length;
7384
7385        function getPageSize() {
7386          var v = pageSizeSelect ? pageSizeSelect.value : '10';
7387          return v === 'all' ? Infinity : parseInt(v, 10);
7388        }
7389
7390        function applyFilter() {
7391          var q = searchInput ? searchInput.value.toLowerCase() : '';
7392          var rows = Array.prototype.slice.call(tbody.rows);
7393          filteredRows = q === '' ? rows : rows.filter(function (row) {
7394            return row.textContent.toLowerCase().indexOf(q) >= 0;
7395          });
7396          currentPage = 1;
7397          render();
7398        }
7399
7400        function render() {
7401          var ps = getPageSize();
7402          var total = filteredRows.length;
7403          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
7404          if (currentPage > totalPages) currentPage = totalPages;
7405          if (currentPage < 1) currentPage = 1;
7406          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
7407          var end = ps === Infinity ? total : Math.min(start + ps, total);
7408          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
7409          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
7410          if (pageInfo) {
7411            if (total === 0) {
7412              pageInfo.textContent = 'No results';
7413            } else if (ps === Infinity) {
7414              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
7415            } else {
7416              pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
7417            }
7418          }
7419          if (countLabel) {
7420            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
7421          }
7422          var edgeDisabled = ps === Infinity;
7423          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
7424          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
7425          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
7426          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
7427          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
7428          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
7429        }
7430
7431        if (searchInput) {
7432          var filterTimer = null;
7433          searchInput.addEventListener('input', function () {
7434            clearTimeout(filterTimer);
7435            filterTimer = setTimeout(applyFilter, 200);
7436          });
7437        }
7438        if (pageSizeSelect) {
7439          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
7440        }
7441        if (firstBtn) {
7442          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
7443        }
7444        if (prevBtn) {
7445          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
7446        }
7447        if (nextBtn) {
7448          nextBtn.addEventListener('click', function () {
7449            var ps = getPageSize();
7450            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
7451            if (currentPage < totalPages) { currentPage++; render(); }
7452          });
7453        }
7454        if (lastBtn) {
7455          lastBtn.addEventListener('click', function () {
7456            var ps = getPageSize();
7457            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7458            render();
7459          });
7460        }
7461        if (jumpInput) {
7462          function skJump() {
7463            var ps = getPageSize();
7464            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7465            var v = parseInt(jumpInput.value, 10);
7466            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
7467          }
7468          jumpInput.addEventListener('change', skJump);
7469          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') skJump(); });
7470        }
7471        table.addEventListener('sloc-sorted', function () { applyFilter(); });
7472        applyFilter();
7473      })();
7474
7475      // ── Hotspots table pagination ────────────────────────────────────────────
7476      (function () {
7477        var table = document.getElementById('hotspots-table');
7478        if (!table) return;
7479        var tbody = table.tBodies[0];
7480        var searchInput = document.getElementById('hotspots-search');
7481        var pageSizeSelect = document.getElementById('hotspots-page-size');
7482        var firstBtn = document.getElementById('hs-first');
7483        var prevBtn = document.getElementById('hs-prev');
7484        var nextBtn = document.getElementById('hs-next');
7485        var lastBtn = document.getElementById('hs-last');
7486        var pageInfo = document.getElementById('hs-page-info');
7487        var jumpInput = document.getElementById('hs-page-jump');
7488        var pageTotal = document.getElementById('hs-page-total');
7489        var countLabel = document.getElementById('hotspots-count-label');
7490        var filteredRows = [];
7491        var currentPage = 1;
7492        var totalAll = tbody.rows.length;
7493
7494        function getPageSize() {
7495          var v = pageSizeSelect ? pageSizeSelect.value : '15';
7496          return v === 'all' ? Infinity : parseInt(v, 10);
7497        }
7498
7499        function applyFilter() {
7500          var q = searchInput ? searchInput.value.toLowerCase() : '';
7501          var rows = Array.prototype.slice.call(tbody.rows);
7502          filteredRows = q === '' ? rows : rows.filter(function (row) {
7503            return row.textContent.toLowerCase().indexOf(q) >= 0;
7504          });
7505          currentPage = 1;
7506          render();
7507        }
7508
7509        function render() {
7510          var ps = getPageSize();
7511          var total = filteredRows.length;
7512          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
7513          if (currentPage > totalPages) currentPage = totalPages;
7514          if (currentPage < 1) currentPage = 1;
7515          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
7516          var end = ps === Infinity ? total : Math.min(start + ps, total);
7517          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
7518          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
7519          if (pageInfo) {
7520            if (total === 0) {
7521              pageInfo.textContent = 'No results';
7522            } else if (ps === Infinity) {
7523              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
7524            } else {
7525              pageInfo.textContent = (start + 1) + '-' + end + ' of ' + total.toLocaleString() + ' files';
7526            }
7527          }
7528          if (countLabel) {
7529            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
7530          }
7531          var edgeDisabled = ps === Infinity;
7532          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
7533          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
7534          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
7535          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
7536          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
7537          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
7538        }
7539
7540        if (searchInput) {
7541          var filterTimer = null;
7542          searchInput.addEventListener('input', function () {
7543            clearTimeout(filterTimer);
7544            filterTimer = setTimeout(applyFilter, 200);
7545          });
7546        }
7547        if (pageSizeSelect) {
7548          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
7549        }
7550        if (firstBtn) {
7551          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
7552        }
7553        if (prevBtn) {
7554          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
7555        }
7556        if (nextBtn) {
7557          nextBtn.addEventListener('click', function () {
7558            var ps = getPageSize();
7559            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
7560            if (currentPage < totalPages) { currentPage++; render(); }
7561          });
7562        }
7563        if (lastBtn) {
7564          lastBtn.addEventListener('click', function () {
7565            var ps = getPageSize();
7566            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7567            render();
7568          });
7569        }
7570        if (jumpInput) {
7571          function hsJump() {
7572            var ps = getPageSize();
7573            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
7574            var v = parseInt(jumpInput.value, 10);
7575            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
7576          }
7577          jumpInput.addEventListener('change', hsJump);
7578          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') hsJump(); });
7579        }
7580        table.addEventListener('sloc-sorted', function () { applyFilter(); });
7581        applyFilter();
7582      })();
7583    })();
7584
7585    (function randomizeWatermarks() {
7586      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7587      if (!wms.length) return;
7588      var placed = [];
7589      function tooClose(t, l) {
7590        for (var i = 0; i < placed.length; i++) {
7591          var dt = Math.abs(placed[i][0] - t);
7592          var dl = Math.abs(placed[i][1] - l);
7593          if (dt < 18 && dl < 18) return true;
7594        }
7595        return false;
7596      }
7597      function pick(leftBias) {
7598        for (var attempt = 0; attempt < 40; attempt++) {
7599          var t = Math.random() * 90;
7600          var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
7601          if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
7602        }
7603        var fb = [Math.random() * 90, Math.random() * 95];
7604        placed.push(fb);
7605        return fb;
7606      }
7607      var half = Math.floor(wms.length / 2);
7608      wms.forEach(function (img, i) {
7609        var pos = pick(i < half);
7610        var sz = Math.floor(Math.random() * 80 + 110);
7611        var rot = (Math.random() * 360).toFixed(1);
7612        var op = (Math.random() * 0.07 + 0.10).toFixed(2);
7613        img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
7614      });
7615    })();
7616
7617    (function spawnCodeParticles() {
7618      var container = document.getElementById('code-particles');
7619      if (!container) return;
7620      var snippets = ['1,247 sloc', 'fn analyze()', 'code_lines', '0 mixed', 'blanks: 312', '// comment', 'pub fn run', 'use std::fs', 'Result<()>', 'let mut n = 0', 'git main', '#[derive]', 'impl Scan', '3,841 physical', 'files: 60', '450 comments', 'cargo build', 'Ok(run)', 'Vec<String>', 'match lang', 'fn main() {', '.rs .go .py', 'sloc_core', 'render_html', '2,163 code'];
7621      for (var i = 0; i < 38; i++) {
7622        (function (idx) {
7623          var el = document.createElement('span');
7624          el.className = 'code-particle';
7625          el.textContent = snippets[idx % snippets.length];
7626          var left = Math.random() * 94 + 2;
7627          var top = Math.random() * 88 + 6;
7628          var dur = (Math.random() * 10 + 9).toFixed(1);
7629          var delay = (Math.random() * 18).toFixed(1);
7630          var rot = (Math.random() * 26 - 13).toFixed(1);
7631          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7632          el.style.left = left.toFixed(1) + '%';
7633          el.style.top = top.toFixed(1) + '%';
7634          el.style.setProperty('--rot', rot + 'deg');
7635          el.style.setProperty('--op', op);
7636          el.style.animationDuration = dur + 's';
7637          el.style.animationDelay = '-' + delay + 's';
7638          container.appendChild(el);
7639        })(i);
7640      }
7641    })();
7642    // ── Metric number formatting ─────────────────────────────────────────────
7643    (function () {
7644      function fmtBig(n) {
7645        if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
7646        if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
7647        return n.toLocaleString();
7648      }
7649      function fmtExact(n) { return n.toLocaleString(); }
7650      document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7651        var n = parseInt(el.getAttribute('data-metric-value'), 10);
7652        if (isNaN(n)) return;
7653        var big = el.querySelector('.metric-big');
7654        var exact = el.querySelector('.metric-exact');
7655        if (big) big.textContent = fmtBig(n);
7656        if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
7657      });
7658      var densityCard = document.querySelector('[data-metric-density]');
7659      if (densityCard) {
7660        var phys = 0, code = 0;
7661        document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7662          var lbl = el.querySelector('.metric-label');
7663          if (!lbl) return;
7664          var t = lbl.textContent.trim().toLowerCase();
7665          var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
7666          if (t === 'physical lines') phys = v;
7667          if (t === 'code') code = v;
7668        });
7669        var pct = phys > 0 ? (code / phys * 100) : 0;
7670        var big = densityCard.querySelector('.metric-big');
7671        var exact = densityCard.querySelector('.metric-exact');
7672        if (big) big.textContent = pct.toFixed(1) + '%';
7673        if (exact) exact.textContent = '';
7674      }
7675      (function(){
7676        var g=document.querySelector('.summary-grid');if(!g)return;
7677        var pad=g.querySelector('.metric-pad');
7678        var real=Array.prototype.slice.call(g.querySelectorAll('.metric')).filter(function(el){return el!==pad;});
7679        if(!real.length)return;
7680        function upd(){
7681          // Pad the strip to an EVEN card count so a true CSS grid lays it out as
7682          // exactly two full rows with every column aligned and every card the
7683          // same size. When the real-card count is odd, reveal the reserve
7684          // "Assertions" pad card; otherwise keep it hidden.
7685          var n=real.length;
7686          if(pad){ if(n%2===1){pad.style.display='';n++;} else {pad.style.display='none';} }
7687          var perRow=window.innerWidth<=640?2:Math.ceil(n/2);
7688          g.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';
7689        }
7690        upd();window.addEventListener('resize',upd);
7691      })();
7692      (function(){if(typeof window.__rptFinish==='function'){window.__rptFinish();return;}var ov=document.getElementById('rpt-loading-overlay');if(ov){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},450);}})();
7693    })();
7694    // ── Info chip interactivity ───────────────────────────────────────────────
7695    (function() {
7696      document.querySelectorAll('.run-id-chip[data-copy]').forEach(function(chip) {
7697        chip.addEventListener('click', function() {
7698          var val = chip.getAttribute('data-copy');
7699          var tt = chip.querySelector('.chip-tooltip');
7700          var orig = tt ? tt.textContent : '';
7701          if (!navigator.clipboard) return;
7702          navigator.clipboard.writeText(val).then(function() {
7703            chip.classList.add('chip-copied-flash');
7704            if (tt) tt.textContent = 'Copied!';
7705            setTimeout(function() {
7706              chip.classList.remove('chip-copied-flash');
7707              if (tt) tt.textContent = orig;
7708            }, 1100);
7709          });
7710        });
7711      });
7712      document.querySelectorAll('.run-id-chip[data-author]').forEach(function(chip) {
7713        var author = chip.getAttribute('data-author');
7714        var el = chip.querySelector('.author-handle');
7715        if (el) el.textContent = '/' + author.replace(/\s+/g, '');
7716      });
7717    })();
7718    // ── Export helpers ────────────────────────────────────────────────────────
7719    function _slocUnh(s){var e=document.createElement('div');e.innerHTML=s;return e.textContent;}
7720    var _SLOC_META={runId:"{{ run.tool.run_id }}",gitCommit:"{% if let Some(c) = run.git_commit_long %}{{ c }}{% else %}(not detected){% endif %}",branch:_slocUnh("{% if let Some(b) = run.git_branch %}{{ b }}{% else %}(not detected){% endif %}"),lastCommitBy:_slocUnh("{% if let Some(a) = run.git_commit_author %}{{ a }}{% else %}(not detected){% endif %}"),scanBy:_slocUnh("{{ scan_performed_by }}"),scanned:"{{ scan_time_pst }}",os:"{{ run.environment.operating_system }} / {{ run.environment.architecture }}",filesAnalyzed:{{ run.summary_totals.files_analyzed }},filesSkipped:{{ run.summary_totals.files_skipped }},physicalLines:{{ run.summary_totals.total_physical_lines }},codeLines:{{ run.summary_totals.code_lines }},commentLines:{{ run.summary_totals.comment_lines }},blankLines:{{ run.summary_totals.blank_lines }},mixedSeparate:{{ run.summary_totals.mixed_lines_separate }},functions:{{ run.summary_totals.functions }},classes:{{ run.summary_totals.classes }},variables:{{ run.summary_totals.variables }},imports:{{ run.summary_totals.imports }},tests:{{ run.summary_totals.test_count }},toolVersion:"{{ tool_version }}"};
7721    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
7722    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7723    function slocDownload(data,name,mime){var b=new Blob([data],{type:mime});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(u);},200);}
7724    function slocCsv(fname,hdrs,rows){slocDownload([hdrs.map(slocEscCsv).join(',')].concat(rows.map(function(r){return r.map(slocEscCsv).join(',');})).join('\r\n'),fname,'text/csv;charset=utf-8;');}
7725    function slocXls(fname,sheet,hdrs,rows){var enc=new TextEncoder();var CT=[];for(var _n=0;_n<256;_n++){var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}function crc32(d){var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}function u2(n){return[n&0xFF,(n>>8)&0xFF];}function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}var ss=[],si={};function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}function colRef(c,r){var s='',n=c+1;while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s+r;}var rx='<row r="1">';hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});rx+='</row>';rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn);var num=c>=2&&cell!==''&&cell!=null&&!isNaN(Number(cell));rx+=num?'<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="'+sns+'" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';var wsh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment horizontal="right"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';var F={'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>','_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets><sheet name="'+xe(sheet)+'" sheetId="1" r:id="rId1"/></sheets></workbook>','xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>','xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':wsh};var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml'];var zparts=[],zcds=[],zoff=0,znf=0;order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);var ea=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});zout.set(new Uint8Array(ea),zpos);slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');}
7726    function slocXlsMulti(fname,sheets){
7727      var enc=new TextEncoder();
7728      var CT=[];
7729      for(var _n=0;_n<256;_n++){var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}
7730      function crc32(d){var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}
7731      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
7732      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
7733      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7734      var ss=[],si={};
7735      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
7736      function colRef(c,r){var s='',n=c+1;while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s+r;}
7737      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
7738      // Style indices: 0=normal 1=col-header(orange-fill/white-bold) 2=number(#,##0/right) 3=section(cream-fill/orange-bold) 4=bold-label 5=number(#,##0/left) 6=text(@)
7739      var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
7740        +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
7741        +'<fonts count="3">'
7742          +'<font><sz val="11"/><name val="Calibri"/></font>'
7743          +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
7744          +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
7745        +'</fonts>'
7746        +'<fills count="4">'
7747          +'<fill><patternFill patternType="none"/></fill>'
7748          +'<fill><patternFill patternType="gray125"/></fill>'
7749          +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
7750          +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
7751        +'</fills>'
7752        +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
7753        +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
7754        +'<cellXfs count="7">'
7755          +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
7756          +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7757          +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
7758          +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7759          +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
7760          +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
7761          +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
7762        +'</cellXfs>'
7763        +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
7764        +'</styleSheet>';
7765      var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
7766      function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
7767      sheets.forEach(function(sh,sheetIdx){
7768        var rx='<row r="1">';
7769        sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
7770        rx+='</row>';
7771        var rn=2;
7772        sh.rows.forEach(function(row){
7773          if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
7774          if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
7775            rx+='<row r="'+rn+'">';
7776            rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
7777            for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
7778            rx+='</row>';rn++;return;
7779          }
7780          rx+='<row r="'+rn+'">';
7781          row.forEach(function(cell,c){
7782            var ref=colRef(c,rn);
7783            if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
7784            if(typeof cell==='object'&&cell!==null){
7785              var cv=cell.v,cs=cell.s!=null?cell.s:0;
7786              if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
7787              else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
7788              return;
7789            }
7790            if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
7791            rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
7792          });
7793          rx+='</row>';rn++;
7794        });
7795        var cw='';
7796        if(sh.colWidths&&sh.colWidths.length>0){
7797          cw='<cols>';
7798          sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
7799          cw+='</cols>';
7800        }
7801        var tblParts='';
7802        if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
7803          tableCounter++;
7804          var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
7805          var tRef='A1:'+colNm(colCount)+rowCount;
7806          tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7807            +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
7808            +'<autoFilter ref="'+tRef+'"/>'
7809            +'<tableColumns count="'+colCount+'">'
7810            +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
7811            +'</tableColumns>'
7812            +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
7813            +'</table>';
7814          wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7815            +'<Relationships xmlns="'+pns+'relationships">'
7816            +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
7817            +'</Relationships>';
7818          tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
7819        }
7820        wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
7821          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
7822          +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
7823      });
7824      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="'+sns+'" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
7825      var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
7826      var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
7827      var ctXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'+ctOver+ctTable+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>';
7828      var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
7829      var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
7830      var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
7831      wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
7832        +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
7833      var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
7834      var F={'[Content_Types].xml':ctXml,'_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':wbXml,'xl/_rels/workbook.xml.rels':wbRXml,'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml};
7835      var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
7836      sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
7837      Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
7838      Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
7839      var zparts=[],zcds=[],zoff=0,znf=0;
7840      order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});
7841      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
7842      var ea=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
7843      var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
7844      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
7845      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
7846      zout.set(new Uint8Array(ea),zpos);
7847      slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
7848    }
7849    window.resetPerFileTable = function() {
7850      var tbl = document.getElementById('per-file-table');
7851      if (!tbl) return;
7852      var shell = tbl.closest('.table-shell');
7853      if (shell) shell.scrollLeft = 0;
7854      Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
7855      Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7856      if (window._pfPaginationReset) window._pfPaginationReset();
7857      var si = document.getElementById('per-file-search');
7858      if (si) si.value = '';
7859    };
7860    var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
7861    var _titleSlug="{{ title }}".replace(/[^a-zA-Z0-9\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'');
7862    var _commitSlug="{% if let Some(c) = run.git_commit_short %}{{ c }}{% endif %}";
7863    var _exportSlug='per-file_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7864    function getReportExportRows(){var r=[];document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){var tds=tr.querySelectorAll('td');if(tds.length<11)return;r.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim(),tds[3].textContent.trim(),tds[4].textContent.trim(),tds[5].textContent.trim(),tds[6].textContent.trim(),tds[7].textContent.trim(),tds[8].textContent.trim(),tds[9].textContent.trim(),tds[10].textContent.trim()]);});return r;}
7865    window.exportReportCsv=function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());};
7866    window.exportReportXls=function(){
7867      var fname='report_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'')+'.xlsx';
7868      function sec(v){return[{_sec:true,v:v}];}
7869      function B(v){return{v:v,s:4};}
7870      function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
7871      // Table cells render with thousands separators (1,656,153) via the |commas
7872      // filter; Number() on that string is NaN, which would store the value as text
7873      // (green-triangle warning, left-aligned). Strip separators so numeric cells
7874      // become real numbers and align correctly. Non-numeric text is left untouched.
7875      function numify(v){var s=String(v==null?'':v).trim();if(s==='')return s;var t=s.replace(/,/g,'');return /^-?\d+(\.\d+)?$/.test(t)?Number(t):v;}
7876      function pnum(v){var t=String(v==null?'':v).replace(/,/g,'').trim();return /^-?\d+(\.\d+)?$/.test(t)?Number(t):0;}
7877      var dens=_SLOC_META.physicalLines>0?(_SLOC_META.codeLines/_SLOC_META.physicalLines*100).toFixed(1)+'%':'0%';
7878      var sumRows=[
7879        sec('RUN INFORMATION'),
7880        [B('Run ID'),_SLOC_META.runId,''],
7881        [B('Git Commit'),_SLOC_META.gitCommit,''],
7882        [B('Branch'),_SLOC_META.branch,''],
7883        [B('Last Commit By'),_SLOC_META.lastCommitBy,''],
7884        [B('Scan By'),_SLOC_META.scanBy,''],
7885        [B('Scanned'),_SLOC_META.scanned,''],
7886        [B('OS'),_SLOC_META.os,''],
7887        [B('Files Analyzed'),N(_SLOC_META.filesAnalyzed),'Total source files included in this analysis'],
7888        [B('Files Skipped'),N(_SLOC_META.filesSkipped),'Files excluded (binary, unsupported, or policy-filtered)'],
7889        [],
7890        sec('CODE METRICS'),
7891        [B('Physical Lines'),N(_SLOC_META.physicalLines),'Total lines including code, comments, and blanks'],
7892        [B('Code Lines'),N(_SLOC_META.codeLines),'Lines containing executable source code'],
7893        [B('Comments'),N(_SLOC_META.commentLines),'Lines consisting entirely of comments or documentation'],
7894        [B('Blank Lines'),N(_SLOC_META.blankLines),'Empty or whitespace-only lines'],
7895        [B('Mixed Separate'),N(_SLOC_META.mixedSeparate),'Lines with both code and trailing comment, counted separately'],
7896        [B('Functions'),N(_SLOC_META.functions),'Best-effort count of function/method definitions'],
7897        [B('Classes / Types'),N(_SLOC_META.classes),'Best-effort count of class, struct, interface definitions'],
7898        [B('Variables'),N(_SLOC_META.variables),'Best-effort count of variable and constant declarations'],
7899        [B('Imports'),N(_SLOC_META.imports),'Best-effort count of import, include, module-use statements'],
7900        [B('Tests'),N(_SLOC_META.tests),'Best-effort count of test cases (GTest, PyTest, JUnit, etc.)'],
7901        [B('Code Density'),{v:dens,s:6},'Percentage of physical lines that contain executable source code'],
7902        [B('Tool Version'),'oxide-sloc '+_SLOC_META.toolVersion,''],
7903      ];
7904      var langHdrs=['Language','Files','Physical Lines','Code Lines','Comments','Blank Lines','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7905      var langRows=[];
7906      document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7907        var tds=tr.querySelectorAll('td');
7908        var row=[];
7909        Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>0?numify(v):v);});
7910        langRows.push(row);
7911      });
7912      var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7913      var pfRows=[];
7914      document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){
7915        var tds=tr.querySelectorAll('td');
7916        if(tds.length<11)return;
7917        var row=[];
7918        Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>=2?numify(v):v);});
7919        pfRows.push(row);
7920      });
7921      var skHdrs=['File','Status','Warnings'];
7922      var skRows=[];
7923      document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){
7924        var tds=tr.querySelectorAll('td');
7925        if(tds.length<3)return;
7926        skRows.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);
7927      });
7928      var covHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
7929      var covRows=[];
7930      document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7931        var tds=tr.querySelectorAll('td');
7932        if(tds.length<4)return;
7933        var phys=pnum(tds[2].textContent);
7934        var code=pnum(tds[3].textContent);
7935        var densStr=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
7936        var row=[tds[0].textContent.trim(),pnum(tds[1].textContent),phys,code,{v:densStr,s:6}];
7937        for(var i=7;i<Math.min(tds.length,14);i++){row.push(numify(tds[i].textContent.trim()));}
7938        covRows.push(row);
7939      });
7940      slocXlsMulti(fname,[
7941        {name:'Summary',hdrs:['Field / Metric','Value','Description'],rows:sumRows,colWidths:[22,45,55],isKv:true},
7942        {name:'Language Breakdown',hdrs:langHdrs,rows:langRows,colWidths:[16,8,14,12,12,12,8,10,10,10,10,8,10,8]},
7943        {name:'Per-File Detail',hdrs:pfHdrs,rows:pfRows,colWidths:[50,12,12,12,12,10,8,10,10,10,10,8,10,8]},
7944        {name:'Code Coverage',hdrs:covHdrs,rows:covRows,colWidths:[18,7,14,12,13,11,10,10,10,8,11,12]},
7945        {name:'Skipped Files',hdrs:skHdrs,rows:skRows,colWidths:[60,25,50]}
7946      ]);
7947    };
7948    Array.prototype.slice.call(document.querySelectorAll('[data-export-csv]')).forEach(function(btn){btn.addEventListener('click',function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());});});
7949    Array.prototype.slice.call(document.querySelectorAll('[data-export-xls]')).forEach(function(btn){btn.addEventListener('click',window.exportReportXls);});
7950    Array.prototype.slice.call(document.querySelectorAll('[data-reset-table]')).forEach(function(btn){btn.addEventListener('click',window.resetPerFileTable);});
7951    var _skippedRh=['File','Status','Warnings'];
7952    var _skippedSlug='skipped_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7953    function getSkippedExportRows(){var r=[];document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){var tds=tr.querySelectorAll('td');if(tds.length<3)return;r.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);});return r;}
7954    (function(){var b=document.getElementById('skipped-export-csv');if(b)b.addEventListener('click',function(){slocCsv(_skippedSlug+'.csv',_skippedRh,getSkippedExportRows());});})();
7955    (function(){var b=document.getElementById('skipped-export-xls');if(b)b.addEventListener('click',function(){slocXls(_skippedSlug+'.xlsx','Skipped Files',_skippedRh,getSkippedExportRows());});})();
7956    // ── Chart.js initialization ───────────────────────────────────────────────
7957    // Deferred so the browser can repaint (dismiss the loading overlay) before
7958    // the canvas/SVG chart work blocks the main thread.
7959    requestAnimationFrame(function() {
7960    try {
7961    (function() {
7962      var D = {{ lang_chart_json|safe }};
7963      var SUB_D = {{ submodule_chart_json|safe }};
7964      var SCAT_D = {{ scatter_chart_json|safe }};
7965      var SEM_D = {{ semantic_chart_json|safe }};
7966      var HIST_D = {{ file_size_histogram_json|safe }};
7967      if (!D || !D.length) return;
7968
7969      var PALETTE = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030',
7970                     '#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082',
7971                     '#D0743C','#5BA8A0','#8B3A8B','#3D7A3D','#AA5500','#005599'];
7972      var OX = '#C45C10', GN = '#2A6846', GY = '#BBBBBB';
7973      var ALL_CHARTS = [];
7974      function hexAlpha(hex, a) {
7975        var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
7976        return 'rgba('+r+','+g+','+b+','+a+')';
7977      }
7978
7979      function fmt(n) {
7980        var v = Number(n), a = Math.abs(v);
7981        if (a >= 1e6) return (v/1e6).toFixed(1).replace(/\.0$/,'') + 'M';
7982        if (a >= 1e4) return Math.round(v/1e3) + 'K';
7983        return v.toLocaleString();
7984      }
7985      function isDark() { return document.body.classList.contains('dark-theme'); }
7986      function clr() {
7987        return isDark()
7988          ? { text: '#d4c5b8', grid: 'rgba(255,255,255,0.10)' }
7989          : { text: '#43342d', grid: '#e6d0bf' };
7990      }
7991      // Legend-highlight alpha for a dataset's drawn label / marker. `chart.$hiDs` is
7992      // set by legend hover (see attachScatterLegend); when it is null every dataset
7993      // draws at full strength. Once a language is hovered, the others fade so the
7994      // hovered one's marker, name and number stay readable through overlapping
7995      // neighbours. Applied globally to every value-labelled Chart.js plot.
7996      function hiAlpha(chart, di) {
7997        var h = chart.$hiDs;
7998        if (h == null) return 1;
7999        return di === h ? 1 : 0.1;
8000      }
8001      // Inline Chart.js plugin: draws a permanent value label on each bar / bubble.
8002      // fmtFn(rawValue, datasetIndex, pointIndex) → string | null
8003      // anchor: 'top' = above vertical bar, 'end' = right of horizontal bar, 'bubble' = above bubble
8004      function makeDlPlugin(fmtFn, anchor) {
8005        return {
8006          afterDatasetsDraw: function(chart) {
8007            var ctx = chart.ctx;
8008            var tc = clr().text;
8009            chart.data.datasets.forEach(function(ds, di) {
8010              var meta = chart.getDatasetMeta(di);
8011              meta.data.forEach(function(el, idx) {
8012                var label = fmtFn(ds.data[idx], di, idx);
8013                if (label == null || label === '') return;
8014                ctx.save();
8015                ctx.globalAlpha = hiAlpha(chart, di);
8016                ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
8017                ctx.fillStyle = tc;
8018                if (anchor === 'top') {
8019                  ctx.textAlign = 'center';
8020                  ctx.textBaseline = 'bottom';
8021                  ctx.fillText(String(label), el.x, el.y - 3);
8022                } else if (anchor === 'end') {
8023                  ctx.textAlign = 'left';
8024                  ctx.textBaseline = 'middle';
8025                  ctx.fillText(String(label), el.x + 5, el.y);
8026                } else {
8027                  ctx.textAlign = 'center';
8028                  ctx.textBaseline = 'bottom';
8029                  var r = (el.options && el.options.radius) ? el.options.radius : 10;
8030                  ctx.fillText(String(label), el.x, el.y - r - 3);
8031                }
8032                ctx.restore();
8033              });
8034            });
8035          }
8036        };
8037      }
8038      // Bubble-chart value labels (language name + code lines above each bubble).
8039      // Shared by the dashboard card and the Full View modal. Honours the legend
8040      // highlight: non-hovered languages' labels fade and the hovered one is drawn
8041      // last so its name + number sit on top of any overlapping neighbours — the
8042      // clustered bubbles at the origin are otherwise an unreadable pile of text.
8043      function scatterLabelPlugin() {
8044        return { afterDatasetsDraw: function(chart) {
8045          var ctx = chart.ctx, tc = clr().text, hi = chart.$hiDs;
8046          function drawOne(di) {
8047            var d = SCAT_D[di]; if (!d) return;
8048            var meta = chart.getDatasetMeta(di), a = hiAlpha(chart, di);
8049            meta.data.forEach(function(el) {
8050              var r = (el.options && el.options.radius) ? el.options.radius : 10;
8051              var ty2 = Math.max(14, el.y - r - 3), ty1 = Math.max(1, ty2 - 14);
8052              ctx.save();
8053              ctx.globalAlpha = a; ctx.fillStyle = tc;
8054              ctx.textBaseline = 'bottom'; ctx.textAlign = 'center';
8055              ctx.font = '800 11px Inter,ui-sans-serif,sans-serif';
8056              ctx.fillText(d.lang, el.x, ty1);
8057              ctx.font = '700 10px Inter,ui-sans-serif,sans-serif';
8058              ctx.fillText(fmt(d.code), el.x, ty2);
8059              ctx.restore();
8060            });
8061          }
8062          chart.data.datasets.forEach(function(_, di) { if (hi == null || di !== hi) drawOne(di); });
8063          if (hi != null && hi >= 0) drawOne(hi);
8064        } };
8065      }
8066      function makeStackedEndPlugin(fmtFn) {
8067        return {
8068          afterDatasetsDraw: function(chart) {
8069            var ctx = chart.ctx;
8070            var tc = clr().text;
8071            var nDs = chart.data.datasets.length;
8072            if (nDs === 0) return;
8073            var lastMeta = chart.getDatasetMeta(nDs - 1);
8074            lastMeta.data.forEach(function(el, idx) {
8075              var total = 0;
8076              chart.data.datasets.forEach(function(ds) { total += ds.data[idx] || 0; });
8077              var label = fmtFn(total, idx);
8078              if (label == null || label === '') return;
8079              ctx.save();
8080              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
8081              ctx.fillStyle = tc;
8082              ctx.textAlign = 'left';
8083              ctx.textBaseline = 'middle';
8084              ctx.fillText(String(label), el.x + 5, el.y);
8085              ctx.restore();
8086            });
8087          }
8088        };
8089      }
8090
8091      function wireDonutLegend(svg) {
8092        if(!svg) return;
8093        // Every donut element carries data-lang: slices (path/circle), leader lines,
8094        // outside labels + % labels (text) and legend rows (g). Hovering any one of
8095        // them emphasises that language across all of them and fades the rest, so the
8096        // slice, its leader line, its label and its legend row move as one picture.
8097        var items=svg.querySelectorAll('[data-lang]');
8098        function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
8099          var tag=el.tagName.toLowerCase();
8100          if(tag==='path'||tag==='circle'){
8101            if(st===1){el.style.opacity='1';el.style.filter='brightness(1.15) drop-shadow(0 3px 9px rgba(0,0,0,.28))';el.style.transform='scale(1.06)';}
8102            else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
8103            else{el.style.opacity='';el.style.filter='';el.style.transform='';}
8104          }else if(tag==='line'){
8105            if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
8106            else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
8107            else{el.style.opacity='';el.style.strokeWidth='';}
8108          }else if(tag==='text'){
8109            if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
8110            else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
8111            else{el.style.opacity='';el.style.fontWeight='';}
8112          }else{ // legend group
8113            if(st===1){el.style.opacity='1';}
8114            else if(st===-1){el.style.opacity='0.4';}
8115            else{el.style.opacity='';}
8116          }
8117        }
8118        function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
8119        function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
8120        svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}rst();});
8121        svg.addEventListener('mousemove',function(e){var t=e.target;while(t&&t!==svg){if(t.getAttribute&&t.getAttribute('data-lang'))return;t=t.parentNode;}rst();});
8122        svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
8123      }
8124      function wireMixLegend(svg) {
8125        if(!svg) return;
8126        var legGs=svg.querySelectorAll('g[data-kind]');
8127        var allRects=svg.querySelectorAll('rect[data-kind]');
8128        if(!legGs.length) return;
8129        function hlKind(kind) {
8130          for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}
8131          for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}
8132        }
8133        function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
8134        for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
8135      }
8136
8137      // ── Language overview: SVG donut + horizontal stacked bars ───────────────
8138      (function() {
8139        var el = document.getElementById('report-lang-overview');
8140        if (!el || !D || !D.length) return;
8141        var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8142        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
8143        function px(n){return Math.round(n);}
8144        function tt(label,val){return ' class="rchit" data-ttl="'+String(label).replace(/&/g,'&amp;').replace(/"/g,'&quot;')+'" data-ttv="'+String(val).replace(/&/g,'&amp;').replace(/"/g,'&quot;')+'"';}
8145        var tot = D.reduce(function(a,d){return a+d.code;},0)||1;
8146        // Donut — height matches the stacked-bar chart so both panels align
8147        var rHb_d=28;
8148        var DH=Math.max(220,D.length*rHb_d+32);
8149        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48,legX=208,DW=395;
8150        var legCount=D.length;
8151        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
8152        var legYStart=Math.round((DH-legCount*legSpacing)/2);
8153        var ds='<svg id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
8154        // One shared transition on every donut element so slices, leader lines,
8155        // outside labels, % labels and the legend all animate together as a single
8156        // picture when a language is hovered. Slices scale from the donut centre.
8157        ds+='<style>#dnt-svg path,#dnt-svg circle,#dnt-svg line,#dnt-svg text,#dnt-svg g{transition:opacity .22s ease,filter .22s ease,transform .22s ease,stroke-width .22s ease;}#dnt-svg path,#dnt-svg circle{transform-origin:'+cx+'px '+cy+'px;}</style>';
8158        if(D.length===1){
8159          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
8160          ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' data-lang="'+esc(D[0].lang)+'" cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+PALETTE[0]+'" stroke-width="'+rsw+'"/>';
8161        } else {
8162          var smalls=[];
8163          var ang=-Math.PI/2;
8164          D.forEach(function(d,i){
8165            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
8166            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
8167            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
8168            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
8169            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
8170            var pct=Math.round(d.code/tot*100);
8171            ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+pct+'%)')+' data-lang="'+esc(d.lang)+'" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+(PALETTE[i%PALETTE.length])+'" stroke="white" stroke-width="2"/>';
8172            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:PALETTE[i%PALETTE.length]});}
8173            ang+=sw;
8174          });
8175          // Small slices (<5%) get outside labels positioned near each slice's own
8176          // angular position (a slice on the left gets its label/leader on the left),
8177          // then nudged apart horizontally so text never overlaps. Leader lines point
8178          // from each slice to its label. Horizontal text keeps long names legible;
8179          // the whole SVG scales up in Full View so these stay readable there too.
8180          if(smalls.length){
8181            smalls.sort(function(a,b){return a.mAng-b.mAng;});
8182            var sPad=6,sRowY=11;
8183            smalls.forEach(function(sm){sm.txt=sm.lang+' '+sm.pct+'%';sm.w=sm.txt.length*5+8;sm.x=Math.max(sPad+sm.w/2,Math.min(DW-sPad-sm.w/2,cx+(Ro+14)*Math.cos(sm.mAng)));});
8184            for(var si=1;si<smalls.length;si++){var mnX=smalls[si-1].x+smalls[si-1].w/2+smalls[si].w/2+3;if(smalls[si].x<mnX)smalls[si].x=mnX;}
8185            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
8186            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
8187            smalls.forEach(function(sm){
8188              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
8189              ds+='<line data-lang="'+esc(sm.lang)+'" x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
8190              ds+='<text data-lang="'+esc(sm.lang)+'" x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="cursor:pointer;">'+esc(sm.txt)+'</text>';
8191            });
8192          }
8193        }
8194        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
8195        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
8196        D.forEach(function(d,i){
8197          var ly=legYStart+i*legSpacing;
8198          var pctL=Math.round(d.code/tot*100);
8199          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
8200          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
8201          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
8202          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
8203          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(PALETTE[i%PALETTE.length])+'"/>';
8204          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
8205          ds+='<text x="'+(legX+100)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(10,legSpacing-3)+'" font-weight="700" fill="#7b675b">'+fmt(d.code)+' ('+pctL+'%)</text>';
8206          ds+='</g>';
8207        });
8208        ds+='</svg>';
8209        // Horizontal stacked-bar chart
8210        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
8211        var LW=108,BW=260,svgW=LW+BW+68;
8212        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
8213        var barBH=Math.min(32,Math.round(barRhb*0.7));
8214        var SH=DH;
8215        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
8216        var bs='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
8217        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
8218        // it cannot fit legibly even at the 6.5 floor (labels shrink to fit instead
8219        // of disappearing; the SVG scales up in Full View so small fonts stay legible).
8220        function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
8221        D.forEach(function(d,i){
8222          var y=barTopPad+i*barRhb,x=LW;
8223          var phys=d.physical||d.code+d.comments+d.blanks;
8224          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
8225          var lmid=y+barBH/2+4;
8226          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
8227          bs+='<g class="lang-bar-row">';
8228          // Hit area ends just past the total label so empty space to the right of the
8229          // bar does not trigger the tooltip — only the name, bar and total are hot.
8230          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
8231          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
8232          bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
8233          if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
8234          if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
8235          if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
8236          bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
8237          bs+='</g>';
8238        });
8239        var ly=SH-14;
8240        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
8241        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
8242        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
8243        var totAll=totC+totCm+totBl||1;
8244        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
8245        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
8246        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
8247        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
8248        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
8249        bs+='<g data-kind="code" style="cursor:pointer;">'
8250          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
8251          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
8252          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
8253          +'</g>';
8254        bs+='<g data-kind="comment" style="cursor:pointer;">'
8255          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
8256          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
8257          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
8258          +'</g>';
8259        bs+='<g data-kind="blank" style="cursor:pointer;">'
8260          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
8261          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
8262          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
8263          +'</g>';
8264        bs+='</svg>';
8265        el.innerHTML='<div class="r-lang-overview">'+
8266          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
8267          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
8268        '</div>';
8269        wireDonutLegend(el.querySelector('svg'));
8270        wireMixLegend(el.querySelectorAll('svg')[1]);
8271      })();
8272
8273      // Shared cursor helper: pointer over data elements, default elsewhere.
8274      // Added to options.onHover on every Chart.js instance.
8275      // Legend items handled separately via legend.onHover / legend.onLeave.
8276      function chartCursor(e, els) {
8277        var t = e.native && e.native.target;
8278        if (t) t.style.cursor = els.length ? 'pointer' : 'default';
8279      }
8280      function legendCursorOn(e) { var t=e.native&&e.native.target; if(t)t.style.cursor='pointer'; }
8281      function legendCursorOff(e){ var t=e.native&&e.native.target; if(t)t.style.cursor='default'; }
8282      // Pushes a right-positioned legend away from the plot by `gap` px. Chart.js
8283      // (v4) places a right legend flush against the plot area: fit() reserves the
8284      // legend box width and _draw() lays items out from `this.left + padding`, so
8285      // the column hugs the bubbles. We reserve `gap` extra width in fit() (which
8286      // shrinks the plot by `gap`), then translate the canvas right by `gap` while
8287      // the legend draws so the column lands in that reserved space — clear of the
8288      // plot. The legendHitBoxes (used only for hover hit-testing, not drawing) are
8289      // shifted by the same `gap` so hover targets stay aligned with what's drawn.
8290      function legendGapPlugin(gap) {
8291        return {
8292          id: 'legendGap',
8293          beforeInit: function(chart) {
8294            var lg = chart.legend; if (!lg) return;
8295            var origFit = lg.fit, origDraw = lg.draw;
8296            lg.fit = function() { origFit.call(this); this.width += gap; this._needGap = true; };
8297            lg.draw = function() {
8298              if (this._needGap && this.legendHitBoxes) {
8299                this.legendHitBoxes.forEach(function(h){ h.left += gap; });
8300                this._needGap = false;
8301              }
8302              var ctx = this.ctx;
8303              ctx.save();
8304              ctx.translate(gap, 0);
8305              origDraw.call(this);
8306              ctx.restore();
8307            };
8308          }
8309        };
8310      }
8311
8312      // ── Project Overview bar ─────────────────────────────────────────────────
8313      var projChart = null;
8314      (function() {
8315        var ySel = document.getElementById('overview-y-axis');
8316        var xSel = document.getElementById('overview-x-mode');
8317        var el = document.getElementById('overview-chart');
8318        var lockedEl = document.getElementById('overview-chart-locked');
8319        var wrap = document.getElementById('canvas-proj-wrap');
8320        var canvas = document.getElementById('canvas-proj');
8321        if (!canvas || !ySel || !xSel) return;
8322        var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines',
8323                         physical:'Physical Lines', files:'Files', comment:'Comment Lines', blank:'Blank Lines' };
8324        function getData() {
8325          var yKey = ySel.value, mode = xSel.value;
8326          var src = mode === 'submodules' ? SUB_D : D;
8327          var lKey = mode === 'submodules' ? 'name' : 'lang';
8328          var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
8329          return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
8330        }
8331        function renderOverview() {
8332          var mode = xSel.value, isHist = mode.indexOf('history') === 0;
8333          if (el) el.style.display = isHist ? 'none' : 'block';
8334          if (lockedEl) lockedEl.style.display = isHist ? 'block' : 'none';
8335          if (isHist) return;
8336          var r = getData();
8337          var c = clr();
8338          if (wrap) wrap.style.height = Math.max(200, Math.min(432, r.sorted.length * 29 + 60)) + 'px';
8339          if (projChart) {
8340            projChart.data.labels = r.sorted.map(function(d){return d[r.lKey];});
8341            projChart.data.datasets[0].data = r.sorted.map(function(d){return d[r.yKey]||0;});
8342            projChart.data.datasets[0].backgroundColor = r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
8343            projChart.data.datasets[0].label = r.yLabel;
8344            projChart.options.scales.x.title.text = r.yLabel;
8345            projChart.update('none'); return;
8346          }
8347          projChart = new Chart(canvas, {
8348            type: 'bar',
8349            data: {
8350              labels: r.sorted.map(function(d){return d[r.lKey];}),
8351              datasets: [{ label: r.yLabel,
8352                data: r.sorted.map(function(d){return d[r.yKey]||0;}),
8353                backgroundColor: r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8354                borderRadius: 3 }]
8355            },
8356            options: {
8357              indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8358              onHover: chartCursor,
8359              animation: { duration: 500, easing: 'easeOutQuart' },
8360              layout: { padding: { right: 64 } },
8361              scales: {
8362                x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8363                     title: { display: true, text: r.yLabel, color: c.text } },
8364                y: { grid: { display: false }, ticks: { color: c.text } }
8365              },
8366              plugins: {
8367                legend: { display: false },
8368                tooltip: {
8369                  callbacks: {
8370                    title: function(items) { return items.length ? items[0].label : ''; },
8371                    label: function(ctx) {
8372                      return '  ' + ctx.dataset.label + ': ' + Number(ctx.parsed.x).toLocaleString();
8373                    }
8374                  }
8375                }
8376              }
8377            },
8378            plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
8379          });
8380          ALL_CHARTS.push(projChart);
8381        }
8382        ySel.addEventListener('change', renderOverview);
8383        xSel.addEventListener('change', renderOverview);
8384        renderOverview();
8385
8386        var overviewExpandBtn = document.getElementById('overview-expand-btn');
8387        if (overviewExpandBtn) {
8388          overviewExpandBtn.addEventListener('click', function() {
8389            var r = getData();
8390            var n = r.sorted.length || 1;
8391            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8392            var modalH = Math.min(Math.max(480, n * 29 + 96), maxH);
8393            var overlay = document.createElement('div');
8394            overlay.className = 'chart-modal-overlay';
8395            overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;">'
8396              + '<button class="chart-modal-close" aria-label="Close">&times;</button>'
8397              + '<div class="chart-modal-header">'
8398              + '<span class="chart-modal-title">Project Overview \u2014 Full View</span>'
8399              + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
8400              + '<select id="ov-modal-y" class="chart-select">'
8401              + '<option value="code">Code Lines</option>'
8402              + '<option value="comments">Comment Lines</option>'
8403              + '<option value="blanks">Blank Lines</option>'
8404              + '<option value="physical">Total Physical Lines</option>'
8405              + '<option value="files">File Count</option>'
8406              + '</select></label>'
8407              + (SUB_D && SUB_D.length ? '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;">X Axis:'
8408              + '<select id="ov-modal-x" class="chart-select">'
8409              + '<option value="languages">Languages</option>'
8410              + '<option value="submodules">Submodules</option>'
8411              + '</select></label>' : '')
8412              + '</div>'
8413              + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-proj-modal"></canvas></div></div>';
8414            document.body.appendChild(overlay);
8415            overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
8416            overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
8417            var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines', physical:'Physical Lines', files:'Files' };
8418            var modalYSel = document.getElementById('ov-modal-y');
8419            var modalXSel = document.getElementById('ov-modal-x');
8420            if (modalYSel) modalYSel.value = ySel ? ySel.value : 'code';
8421            if (modalXSel && xSel) modalXSel.value = (xSel.value === 'languages' || xSel.value === 'submodules') ? xSel.value : 'languages';
8422            var modalCanvas = document.getElementById('canvas-proj-modal');
8423            if (!modalCanvas) return;
8424            var c = clr();
8425            function getModalData() {
8426              var yKey = modalYSel ? modalYSel.value : 'code';
8427              var mode = modalXSel ? modalXSel.value : 'languages';
8428              var src = mode === 'submodules' ? SUB_D : D;
8429              var lKey = mode === 'submodules' ? 'name' : 'lang';
8430              var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
8431              return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
8432            }
8433            var ovModalChart = null;
8434            function renderOverviewModal() {
8435              var r2 = getModalData();
8436              if (ovModalChart) {
8437                ovModalChart.data.labels = r2.sorted.map(function(d){return d[r2.lKey];});
8438                ovModalChart.data.datasets[0].data = r2.sorted.map(function(d){return d[r2.yKey]||0;});
8439                ovModalChart.data.datasets[0].backgroundColor = r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
8440                ovModalChart.data.datasets[0].label = r2.yLabel;
8441                ovModalChart.options.scales.x.title.text = r2.yLabel;
8442                ovModalChart.update('none'); return;
8443              }
8444              ovModalChart = new Chart(modalCanvas, {
8445                type: 'bar',
8446                data: {
8447                  labels: r2.sorted.map(function(d){return d[r2.lKey];}),
8448                  datasets: [{ label: r2.yLabel,
8449                    data: r2.sorted.map(function(d){return d[r2.yKey]||0;}),
8450                    backgroundColor: r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8451                    borderRadius: 3 }]
8452                },
8453                options: {
8454                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8455                  onHover: chartCursor,
8456                  animation: { duration: 500, easing: 'easeOutQuart' },
8457                  layout: { padding: { right: 64 } },
8458                  scales: {
8459                    x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
8460                         title:{display:true, text:r2.yLabel, color:c.text} },
8461                    y: { grid:{display:false}, ticks:{color:c.text} }
8462                  },
8463                  plugins: {
8464                    legend:{display:false},
8465                    tooltip:{callbacks:{
8466                      title:function(items){return items.length?items[0].label:'';},
8467                      label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x).toLocaleString();}
8468                    }}
8469                  }
8470                },
8471                plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
8472              });
8473            }
8474            renderOverviewModal();
8475            if (modalYSel) modalYSel.addEventListener('change', renderOverviewModal);
8476            if (modalXSel) modalXSel.addEventListener('change', renderOverviewModal);
8477          });
8478        }
8479      })();
8480
8481      // ── Language Composition (SVG — matches /runs/result behaviour) ──────────
8482      (function() {
8483        var el = document.getElementById('comp-svg-container');
8484        if (!el || !D || !D.length) return;
8485        var cData = D.slice(0, 15);
8486        var cMode = 'absolute';
8487        var CX = OX, CG = GN, CB = '#BBBBBB';
8488        var CFONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
8489        function cEsc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
8490        function cPx(n){return Math.round(n);}
8491        function cTT(l,v){return ' class="rchit" data-ttl="'+String(l).replace(/&/g,'&amp;').replace(/"/g,'&quot;')+'" data-ttv="'+String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;')+'"';}
8492        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if it
8493        // cannot fit legibly even at the 6.5 floor (labels shrink to fit rather than
8494        // disappear; the SVG scales up in Full View so small fonts stay legible).
8495        function cFitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
8496        function cLT(l,v){return ' data-ttl="'+l+'" data-ttv="'+v.replace(/"/g,'&quot;')+'"';}
8497        function renderCompSVG() {
8498          var isPct = cMode === 'pct';
8499          var totC=cData.reduce(function(a,d){return a+(d.code||0);},0);
8500          var totCm=cData.reduce(function(a,d){return a+(d.comments||0);},0);
8501          var totBl=cData.reduce(function(a,d){return a+(d.blanks||0);},0);
8502          var totAll=totC+totCm+totBl||1;
8503          var svgW=Math.max(320,el.offsetWidth||540);
8504          var LW=108,legendH=24,topPad=4;
8505          var MIN_SVG_H=220;
8506          var rHb=Math.min(80,Math.max(26,Math.floor((MIN_SVG_H-legendH-topPad-10)/cData.length)));
8507          var bH=Math.min(38,Math.round(rHb*0.68));
8508          var BW=Math.max(120,svgW-LW-84);
8509          var SH=Math.max(MIN_SVG_H,cData.length*rHb+legendH+topPad+10);
8510          var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
8511          if(isPct){
8512            cData.forEach(function(d,i){
8513              var t2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
8514              var cW=(d.code||0)/t2*BW,cmW=(d.comments||0)/t2*BW,blW=(d.blanks||0)/t2*BW;
8515              var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
8516              var lmid=y+Math.floor(bH/2)+4;
8517              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||t2);
8518              s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+CFONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+cEsc(d.lang)+'</text>';
8519              if(cW>0.5){s+='<rect'+cTT(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cW)+'" height="'+bH+'" fill="'+CX+'"/>';var _fc=cFitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+cPx(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
8520              if(cmW>0.5){s+='<rect'+cTT(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cmW)+'" height="'+bH+'" fill="'+CG+'"/>';var _fm=cFitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+cPx(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
8521              if(blW>0.5){s+='<rect'+cTT(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(blW)+'" height="'+bH+'" fill="'+CB+'"/>';var _fb=cFitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+cPx(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
8522              s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+CFONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+Math.round((d.code||0)/t2*100)+'%</text>';
8523            });
8524          } else {
8525            var maxT=Math.max.apply(null,cData.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);})) || 1;
8526            cData.forEach(function(d,i){
8527              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
8528              var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
8529              var lmid=y+Math.floor(bH/2)+4;
8530              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0));
8531              s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+CFONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+cEsc(d.lang)+'</text>';
8532              if(cW>0.5){s+='<rect'+cTT(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cW)+'" height="'+bH+'" fill="'+CX+'"/>';var _fc=cFitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+cPx(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
8533              if(cmW>0.5){s+='<rect'+cTT(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cmW)+'" height="'+bH+'" fill="'+CG+'"/>';var _fm=cFitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+cPx(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
8534              if(blW>0.5){s+='<rect'+cTT(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(blW)+'" height="'+bH+'" fill="'+CB+'"/>';var _fb=cFitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+cPx(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
8535              var phys=d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0);
8536              s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+CFONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
8537            });
8538          }
8539          var ly=SH-legendH+4;
8540          var ttC=cLT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
8541          var ttCm=cLT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
8542          var ttBl=cLT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
8543          var legSt=LW+Math.max(0,Math.round((BW-194)/2));
8544          s+='<g data-kind="code" style="cursor:pointer;"><rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/><rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+CX+'"'+ttC+'/><text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text></g>';
8545          s+='<g data-kind="comment" style="cursor:pointer;"><rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/><rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+CG+'"'+ttCm+'/><text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text></g>';
8546          s+='<g data-kind="blank" style="cursor:pointer;"><rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/><rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+CB+'"'+ttBl+'/><text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text></g>';
8547          s+='</svg>';
8548          el.innerHTML=s;
8549          wireMixLegend(el.querySelector('svg'));
8550        }
8551        document.querySelectorAll('[data-comp-tab]').forEach(function(btn){
8552          btn.addEventListener('click', function(){
8553            document.querySelectorAll('[data-comp-tab]').forEach(function(b){b.classList.remove('active');});
8554            btn.classList.add('active');
8555            cMode=btn.getAttribute('data-comp-tab');
8556            renderCompSVG();
8557          });
8558        });
8559        renderCompSVG();
8560        window.addEventListener('resize', renderCompSVG);
8561      })();
8562
8563      // Custom HTML legend for the bubble chart: balanced columns (~15 rows max
8564      // per column, evenly distributed) placed to the right of the canvas. Chart.js'
8565      // native right-legend fills one column to full height then dumps the rest into
8566      // a tiny second column (and clips when space is tight) — this gives even columns
8567      // and never hides languages. Hover mirrors the old dim/highlight behaviour.
8568      //
8569      // Must run BEFORE `new Chart(canvas, …)` so Chart.js' resize observer binds to
8570      // the inner wrapper (not the full-width host). Returns a holder whose `.chart`
8571      // field the caller assigns once the chart exists, so hover can drive it.
8572      // expandBtnId: when set (compact card), the legend is capped to what fits in
8573      // 2 columns at the available height and a trailing "+N more" row links to Full
8574      // View. When null (Full View itself) every language is shown across (up to) 2
8575      // tall columns. Languages are ordered by code lines so the compact view keeps
8576      // the biggest ones; colours/hover still key off each language's original index.
8577      function attachScatterLegend(canvas, expandBtnId) {
8578        var holder = { chart: null };
8579        var host = canvas && canvas.parentNode;
8580        if (!host) return holder;
8581        host.style.display = 'flex';
8582        host.style.alignItems = 'center';
8583        host.style.gap = '12px';
8584        var cwrap = document.createElement('div');
8585        cwrap.style.cssText = 'position:relative;flex:1 1 auto;min-width:0;height:100%;';
8586        host.insertBefore(cwrap, canvas);
8587        cwrap.appendChild(canvas);
8588
8589        var n = SCAT_D.length;
8590        var availH = Math.max(120, host.clientHeight || 224);
8591        var rowsFit = Math.max(2, Math.floor(availH / 18));   // readable pitch
8592        // Never more than 2 columns; compact view truncates to fit, Full View shows all.
8593        var truncated = expandBtnId ? (n > 2 * rowsFit) : false;
8594        var realShown = truncated ? (2 * rowsFit - 1) : n;
8595        var totalItems = truncated ? (2 * rowsFit) : n;
8596        // Split into 2 equal columns once a single column would exceed ~18 rows, even
8597        // when the (tall) Full-View modal could fit them all in one column.
8598        var cols = totalItems > Math.min(rowsFit, 18) ? 2 : 1;
8599        var perCol = Math.ceil(totalItems / cols);
8600        var rowH = Math.max(14, Math.min(30, Math.floor(availH / perCol)));
8601
8602        // Order by code lines desc so the compact view keeps the biggest languages.
8603        var order = SCAT_D.map(function(_, i){ return i; })
8604          .sort(function(a, b){ return (SCAT_D[b].code || 0) - (SCAT_D[a].code || 0); });
8605
8606        var leg = document.createElement('div');
8607        leg.style.cssText = 'flex:0 0 auto;display:grid;grid-auto-flow:column;'
8608          + 'grid-template-rows:repeat(' + perCol + ',' + rowH + 'px);column-gap:18px;'
8609          + 'align-content:center;font-size:12px;line-height:1;';
8610        function setHi(idx) {
8611          var chart = holder.chart; if (!chart) return;
8612          chart.$hiDs = idx;   // read by scatterLabelPlugin so labels fade in step
8613          chart.data.datasets.forEach(function(ds, i) {
8614            var b = PALETTE[i % PALETTE.length];
8615            ds.backgroundColor = i === idx ? b + 'b8' : b + '20';
8616            ds.borderColor = i === idx ? b : b + '30';
8617          });
8618          chart.setActiveElements([{ datasetIndex: idx, index: 0 }]);
8619          chart.update();
8620        }
8621        function clearHi() {
8622          var chart = holder.chart; if (!chart) return;
8623          chart.$hiDs = null;
8624          chart.data.datasets.forEach(function(ds, i) {
8625            var b = PALETTE[i % PALETTE.length];
8626            ds.backgroundColor = b + 'b8';
8627            ds.borderColor = b;
8628          });
8629          chart.setActiveElements([]);
8630          chart.update('none');
8631        }
8632        function addItem(swColor, label, idx, isMore) {
8633          var it = document.createElement('div');
8634          it.style.cssText = 'display:flex;align-items:center;gap:7px;white-space:nowrap;'
8635            + ((idx != null || isMore) ? 'cursor:pointer;' : '');
8636          var sw = document.createElement('span');
8637          sw.style.cssText = 'width:22px;height:12px;border-radius:2px;flex:0 0 auto;background:'
8638            + swColor + ';' + (isMore ? 'opacity:0.45;' : '');
8639          var tx = document.createElement('span');
8640          tx.textContent = label;
8641          if (isMore) { tx.style.fontStyle = 'italic'; tx.style.opacity = '0.8'; }
8642          it.appendChild(sw); it.appendChild(tx);
8643          if (idx != null) {
8644            it.addEventListener('mouseenter', function(){ setHi(idx); });
8645            it.addEventListener('mouseleave', clearHi);
8646          }
8647          if (isMore) {
8648            it.addEventListener('click', function(){
8649              var b = document.getElementById(expandBtnId); if (b) b.click();
8650            });
8651          }
8652          leg.appendChild(it);
8653        }
8654        for (var k = 0; k < realShown; k++) {
8655          var oi = order[k];
8656          addItem(PALETTE[oi % PALETTE.length], SCAT_D[oi].lang, oi, false);
8657        }
8658        if (truncated) addItem('#9a8c82', '+' + (n - realShown) + ' more — Full View', null, true);
8659        host.appendChild(leg);
8660        return holder;
8661      }
8662
8663      // ── Scatter / Bubble chart ────────────────────────────────────────────────
8664      (function() {
8665        var canvas = document.getElementById('canvas-scatter');
8666        if (!canvas || !SCAT_D || !SCAT_D.length) return;
8667        var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8668        var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8669        var c = clr();
8670        var legHolder = attachScatterLegend(canvas, 'scatter-expand-btn');
8671        var chart = new Chart(canvas, {
8672          type: 'bubble',
8673          data: {
8674            datasets: SCAT_D.map(function(d, i) {
8675              return {
8676                label: d.lang,
8677                data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8678                backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8679                borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8680                hoverBorderWidth: 2
8681              };
8682            })
8683          },
8684          options: {
8685            responsive: true, maintainAspectRatio: false,
8686            onHover: chartCursor,
8687            animation: { duration: 500, easing: 'easeOutQuart' },
8688            layout: { padding: { top: 44, right: 12 } },
8689            scales: {
8690              x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6,
8691                   grid: { color: c.grid },
8692                   ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } },
8693                   title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
8694              y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} },
8695                   title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
8696            },
8697            plugins: {
8698              legend: { display: false },
8699              tooltip: {
8700                callbacks: {
8701                  title: function(items) { return items.length ? items[0].dataset.label : ''; },
8702                  label: function(ctx){
8703                    var d = SCAT_D[ctx.datasetIndex];
8704                    return [
8705                      '  Files analyzed: ' + fmt(d.files),
8706                      '  Code lines: ' + Number(d.code).toLocaleString(),
8707                      '  Physical lines: ' + Number(d.physical).toLocaleString()
8708                    ];
8709                  }
8710                }
8711              }
8712            }
8713          },
8714          plugins: [scatterLabelPlugin()]
8715        });
8716        ALL_CHARTS.push(chart);
8717        legHolder.chart = chart;
8718      })();
8719
8720      // ── Submodule breakdown ──────────────────────────────────────────────────
8721      // No-op plugins: hover row-dimming was removed because the flashing row
8722      // background looked out of place vs. every other chart. Kept as empty stubs
8723      // so the (inline + Full View) chart configs that reference them stay valid.
8724      var rowDimPlugin = {};
8725      var barJumpPlugin = {};
8726      var subChart = null;
8727      (function() {
8728        if (!SUB_D || !SUB_D.length) return;
8729        var subYSel = document.getElementById('sub-y-axis');
8730        var subSortSel = document.getElementById('sub-sort');
8731        var wrap = document.getElementById('canvas-sub-wrap');
8732        var canvas = document.getElementById('canvas-sub');
8733        if (!canvas) return;
8734        var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines',
8735                         physical:'Physical Lines', files:'Files' };
8736        var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8737        function renderSubmodule() {
8738          var yKey = subYSel ? subYSel.value : 'code';
8739          var sortMode = subSortSel ? subSortSel.value : 'desc';
8740          var data = SUB_D.slice();
8741          if (sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8742          else if (sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8743          else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8744          data = data.slice(0, 30);
8745          var c = clr();
8746          var col = SUB_COLS[yKey] || OX;
8747          if (wrap) wrap.style.height = Math.max(200, Math.min(540, data.length * 28 + 60)) + 'px';
8748          if (subChart) {
8749            subChart.data.labels = data.map(function(d){return d.name;});
8750            subChart.data.datasets[0].data = data.map(function(d){return d[yKey]||0;});
8751            subChart.data.datasets[0].backgroundColor = col;
8752            subChart.data.datasets[0].label = Y_LABELS[yKey]||yKey;
8753            subChart.options.scales.x.title.text = Y_LABELS[yKey]||yKey;
8754            subChart.update('none'); return;
8755          }
8756          subChart = new Chart(canvas, {
8757            type: 'bar',
8758            data: {
8759              labels: data.map(function(d){return d.name;}),
8760              datasets: [{ label: Y_LABELS[yKey]||yKey,
8761                data: data.map(function(d){return d[yKey]||0;}),
8762                backgroundColor: col, hoverBackgroundColor: col === OX ? '#d97020' : col,
8763                borderRadius: 3 }]
8764            },
8765            options: {
8766              indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8767              onHover: chartCursor,
8768              animation: { duration: 500, easing: 'easeOutQuart' },
8769              transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8770              layout: { padding: { right: 64 } },
8771              scales: {
8772                x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8773                     title: { display: true, text: Y_LABELS[yKey]||yKey, color: c.text } },
8774                y: { grid: { display: false }, ticks: { color: c.text } }
8775              },
8776              plugins: {
8777                legend: { display: false },
8778                tooltip: {
8779                  callbacks: {
8780                    title: function(items) { return items.length ? items[0].label : ''; },
8781                    label: function(ctx){
8782                      var d = data[ctx.dataIndex] || {};
8783                      return [
8784                        '  Code: ' + Number(d.code||0).toLocaleString(),
8785                        '  Comments: ' + Number(d.comment||0).toLocaleString(),
8786                        '  Blanks: ' + Number(d.blank||0).toLocaleString(),
8787                        '  Physical: ' + Number(d.physical||0).toLocaleString(),
8788                        '  Files: ' + fmt(d.files||0)
8789                      ];
8790                    }
8791                  }
8792                }
8793              }
8794            },
8795            plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end'), barJumpPlugin]
8796          });
8797          ALL_CHARTS.push(subChart);
8798        }
8799        if (subYSel) subYSel.addEventListener('change', renderSubmodule);
8800        if (subSortSel) subSortSel.addEventListener('change', renderSubmodule);
8801        renderSubmodule();
8802      })();
8803
8804      // ── Submodule composition: stacked horizontal bar (Chart.js) ─────────────
8805      var subCompChart = null;
8806      // Plugin: draw value label inside each visible segment of a stacked horizontal bar.
8807      var segLabelPlugin = {
8808        afterDatasetsDraw: function(chart) {
8809          var ctx = chart.ctx, nDs = chart.data.datasets.length;
8810          var tc = clr().text;
8811          for (var di = 0; di < nDs; di++) {
8812            var meta = chart.getDatasetMeta(di);
8813            if (meta.hidden) continue;
8814            meta.data.forEach(function(el, idx) {
8815              var v = chart.data.datasets[di].data[idx] || 0;
8816              if (!v) return;
8817              var w = Math.abs(el.x - el.base);
8818              if (w < 28) return; // too narrow to show label
8819              ctx.save();
8820              ctx.font = '600 10px Inter,ui-sans-serif,sans-serif';
8821              ctx.fillStyle = di === 0 ? '#fff' : (di === 1 ? '#fff' : '#555');
8822              ctx.textAlign = 'center';
8823              ctx.textBaseline = 'middle';
8824              ctx.fillText(fmt(v), el.base + w / 2, el.y);
8825              ctx.restore();
8826            });
8827          }
8828        }
8829      };
8830      (function() {
8831        var el = document.getElementById('submodule-donut');
8832        if (!el || !SUB_D || !SUB_D.length) return;
8833        var data = SUB_D.slice().sort(function(a,b){
8834          return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));
8835        }).slice(0, 15);
8836        var h = Math.max(150, Math.min(540, data.length * 40 + 90));
8837        el.style.height = h + 'px';
8838        el.style.position = 'relative';
8839        var cv = document.createElement('canvas');
8840        cv.id = 'canvas-sub-comp';
8841        el.innerHTML = '';
8842        el.appendChild(cv);
8843        var c = clr();
8844        subCompChart = new Chart(cv, {
8845          type: 'bar',
8846          data: {
8847            labels: data.map(function(d){ return d.name; }),
8848            datasets: [
8849              { label: 'Code',     data: data.map(function(d){ return d.code||0; }),    backgroundColor: OX, hoverBackgroundColor: '#d97020', borderRadius: 0, borderSkipped: false },
8850              { label: 'Comments', data: data.map(function(d){ return d.comment||0; }), backgroundColor: GN, hoverBackgroundColor: '#3a8a5e', borderRadius: 0, borderSkipped: false },
8851              { label: 'Blank',    data: data.map(function(d){ return d.blank||0; }),   backgroundColor: GY, hoverBackgroundColor: '#999',    borderRadius: 0, borderSkipped: false }
8852            ]
8853          },
8854          options: {
8855            indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8856            onHover: chartCursor,
8857            animation: { duration: 500, easing: 'easeOutQuart' },
8858            transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8859            layout: { padding: { right: 56 } },
8860            scales: {
8861              x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){ return fmt(v); } } },
8862              y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8863            },
8864            plugins: {
8865              legend: {
8866                position: 'bottom',
8867                labels: { color: c.text, usePointStyle: true, pointStyle: 'rect', font: { size: 11, weight: '700' }, padding: 16 },
8868                onHover: function(e, item, leg) {
8869                  legendCursorOn(e);
8870                  var ch = leg.chart, di = item.datasetIndex;
8871                  var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8872                  ch.data.datasets.forEach(function(ds, i) {
8873                    ds.backgroundColor = i===di ? orig[i] : hexAlpha(orig[i], 0.15);
8874                    ds.hoverBackgroundColor = i===di ? hov[i] : hexAlpha(orig[i], 0.15);
8875                  });
8876                  // show tooltip on first bar row with all datasets (index mode)
8877                  var n = ch.data.datasets.length, ae = [];
8878                  for (var ii = 0; ii < n; ii++) { ae.push({ datasetIndex: ii, index: 0 }); }
8879                  var fp = ch.getDatasetMeta(di).data[0];
8880                  ch.setActiveElements([{ datasetIndex: di, index: 0 }]);
8881                  ch.tooltip.setActiveElements(ae, fp ? { x: fp.x, y: fp.y } : { x: 0, y: 0 });
8882                  ch.update();
8883                },
8884                onLeave: function(e, item, leg) {
8885                  legendCursorOff(e);
8886                  var ch = leg.chart;
8887                  var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8888                  ch.data.datasets.forEach(function(ds, i) { ds.backgroundColor = orig[i]; ds.hoverBackgroundColor = hov[i]; });
8889                  ch.setActiveElements([]);
8890                  ch.tooltip.setActiveElements([], {});
8891                  ch.update('none');
8892                }
8893              },
8894              tooltip: {
8895                mode: 'index',
8896                callbacks: {
8897                  title: function(items){ return items.length ? items[0].label : ''; },
8898                  label: function(ctx){
8899                    var v = ctx.parsed.x || 0;
8900                    return '  ' + ctx.dataset.label + ': ' + Number(v).toLocaleString();
8901                  },
8902                  footer: function(items){
8903                    var tot = items.reduce(function(s,i){ return s + (i.parsed.x||0); }, 0);
8904                    return 'Total: ' + Number(tot).toLocaleString();
8905                  }
8906                }
8907              }
8908            }
8909          },
8910          plugins: [makeStackedEndPlugin(function(v){ return fmt(v); }), segLabelPlugin, rowDimPlugin]
8911        });
8912        ALL_CHARTS.push(subCompChart);
8913      })();
8914
8915      // ── Semantic Metrics ─────────────────────────────────────────────────────
8916      (function() {
8917        if (!SEM_D || !SEM_D.length) return;
8918        var semSel = document.getElementById('semantic-metric');
8919        var canvas = document.getElementById('canvas-semantic');
8920        if (!canvas) return;
8921        var SEM_LABELS = { functions:'Functions', classes:'Classes / Types', variables:'Variables',
8922                           imports:'Imports', tests:'Tests' };
8923        var SEM_COLS = { functions:OX, classes:'#4472C4', variables:GN, imports:'#805099', tests:'#B23030' };
8924        var SEM_HCOLS = { functions:'#d97020', classes:'#5a8ad8', variables:'#3a8a5e', imports:'#9a68b3', tests:'#cc4545' };
8925        var semChart = null;
8926        function renderSemantic() {
8927          var mKey = semSel ? semSel.value : 'functions';
8928          var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
8929          var c = clr();
8930          var col = SEM_COLS[mKey] || OX;
8931          var hCol = SEM_HCOLS[mKey] || '#d97020';
8932          if (semChart) {
8933            semChart.data.labels = data.map(function(d){return d.lang;});
8934            semChart.data.datasets[0].data = data.map(function(d){return d[mKey]||0;});
8935            semChart.data.datasets[0].backgroundColor = col;
8936            semChart.data.datasets[0].hoverBackgroundColor = hCol;
8937            semChart.data.datasets[0].label = SEM_LABELS[mKey]||mKey;
8938            semChart.update('none'); return;
8939          }
8940          semChart = new Chart(canvas, {
8941            type: 'bar',
8942            data: {
8943              labels: data.map(function(d){return d.lang;}),
8944              datasets: [{ label: SEM_LABELS[mKey]||mKey,
8945                data: data.map(function(d){return d[mKey]||0;}),
8946                backgroundColor: col, hoverBackgroundColor: hCol,
8947                borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8948            },
8949            options: {
8950              responsive: true, maintainAspectRatio: false,
8951              onHover: chartCursor,
8952              animation: { duration: 500, easing: 'easeOutQuart' },
8953              transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8954              layout: { padding: { top: 18 } },
8955              scales: {
8956                x: { grid: { display: false }, ticks: { color: c.text } },
8957                y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8958              },
8959              plugins: {
8960                legend: { display: false },
8961                tooltip: {
8962                  callbacks: {
8963                    title: function(items) { return items.length ? items[0].label : ''; },
8964                    label: function(ctx) {
8965                      var d = data[ctx.dataIndex] || {};
8966                      var lines = ['  ' + (SEM_LABELS[mKey]||mKey) + ': ' + Number(ctx.parsed.y).toLocaleString()];
8967                      var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== mKey && (d[k]||0) > 0; });
8968                      others.forEach(function(k) {
8969                        lines.push('  ' + SEM_LABELS[k] + ': ' + Number(d[k]||0).toLocaleString());
8970                      });
8971                      return lines;
8972                    }
8973                  }
8974                }
8975              }
8976            },
8977            plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8978          });
8979          ALL_CHARTS.push(semChart);
8980        }
8981        if (semSel) semSel.addEventListener('change', renderSemantic);
8982        renderSemantic();
8983
8984        var semExpandBtn = document.getElementById('semantic-expand-btn');
8985        if (semExpandBtn) {
8986          semExpandBtn.addEventListener('click', function() {
8987            var mKey = semSel ? semSel.value : 'functions';
8988            var n = SEM_D.length || 1;
8989            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8990            var modalH = Math.min(Math.max(400, n * 46 + 96), maxH);
8991            var overlay = document.createElement('div');
8992            overlay.className = 'chart-modal-overlay';
8993            var semOptHtml = '<option value="functions">Functions</option>'
8994              + '<option value="classes">Classes / Types</option>'
8995              + '<option value="variables">Variables</option>'
8996              + '<option value="imports">Imports</option>'
8997              + '<option value="tests">Tests</option>';
8998            var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">Semantic Metrics \u2014 Full View</span>'
8999              + '<select class="chart-select" id="sem-modal-metric">' + semOptHtml + '</select></div>';
9000            overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;"><button class="chart-modal-close" aria-label="Close">&times;</button>' + hdr + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-semantic-modal"></canvas></div></div>';
9001            document.body.appendChild(overlay);
9002            overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
9003            overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
9004            var modalSel = document.getElementById('sem-modal-metric');
9005            if (modalSel) modalSel.value = mKey;
9006            var modalCanvas = document.getElementById('canvas-semantic-modal');
9007            var semModalChart = null;
9008            function renderSemModal(key) {
9009              if (semModalChart) { semModalChart.destroy(); semModalChart = null; }
9010              if (!modalCanvas) return;
9011              var data = SEM_D.slice().sort(function(a,b){return (b[key]||0)-(a[key]||0);});
9012              var c = clr();
9013              var col = SEM_COLS[key] || OX;
9014              var hcol = SEM_HCOLS[key] || '#d97020';
9015              semModalChart = new Chart(modalCanvas, {
9016                type: 'bar',
9017                data: {
9018                  labels: data.map(function(d){return d.lang;}),
9019                  datasets: [{ label: SEM_LABELS[key]||key, data: data.map(function(d){return d[key]||0;}),
9020                    backgroundColor: col, hoverBackgroundColor: hcol,
9021                    borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
9022                },
9023                options: {
9024                  responsive: true, maintainAspectRatio: false,
9025                  onHover: chartCursor,
9026                  animation: { duration: 500, easing: 'easeOutQuart' },
9027                  transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
9028                  layout: { padding: { top: 18 } },
9029                  scales: {
9030                    x: { grid: { display: false }, ticks: { color: c.text } },
9031                    y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
9032                  },
9033                  plugins: { legend: { display: false }, tooltip: { callbacks: {
9034                    title: function(items){return items.length?items[0].label:'';},
9035                    label: function(ctx){
9036                      var d = data[ctx.dataIndex] || {};
9037                      var lines = ['  '+(SEM_LABELS[key]||key)+': '+Number(ctx.parsed.y).toLocaleString()];
9038                      var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== key && (d[k]||0) > 0; });
9039                      others.forEach(function(k){ lines.push('  '+SEM_LABELS[k]+': '+Number(d[k]||0).toLocaleString()); });
9040                      return lines;
9041                    }
9042                  }}}
9043                },
9044                plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
9045              });
9046            }
9047            renderSemModal(mKey);
9048            if (modalSel) modalSel.addEventListener('change', function() { renderSemModal(this.value); });
9049          });
9050        }
9051      })();
9052
9053      // ── Comment Density: comments / (code + comments) per language ──────────
9054      (function() {
9055        var canvas = document.getElementById('canvas-density');
9056        if (!canvas || !D || !D.length) return;
9057        var data = D.slice().sort(function(a,b){
9058          var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
9059          var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
9060          return db-da;
9061        });
9062        var labels = data.map(function(d){return d.lang;});
9063        var densities = data.map(function(d){
9064          var sig=(d.code||0)+(d.comments||0);
9065          return sig>0?Math.round((d.comments||0)/sig*1000)/10:0;
9066        });
9067        var wrap = canvas.parentElement;
9068        if (wrap) wrap.style.height = Math.max(150, Math.min(500, data.length*29+36))+'px';
9069        var c = clr();
9070        var densChart = new Chart(canvas, {
9071          type: 'bar',
9072          data: {
9073            labels: labels,
9074            datasets: [{ label: 'Comment %',
9075              data: densities,
9076              backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}),
9077              borderRadius: 4
9078            }]
9079          },
9080          options: {
9081            indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9082            onHover: chartCursor,
9083            animation: { duration: 500, easing: 'easeOutQuart' },
9084            layout: { padding: { right: 42 } },
9085            scales: {
9086              x: { min: 0, max: 100,
9087                   grid: { color: c.grid },
9088                   ticks: { color: c.text, callback: function(v){return v+'%';} },
9089                   title: { display: true, text: 'Comment %', color: c.text } },
9090              y: { grid: { display: false }, ticks: { color: c.text } }
9091            },
9092            plugins: {
9093              legend: { display: false },
9094              tooltip: { callbacks: {
9095                title: function(items){return items.length?items[0].label:'';},
9096                label: function(ctx){
9097                  var d=data[ctx.dataIndex]||{};
9098                  var sig=(d.code||0)+(d.comments||0);
9099                  return ['  Comment ratio: '+ctx.parsed.x+'%',
9100                          '  Comments: '+Number(d.comments||0).toLocaleString(),
9101                          '  Significant lines: '+Number(sig).toLocaleString()];
9102                }
9103              }}
9104            }
9105          },
9106          plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
9107        });
9108        ALL_CHARTS.push(densChart);
9109      })();
9110
9111      // ── File Size Distribution histogram ──────────────────────────────────────
9112      (function() {
9113        var canvas = document.getElementById('canvas-filesize');
9114        if (!canvas || !HIST_D || !HIST_D.length) return;
9115        var labels = HIST_D.map(function(d){return d.label;});
9116        var counts = HIST_D.map(function(d){return d.count||0;});
9117        var total = counts.reduce(function(a,b){return a+b;},0);
9118        var c = clr();
9119        var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
9120        var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
9121        var fsChart = new Chart(canvas, {
9122          type: 'bar',
9123          data: {
9124            labels: labels,
9125            datasets: [{ label: 'Files',
9126              data: counts,
9127              backgroundColor: fsBg,
9128              hoverBackgroundColor: fsHv,
9129              borderRadius: 6,
9130              borderWidth: 0,
9131              hoverBorderWidth: 0
9132            }]
9133          },
9134          options: {
9135            responsive: true, maintainAspectRatio: false,
9136            onHover: chartCursor,
9137            animation: { duration: 500, easing: 'easeOutQuart' },
9138            transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
9139            layout: { padding: { top: 18 } },
9140            scales: {
9141              x: { grid: { display: false }, ticks: { color: c.text, font: { size: 11 } } },
9142              y: { beginAtZero: true,
9143                   grid: { color: c.grid },
9144                   ticks: { color: c.text, precision: 0 },
9145                   title: { display: true, text: 'File Count', color: c.text } }
9146            },
9147            plugins: {
9148              legend: { display: false },
9149              tooltip: { callbacks: {
9150                label: function(ctx) {
9151                  var n = ctx.parsed.y;
9152                  var pct = total > 0 ? Math.round(n/total*1000)/10 : 0;
9153                  return ['  Files: '+n, '  Share: '+pct+'%'];
9154                }
9155              }}
9156            }
9157          },
9158          plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
9159        });
9160        ALL_CHARTS.push(fsChart);
9161      })();
9162
9163      // ── Expand button handlers ────────────────────────────────────────────────
9164      (function() {
9165        function makeOverlay(title, h, subtitle, ctrlHtml) {
9166          var overlay = document.createElement('div');
9167          overlay.className = 'chart-modal-overlay';
9168          var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
9169          var hAttr = 'height:' + Math.min(h || 696, maxH) + 'px;';
9170          var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
9171          var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">' + title + '</span>' + (ctrlHtml || '') + '</div>';
9172          overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;"><button class="chart-modal-close" aria-label="Close">&times;</button>' + hdr + subHtml + '<div style="position:relative;width:100%;' + hAttr + '"><canvas id="modal-expand-canvas"></canvas></div></div>';
9173          document.body.appendChild(overlay);
9174          overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
9175          overlay.addEventListener('click', function(e){ if(e.target === overlay) document.body.removeChild(overlay); });
9176          return document.getElementById('modal-expand-canvas');
9177        }
9178
9179        // Language Composition
9180        (function(){
9181          var btn = document.getElementById('comp-expand-btn');
9182          if(!btn) return;
9183          btn.addEventListener('click', function(){
9184            var activeTab = document.querySelector('[data-comp-tab].active');
9185            var compMode = activeTab ? activeTab.getAttribute('data-comp-tab') : 'absolute';
9186            var ctrlHtml = '<select class="chart-select" id="comp-modal-mode">'
9187              + '<option value="absolute">Absolute Lines</option>'
9188              + '<option value="pct">100% Normalized</option>'
9189              + '</select>';
9190            var canvas = makeOverlay('Language Composition \u2014 Full View', undefined, null, ctrlHtml);
9191            if(!canvas) return;
9192            var modalMode = document.getElementById('comp-modal-mode');
9193            if(modalMode) modalMode.value = compMode;
9194            var compModalChart = null;
9195            function renderCompModal(mode) {
9196              if(compModalChart) { compModalChart.destroy(); compModalChart = null; }
9197              var data = D.slice(0, 15);
9198              var c = clr(), isPct = mode === 'pct';
9199              var tot = function(d){ return (d.code||0)+(d.comments||0)+(d.blanks||0)||1; };
9200              var codeD = data.map(function(d){ return isPct ? (d.code||0)/tot(d)*100 : d.code||0; });
9201              var cmD   = data.map(function(d){ return isPct ? (d.comments||0)/tot(d)*100 : d.comments||0; });
9202              var blD   = data.map(function(d){ return isPct ? (d.blanks||0)/tot(d)*100 : d.blanks||0; });
9203              var tickCb = isPct ? function(v){return v.toFixed(0)+'%';} : function(v){return fmt(v);};
9204              compModalChart = new Chart(canvas, {
9205                type: 'bar',
9206                data: {
9207                  labels: data.map(function(d){ return d.lang; }),
9208                  datasets: [
9209                    { label:'Code',     data: codeD, backgroundColor: OX, borderRadius: 3 },
9210                    { label:'Comments', data: cmD,   backgroundColor: GN, borderRadius: 3 },
9211                    { label:'Blanks',   data: blD,   backgroundColor: GY, borderRadius: 3 }
9212                  ]
9213                },
9214                options: {
9215                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9216                  layout: { padding: { right: 64 } },
9217                  scales: {
9218                    x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: tickCb } },
9219                    y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
9220                  },
9221                  plugins: { legend: { position: 'bottom', labels: { color: c.text } } }
9222                },
9223                plugins: [makeStackedEndPlugin(function(total, idx) {
9224                  if (isPct) return '';
9225                  var d = data[idx]; return fmt(Math.round(d && d.physical ? d.physical : total));
9226                })]
9227              });
9228            }
9229            renderCompModal(compMode);
9230            if(modalMode) modalMode.addEventListener('change', function(){ renderCompModal(this.value); });
9231          });
9232        })();
9233
9234        // Files vs Code Lines (Scatter)
9235        (function(){
9236          var btn = document.getElementById('scatter-expand-btn');
9237          if(!btn || !SCAT_D || !SCAT_D.length) return;
9238          btn.addEventListener('click', function(){
9239            var canvas = makeOverlay('Files vs Code Lines \u2014 Full View', undefined, 'File count vs SLOC per language');
9240            if(!canvas) return;
9241            var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
9242            var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
9243            var c = clr();
9244            var scLegHolder = attachScatterLegend(canvas);
9245            var scExpand = new Chart(canvas, {
9246              type: 'bubble',
9247              data: {
9248                datasets: SCAT_D.map(function(d, i) {
9249                  return {
9250                    label: d.lang,
9251                    data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
9252                    backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
9253                    borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
9254                    hoverBorderWidth: 2
9255                  };
9256                })
9257              },
9258              options: {
9259                responsive: true, maintainAspectRatio: false,
9260                onHover: chartCursor,
9261                animation: { duration: 500, easing: 'easeOutQuart' },
9262                layout: { padding: { top: 44, right: 12 } },
9263                scales: {
9264                  x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6, grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } }, title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
9265                  y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} }, title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
9266                },
9267                plugins: {
9268                  legend: { display: false },
9269                  tooltip: { callbacks: {
9270                    title: function(items){ return items.length ? items[0].dataset.label : ''; },
9271                    label: function(ctx){
9272                      var d = SCAT_D[ctx.datasetIndex];
9273                      return ['  Files analyzed: '+fmt(d.files), '  Code lines: '+Number(d.code).toLocaleString(), '  Physical lines: '+Number(d.physical).toLocaleString()];
9274                    }
9275                  }}
9276                }
9277              },
9278              plugins: [scatterLabelPlugin()]
9279            });
9280            scLegHolder.chart = scExpand;
9281          });
9282        })();
9283
9284        // Comment Density
9285        (function(){
9286          var btn = document.getElementById('density-expand-btn');
9287          if(!btn) return;
9288          btn.addEventListener('click', function(){
9289            var data = D.slice().sort(function(a,b){
9290              var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
9291              var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
9292              return db-da;
9293            });
9294            var h = Math.min(Math.max(672, data.length * 46 + 96), Math.max(400, Math.floor(window.innerHeight * 0.82) - 130));
9295            var canvas = makeOverlay('Comment Density \u2014 Full View', h, 'Comment ratio per language');
9296            if(!canvas) return;
9297            var densities = data.map(function(d){ var sig=(d.code||0)+(d.comments||0); return sig>0?Math.round((d.comments||0)/sig*1000)/10:0; });
9298            var c = clr();
9299            new Chart(canvas, {
9300              type: 'bar',
9301              data: {
9302                labels: data.map(function(d){return d.lang;}),
9303                datasets: [{ label: 'Comment %', data: densities,
9304                  backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius: 4 }]
9305              },
9306              options: {
9307                indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9308                animation: { duration: 500, easing: 'easeOutQuart' },
9309                layout: { padding: { right: 42 } },
9310                scales: {
9311                  x: { min:0, max:100, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return v+'%';}} },
9312                  y: { grid:{display:false}, ticks:{color:c.text} }
9313                },
9314                plugins: { legend:{display:false}, tooltip:{callbacks:{
9315                  title:function(items){return items.length?items[0].label:'';},
9316                  label:function(ctx){
9317                    var d=data[ctx.dataIndex]||{};
9318                    var sig=(d.code||0)+(d.comments||0);
9319                    return ['  Comment ratio: '+ctx.parsed.x.toFixed(1)+'%',
9320                            '  Comments: '+Number(d.comments||0).toLocaleString(),
9321                            '  Significant lines: '+Number(sig).toLocaleString()];
9322                  }
9323                }}}
9324              },
9325              plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
9326            });
9327          });
9328        })();
9329
9330        // File Size Distribution
9331        (function(){
9332          var btn = document.getElementById('filesize-expand-btn');
9333          if(!btn || !HIST_D || !HIST_D.length) return;
9334          btn.addEventListener('click', function(){
9335            var canvas = makeOverlay('File Size Distribution \u2014 Full View', undefined, 'File count per SLOC bucket');
9336            if(!canvas) return;
9337            var labels = HIST_D.map(function(d){return d.label;});
9338            var counts = HIST_D.map(function(d){return d.count||0;});
9339            var total = counts.reduce(function(a,b){return a+b;},0);
9340            var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
9341            var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
9342            var c = clr();
9343            new Chart(canvas, {
9344              type: 'bar',
9345              data: {
9346                labels: labels,
9347                datasets: [{ label: 'Files', data: counts,
9348                  backgroundColor: fsBg, hoverBackgroundColor: fsHv,
9349                  borderRadius: 6, borderWidth: 0, hoverBorderWidth: 0 }]
9350              },
9351              options: {
9352                responsive: true, maintainAspectRatio: false,
9353                animation: { duration: 500, easing: 'easeOutQuart' },
9354                transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
9355                layout: { padding: { top: 18 } },
9356                scales: {
9357                  x: { grid:{display:false}, ticks:{color:c.text} },
9358                  y: { beginAtZero:true, grid:{color:c.grid}, ticks:{color:c.text, precision:0}, title:{display:true, text:'File Count', color:c.text} }
9359                },
9360                plugins: { legend:{display:false}, tooltip:{callbacks:{label:function(ctx){ var pct=total>0?Math.round(ctx.parsed.y/total*1000)/10:0; return ['  Files: '+ctx.parsed.y, '  Share: '+pct+'%']; }}} }
9361              },
9362              plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
9363            });
9364          });
9365        })();
9366
9367        // Submodule Breakdown — Full View with live Y Axis + Sort controls
9368        (function(){
9369          var btn = document.getElementById('sub-expand-btn');
9370          if(!btn || !SUB_D || !SUB_D.length) return;
9371          btn.addEventListener('click', function(){
9372            var subYSel = document.getElementById('sub-y-axis');
9373            var subSortSel = document.getElementById('sub-sort');
9374            var initY = subYSel ? subYSel.value : 'code';
9375            var initSort = subSortSel ? subSortSel.value : 'desc';
9376            var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines', physical:'Physical Lines', files:'Files' };
9377            var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
9378            var SUB_HCOLS = { code:'#d97020', comment:'#3a8a5e', blank:'#999', physical:'#5a8ad8', files:'#9a68b3' };
9379            var n = Math.min(SUB_D.length, 30);
9380            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
9381            var modalH = Math.min(Math.max(480, n * 36 + 96), maxH);
9382            var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
9383              + '<select class="chart-select" id="sub-modal-y">'
9384              + '<option value="code">Code Lines</option>'
9385              + '<option value="comment">Comment Lines</option>'
9386              + '<option value="blank">Blank Lines</option>'
9387              + '<option value="physical">Physical Lines</option>'
9388              + '<option value="files">File Count</option>'
9389              + '</select></label>'
9390              + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
9391              + '<select class="chart-select" id="sub-modal-sort">'
9392              + '<option value="desc">Value \u2193</option>'
9393              + '<option value="asc">Value \u2191</option>'
9394              + '<option value="name">Name A\u2192Z</option>'
9395              + '</select></label>';
9396            var canvas = makeOverlay('Submodule Breakdown \u2014 Full View', modalH, null, ctrlHtml);
9397            if(!canvas) return;
9398            var modalY = document.getElementById('sub-modal-y');
9399            var modalSort = document.getElementById('sub-modal-sort');
9400            if(modalY) modalY.value = initY;
9401            if(modalSort) modalSort.value = initSort;
9402            var subModalChart = null;
9403            function renderSubModal(yKey, sortMode) {
9404              if(subModalChart) { subModalChart.destroy(); subModalChart = null; }
9405              var data = SUB_D.slice();
9406              if(sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
9407              else if(sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
9408              else data.sort(function(a,b){return a.name.localeCompare(b.name);});
9409              data = data.slice(0, 30);
9410              var c = clr(), col = SUB_COLS[yKey]||OX, hcol = SUB_HCOLS[yKey]||'#d97020';
9411              subModalChart = new Chart(canvas, {
9412                type: 'bar',
9413                data: {
9414                  labels: data.map(function(d){return d.name;}),
9415                  datasets: [{ label: Y_LABELS[yKey]||yKey,
9416                    data: data.map(function(d){return d[yKey]||0;}),
9417                    backgroundColor: col, hoverBackgroundColor: hcol, borderRadius: 3 }]
9418                },
9419                options: {
9420                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
9421                  onHover: chartCursor,
9422                  animation: { duration: 500, easing: 'easeOutQuart' },
9423                  transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
9424                  layout: { padding: { right: 72 } },
9425                  scales: {
9426                    x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
9427                         title:{display:true, text:Y_LABELS[yKey]||yKey, color:c.text} },
9428                    y: { grid:{display:false}, ticks:{color:c.text} }
9429                  },
9430                  plugins: {
9431                    legend:{display:false},
9432                    tooltip: { callbacks: {
9433                      title: function(items){return items.length?items[0].label:'';},
9434                      label: function(ctx){
9435                        var d = data[ctx.dataIndex]||{};
9436                        return ['  Code: '+Number(d.code||0).toLocaleString(),
9437                                '  Comments: '+Number(d.comment||0).toLocaleString(),
9438                                '  Blanks: '+Number(d.blank||0).toLocaleString(),
9439                                '  Physical: '+Number(d.physical||0).toLocaleString(),
9440                                '  Files: '+fmt(d.files||0)];
9441                      }
9442                    }}
9443                  }
9444                },
9445                plugins: [makeDlPlugin(function(v){return fmt(v||0);}, 'end'), barJumpPlugin]
9446              });
9447            }
9448            renderSubModal(initY, initSort);
9449            if(modalY) modalY.addEventListener('change', function(){ renderSubModal(this.value, modalSort ? modalSort.value : 'desc'); });
9450            if(modalSort) modalSort.addEventListener('change', function(){ renderSubModal(modalY ? modalY.value : 'code', this.value); });
9451          });
9452        })();
9453
9454        // Submodule Composition — Full View (Chart.js with sort control)
9455        (function(){
9456          var btn = document.getElementById('sub-comp-expand-btn');
9457          if(!btn || !SUB_D || !SUB_D.length) return;
9458          btn.addEventListener('click', function(){
9459            var n = Math.min(SUB_D.length, 20);
9460            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
9461            var modalH = Math.min(Math.max(400, n * 40 + 90), maxH);
9462            var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
9463              + '<select class="chart-select" id="sub-comp-modal-sort">'
9464              + '<option value="desc">Total Lines \u2193</option>'
9465              + '<option value="asc">Total Lines \u2191</option>'
9466              + '<option value="name">Name A\u2192Z</option>'
9467              + '</select></label>';
9468            var canvas = makeOverlay('Submodule Composition \u2014 Full View', modalH, null, ctrlHtml);
9469            if(!canvas) return;
9470            var modalSort = document.getElementById('sub-comp-modal-sort');
9471            var scModalChart = null;
9472            function renderSCModal(sortMode) {
9473              if(scModalChart) { scModalChart.destroy(); scModalChart = null; }
9474              var data = SUB_D.slice();
9475              if(sortMode==='asc') data.sort(function(a,b){return ((a.code||0)+(a.comment||0)+(a.blank||0))-((b.code||0)+(b.comment||0)+(b.blank||0));});
9476              else if(sortMode==='name') data.sort(function(a,b){return a.name.localeCompare(b.name);});
9477              else data.sort(function(a,b){return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));});
9478              data = data.slice(0, 20);
9479              var c = clr();
9480              scModalChart = new Chart(canvas, {
9481                type: 'bar',
9482                data: {
9483                  labels: data.map(function(d){ return d.name; }),
9484                  datasets: [
9485                    { label:'Code',     data:data.map(function(d){return d.code||0;}),    backgroundColor:OX, hoverBackgroundColor:'#d97020', borderRadius:0, borderSkipped:false },
9486                    { label:'Comments', data:data.map(function(d){return d.comment||0;}), backgroundColor:GN, hoverBackgroundColor:'#3a8a5e', borderRadius:0, borderSkipped:false },
9487                    { label:'Blank',    data:data.map(function(d){return d.blank||0;}),   backgroundColor:GY, hoverBackgroundColor:'#999',    borderRadius:0, borderSkipped:false }
9488                  ]
9489                },
9490                options: {
9491                  indexAxis:'y', responsive:true, maintainAspectRatio:false,
9492                  onHover: chartCursor,
9493                  animation: { duration: 500, easing: 'easeOutQuart' },
9494                  transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
9495                  layout:{ padding:{ right:72 } },
9496                  scales: {
9497                    x:{ stacked:true, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}} },
9498                    y:{ stacked:true, grid:{display:false}, ticks:{color:c.text} }
9499                  },
9500                  plugins: {
9501                    legend:{ position:'bottom', labels:{color:c.text, usePointStyle:true, pointStyle:'rect', font:{size:11,weight:'700'}, padding:16},
9502                      onHover:function(e,item,leg){ legendCursorOn(e); var ch=leg.chart,di=item.datasetIndex; var orig=[OX,GN,GY],hov=['#d97020','#3a8a5e','#999']; ch.data.datasets.forEach(function(ds,i){ ds.backgroundColor=i===di?orig[i]:hexAlpha(orig[i],0.15); ds.hoverBackgroundColor=i===di?hov[i]:hexAlpha(orig[i],0.15); }); var n=ch.data.datasets.length,ae=[]; for(var ii=0;ii<n;ii++){ae.push({datasetIndex:ii,index:0});} var fp=ch.getDatasetMeta(di).data[0]; ch.setActiveElements([{datasetIndex:di,index:0}]); ch.tooltip.setActiveElements(ae,fp?{x:fp.x,y:fp.y}:{x:0,y:0}); ch.update(); },
9503                      onLeave:function(e,item,leg){ legendCursorOff(e); var ch=leg.chart; var orig=[OX,GN,GY],hov=['#d97020','#3a8a5e','#999']; ch.data.datasets.forEach(function(ds,i){ ds.backgroundColor=orig[i]; ds.hoverBackgroundColor=hov[i]; }); ch.setActiveElements([]); ch.tooltip.setActiveElements([],{}); ch.update('none'); }
9504                    },
9505                    tooltip:{ mode:'index', callbacks:{
9506                      title:function(items){return items.length?items[0].label:'';},
9507                      label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
9508                      footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}
9509                    }}
9510                  }
9511                },
9512                plugins: [makeStackedEndPlugin(function(v){return fmt(v);}), segLabelPlugin, rowDimPlugin]
9513              });
9514            }
9515            renderSCModal('desc');
9516            if(modalSort) modalSort.addEventListener('change', function(){ renderSCModal(this.value); });
9517          });
9518        })();
9519
9520        // Language overview (donut + line-mix) — clone both SVGs side-by-side
9521        (function(){
9522          var btn = document.getElementById('lang-overview-expand-btn');
9523          if(!btn) return;
9524          btn.addEventListener('click', function(){
9525            var src = document.getElementById('report-lang-overview');
9526            if(!src) return;
9527            var overlay = document.createElement('div');
9528            overlay.className = 'chart-modal-overlay';
9529            overlay.innerHTML = '<div class="chart-modal" style="max-width:1600px;"><button class="chart-modal-close" aria-label="Close">&times;</button><div class="chart-modal-header"><span class="chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="lang-overview-modal-wrap" style="width:100%;"></div></div>';
9530            document.body.appendChild(overlay);
9531            overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
9532            overlay.addEventListener('click', function(e){ if(e.target===overlay) document.body.removeChild(overlay); });
9533            var wrap = document.getElementById('lang-overview-modal-wrap');
9534            if(wrap) {
9535              wrap.innerHTML = src.innerHTML;
9536              var svgs = wrap.querySelectorAll('svg');
9537              for(var i=0;i<svgs.length;i++){
9538                svgs[i].removeAttribute('width');
9539                svgs[i].removeAttribute('height');
9540                svgs[i].style.cssText='display:block;width:100%;height:auto;';
9541              }
9542              var ov = wrap.querySelector('.r-lang-overview');
9543              if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
9544              var cells = wrap.querySelectorAll('.r-lang-overview-cell');
9545              if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
9546              if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
9547              wireDonutLegend(wrap.querySelector('svg'));
9548              wireMixLegend(wrap.querySelectorAll('svg')[1]);
9549              requestAnimationFrame(function(){
9550                var ss=wrap.querySelectorAll('svg');
9551                if(ss.length>=2){var bh=ss[1].getBoundingClientRect().height;if(bh>0){ss[0].style.cssText='display:block;height:'+bh+'px;width:auto;max-width:100%;';}}
9552              });
9553            }
9554          });
9555        })();
9556      })();
9557
9558      // ── Dark mode sync ────────────────────────────────────────────────────────
9559      document.querySelectorAll('[data-theme-toggle]').forEach(function(btn) {
9560        btn.addEventListener('click', function() {
9561          setTimeout(function() {
9562            var c = clr();
9563            ALL_CHARTS.forEach(function(chart) {
9564              if (chart.options.scales) {
9565                Object.keys(chart.options.scales).forEach(function(k) {
9566                  var ax = chart.options.scales[k];
9567                  if (ax.grid) ax.grid.color = c.grid;
9568                  if (ax.ticks) ax.ticks.color = c.text;
9569                  if (ax.title) ax.title.color = c.text;
9570                });
9571              }
9572              if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels)
9573                chart.options.plugins.legend.labels.color = c.text;
9574              chart.update('none');
9575            });
9576          }, 60);
9577        });
9578      });
9579
9580      // ── Pre-render all chart variants for PDF export ──────────────────────────
9581      (function() {
9582        var root = document.getElementById('pdf-variants');
9583        if (!root) return;
9584
9585        // Plugin: fill a light background behind every off-screen chart so the PNG
9586        // is opaque — without this Chart.js canvases are transparent and render as
9587        // blank white boxes in print.
9588        var PDF_BG = {
9589          id: 'pdfBg',
9590          beforeDraw: function(ch) {
9591            var ctx = ch.canvas.getContext('2d');
9592            ctx.save();
9593            ctx.globalCompositeOperation = 'destination-over';
9594            ctx.fillStyle = '#faf6f0';
9595            ctx.fillRect(0, 0, ch.canvas.width, ch.canvas.height);
9596            ctx.restore();
9597          }
9598        };
9599
9600        // Off-screen Chart.js render → PNG data-URL → destroy chart
9601        function snap(type, data, opts, w, h) {
9602          var c = document.createElement('canvas');
9603          c.width = w || 900; c.height = h || 280;
9604          var ch = new Chart(c, {
9605            type: type, data: data,
9606            options: Object.assign({}, opts, {
9607              animation: false, responsive: false, devicePixelRatio: 1,
9608              // Breathing room so labels never clip at the canvas edge
9609              layout: { padding: { top: 10, right: 18, bottom: 10, left: 10 } }
9610            }),
9611            plugins: [PDF_BG]
9612          });
9613          var png = c.toDataURL('image/png');
9614          ch.destroy();
9615          return png;
9616        }
9617
9618        function mkPanel(label, imgSrc) {
9619          var d = document.createElement('div'); d.className = 'pdf-variant-panel';
9620          if (label) {
9621            var lbl = document.createElement('div');
9622            lbl.className = 'pdf-variant-label'; lbl.textContent = label;
9623            d.appendChild(lbl);
9624          }
9625          if (imgSrc) {
9626            var img = document.createElement('img');
9627            img.className = 'pdf-variant-img'; img.src = imgSrc;
9628            d.appendChild(img);
9629          }
9630          return d;
9631        }
9632
9633        function mkGroup(title) {
9634          var g = document.createElement('div'); g.className = 'pdf-variant-group';
9635          var h = document.createElement('h2'); h.className = 'pdf-variant-group-title'; h.textContent = title;
9636          g.appendChild(h);
9637          var grid = document.createElement('div'); grid.className = 'pdf-variant-grid';
9638          g.appendChild(grid);
9639          return { group: g, grid: grid };
9640        }
9641
9642        var tc = '#43342d', gc = 'rgba(0,0,0,0.07)';
9643
9644        // ── Project Overview — 4 Y-axis variants ─────────────────────────────────
9645        var pgProj = mkGroup('Project Overview');
9646        var projVariants = [
9647          { label:'Code Lines',     fn:function(d){return d.code||0;} },
9648          { label:'Comment Lines',  fn:function(d){return d.comments||0;} },
9649          { label:'Physical Lines', fn:function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0);} },
9650          { label:'File Count',     fn:function(d){return d.files||0;} }
9651        ];
9652        projVariants.forEach(function(y) {
9653          var sorted = D.slice().sort(function(a,b){return y.fn(b)-y.fn(a);});
9654          var h = Math.max(110, Math.min(360, sorted.length*18+40));
9655          var png = snap('bar', {
9656            labels: sorted.map(function(d){return d.lang;}),
9657            datasets:[{ label:y.label, data:sorted.map(y.fn),
9658                        backgroundColor:sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius:3 }]
9659          }, {
9660            indexAxis:'y',
9661            scales:{
9662              x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9663              y:{grid:{display:false},ticks:{color:tc}}
9664            },
9665            plugins:{legend:{display:false}}
9666          }, 900, h);
9667          pgProj.grid.appendChild(mkPanel(y.label, png));
9668        });
9669        root.appendChild(pgProj.group);
9670
9671        // ── Language Composition — Absolute Lines + Composition % ────────────────
9672        var pgComp = mkGroup('Language Composition');
9673        var cData = D.slice(0,15);
9674        var totFn = function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0)||1;};
9675        var compH = Math.max(110, Math.min(340, cData.length*18+50));
9676        [{id:'absolute',label:'Absolute Lines',isPct:false},{id:'pct',label:'Composition %',isPct:true}]
9677          .forEach(function(m) {
9678            var pct = m.isPct;
9679            var png = snap('bar', {
9680              labels: cData.map(function(d){return d.lang;}),
9681              datasets:[
9682                {label:'Code',     data:cData.map(function(d){return pct?(d.code||0)/totFn(d)*100:d.code||0;}),    backgroundColor:OX,borderRadius:3},
9683                {label:'Comments', data:cData.map(function(d){return pct?(d.comments||0)/totFn(d)*100:d.comments||0;}),backgroundColor:GN,borderRadius:3},
9684                {label:'Blanks',   data:cData.map(function(d){return pct?(d.blanks||0)/totFn(d)*100:d.blanks||0;}),  backgroundColor:GY,borderRadius:3}
9685              ]
9686            }, {
9687              indexAxis:'y',
9688              scales:{
9689                x:{stacked:true,grid:{color:gc},ticks:{color:tc,callback:pct?function(v){return v.toFixed(0)+'%';}:function(v){return fmt(v);}}},
9690                y:{stacked:true,grid:{display:false},ticks:{color:tc}}
9691              },
9692              plugins:{legend:{position:'bottom',labels:{color:tc}}}
9693            }, 900, compH);
9694            pgComp.grid.appendChild(mkPanel(m.label, png));
9695          });
9696        root.appendChild(pgComp.group);
9697
9698        // ── Files vs Code Lines — render off-screen (bubble chart, single-col centred) ─
9699        if (SCAT_D && SCAT_D.length) {
9700          var pgScat = mkGroup('Files vs Code Lines');
9701          pgScat.grid.classList.add('single-col'); // CSS class drives centering in print
9702          var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical||0;})) || 1;
9703          var scatPng = snap('bubble', {
9704            datasets: SCAT_D.map(function(d, i) {
9705              return {
9706                label: d.lang,
9707                data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt((d.physical||0)/maxP)*20)) }],
9708                backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
9709                borderColor: PALETTE[i % PALETTE.length], borderWidth: 1
9710              };
9711            })
9712          }, {
9713            scales: {
9714              x: { grid:{color:gc}, ticks:{color:tc}, title:{display:true, text:'Files Analyzed', color:tc} },
9715              y: { grid:{color:gc}, ticks:{color:tc, callback:function(v){return fmt(v);}}, title:{display:true, text:'Code Lines', color:tc} }
9716            },
9717            plugins: { legend:{position:'right', labels:{color:tc, boxWidth:12}} }
9718          }, 900, 260);
9719          pgScat.grid.appendChild(mkPanel('Files \u00d7 Code Lines (bubble size \u221d physical lines)', scatPng));
9720          root.appendChild(pgScat.group);
9721        }
9722
9723        // ── Semantic Metrics — up to 5 metrics, skip empty ones ─────────────────
9724        if (SEM_D && SEM_D.length) {
9725          var pgSem = mkGroup('Semantic Metrics');
9726          var SL={functions:'Functions',classes:'Classes / Types',variables:'Variables',imports:'Imports',tests:'Tests'};
9727          var SC={functions:OX,classes:'#4472C4',variables:GN,imports:'#805099',tests:'#B23030'};
9728          Object.keys(SL).forEach(function(mKey) {
9729            var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
9730            if (!data.some(function(d){return (d[mKey]||0)>0;})) return;
9731            var semH = 210; // vertical bar — fixed height; width drives layout, not row count
9732            var png = snap('bar', {
9733              labels: data.map(function(d){return d.lang;}),
9734              datasets:[{label:SL[mKey],data:data.map(function(d){return d[mKey]||0;}),backgroundColor:SC[mKey],borderRadius:4}]
9735            }, {
9736              scales:{
9737                x:{grid:{display:false},ticks:{color:tc}},
9738                y:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}}}
9739              },
9740              plugins:{legend:{display:false}}
9741            }, 900, semH);
9742            pgSem.grid.appendChild(mkPanel(SL[mKey], png));
9743          });
9744          root.appendChild(pgSem.group);
9745        }
9746
9747        // ── Submodule Breakdown — 3 Y-axis variants + donut SVG clone ────────────
9748        if (SUB_D && SUB_D.length) {
9749          var pgSub = mkGroup('Submodule Breakdown');
9750          [{key:'code',label:'Code Lines',col:OX},{key:'comment',label:'Comment Lines',col:GN},{key:'files',label:'File Count',col:'#805099'}]
9751            .forEach(function(y) {
9752              var data = SUB_D.slice().sort(function(a,b){return (b[y.key]||0)-(a[y.key]||0);}).slice(0,30);
9753              if (!data.length) return;
9754              var subH = Math.max(100, Math.min(420, data.length*16+30));
9755              var png = snap('bar', {
9756                labels: data.map(function(d){return d.name;}),
9757                datasets:[{label:y.label,data:data.map(function(d){return d[y.key]||0;}),backgroundColor:y.col,borderRadius:3}]
9758              }, {
9759                indexAxis:'y',
9760                scales:{
9761                  x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9762                  y:{grid:{display:false},ticks:{color:tc}}
9763                },
9764                plugins:{legend:{display:false}}
9765              }, 900, subH);
9766              pgSub.grid.appendChild(mkPanel(y.label, png));
9767            });
9768          var donutEl = document.getElementById('submodule-donut');
9769          if (donutEl && donutEl.innerHTML.trim()) {
9770            var dp = document.createElement('div'); dp.className = 'pdf-variant-panel';
9771            var dl = document.createElement('div'); dl.className = 'pdf-variant-label'; dl.textContent = 'Distribution';
9772            dp.appendChild(dl);
9773            var dw = document.createElement('div'); dw.style.cssText = 'display:flex;justify-content:center;';
9774            dw.innerHTML = donutEl.innerHTML; dp.appendChild(dw);
9775            pgSub.grid.appendChild(dp);
9776          }
9777          root.appendChild(pgSub.group);
9778        }
9779      })();
9780    })();
9781    window.oxSlocChartsReady = true;
9782    } catch(e) { window.oxSlocChartError = String(e); window.oxSlocChartsReady = true; }
9783    }); // end requestAnimationFrame
9784    // Safety net: if rAF never fires (headless browsers throttle it), mark ready
9785    // unconditionally so the PDF capture does not wait the full 15 s.
9786    setTimeout(function() { if (!window.oxSlocChartsReady) window.oxSlocChartsReady = true; }, 3000);
9787    // ── SVG tooltip delegation ───────────────────────────────────────────────
9788    (function(){
9789      var tt = document.getElementById('r-tt');
9790      if (!tt) return;
9791      function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
9792      function show(e, html) { tt.innerHTML=html; tt.style.display='block'; move(e); }
9793      function hide() { tt.style.display='none'; }
9794      function move(e) {
9795        var x=e.clientX+16, y=e.clientY-12;
9796        var r=tt.getBoundingClientRect();
9797        if (x+r.width>window.innerWidth-8) x=e.clientX-r.width-8;
9798        if (y+r.height>window.innerHeight-8) y=e.clientY-r.height-8;
9799        tt.style.left=x+'px'; tt.style.top=y+'px';
9800      }
9801      document.addEventListener('mouseover', function(e) {
9802        var t=e.target;
9803        while(t&&t.getAttribute){
9804          var l=t.getAttribute('data-ttl');
9805          if(l!==null){ show(e,'<strong>'+escH(l)+'</strong><br>'+escH(t.getAttribute('data-ttv')||'').replace(/\n/g,'<br>')); return; }
9806          t=t.parentNode;
9807        }
9808      });
9809      document.addEventListener('mouseout', function(e) {
9810        var t=e.target;
9811        while(t&&t.getAttribute){
9812          if(t.getAttribute('data-ttl')!==null){ hide(); return; }
9813          t=t.parentNode;
9814        }
9815      });
9816      document.addEventListener('mousemove', function(e) {
9817        if(tt.style.display!=='none') move(e);
9818      });
9819      window.addEventListener('blur', function() { hide(); });
9820      document.addEventListener('visibilitychange', function() { if(document.hidden) hide(); });
9821    })();
9822    // Auto-populate title on any td that is visually truncated but has no explicit title
9823    requestAnimationFrame(function() {
9824      document.querySelectorAll('td').forEach(function(td) {
9825        if (!td.title && td.scrollWidth > td.clientWidth) {
9826          td.title = td.textContent.trim();
9827        }
9828      });
9829    });
9830
9831  </script>
9832  <script nonce="{{ nonce }}">
9833  (function(){
9834    var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
9835    function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
9836    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
9837    function init(){
9838      var btn=document.getElementById('settings-btn');if(!btn)return;
9839      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9840      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
9841      document.body.appendChild(m);
9842      var g=document.getElementById('scheme-grid');
9843      if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
9844      var cl=document.getElementById('settings-close');
9845      window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};var tzSel=document.getElementById('tz-select');var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);
9846      btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
9847      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
9848      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
9849    }
9850    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9851  }());
9852  </script>
9853  <script nonce="{{ nonce }}">
9854  (function(){
9855    // Format delta card unmodified-lines value with comma separators
9856    Array.prototype.slice.call(document.querySelectorAll('.delta-card-inline[data-raw] .delta-card-val')).forEach(function(el){
9857      var raw=parseInt(el.parentNode.getAttribute('data-raw'),10);
9858      if(!isNaN(raw))el.textContent=raw.toLocaleString();
9859    });
9860    // Format code-before / code-now numbers in the prev-scan summary line
9861    Array.prototype.slice.call(document.querySelectorAll('.prev-scan-summary [data-raw]')).forEach(function(el){
9862      var raw=parseInt(el.getAttribute('data-raw'),10);
9863      if(!isNaN(raw))el.textContent=raw.toLocaleString();
9864    });
9865  }());
9866  </script>
9867  {% if has_style_data %}
9868  <script nonce="{{ nonce }}">
9869  (function(){
9870    var CHART_DATA = {{ style_chart_json|safe }};
9871    var FILE_DATA  = {{ style_file_json|safe }};
9872    var SCORE_THRESHOLD = {{ style_score_threshold }};
9873    var activeLang = CHART_DATA.length ? CHART_DATA[0].family : '';
9874    var sftSortKey = '';
9875    var sftSortDir = 1;
9876    function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
9877    // Official style guide URLs — covers every guide produced by the language analysers
9878    var GUIDE_URLS = {
9879      'PEP 8':'https://peps.python.org/pep-0008/',
9880      'PEP 8 (99-col)':'https://peps.python.org/pep-0008/',
9881      'Black':'https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html',
9882      'Google Python':'https://google.github.io/styleguide/pyguide.html',
9883      'Effective Go':'https://go.dev/doc/effective_go',
9884      'Uber Go':'https://github.com/uber-go/guide/blob/master/style.md',
9885      'Google Go':'https://google.github.io/styleguide/go/',
9886      'LLVM':'https://llvm.org/docs/CodingStandards.html',
9887      'Google':'https://google.github.io/styleguide/cppguide.html',
9888      'Mozilla':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/',
9889      'Microsoft':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9890      'WebKit':'https://webkit.org/code-style-guidelines/',
9891      'rustfmt defaults':'https://doc.rust-lang.org/rustfmt/',
9892      'Mozilla Rust':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/coding-style-rust.html',
9893      'Rust API Guidelines':'https://rust-lang.github.io/api-guidelines/',
9894      'Relaxed (120-col)':'https://doc.rust-lang.org/rustfmt/',
9895      'Airbnb':'https://airbnb.io/javascript/',
9896      'Google JS':'https://google.github.io/styleguide/jsguide.html',
9897      'Standard.js':'https://standardjs.com/',
9898      'Prettier':'https://prettier.io/docs/en/options.html',
9899      'Airbnb TS':'https://airbnb.io/javascript/',
9900      'Google TS':'https://google.github.io/styleguide/tsguide.html',
9901      'Angular':'https://angular.dev/style-guide',
9902      'Microsoft TS':'https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines',
9903      'Google Java':'https://google.github.io/styleguide/javaguide.html',
9904      'Oracle/Sun':'https://www.oracle.com/java/technologies/javase/codeconventions-contents.html',
9905      'Spring':'https://github.com/spring-projects/spring-framework/wiki/Code-Style',
9906      'JetBrains':'https://www.jetbrains.com/help/idea/code-style.html',
9907      'Android':'https://source.android.com/docs/setup/contribute/code-style',
9908      'Google Kotlin':'https://developer.android.com/kotlin/style-guide',
9909      'Apache Groovy':'https://groovy-lang.org/style-guide.html',
9910      'Gradle DSL':'https://docs.gradle.org/current/userguide/groovy_build_script_primer.html',
9911      'Scala Style Guide':'https://docs.scala-lang.org/style/',
9912      'Lightbend':'https://docs.scala-lang.org/style/',
9913      'Spark':'https://spark.apache.org/contributing.html',
9914      'RuboCop':'https://docs.rubocop.org/rubocop/',
9915      'Airbnb Ruby':'https://github.com/airbnb/ruby',
9916      'Standard Ruby':'https://github.com/standardrb/standard',
9917      'Microsoft .NET':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9918      'Google C#':'https://google.github.io/styleguide/csharp-style.html',
9919      'StyleCop':'https://github.com/DotNetAnalyzers/StyleCopAnalyzers',
9920      'Microsoft F#':'https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting',
9921      'FSharp.Formatting':'https://fsprojects.github.io/FSharp.Formatting/'
9922    };
9923    // Human-readable descriptions for each guide shown in bar tooltips
9924    var GUIDE_DESC = {
9925      'PEP 8':'4-space | 79-col | Python style standard',
9926      'PEP 8 (99-col)':'4-space | 99-col | relaxed line limit',
9927      'Black':'4-space | 88-col | double quotes enforced',
9928      'Google Python':'4-space | 80-col | double quotes preferred',
9929      'Effective Go':'tabs | ~80-col | gofmt standard',
9930      'Uber Go':'tabs | 120-col max',
9931      'Google Go':'tabs | 80-col',
9932      'LLVM':'2-space | 80-col | C/C++ LLVM project style',
9933      'Google':'2-space | 80-col | Google C++ style',
9934      'Mozilla':'4-space | 80-col | Firefox codebase style',
9935      'Microsoft':'4-space | Allman braces | C++ Win32 style',
9936      'WebKit':'4-space | 80-col | WebKit engine style',
9937      'rustfmt defaults':'4-space | 100-col | official Rust formatter',
9938      'Mozilla Rust':'4-space | 100-col | Firefox Rust style',
9939      'Rust API Guidelines':'4-space | naming + docs conventions',
9940      'Relaxed (120-col)':'4-space | 120-col | relaxed line limit',
9941      'Airbnb':'2-space | single quotes | no semicolons opt',
9942      'Google JS':'2-space | 80-col | single quotes',
9943      'Standard.js':'2-space | no semicolons | single quotes',
9944      'Prettier':'2-space | 80-col | double quotes | semicolons',
9945      'Airbnb TS':'2-space | single quotes | TypeScript variant',
9946      'Google TS':'2-space | 80-col | single quotes | TypeScript',
9947      'Angular':'2-space | Angular team TypeScript conventions',
9948      'Microsoft TS':'4-space | TypeScript compiler team style',
9949      'Google Java':'2-space | 100-col | Google Java guide',
9950      'Oracle/Sun':'4-space | 80-col | original Java conventions',
9951      'Spring':'4-space | Spring Framework code style',
9952      'JetBrains':'4-space | IntelliJ default Java style',
9953      'Android':'4-space | 100-col | AOSP Java style',
9954      'Google Kotlin':'4-space | 100-col | Android Kotlin style',
9955      'Apache Groovy':'4-space | Apache Groovy style',
9956      'Gradle DSL':'4-space | Gradle build script conventions',
9957      'Scala Style Guide':'2-space | 100-col | official Scala style',
9958      'Lightbend':'2-space | Lightbend/Akka Scala style',
9959      'Spark':'2-space | Apache Spark Scala style',
9960      'RuboCop':'2-space | 120-col | community Ruby style',
9961      'Airbnb Ruby':'2-space | 80-col | Airbnb Ruby guide',
9962      'Standard Ruby':'2-space | 80-col | StandardRB formatter',
9963      'Microsoft .NET':'4-space | Allman braces | .NET C# style',
9964      'Google C#':'2-space | Google C# style guide',
9965      'StyleCop':'4-space | StyleCop analyzer rules',
9966      'Microsoft F#':'4-space | official F# formatting guide',
9967      'FSharp.Formatting':'4-space | FSharp.Formatting conventions'
9968    };
9969    function renderBars(family){
9970      var wrap=document.getElementById('style-guide-bars');
9971      if(!wrap)return;
9972      wrap.innerHTML='';
9973      var grp=null;
9974      for(var i=0;i<CHART_DATA.length;i++){if(CHART_DATA[i].family===family){grp=CHART_DATA[i];break;}}
9975      if(!grp||!grp.guides.length)return;
9976      grp.guides.forEach(function(d){
9977        var isTop=(d.guide===grp.dominant);
9978        var row=document.createElement('div');row.className='style-guide-row';
9979        // Hover tooltip showing guide name + score + description
9980        var tip=document.createElement('div');tip.className='style-bar-tip';
9981        var desc=GUIDE_DESC[d.guide]||'';
9982        tip.textContent=d.guide+': '+d.score+'%'+(desc?' \u00b7 '+desc:'');
9983        var lbl=document.createElement('div');lbl.className='style-guide-label';
9984        lbl.textContent=d.guide;
9985        if(isTop)lbl.style.color='var(--oxide)';
9986        var track=document.createElement('div');track.className='style-guide-track';
9987        var fill=document.createElement('div');fill.className='style-guide-fill';
9988        fill.style.width='0%';
9989        var pct=document.createElement('div');pct.className='style-guide-score';
9990        pct.textContent=d.score+'%';
9991        if(isTop)pct.style.color='var(--oxide)';
9992        track.appendChild(fill);
9993        row.appendChild(tip);
9994        row.appendChild(lbl);row.appendChild(track);row.appendChild(pct);
9995        wrap.appendChild(row);
9996        setTimeout(function(f,s){return function(){f.style.width=s+'%';};}(fill,d.score),60);
9997      });
9998    }
9999    function initTabs(){
10000      var tabsWrap=document.getElementById('style-lang-tabs');
10001      if(!tabsWrap||!CHART_DATA.length)return;
10002      CHART_DATA.forEach(function(grp){
10003        var btn=document.createElement('button');
10004        btn.className='style-lang-tab'+(grp.family===activeLang?' active':'');
10005        btn.textContent=grp.family+' ('+grp.files+')';
10006        btn.onclick=function(){
10007          activeLang=grp.family;
10008          var tabs=tabsWrap.querySelectorAll('.style-lang-tab');
10009          for(var i=0;i<tabs.length;i++)tabs[i].className='style-lang-tab';
10010          btn.className='style-lang-tab active';
10011          renderBars(activeLang);
10012        };
10013        tabsWrap.appendChild(btn);
10014      });
10015      renderBars(activeLang);
10016    }
10017    function buildGuideHtml(guide){
10018      if(!guide||guide==='\u2014'||guide==='Unknown')return'<span style="color:var(--muted);">\u2014</span>';
10019      var url=GUIDE_URLS[guide];
10020      var desc=GUIDE_DESC[guide]||'';
10021      var tipText='Open official '+guide+' documentation'+(desc?' \u00b7 '+desc:'');
10022      if(url){return'<a href="'+escH(url)+'" target="_blank" rel="noopener" class="style-badge">'+escH(guide)+'</a>';}
10023      return'<span class="style-badge">'+escH(guide)+'</span>';
10024    }
10025    function buildSigsHtml(sigs){
10026      if(!sigs||!sigs.length)return'<span style="color:var(--muted);">\u2014</span>';
10027      var html='';
10028      var visible=sigs.slice(0,2);
10029      var rest=sigs.slice(2);
10030      visible.forEach(function(s){html+='<span class="style-sig-chip">'+escH(s.v)+'</span>';});
10031      if(rest.length){html+='<span style="color:var(--muted);font-size:11px;margin-left:2px;">\u22EF</span>';}
10032      return html;
10033    }
10034    var _sigPop=null;
10035    window.showSigPop=function(btn,ev){
10036      ev.stopPropagation();
10037      if(_sigPop){var prev=_sigPop;_sigPop=null;prev.remove();if(btn._ownPop===prev)return;}
10038      var sigs;try{sigs=JSON.parse(btn.getAttribute('data-sigs'));}catch(e){return;}
10039      var pop=document.createElement('div');
10040      pop.className='style-sig-pop';
10041      pop.setAttribute('role','tooltip');
10042      var inner='<div class="style-sig-pop-title">All Signals</div>';
10043      sigs.forEach(function(s){
10044        inner+='<div class="style-sig-pop-row"><span class="style-sig-pop-key">'+escH(s.k)+':</span><span class="style-sig-pop-val">'+escH(s.v)+'</span></div>';
10045      });
10046      pop.innerHTML=inner;
10047      document.body.appendChild(pop);
10048      _sigPop=pop;
10049      btn._ownPop=pop;
10050      var r=btn.getBoundingClientRect();
10051      var pw=pop.offsetWidth||220;
10052      var left=r.left;
10053      if(left+pw>window.innerWidth-8)left=window.innerWidth-pw-8;
10054      if(left<8)left=8;
10055      var top=r.bottom+6;
10056      if(top+(pop.offsetHeight||120)>window.innerHeight-8)top=r.top-(pop.offsetHeight||120)-6;
10057      pop.style.left=left+'px';
10058      pop.style.top=top+'px';
10059      function dismiss(e){if(!pop.contains(e.target)){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
10060      function dismissKey(e){if(e.key==='Escape'){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
10061      setTimeout(function(){document.addEventListener('click',dismiss);document.addEventListener('keydown',dismissKey);},0);
10062    }
10063    var sftRows=[];
10064    var sftFilteredRows=[];
10065    var sftCurrentPage=1;
10066    function sftGetPageSize(){
10067      var sel=document.getElementById('sft-page-size');
10068      var v=sel?sel.value:'20';
10069      return v==='all'?Infinity:parseInt(v,10);
10070    }
10071    function sftApplyFilter(){
10072      var inp=document.getElementById('sft-search');
10073      var q=inp?inp.value.toLowerCase():'';
10074      var sorted=sftRows.slice();
10075      if(sftSortKey){
10076        sorted.sort(function(a,b){
10077          if(sftSortKey==='score'){var av=a.score||0,bv=b.score||0;return sftSortDir*(av-bv);}
10078          var av=String(a[sftSortKey]||'').toLowerCase(),bv=String(b[sftSortKey]||'').toLowerCase();
10079          return av<bv?-1*sftSortDir:av>bv?1*sftSortDir:0;
10080        });
10081      }
10082      sftFilteredRows=q===''?sorted:sorted.filter(function(f){
10083        return (f.path||'').toLowerCase().indexOf(q)>=0
10084          ||(f.lang||'').toLowerCase().indexOf(q)>=0
10085          ||(f.guide||'').toLowerCase().indexOf(q)>=0
10086          ||(f.indent||'').toLowerCase().indexOf(q)>=0;
10087      });
10088      sftCurrentPage=1;
10089      renderSftTable();
10090    }
10091    function renderSftTable(){
10092      var tbody=document.getElementById('style-file-tbody');
10093      if(!tbody)return;
10094      var ps=sftGetPageSize();
10095      var total=sftFilteredRows.length;
10096      var totalAll=sftRows.length;
10097      var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(total/ps));
10098      if(sftCurrentPage>totalPages)sftCurrentPage=totalPages;
10099      if(sftCurrentPage<1)sftCurrentPage=1;
10100      var start=ps===Infinity?0:(sftCurrentPage-1)*ps;
10101      var end=ps===Infinity?total:Math.min(start+ps,total);
10102      var page=sftFilteredRows.slice(start,end);
10103      var html='';
10104      page.forEach(function(f){
10105        var barW=Math.round(f.score);
10106        var guide=f.guide&&f.guide!=='Unknown'?f.guide:'';
10107        var badge=guide?buildGuideHtml(guide):'<span style="color:var(--muted);">\u2014</span>';
10108                var sigHtml=buildSigsHtml(f.signals);
10109        var rowClass=SCORE_THRESHOLD>0&&f.score<SCORE_THRESHOLD?' class="style-row-warn"':'';
10110        html+='<tr'+rowClass+'>'
10111          +'<td title="'+escH(f.path)+'">'+escH(f.path.replace(/^.*[\/\\]/,''))+'</td>'
10112          +'<td>'+escH(f.lang)+'</td>'
10113          +'<td>'+escH(f.indent)+'</td>'
10114          +'<td class="guide-cell" data-gtip="'+(guide?(escH(guide)+(GUIDE_DESC[guide]?' \u00b7 '+escH(GUIDE_DESC[guide]):'')):'')+'">' +badge+'</td>'
10115          +'<td><span class="style-score-bar"><span class="style-score-fill" style="width:'+barW+'%"></span></span>'+f.score+'%</td>'
10116          +'<td class="sig-cell" data-sigs="'+escH(JSON.stringify(f.signals||[]))+'">'+sigHtml+'</td>'
10117          +'</tr>';
10118      });
10119      tbody.innerHTML=html||'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
10120      var pageInfo=document.getElementById('sft-page-info');
10121      var firstBtn=document.getElementById('sft-first');
10122      var prevBtn=document.getElementById('sft-prev');
10123      var nextBtn=document.getElementById('sft-next');
10124      var lastBtn=document.getElementById('sft-last');
10125      var jumpInput=document.getElementById('sft-page-jump');
10126      var pageTotal=document.getElementById('sft-page-total');
10127      var countLabel=document.getElementById('sft-count-label');
10128      if(pageInfo){
10129        if(total===0){pageInfo.textContent='No results';}
10130        else if(ps===Infinity){pageInfo.textContent='All '+total.toLocaleString()+' files';}
10131        else{pageInfo.textContent=(start+1)+'\u2013'+end+' of '+total.toLocaleString()+' files';}
10132      }
10133      if(countLabel){countLabel.textContent=(total<totalAll&&total>0)?'('+total.toLocaleString()+' matching)':'';}
10134      var edgeOff=ps===Infinity;
10135      if(firstBtn)firstBtn.disabled=sftCurrentPage<=1||edgeOff;
10136      if(prevBtn)prevBtn.disabled=sftCurrentPage<=1||edgeOff;
10137      if(nextBtn)nextBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
10138      if(lastBtn)lastBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
10139      if(jumpInput){jumpInput.value=sftCurrentPage;jumpInput.max=totalPages;jumpInput.disabled=edgeOff;}
10140      if(pageTotal)pageTotal.textContent=totalPages.toLocaleString();
10141    }
10142    function initStyleTable(){
10143      if(!FILE_DATA.length){
10144        var tb=document.getElementById('style-file-tbody');
10145        if(tb)tb.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
10146        return;
10147      }
10148      sftRows=FILE_DATA.slice();
10149      sftFilteredRows=sftRows.slice();
10150      sftApplyFilter();
10151      // Signal & guide cell tooltip (appears above hovered cell, arrow points down)
10152      var chipTipEl=document.createElement('div');
10153      chipTipEl.className='sig-tip';
10154      document.body.appendChild(chipTipEl);
10155      function _showSigTip(html,cell){
10156        chipTipEl.innerHTML=html;
10157        chipTipEl.style.display='block';
10158        var r=cell.getBoundingClientRect();
10159        var tw=chipTipEl.offsetWidth||220;
10160        var th=chipTipEl.offsetHeight||80;
10161        var cx=r.left+r.width/2;
10162        var left=cx-tw/2;
10163        if(left<8)left=8;
10164        if(left+tw>window.innerWidth-8)left=window.innerWidth-tw-8;
10165        var arrowPct=Math.round((cx-left)/tw*100);
10166        if(arrowPct<10)arrowPct=10;
10167        if(arrowPct>90)arrowPct=90;
10168        chipTipEl.style.setProperty('--sig-tip-ax',arrowPct+'%');
10169        var top=r.top-th-12;
10170        if(top<8)top=r.bottom+8;
10171        chipTipEl.style.left=left+'px';
10172        chipTipEl.style.top=top+'px';
10173        chipTipEl.classList.add('visible');
10174      }
10175      function _hideSigTip(){
10176        chipTipEl.classList.remove('visible');
10177        chipTipEl.style.display='none';
10178      }
10179      function _buildSigHtml(cell){
10180        var sigs;try{sigs=JSON.parse(cell.getAttribute('data-sigs'));}catch(ex){return null;}
10181        if(!sigs||!sigs.length)return null;
10182        var html='<div class="sig-tip-hd">Signals</div>';
10183        sigs.forEach(function(s){
10184          html+='<div class="sig-tip-row"><span class="sig-tip-k">'+escH(s.k)+':</span><span class="sig-tip-v">'+escH(s.v)+'</span></div>';
10185        });
10186        return html;
10187      }
10188      function _buildGuideHtml(cell){
10189        var tip=cell.getAttribute('data-gtip')||'';
10190        if(!tip)return null;
10191        var parts=tip.split(' \u00b7 ',2);
10192        var html='<div class="sig-tip-hd">'+escH(parts[0])+'</div>';
10193        if(parts[1])html+='<div class="sig-tip-v" style="font-size:11px;">'+escH(parts[1])+'</div>';
10194        return html;
10195      }
10196      var sigTbl=document.getElementById('style-file-table');
10197      if(sigTbl){
10198        sigTbl.addEventListener('mouseover',function(e){
10199          var sc=e.target.closest?e.target.closest('.sig-cell'):null;
10200          var gc=e.target.closest?e.target.closest('.guide-cell'):null;
10201          if(sc){var h=_buildSigHtml(sc);if(h)_showSigTip(h,sc);return;}
10202          if(gc){var h=_buildGuideHtml(gc);if(h){var badge=gc.querySelector('.style-badge')||gc;_showSigTip(h,badge);}return;}
10203          _hideSigTip();
10204        });
10205        sigTbl.addEventListener('mouseleave',function(){
10206          _hideSigTip();
10207        });
10208        sigTbl.addEventListener('mouseout',function(e){
10209          if(!e.relatedTarget||!sigTbl.contains(e.relatedTarget))_hideSigTip();
10210        });
10211      }
10212      // Wire up sortable column headers
10213      var ths=document.querySelectorAll('#style-file-table thead th[data-sort-key]');
10214      for(var i=0;i<ths.length;i++){(function(th){
10215        th.style.cursor='pointer';
10216        th.addEventListener('click',function(){
10217          var key=th.getAttribute('data-sort-key');
10218          if(sftSortKey===key){sftSortDir*=-1;}else{sftSortKey=key;sftSortDir=1;}
10219          for(var j=0;j<ths.length;j++){
10220            ths[j].classList.remove('sft-sort-asc','sft-sort-desc');
10221            var ind=ths[j].querySelector('.style-sort-ind');
10222            if(ind)ind.textContent='\u25BE';
10223          }
10224          th.classList.add(sftSortDir===1?'sft-sort-asc':'sft-sort-desc');
10225          var tind=th.querySelector('.style-sort-ind');
10226          if(tind)tind.textContent=sftSortDir===1?'\u25B2':'\u25BC';
10227          sftApplyFilter();
10228        });
10229      })(ths[i]);}
10230      var searchInput=document.getElementById('sft-search');
10231      if(searchInput){
10232        var sftTimer=null;
10233        searchInput.addEventListener('input',function(){clearTimeout(sftTimer);sftTimer=setTimeout(sftApplyFilter,200);});
10234      }
10235      var pageSel=document.getElementById('sft-page-size');
10236      if(pageSel){pageSel.addEventListener('change',function(){sftCurrentPage=1;renderSftTable();});}
10237      var sftFirstBtn=document.getElementById('sft-first');
10238      var sftPrevBtn=document.getElementById('sft-prev');
10239      var sftNextBtn=document.getElementById('sft-next');
10240      var sftLastBtn=document.getElementById('sft-last');
10241      var sftJumpInput=document.getElementById('sft-page-jump');
10242      if(sftFirstBtn){sftFirstBtn.addEventListener('click',function(){sftCurrentPage=1;renderSftTable();});}
10243      if(sftPrevBtn){sftPrevBtn.addEventListener('click',function(){if(sftCurrentPage>1){sftCurrentPage--;renderSftTable();}});}
10244      if(sftNextBtn){sftNextBtn.addEventListener('click',function(){
10245        var ps=sftGetPageSize();
10246        var totalPages=ps===Infinity?1:Math.ceil(sftFilteredRows.length/ps);
10247        if(sftCurrentPage<totalPages){sftCurrentPage++;renderSftTable();}
10248      });}
10249      if(sftLastBtn){sftLastBtn.addEventListener('click',function(){
10250        var ps=sftGetPageSize();
10251        sftCurrentPage=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
10252        renderSftTable();
10253      });}
10254      if(sftJumpInput){
10255        function sftJump(){
10256          var ps=sftGetPageSize();
10257          var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
10258          var v=parseInt(sftJumpInput.value,10);
10259          if(!isNaN(v)){sftCurrentPage=Math.max(1,Math.min(v,totalPages));renderSftTable();}
10260        }
10261        sftJumpInput.addEventListener('change',sftJump);
10262        sftJumpInput.addEventListener('keydown',function(e){if(e.key==='Enter')sftJump();});
10263      }
10264    }
10265    function initSigInfoBtn(){
10266      var btn=document.getElementById('sig-info-btn');
10267      if(!btn)return;
10268      btn.addEventListener('click',function(){
10269        var overlay=document.createElement('div');
10270        overlay.className='style-sig-info-overlay';
10271        var GLOSSARY=[
10272          ['Quote Style','Dominant string quote character used in the file (single quotes, double quotes, or mixed)'],
10273          ['Indentation','Leading-whitespace style detected: Tabs, 2-Space, 4-Space, 8-Space, or Mixed'],
10274          ['Brace Style','Opening brace placement: K\u0026R / Attach (same line as statement) or Allman (own line)'],
10275          ['Semicolons','Whether statement-ending semicolons are present (JS/TS). \u201cNone detected\u201d means ASI-style.'],
10276          ['Variable Declarations','Preferred declaration keyword: const/let vs var (JS), short := vs var (Go)'],
10277          ['Function Naming','Dominant function naming convention: snake_case or CamelCase'],
10278          ['Type Hints','Whether Python PEP 484 type annotations (:Type, ->Type) are used in the file'],
10279          ['Wildcard Imports','Presence of import * wildcard import statements (Java/Kotlin)'],
10280          ['Pointer Style','Pointer/reference alignment in C/C++: *var (name-attached) or Type* (type-attached)'],
10281          ['Arrow Functions','Count of arrow function => expressions detected in JS/TS files'],
10282          ['Max Line Length','Character length of the longest line found in the file'],
10283          ['Error Handling','Presence of Go-style if err != nil error-checking patterns'],
10284          ['Type Inference','Whether the C# var keyword is used for implicit type inference'],
10285          ['Frozen String Literal','Whether the # frozen_string_literal: true pragma is present (Ruby)'],
10286          ['Space Before Paren','Spacing convention before opening parentheses in control structures (C/C++)'],
10287          ['Include Guard','Whether #pragma once is used as a header include guard (C/C++)']
10288        ];
10289        var rows='';
10290        GLOSSARY.forEach(function(g){rows+='<span class="style-sig-info-name">'+escH(g[0])+'</span><span class="style-sig-info-desc">'+escH(g[1])+'</span>';});
10291        overlay.innerHTML='<div class="style-sig-info-modal" role="dialog" aria-modal="true" aria-label="Signal glossary"><button type="button" class="style-sig-info-close" aria-label="Close">\u00D7</button><h3 style="margin:0 0 6px;font-size:17px;">Signal Glossary</h3><p style="color:var(--muted);font-size:13px;margin:0 0 2px;">Lexical signals detected per file. Values reflect dominant patterns in the source text.</p><div class="style-sig-info-grid">'+rows+'</div></div>';
10292        document.body.appendChild(overlay);
10293        overlay.addEventListener('click',function(e){if(e.target===overlay||e.target.classList.contains('style-sig-info-close')){overlay.remove();}});
10294      });
10295    }
10296    function init(){initTabs();initStyleTable();initSigInfoBtn();}
10297    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10298  }());
10299  </script>
10300  {% endif %}
10301  <script nonce="{{ nonce }}">
10302  (function(){
10303    var params=new URLSearchParams(location.search);
10304    if(params.get('autoprint')!=='1')return;
10305    var overlay=document.createElement('div');
10306    overlay.id='autoprint-overlay';
10307    overlay.style.cssText='position:fixed;inset:0;z-index:99999;background:var(--bg,#fff);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;';
10308    overlay.innerHTML='<div style="font-size:20px;font-weight:800;color:var(--text,#1a1a1a);">Preparing PDF\u2026</div>'
10309      +'<div style="font-size:13px;color:var(--muted,#666);">Use your browser\u2019s print dialog \u2192 <strong>Save as PDF</strong>.</div>'
10310      +'<div style="width:200px;height:4px;border-radius:2px;background:rgba(0,0,0,0.1);overflow:hidden;">'
10311      +'<div id="autoprint-bar" style="height:100%;width:0%;background:#e07b3a;transition:width 1.5s ease;border-radius:2px;"></div></div>';
10312    document.body.appendChild(overlay);
10313    setTimeout(function(){var b=document.getElementById('autoprint-bar');if(b)b.style.width='80%';},50);
10314    var deadline=Date.now()+12000;
10315    function tryPrint(){
10316      if(window.oxSlocChartsReady||Date.now()>deadline){
10317        var b=document.getElementById('autoprint-bar');
10318        if(b)b.style.width='100%';
10319        setTimeout(function(){
10320          overlay.style.display='none';
10321          window.print();
10322        },350);
10323      } else {
10324        setTimeout(tryPrint,150);
10325      }
10326    }
10327    if(document.readyState==='loading'){
10328      document.addEventListener('DOMContentLoaded',function(){setTimeout(tryPrint,250);});
10329    } else {
10330      setTimeout(tryPrint,250);
10331    }
10332    window.addEventListener('afterprint',function(){overlay.remove();});
10333  }());
10334  </script>
10335  <footer class="report-footer">local code analysis &mdash; metrics, history and reports &nbsp;&middot;&nbsp; oxide-sloc v{{ tool_version }} &nbsp;&middot;&nbsp; AGPL-3.0-or-later &nbsp;&middot;&nbsp; offline / air-gapped build</footer>
10336  {% if let Some(banner) = report_header_footer %}
10337  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
10338  {% endif %}
10339</body>
10340</html>"##,
10341    ext = "html"
10342)]
10343// Template structs need many bool fields to pass Askama rendering flags.
10344// Fields are consumed by the Askama proc-macro; clippy cannot trace that usage.
10345#[allow(clippy::struct_excessive_bools, dead_code)]
10346struct ReportTemplate<'a> {
10347    nonce: String,
10348    title: String,
10349    browser_title: String,
10350    scan_performed_by: String,
10351    scan_time_pst: String,
10352    tool_version: String,
10353    is_sub_report: bool,
10354    run: &'a AnalysisRun,
10355    language_rows: Vec<LanguageRow>,
10356    file_rows: Vec<FileRow>,
10357    skipped_rows: Vec<FileRow>,
10358    config_json: String,
10359    lang_chart_json: String,
10360    submodule_chart_json: String,
10361    scatter_chart_json: String,
10362    semantic_chart_json: String,
10363    file_size_histogram_json: String,
10364    has_submodule_data: bool,
10365    has_semantic_data: bool,
10366    has_coverage_data: bool,
10367    has_fn_coverage: bool,
10368    has_branch_coverage: bool,
10369    test_files_count: u64,
10370    test_assertion_count: u64,
10371    test_suite_count: u64,
10372    test_density: String,
10373    most_tested_lang: String,
10374    langs_with_tests: usize,
10375    cov_line_pct: String,
10376    cov_fn_pct: String,
10377    cov_branch_pct: String,
10378    cov_line_class: String,
10379    cov_fn_class: String,
10380    cov_branch_class: String,
10381    has_run_warnings: bool,
10382    warning_count: usize,
10383    warning_summary_rows: Vec<WarningSummaryRow>,
10384    warning_opportunity_rows: Vec<WarningOpportunityRow>,
10385    warning_console_full: String,
10386    logo_text_uri: String,
10387    small_logo_uri: String,
10388    /// Data-URI for a custom logo, or None to show the default `OxideSLOC` logo.
10389    custom_logo_uri: Option<String>,
10390    /// Optional company/team name shown instead of "`OxideSLOC`" in the nav header.
10391    company_name: Option<String>,
10392    /// CSS hex accent colour override (e.g. `#3b82f6`), or None for the default.
10393    accent_hex: Option<String>,
10394    /// Text for the header/footer identification banner on every report page.
10395    report_header_footer: Option<String>,
10396    chart_js: &'static str,
10397    run_id_short: String,
10398    /// When the HTML was generated alongside a PDF (e.g. via CLI with both
10399    /// `--html-out` and `--pdf-out`), this holds the relative URL to that PDF.
10400    /// The "View PDF" button navigates directly to it instead of the server route.
10401    standalone_pdf_url: Option<String>,
10402    /// Direct link to the commit on the hosting forge (GitHub, Bitbucket, GitLab, …).
10403    /// `None` when the remote URL is absent or unrecognised.
10404    git_commit_url: Option<String>,
10405    /// Direct link to the branch on the hosting forge.
10406    /// `None` when the remote URL or branch is absent/unrecognised.
10407    git_branch_url: Option<String>,
10408    /// Whether any style data was collected.
10409    has_style_data: bool,
10410    /// Number of language groups in the style summary (0 when none).
10411    style_lang_count: usize,
10412    /// Files scoring below this threshold are highlighted in the per-file table. 0 = off.
10413    style_score_threshold: u8,
10414    /// Serialised JSON for the multi-language style-guide chart (empty string when none).
10415    style_chart_json: String,
10416    /// Serialised JSON for the per-file style table (empty string when none).
10417    style_file_json: String,
10418    /// Aggregate style summary, cloned from `AnalysisRun::style_summary`.
10419    style_summary: Option<StyleSummary>,
10420    /// True when a previous-scan delta was provided (shows the delta panel).
10421    has_delta: bool,
10422    delta_code_added: i64,
10423    delta_code_removed: i64,
10424    delta_unmodified_lines: i64,
10425    delta_files_added: usize,
10426    delta_files_removed: usize,
10427    delta_files_modified: usize,
10428    delta_files_unchanged: usize,
10429    delta_files_total: usize,
10430    prev_code_lines: u64,
10431    prev_scan_count: usize,
10432    prev_scan_label: String,
10433    prev_run_id: String,
10434    /// Whether a COCOMO estimate is available.
10435    has_cocomo: bool,
10436    /// Pre-formatted COCOMO effort string (e.g. "14.32 person-months").
10437    cocomo_effort_str: String,
10438    /// Pre-formatted COCOMO schedule string (e.g. "6.18 months").
10439    cocomo_duration_str: String,
10440    /// Pre-formatted COCOMO average team-size string (e.g. "2.32").
10441    cocomo_staff_str: String,
10442    /// Pre-formatted KSLOC input for COCOMO (e.g. "12.53").
10443    cocomo_ksloc_str: String,
10444    /// Display label for the COCOMO mode (e.g. "Organic").
10445    cocomo_mode_label: String,
10446    /// Tooltip text explaining the selected COCOMO mode.
10447    cocomo_mode_tooltip: String,
10448    /// Unique Lines of Code across all analyzed files.
10449    uloc: u64,
10450    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty string.
10451    dryness_pct_str: String,
10452    /// Number of duplicate file groups detected.
10453    duplicate_group_count: usize,
10454    /// True when an `--activity-window` scan attached per-file git activity.
10455    has_hotspots: bool,
10456    /// Top-N files by `code_lines × recent commits` (empty unless activity was collected).
10457    hotspot_rows: Vec<HotspotRow>,
10458    /// True when an attribution scan populated per-author ownership.
10459    has_ownership: bool,
10460    /// Per-contributor ownership rows (empty unless attribution ran on a git repo).
10461    ownership_rows: Vec<AuthorReportRow>,
10462    /// Headline ownership summary stats (mirrors the web /code-ownership chips).
10463    own_contributors: usize,
10464    own_top_name: String,
10465    own_top_pct_str: String,
10466    own_bus_factor: usize,
10467    own_total_code: u64,
10468    own_dev_code: u64,
10469    own_test_code: u64,
10470    own_test_pct_str: String,
10471    own_total_comment: u64,
10472}
10473
10474// ─────────────────────────────────────────────────────────────────────────────
10475// CSV export
10476// ─────────────────────────────────────────────────────────────────────────────
10477
10478fn csv_escape(s: &str) -> String {
10479    if s.contains(',') || s.contains('"') || s.contains('\n') {
10480        format!("\"{}\"", s.replace('"', "\"\""))
10481    } else {
10482        s.to_string()
10483    }
10484}
10485
10486/// Write a two-section CSV: language summary followed by per-file detail.
10487///
10488/// # Errors
10489///
10490/// Returns an error if the file cannot be written.
10491pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
10492    let mut out = String::new();
10493
10494    // ── Section 1: Summary ──────────────────────────────────────────────────
10495    out.push_str("# Summary\r\n");
10496    out.push_str("Metric,Value\r\n");
10497    let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
10498    let _ = write!(
10499        out,
10500        "Timestamp,{}\r\n",
10501        csv_escape(
10502            &run.tool
10503                .timestamp_utc
10504                .format("%Y-%m-%d %H:%M:%S UTC")
10505                .to_string()
10506        )
10507    );
10508    let _ = write!(
10509        out,
10510        "Report Title,{}\r\n",
10511        csv_escape(&run.effective_configuration.reporting.report_title)
10512    );
10513    let _ = write!(
10514        out,
10515        "Files Analyzed,{}\r\n",
10516        run.summary_totals.files_analyzed
10517    );
10518    let _ = write!(
10519        out,
10520        "Files Skipped,{}\r\n",
10521        run.summary_totals.files_skipped
10522    );
10523    let _ = write!(
10524        out,
10525        "Physical Lines,{}\r\n",
10526        run.summary_totals.total_physical_lines
10527    );
10528    let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
10529    let _ = write!(
10530        out,
10531        "Comment Lines,{}\r\n",
10532        run.summary_totals.comment_lines
10533    );
10534    let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
10535    let _ = write!(
10536        out,
10537        "Mixed Lines (separate),{}\r\n",
10538        run.summary_totals.mixed_lines_separate
10539    );
10540
10541    // ── Section 2: Language breakdown ───────────────────────────────────────
10542    out.push_str("\r\n# By Language\r\n");
10543    out.push_str(
10544        "Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
10545    );
10546    for lang in &run.totals_by_language {
10547        let _ = write!(
10548            out,
10549            "{},{},{},{},{},{},{}\r\n",
10550            csv_escape(lang.language.display_name()),
10551            lang.files,
10552            lang.total_physical_lines,
10553            lang.code_lines,
10554            lang.comment_lines,
10555            lang.blank_lines,
10556            lang.mixed_lines_separate,
10557        );
10558    }
10559
10560    // ── Section 3: Per-file detail (if present) ─────────────────────────────
10561    write_csv_per_file_section(&mut out, run);
10562
10563    // ── Section 4: Code ownership (if an attribution scan populated it) ──────
10564    write_csv_ownership_section(&mut out, run);
10565
10566    fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
10567}
10568
10569/// Append the code-ownership section to a CSV buffer. No-op unless an attribution scan
10570/// populated `run.authors`. Rows are ordered by code lines owned (as produced by the engine).
10571fn write_csv_ownership_section(out: &mut String, run: &AnalysisRun) {
10572    let rows = build_author_rows(run);
10573    if rows.is_empty() {
10574        return;
10575    }
10576    out.push_str("\r\n# Code Ownership\r\n");
10577    out.push_str(
10578        "Author,Email,Code Lines,Comment Lines,Blank Lines,Total Lines,Code %,Files Owned\r\n",
10579    );
10580    for a in &rows {
10581        let _ = write!(
10582            out,
10583            "{},{},{},{},{},{},{},{}\r\n",
10584            csv_escape(&a.name),
10585            csv_escape(&a.email),
10586            a.code,
10587            a.comment,
10588            a.blank,
10589            a.total,
10590            a.code_pct_str,
10591            a.files_owned,
10592        );
10593    }
10594}
10595
10596/// Append the per-file detail section to a CSV buffer. No-op when there are no per-file records.
10597fn write_csv_per_file_section(out: &mut String, run: &AnalysisRun) {
10598    if run.per_file_records.is_empty() {
10599        return;
10600    }
10601    // Only emit the git-activity columns when an --activity-window scan populated them.
10602    let has_activity = run
10603        .per_file_records
10604        .iter()
10605        .any(|r| r.commit_count.is_some());
10606    out.push_str("\r\n# Per File\r\n");
10607    out.push_str(
10608        "Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor",
10609    );
10610    if has_activity {
10611        out.push_str(",Commits,Last Changed");
10612    }
10613    out.push_str("\r\n");
10614    for rec in &run.per_file_records {
10615        let _ = write!(
10616            out,
10617            "{},{},{},{},{},{},{},{},{},{}",
10618            csv_escape(&rec.relative_path),
10619            csv_escape(
10620                &rec.language
10621                    .map(|l| l.display_name().to_string())
10622                    .unwrap_or_default()
10623            ),
10624            rec.size_bytes,
10625            rec.effective_counts.code_lines,
10626            rec.effective_counts.comment_lines,
10627            rec.effective_counts.blank_lines,
10628            rec.raw_line_categories.total_physical_lines,
10629            rec.generated,
10630            rec.minified,
10631            rec.vendor,
10632        );
10633        if has_activity {
10634            let _ = write!(
10635                out,
10636                ",{},{}",
10637                rec.commit_count.map(|c| c.to_string()).unwrap_or_default(),
10638                csv_escape(rec.last_commit_date.as_deref().unwrap_or("")),
10639            );
10640        }
10641        out.push_str("\r\n");
10642    }
10643}
10644
10645/// Write a diff/delta as CSV.
10646///
10647/// # Errors
10648///
10649/// Returns an error if the file cannot be written.
10650pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10651    let s = &cmp.summary;
10652    let mut out = String::new();
10653
10654    out.push_str("# Diff Summary\r\n");
10655    out.push_str("Metric,Value\r\n");
10656    let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
10657    let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
10658    let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
10659    let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
10660    let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
10661    let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
10662    let _ = write!(out, "Files Total,{}\r\n", cmp.files_total);
10663    let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
10664    let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
10665    let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
10666    let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
10667
10668    out.push_str("\r\n# File Deltas\r\n");
10669    out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
10670    for f in &cmp.file_deltas {
10671        let status = match f.status {
10672            sloc_core::FileChangeStatus::Added => "Added",
10673            sloc_core::FileChangeStatus::Removed => "Removed",
10674            sloc_core::FileChangeStatus::Modified => "Modified",
10675            sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10676        };
10677        let _ = write!(
10678            out,
10679            "{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
10680            status,
10681            csv_escape(&f.relative_path),
10682            csv_escape(f.language.as_deref().unwrap_or("")),
10683            f.baseline_code,
10684            f.current_code,
10685            f.code_delta,
10686            f.baseline_comment,
10687            f.current_comment,
10688            f.comment_delta,
10689            f.baseline_blank,
10690            f.current_blank,
10691            f.blank_delta,
10692            f.total_delta,
10693        );
10694    }
10695
10696    fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
10697}
10698
10699// ─────────────────────────────────────────────────────────────────────────────
10700// XLSX export — self-contained, no external crates required.
10701//
10702// An .xlsx file is a ZIP archive containing a set of XML files.  We write the
10703// ZIP with the STORE (uncompressed) method so we only need a CRC-32 routine
10704// and straightforward byte-level framing — both implemented inline below.
10705// ─────────────────────────────────────────────────────────────────────────────
10706
10707fn crc32(data: &[u8]) -> u32 {
10708    let mut crc: u32 = 0xffff_ffff;
10709    for &b in data {
10710        crc ^= u32::from(b);
10711        for _ in 0..8 {
10712            crc = if crc & 1 == 0 {
10713                crc >> 1
10714            } else {
10715                (crc >> 1) ^ 0xedb8_8320
10716            };
10717        }
10718    }
10719    !crc
10720}
10721
10722struct ZipEntry {
10723    name: Vec<u8>,
10724    data: Vec<u8>,
10725    crc: u32,
10726    offset: u32,
10727}
10728
10729#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by caller
10730fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
10731    let crc = crc32(&data);
10732    let offset = buf.len() as u32;
10733    let name_bytes = name.as_bytes().to_vec();
10734    let size = data.len() as u32;
10735
10736    // Local file header (signature 0x04034b50)
10737    buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
10738    buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
10739    buf.extend_from_slice(&0u16.to_le_bytes()); // flags
10740    buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
10741    buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
10742    buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
10743    buf.extend_from_slice(&crc.to_le_bytes());
10744    buf.extend_from_slice(&size.to_le_bytes()); // compressed size
10745    buf.extend_from_slice(&size.to_le_bytes()); // uncompressed size
10746    buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
10747    buf.extend_from_slice(&0u16.to_le_bytes()); // extra field length
10748    buf.extend_from_slice(&name_bytes);
10749    buf.extend_from_slice(&data);
10750
10751    entries.push(ZipEntry {
10752        name: name_bytes,
10753        data,
10754        crc,
10755        offset,
10756    });
10757}
10758
10759#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by ZIP spec limits
10760fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
10761    let central_start = buf.len() as u32;
10762
10763    for e in entries {
10764        let size = e.data.len() as u32;
10765        buf.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); // central dir sig
10766        buf.extend_from_slice(&20u16.to_le_bytes()); // version made by
10767        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
10768        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
10769        buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
10770        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
10771        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
10772        buf.extend_from_slice(&e.crc.to_le_bytes());
10773        buf.extend_from_slice(&size.to_le_bytes());
10774        buf.extend_from_slice(&size.to_le_bytes());
10775        buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
10776        buf.extend_from_slice(&0u16.to_le_bytes()); // extra
10777        buf.extend_from_slice(&0u16.to_le_bytes()); // comment
10778        buf.extend_from_slice(&0u16.to_le_bytes()); // disk start
10779        buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
10780        buf.extend_from_slice(&0u32.to_le_bytes()); // external attrs
10781        buf.extend_from_slice(&e.offset.to_le_bytes());
10782        buf.extend_from_slice(&e.name);
10783    }
10784
10785    let central_size = buf.len() as u32 - central_start;
10786    let n = entries.len() as u16;
10787
10788    // End of central directory record
10789    buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
10790    buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
10791    buf.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir
10792    buf.extend_from_slice(&n.to_le_bytes()); // entries on this disk
10793    buf.extend_from_slice(&n.to_le_bytes()); // total entries
10794    buf.extend_from_slice(&central_size.to_le_bytes());
10795    buf.extend_from_slice(&central_start.to_le_bytes());
10796    buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
10797
10798    buf
10799}
10800
10801fn xml_escape(s: &str) -> String {
10802    s.replace('&', "&amp;")
10803        .replace('<', "&lt;")
10804        .replace('>', "&gt;")
10805        .replace('"', "&quot;")
10806        .replace('\'', "&apos;")
10807}
10808
10809/// Build a worksheet XML with the given header row and data rows.
10810// ── XLSX style-index constants ──────────────────────────────────────────────
10811// Indices into the <cellXfs> table in styles.xml.
10812// 0 = default (unused placeholder)
10813// 1 = HEADER   bold white text, navy fill (#283790), all-side thin border, centered
10814// 2 = BODY     normal text, white fill, thin border
10815// 3 = BODY_ALT normal text, cream fill (#F5EFE8), thin border  (alternating rows)
10816// 4 = NUM      #,##0, right-aligned, white fill, thin border
10817// 5 = NUM_ALT  #,##0, right-aligned, cream fill, thin border   (alternating rows)
10818// 6 = KV_KEY   bold navy text (#283790), warm-surface fill (#FBF7F2), thin border
10819// 7 = KV_VAL   normal text, white fill, thin border  (key-value sheets: Summary)
10820const XLS_HEADER: u32 = 1;
10821const XLS_BODY: u32 = 2;
10822const XLS_BODY_ALT: u32 = 3;
10823const XLS_NUM: u32 = 4;
10824const XLS_NUM_ALT: u32 = 5;
10825const XLS_KV_KEY: u32 = 6;
10826const XLS_KV_VAL: u32 = 7;
10827
10828struct XlSheet<'a> {
10829    name: &'a str,
10830    tab_color: &'a str, // AARRGGBB hex without '#', e.g. "FF283790"
10831    headers: &'a [&'a str],
10832    rows: Vec<Vec<String>>,
10833    col_widths: Vec<f64>, // per-column character widths; last entry used for overflow cols
10834    is_kv: bool,          // key-value layout (Summary): col A = key style, no autofilter
10835}
10836
10837#[allow(clippy::cast_possible_truncation)] // n % 26 fits in u8 by construction
10838fn xl_col_name(idx: usize) -> String {
10839    let mut n = idx + 1;
10840    let mut s = String::new();
10841    while n > 0 {
10842        n -= 1;
10843        s.insert(0, char::from(b'A' + (n % 26) as u8));
10844        n /= 26;
10845    }
10846    s
10847}
10848
10849const fn xl_styles() -> &'static str {
10850    "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
10851<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
10852<numFmts count=\"1\">\
10853<numFmt numFmtId=\"164\" formatCode=\"#,##0\"/>\
10854</numFmts>\
10855<fonts count=\"3\">\
10856<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\
10857<font><b/><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font>\
10858<font><b/><sz val=\"11\"/><color rgb=\"FF283790\"/><name val=\"Calibri\"/></font>\
10859</fonts>\
10860<fills count=\"5\">\
10861<fill><patternFill patternType=\"none\"/></fill>\
10862<fill><patternFill patternType=\"gray125\"/></fill>\
10863<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF283790\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10864<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFF5EFE8\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10865<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFBF7F2\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10866</fills>\
10867<borders count=\"2\">\
10868<border><left/><right/><top/><bottom/><diagonal/></border>\
10869<border>\
10870<left style=\"thin\"><color rgb=\"FFD0B8A0\"/></left>\
10871<right style=\"thin\"><color rgb=\"FFD0B8A0\"/></right>\
10872<top style=\"thin\"><color rgb=\"FFD0B8A0\"/></top>\
10873<bottom style=\"thin\"><color rgb=\"FFD0B8A0\"/></bottom>\
10874<diagonal/>\
10875</border>\
10876</borders>\
10877<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\
10878<cellXfs count=\"8\">\
10879<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\
10880<xf numFmtId=\"0\" fontId=\"1\" fillId=\"2\" borderId=\"1\" xfId=\"0\" \
10881applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10882<alignment horizontal=\"center\" vertical=\"center\"/></xf>\
10883<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10884<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" applyFill=\"1\" applyBorder=\"1\"/>\
10885<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" \
10886applyNumberFormat=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10887<alignment horizontal=\"right\"/></xf>\
10888<xf numFmtId=\"164\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" \
10889applyNumberFormat=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10890<alignment horizontal=\"right\"/></xf>\
10891<xf numFmtId=\"0\" fontId=\"2\" fillId=\"4\" borderId=\"1\" xfId=\"0\" \
10892applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\"/>\
10893<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10894</cellXfs>\
10895</styleSheet>"
10896}
10897
10898fn xl_sheet_xml(sheet: &XlSheet<'_>) -> Vec<u8> {
10899    let ncols = sheet.headers.len();
10900    let ndata = sheet.rows.len();
10901    let last_col = xl_col_name(ncols.saturating_sub(1));
10902    let last_row = ndata + 1;
10903    let range = format!("A1:{last_col}{last_row}");
10904
10905    let mut xml = String::with_capacity(4096 + ndata * 256);
10906    let _ = write!(
10907        xml,
10908        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10909         <worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
10910         <sheetPr><tabColor rgb=\"{tc}\"/></sheetPr>\n\
10911         <dimension ref=\"{rng}\"/>\n\
10912         <sheetViews><sheetView workbookViewId=\"0\">\
10913         <pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>\
10914         <selection pane=\"bottomLeft\" activeCell=\"A2\" sqref=\"A2\"/>\
10915         </sheetView></sheetViews>\n\
10916         <sheetFormatPr defaultRowHeight=\"15\"/>\n",
10917        tc = sheet.tab_color,
10918        rng = range,
10919    );
10920
10921    xl_write_col_widths(&mut xml, &sheet.col_widths, ncols);
10922    xml.push_str("<sheetData>\n");
10923    xl_write_header_row(&mut xml, sheet.headers);
10924    xl_write_data_rows(&mut xml, &sheet.rows, sheet.is_kv);
10925    xml.push_str("</sheetData>\n");
10926    if !sheet.is_kv && ncols > 0 {
10927        let _ = writeln!(xml, "<autoFilter ref=\"{range}\"/>");
10928    }
10929    xml.push_str("</worksheet>");
10930    xml.into_bytes()
10931}
10932
10933fn xl_write_col_widths(xml: &mut String, col_widths: &[f64], ncols: usize) {
10934    if col_widths.is_empty() {
10935        return;
10936    }
10937    let default_w = *col_widths.last().unwrap_or(&10.0);
10938    xml.push_str("<cols>\n");
10939    for ci in 0..ncols {
10940        let w = col_widths.get(ci).copied().unwrap_or(default_w);
10941        let _ = writeln!(
10942            xml,
10943            "  <col min=\"{n}\" max=\"{n}\" width=\"{w:.1}\" customWidth=\"1\"/>",
10944            n = ci + 1
10945        );
10946    }
10947    xml.push_str("</cols>\n");
10948}
10949
10950fn xl_write_header_row(xml: &mut String, headers: &[&str]) {
10951    let _ = write!(xml, "<row r=\"1\" ht=\"18\" customHeight=\"1\">");
10952    for (ci, &h) in headers.iter().enumerate() {
10953        let _ = write!(
10954            xml,
10955            "<c r=\"{}1\" t=\"inlineStr\" s=\"{}\"><is><t>{}</t></is></c>",
10956            xl_col_name(ci),
10957            XLS_HEADER,
10958            xml_escape(h),
10959        );
10960    }
10961    xml.push_str("</row>\n");
10962}
10963
10964const fn xl_cell_style(is_kv: bool, ci: usize, is_num: bool, is_alt: bool) -> u32 {
10965    if is_kv {
10966        if ci == 0 {
10967            XLS_KV_KEY
10968        } else if is_num {
10969            XLS_NUM
10970        } else {
10971            XLS_KV_VAL
10972        }
10973    } else if is_num {
10974        if is_alt { XLS_NUM_ALT } else { XLS_NUM }
10975    } else if is_alt {
10976        XLS_BODY_ALT
10977    } else {
10978        XLS_BODY
10979    }
10980}
10981
10982fn xl_write_data_rows(xml: &mut String, rows: &[Vec<String>], is_kv: bool) {
10983    for (ri, row) in rows.iter().enumerate() {
10984        let row_num = ri + 2;
10985        let is_alt = ri % 2 == 1;
10986        let _ = write!(xml, "<row r=\"{row_num}\">");
10987        for (ci, cell) in row.iter().enumerate() {
10988            let cell_ref = format!("{}{}", xl_col_name(ci), row_num);
10989            let is_num = !cell.is_empty() && cell.parse::<f64>().is_ok();
10990            let s = xl_cell_style(is_kv, ci, is_num, is_alt);
10991            if is_num {
10992                let _ = write!(
10993                    xml,
10994                    "<c r=\"{cell_ref}\" s=\"{s}\"><v>{}</v></c>",
10995                    xml_escape(cell)
10996                );
10997            } else {
10998                let _ = write!(
10999                    xml,
11000                    "<c r=\"{cell_ref}\" t=\"inlineStr\" s=\"{s}\"><is><t>{}</t></is></c>",
11001                    xml_escape(cell),
11002                );
11003            }
11004        }
11005        xml.push_str("</row>\n");
11006    }
11007}
11008
11009fn build_xlsx(sheets: &[XlSheet<'_>]) -> Vec<u8> {
11010    let mut buf: Vec<u8> = Vec::new();
11011    let mut entries: Vec<ZipEntry> = Vec::new();
11012
11013    // ── [Content_Types].xml ─────────────────────────────────────────────────
11014    let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
11015    ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
11016    ct.push_str("  <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
11017    ct.push_str("  <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
11018    ct.push_str("  <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
11019    ct.push_str("  <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
11020    for (i, _) in sheets.iter().enumerate() {
11021        let _ = writeln!(
11022            ct,
11023            "  <Override PartName=\"/xl/worksheets/sheet{}.xml\" \
11024             ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
11025            i + 1
11026        );
11027    }
11028    ct.push_str("</Types>");
11029    zip_add(
11030        &mut entries,
11031        &mut buf,
11032        "[Content_Types].xml",
11033        ct.into_bytes(),
11034    );
11035
11036    // ── _rels/.rels ─────────────────────────────────────────────────────────
11037    let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
11038<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
11039  <Relationship Id=\"rId1\" \
11040  Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" \
11041  Target=\"xl/workbook.xml\"/>\n\
11042</Relationships>";
11043    zip_add(
11044        &mut entries,
11045        &mut buf,
11046        "_rels/.rels",
11047        rels.as_bytes().to_vec(),
11048    );
11049
11050    // ── xl/workbook.xml ──────────────────────────────────────────────────────
11051    let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
11052    wb.push_str(
11053        "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
11054         xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
11055    );
11056    wb.push_str("  <sheets>\n");
11057    for (i, sheet) in sheets.iter().enumerate() {
11058        let _ = writeln!(
11059            wb,
11060            "    <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
11061            xml_escape(sheet.name),
11062            i + 1,
11063            i + 1
11064        );
11065    }
11066    wb.push_str("  </sheets>\n</workbook>");
11067    zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
11068
11069    // ── xl/_rels/workbook.xml.rels ───────────────────────────────────────────
11070    let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
11071    wbr.push_str(
11072        "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
11073    );
11074    for (i, _) in sheets.iter().enumerate() {
11075        let _ = writeln!(
11076            wbr,
11077            "  <Relationship Id=\"rId{}\" \
11078             Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
11079             Target=\"worksheets/sheet{}.xml\"/>",
11080            i + 1,
11081            i + 1
11082        );
11083    }
11084    let _ = writeln!(
11085        wbr,
11086        "  <Relationship Id=\"rId{}\" \
11087         Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
11088         Target=\"styles.xml\"/>",
11089        sheets.len() + 1
11090    );
11091    wbr.push_str("</Relationships>");
11092    zip_add(
11093        &mut entries,
11094        &mut buf,
11095        "xl/_rels/workbook.xml.rels",
11096        wbr.into_bytes(),
11097    );
11098
11099    // ── xl/styles.xml ───────────────────────────────────────────────────────
11100    zip_add(
11101        &mut entries,
11102        &mut buf,
11103        "xl/styles.xml",
11104        xl_styles().as_bytes().to_vec(),
11105    );
11106
11107    // ── worksheets ───────────────────────────────────────────────────────────
11108    for (i, sheet) in sheets.iter().enumerate() {
11109        let sheet_xml = xl_sheet_xml(sheet);
11110        let name = format!("xl/worksheets/sheet{}.xml", i + 1);
11111        zip_add(&mut entries, &mut buf, &name, sheet_xml);
11112    }
11113
11114    zip_finish(buf, &entries)
11115}
11116
11117/// Write an analysis run as a multi-sheet Excel workbook.
11118///
11119/// # Errors
11120///
11121/// Returns an error if the file cannot be written.
11122#[allow(clippy::too_many_lines)]
11123pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
11124    // Sheet 1 — Summary
11125    let summary_rows: Vec<Vec<String>> = vec![
11126        vec!["Run ID".into(), run.tool.run_id.clone()],
11127        vec![
11128            "Timestamp".into(),
11129            run.tool
11130                .timestamp_utc
11131                .format("%Y-%m-%d %H:%M:%S UTC")
11132                .to_string(),
11133        ],
11134        vec![
11135            "Report Title".into(),
11136            run.effective_configuration.reporting.report_title.clone(),
11137        ],
11138        vec![
11139            "Files Analyzed".into(),
11140            run.summary_totals.files_analyzed.to_string(),
11141        ],
11142        vec![
11143            "Files Skipped".into(),
11144            run.summary_totals.files_skipped.to_string(),
11145        ],
11146        vec![
11147            "Physical Lines".into(),
11148            run.summary_totals.total_physical_lines.to_string(),
11149        ],
11150        vec![
11151            "Code Lines".into(),
11152            run.summary_totals.code_lines.to_string(),
11153        ],
11154        vec![
11155            "Comment Lines".into(),
11156            run.summary_totals.comment_lines.to_string(),
11157        ],
11158        vec![
11159            "Blank Lines".into(),
11160            run.summary_totals.blank_lines.to_string(),
11161        ],
11162        vec![
11163            "Mixed Lines (separate)".into(),
11164            run.summary_totals.mixed_lines_separate.to_string(),
11165        ],
11166    ];
11167
11168    // Sheet 2 — By Language
11169    let lang_rows: Vec<Vec<String>> = run
11170        .totals_by_language
11171        .iter()
11172        .map(|l| {
11173            vec![
11174                l.language.display_name().to_string(),
11175                l.files.to_string(),
11176                l.total_physical_lines.to_string(),
11177                l.code_lines.to_string(),
11178                l.comment_lines.to_string(),
11179                l.blank_lines.to_string(),
11180                l.mixed_lines_separate.to_string(),
11181            ]
11182        })
11183        .collect();
11184
11185    // Sheet 3 — Per File
11186    let file_rows: Vec<Vec<String>> = run
11187        .per_file_records
11188        .iter()
11189        .map(|r| {
11190            vec![
11191                r.relative_path.clone(),
11192                r.language
11193                    .map(|l| l.display_name().to_string())
11194                    .unwrap_or_default(),
11195                r.size_bytes.to_string(),
11196                r.effective_counts.code_lines.to_string(),
11197                r.effective_counts.comment_lines.to_string(),
11198                r.effective_counts.blank_lines.to_string(),
11199                r.raw_line_categories.total_physical_lines.to_string(),
11200                r.generated.to_string(),
11201                r.minified.to_string(),
11202                r.vendor.to_string(),
11203            ]
11204        })
11205        .collect();
11206
11207    // Sheet 4 — Skipped Files
11208    let skipped_rows: Vec<Vec<String>> = run
11209        .skipped_file_records
11210        .iter()
11211        .map(|r| {
11212            vec![
11213                r.relative_path.clone(),
11214                format!("{:?}", r.status),
11215                r.size_bytes.to_string(),
11216            ]
11217        })
11218        .collect();
11219
11220    let summary_hdrs: &[&str] = &["Metric", "Value"];
11221    let lang_hdrs: &[&str] = &[
11222        "Language",
11223        "Files",
11224        "Physical Lines",
11225        "Code Lines",
11226        "Comments",
11227        "Blank",
11228        "Mixed",
11229    ];
11230    let file_hdrs: &[&str] = &[
11231        "Path",
11232        "Language",
11233        "Size (bytes)",
11234        "Code Lines",
11235        "Comments",
11236        "Blank Lines",
11237        "Physical Lines",
11238        "Generated",
11239        "Minified",
11240        "Vendor",
11241    ];
11242    let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
11243
11244    let sheets = vec![
11245        XlSheet {
11246            name: "Summary",
11247            tab_color: "FF283790",
11248            headers: summary_hdrs,
11249            rows: summary_rows,
11250            col_widths: vec![26.0, 44.0],
11251            is_kv: true,
11252        },
11253        XlSheet {
11254            name: "By Language",
11255            tab_color: "FFB85D33",
11256            headers: lang_hdrs,
11257            rows: lang_rows,
11258            col_widths: vec![20.0, 9.0, 15.0, 13.0, 13.0, 11.0, 11.0],
11259            is_kv: false,
11260        },
11261        XlSheet {
11262            name: "Per File",
11263            tab_color: "FF2A6846",
11264            headers: file_hdrs,
11265            rows: file_rows,
11266            col_widths: vec![48.0, 14.0, 13.0, 13.0, 11.0, 11.0, 15.0, 11.0, 11.0, 9.0],
11267            is_kv: false,
11268        },
11269        XlSheet {
11270            name: "Skipped",
11271            tab_color: "FF7B675B",
11272            headers: skipped_hdrs,
11273            rows: skipped_rows,
11274            col_widths: vec![52.0, 24.0, 13.0],
11275            is_kv: false,
11276        },
11277    ];
11278
11279    let bytes = build_xlsx(&sheets);
11280    fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
11281}
11282
11283/// Write a diff comparison as an Excel workbook.
11284///
11285/// # Errors
11286///
11287/// Returns an error if the file cannot be written.
11288pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
11289    let s = &cmp.summary;
11290
11291    let summary_rows: Vec<Vec<String>> = vec![
11292        vec!["Baseline Run".into(), s.baseline_run_id.clone()],
11293        vec!["Current Run".into(), s.current_run_id.clone()],
11294        vec!["Files Added".into(), cmp.files_added.to_string()],
11295        vec!["Files Removed".into(), cmp.files_removed.to_string()],
11296        vec!["Files Modified".into(), cmp.files_modified.to_string()],
11297        vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
11298        vec!["Files Total".into(), cmp.files_total.to_string()],
11299        vec!["Code Δ".into(), s.code_lines_delta.to_string()],
11300        vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
11301        vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
11302        vec!["Total Δ".into(), s.total_lines_delta.to_string()],
11303    ];
11304
11305    let delta_rows: Vec<Vec<String>> = cmp
11306        .file_deltas
11307        .iter()
11308        .map(|f| {
11309            let status = match f.status {
11310                sloc_core::FileChangeStatus::Added => "Added",
11311                sloc_core::FileChangeStatus::Removed => "Removed",
11312                sloc_core::FileChangeStatus::Modified => "Modified",
11313                sloc_core::FileChangeStatus::Unchanged => "Unchanged",
11314            };
11315            vec![
11316                status.to_string(),
11317                f.relative_path.clone(),
11318                f.language.clone().unwrap_or_default(),
11319                f.baseline_code.to_string(),
11320                f.current_code.to_string(),
11321                f.code_delta.to_string(),
11322                f.baseline_comment.to_string(),
11323                f.current_comment.to_string(),
11324                f.comment_delta.to_string(),
11325                f.total_delta.to_string(),
11326            ]
11327        })
11328        .collect();
11329
11330    let summary_hdrs: &[&str] = &["Metric", "Value"];
11331    let delta_hdrs: &[&str] = &[
11332        "Status",
11333        "Path",
11334        "Language",
11335        "Baseline Code",
11336        "Current Code",
11337        "Code Δ",
11338        "Baseline Comment",
11339        "Current Comment",
11340        "Comment Δ",
11341        "Total Δ",
11342    ];
11343
11344    let sheets = vec![
11345        XlSheet {
11346            name: "Diff Summary",
11347            tab_color: "FF283790",
11348            headers: summary_hdrs,
11349            rows: summary_rows,
11350            col_widths: vec![26.0, 44.0],
11351            is_kv: true,
11352        },
11353        XlSheet {
11354            name: "File Deltas",
11355            tab_color: "FFB85D33",
11356            headers: delta_hdrs,
11357            rows: delta_rows,
11358            col_widths: vec![12.0, 48.0, 16.0, 14.0, 14.0, 11.0, 14.0, 14.0, 11.0, 11.0],
11359            is_kv: false,
11360        },
11361    ];
11362
11363    let bytes = build_xlsx(&sheets);
11364    fs::write(path, bytes)
11365        .with_context(|| format!("failed to write diff XLSX to {}", path.display()))
11366}
11367
11368// ── Confluence rendering ────────────────────────────────────────────────────
11369
11370fn html_esc(s: &str) -> String {
11371    s.replace('&', "&amp;")
11372        .replace('<', "&lt;")
11373        .replace('>', "&gt;")
11374        .replace('"', "&quot;")
11375}
11376
11377/// Generates Confluence storage-format XHTML for a scan result page.
11378/// Includes an info panel, summary stats, per-language table, and an optional
11379/// link back to the full oxide-sloc HTML report.
11380#[must_use]
11381pub fn render_confluence_storage(run: &AnalysisRun, report_url: Option<&str>) -> String {
11382    let mut out = String::with_capacity(8192);
11383
11384    let project = run.effective_configuration.reporting.report_title.as_str();
11385    let branch = run.git_branch.as_deref().unwrap_or("—");
11386    let commit = run.git_commit_short.as_deref().unwrap_or("—");
11387    let scanned = run
11388        .tool
11389        .timestamp_utc
11390        .format("%Y-%m-%d %H:%M UTC")
11391        .to_string();
11392
11393    // Info panel macro
11394    out.push_str(
11395        "<ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\">\
11396         <ac:rich-text-body><p>",
11397    );
11398    let _ = write!(
11399        out,
11400        "<strong>Project:</strong> {proj} &nbsp;·&nbsp; \
11401         <strong>Branch:</strong> {branch} &nbsp;·&nbsp; \
11402         <strong>Commit:</strong> {commit} &nbsp;·&nbsp; \
11403         <strong>Scanned:</strong> {scanned}",
11404        proj = html_esc(project),
11405        branch = html_esc(branch),
11406        commit = html_esc(commit),
11407        scanned = html_esc(&scanned),
11408    );
11409    out.push_str("</p></ac:rich-text-body></ac:structured-macro>");
11410
11411    // Summary stats table
11412    out.push_str("<h2>Summary</h2>");
11413    out.push_str(
11414        "<table><thead><tr>\
11415         <th>Files Analyzed</th><th>Code Lines</th><th>Comment Lines</th>\
11416         <th>Blank Lines</th><th>Languages</th>\
11417         </tr></thead><tbody><tr>",
11418    );
11419    let t = &run.summary_totals;
11420    let _ = write!(
11421        out,
11422        "<td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>",
11423        t.files_analyzed,
11424        t.code_lines,
11425        t.comment_lines,
11426        t.blank_lines,
11427        run.totals_by_language.len(),
11428    );
11429    out.push_str("</tr></tbody></table>");
11430
11431    // Per-language breakdown table
11432    if !run.totals_by_language.is_empty() {
11433        out.push_str("<h2>Language Breakdown</h2>");
11434        out.push_str(
11435            "<table><thead><tr>\
11436             <th>Language</th><th>Files</th><th>Code</th><th>Comments</th><th>Blank</th>\
11437             </tr></thead><tbody>",
11438        );
11439        for lang in &run.totals_by_language {
11440            let _ = write!(
11441                out,
11442                "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
11443                html_esc(lang.language.display_name()),
11444                lang.files,
11445                lang.code_lines,
11446                lang.comment_lines,
11447                lang.blank_lines,
11448            );
11449        }
11450        out.push_str("</tbody></table>");
11451    }
11452
11453    // Link back to full report
11454    if let Some(url) = report_url {
11455        let _ = write!(
11456            out,
11457            "<p><strong>Full interactive report:</strong> \
11458             <a href=\"{url}\">{url_disp}</a></p>",
11459            url = html_esc(url),
11460            url_disp = html_esc(url),
11461        );
11462    }
11463
11464    out
11465}
11466
11467/// Generates Confluence wiki markup (legacy syntax) for copy/paste into a
11468/// Confluence page editor.
11469#[must_use]
11470pub fn render_confluence_wiki_markup(run: &AnalysisRun) -> String {
11471    let mut out = String::with_capacity(4096);
11472
11473    let project = run.effective_configuration.reporting.report_title.as_str();
11474    let branch = run.git_branch.as_deref().unwrap_or("—");
11475    let commit = run.git_commit_short.as_deref().unwrap_or("—");
11476    let scanned = run
11477        .tool
11478        .timestamp_utc
11479        .format("%Y-%m-%d %H:%M UTC")
11480        .to_string();
11481
11482    let _ = writeln!(out, "{{info}}");
11483    let _ = writeln!(
11484        out,
11485        "Project: {project}  ·  Branch: {branch}  ·  Commit: {commit}  ·  Scanned: {scanned}"
11486    );
11487    let _ = writeln!(out, "{{info}}");
11488    out.push('\n');
11489
11490    let t = &run.summary_totals;
11491    let _ = writeln!(out, "h2. Summary");
11492    let _ = writeln!(
11493        out,
11494        "||Files Analyzed||Code Lines||Comment Lines||Blank Lines||Languages||"
11495    );
11496    let _ = writeln!(
11497        out,
11498        "|{}|{}|{}|{}|{}|",
11499        t.files_analyzed,
11500        t.code_lines,
11501        t.comment_lines,
11502        t.blank_lines,
11503        run.totals_by_language.len(),
11504    );
11505    out.push('\n');
11506
11507    if !run.totals_by_language.is_empty() {
11508        let _ = writeln!(out, "h2. Language Breakdown");
11509        let _ = writeln!(out, "||Language||Files||Code||Comments||Blank||");
11510        for lang in &run.totals_by_language {
11511            let _ = writeln!(
11512                out,
11513                "|{}|{}|{}|{}|{}|",
11514                lang.language.display_name(),
11515                lang.files,
11516                lang.code_lines,
11517                lang.comment_lines,
11518                lang.blank_lines,
11519            );
11520        }
11521        out.push('\n');
11522    }
11523
11524    let _ = writeln!(
11525        out,
11526        "*Total:* {} code lines · {} files · {} languages",
11527        t.code_lines,
11528        t.files_analyzed,
11529        run.totals_by_language.len(),
11530    );
11531
11532    out
11533}
11534
11535#[cfg(test)]
11536mod tests {
11537    use super::*;
11538    use tempfile::tempdir;
11539
11540    // ── base64_encode ────────────────────────────────────────────────────────────
11541
11542    #[test]
11543    fn base64_encode_empty() {
11544        assert_eq!(base64_encode(b""), "");
11545    }
11546
11547    #[test]
11548    fn base64_encode_one_byte() {
11549        assert_eq!(base64_encode(b"M"), "TQ==");
11550    }
11551
11552    #[test]
11553    fn base64_encode_two_bytes() {
11554        assert_eq!(base64_encode(b"Ma"), "TWE=");
11555    }
11556
11557    #[test]
11558    fn base64_encode_three_bytes_no_padding() {
11559        assert_eq!(base64_encode(b"Man"), "TWFu");
11560    }
11561
11562    #[test]
11563    fn base64_encode_hello() {
11564        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
11565    }
11566
11567    #[test]
11568    fn base64_encode_roundtrip_length_multiple_of_3() {
11569        let data = b"abcdef";
11570        let encoded = base64_encode(data);
11571        assert_eq!(encoded.len(), 8);
11572        assert!(!encoded.contains('='));
11573    }
11574
11575    #[test]
11576    fn base64_encode_all_zeros() {
11577        assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
11578    }
11579
11580    #[test]
11581    fn base64_encode_binary_data() {
11582        let data: Vec<u8> = (0u8..=255).collect();
11583        let encoded = base64_encode(&data);
11584        assert!(!encoded.is_empty());
11585        assert!(
11586            encoded
11587                .chars()
11588                .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
11589        );
11590    }
11591
11592    // ── json_escape ──────────────────────────────────────────────────────────────
11593
11594    #[test]
11595    fn json_escape_no_special_chars() {
11596        assert_eq!(json_escape("hello world"), "hello world");
11597    }
11598
11599    #[test]
11600    fn json_escape_backslash() {
11601        assert_eq!(json_escape(r"path\to\file"), r"path\\to\\file");
11602    }
11603
11604    #[test]
11605    fn json_escape_double_quote() {
11606        assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
11607    }
11608
11609    #[test]
11610    fn json_escape_both_special_chars() {
11611        assert_eq!(json_escape(r#"a\"b"#), r#"a\\\"b"#);
11612    }
11613
11614    #[test]
11615    fn json_escape_empty_string() {
11616        assert_eq!(json_escape(""), "");
11617    }
11618
11619    #[test]
11620    fn json_escape_only_backslashes() {
11621        assert_eq!(json_escape(r"\\"), r"\\\\");
11622    }
11623
11624    // ── coverage_pct_str ─────────────────────────────────────────────────────────
11625
11626    #[test]
11627    fn coverage_pct_str_zero_found_returns_empty() {
11628        assert_eq!(coverage_pct_str(0, 0), "");
11629    }
11630
11631    #[test]
11632    fn coverage_pct_str_full_coverage() {
11633        assert_eq!(coverage_pct_str(100, 100), "100.0");
11634    }
11635
11636    #[test]
11637    fn coverage_pct_str_half_coverage() {
11638        assert_eq!(coverage_pct_str(50, 100), "50.0");
11639    }
11640
11641    #[test]
11642    fn coverage_pct_str_one_decimal_precision() {
11643        let s = coverage_pct_str(7, 10);
11644        assert_eq!(s, "70.0");
11645    }
11646
11647    #[test]
11648    fn coverage_pct_str_zero_hit_but_found() {
11649        assert_eq!(coverage_pct_str(0, 10), "0.0");
11650    }
11651
11652    #[test]
11653    fn coverage_pct_str_non_round_percentage() {
11654        let s = coverage_pct_str(1, 3);
11655        assert!(!s.is_empty());
11656        assert!(s.contains('.'), "result must have decimal point");
11657    }
11658
11659    // ── coverage_class ───────────────────────────────────────────────────────────
11660
11661    #[test]
11662    fn coverage_class_zero_found_is_muted() {
11663        assert_eq!(coverage_class(0, 0), "muted");
11664    }
11665
11666    #[test]
11667    fn coverage_class_100_pct_is_good() {
11668        assert_eq!(coverage_class(100, 100), "good");
11669    }
11670
11671    #[test]
11672    fn coverage_class_80_pct_is_good() {
11673        assert_eq!(coverage_class(80, 100), "good");
11674    }
11675
11676    #[test]
11677    fn coverage_class_79_pct_is_warn() {
11678        assert_eq!(coverage_class(79, 100), "warn");
11679    }
11680
11681    #[test]
11682    fn coverage_class_60_pct_is_warn() {
11683        assert_eq!(coverage_class(60, 100), "warn");
11684    }
11685
11686    #[test]
11687    fn coverage_class_59_pct_is_danger() {
11688        assert_eq!(coverage_class(59, 100), "danger");
11689    }
11690
11691    #[test]
11692    fn coverage_class_zero_hit_is_danger() {
11693        assert_eq!(coverage_class(0, 100), "danger");
11694    }
11695
11696    // ── format_test_density ──────────────────────────────────────────────────────
11697
11698    #[test]
11699    fn format_test_density_zero_code_returns_zero() {
11700        assert_eq!(format_test_density(0, 5), "0.0");
11701    }
11702
11703    #[test]
11704    fn format_test_density_zero_tests_returns_zero() {
11705        assert_eq!(format_test_density(100, 0), "0.0");
11706    }
11707
11708    #[test]
11709    fn format_test_density_both_zero() {
11710        assert_eq!(format_test_density(0, 0), "0.0");
11711    }
11712
11713    #[test]
11714    fn format_test_density_1_test_per_1000_lines() {
11715        assert_eq!(format_test_density(1000, 1), "1.0");
11716    }
11717
11718    #[test]
11719    fn format_test_density_10_tests_per_100_lines() {
11720        assert_eq!(format_test_density(100, 10), "100.0");
11721    }
11722
11723    #[test]
11724    fn format_test_density_fractional() {
11725        let s = format_test_density(1000, 3);
11726        assert!(!s.is_empty());
11727        assert!(s.contains('.'));
11728    }
11729
11730    // ── html_esc ─────────────────────────────────────────────────────────────────
11731
11732    #[test]
11733    fn html_esc_no_special_chars() {
11734        assert_eq!(html_esc("hello"), "hello");
11735    }
11736
11737    #[test]
11738    fn html_esc_ampersand() {
11739        assert_eq!(html_esc("a&b"), "a&amp;b");
11740    }
11741
11742    #[test]
11743    fn html_esc_less_than() {
11744        assert_eq!(html_esc("a<b"), "a&lt;b");
11745    }
11746
11747    #[test]
11748    fn html_esc_greater_than() {
11749        assert_eq!(html_esc("a>b"), "a&gt;b");
11750    }
11751
11752    #[test]
11753    fn html_esc_double_quote() {
11754        assert_eq!(html_esc(r#"a"b"#), "a&quot;b");
11755    }
11756
11757    #[test]
11758    fn html_esc_all_special_chars() {
11759        assert_eq!(
11760            html_esc(r#"<a href="x&y">z</a>"#),
11761            "&lt;a href=&quot;x&amp;y&quot;&gt;z&lt;/a&gt;"
11762        );
11763    }
11764
11765    #[test]
11766    fn html_esc_empty_string() {
11767        assert_eq!(html_esc(""), "");
11768    }
11769
11770    // ── png_data_uri ─────────────────────────────────────────────────────────────
11771
11772    #[test]
11773    fn png_data_uri_has_correct_prefix() {
11774        let uri = png_data_uri(b"\x89PNG\r\n\x1a\n");
11775        assert!(uri.starts_with("data:image/png;base64,"));
11776    }
11777
11778    #[test]
11779    fn png_data_uri_non_empty_for_non_empty_input() {
11780        let uri = png_data_uri(b"fake-png-bytes");
11781        assert!(uri.len() > "data:image/png;base64,".len());
11782    }
11783
11784    // ── load_custom_logo ─────────────────────────────────────────────────────────
11785
11786    #[test]
11787    fn load_custom_logo_nonexistent_file_returns_none() {
11788        let result = load_custom_logo(std::path::Path::new("/nonexistent/__sloc_logo__.png"));
11789        assert!(result.is_none());
11790    }
11791
11792    #[test]
11793    fn load_custom_logo_png_file_returns_data_uri() {
11794        let dir = tempdir().unwrap();
11795        let path = dir.path().join("logo.png");
11796        std::fs::write(&path, b"\x89PNG\r\n\x1a\nfake-png-data").unwrap();
11797        let result = load_custom_logo(&path);
11798        assert!(result.is_some());
11799        let uri = result.unwrap();
11800        assert!(uri.starts_with("data:image/png;base64,"));
11801    }
11802
11803    #[test]
11804    fn load_custom_logo_svg_file_uses_svg_mime() {
11805        let dir = tempdir().unwrap();
11806        let path = dir.path().join("logo.svg");
11807        std::fs::write(&path, b"<svg></svg>").unwrap();
11808        let result = load_custom_logo(&path);
11809        assert!(result.is_some());
11810        let uri = result.unwrap();
11811        assert!(uri.starts_with("data:image/svg+xml;base64,"));
11812    }
11813
11814    #[test]
11815    fn load_custom_logo_unknown_extension_treated_as_png() {
11816        let dir = tempdir().unwrap();
11817        let path = dir.path().join("logo.bin");
11818        std::fs::write(&path, b"some-bytes").unwrap();
11819        let result = load_custom_logo(&path);
11820        assert!(result.is_some());
11821        let uri = result.unwrap();
11822        assert!(uri.starts_with("data:image/png;base64,"));
11823    }
11824}
11825
11826#[cfg(test)]
11827mod coverage_boost_report_tests {
11828    use super::*;
11829    use std::path::Path;
11830
11831    // ── derive_commit_url / derive_branch_url ────────────────────────────────
11832
11833    #[test]
11834    fn derive_commit_url_github_uses_commit_segment() {
11835        let url = derive_commit_url(
11836            "https://github.com/org/repo.git",
11837            "abc1234abc1234abc1234abc1234abc1234abc1234",
11838        );
11839        assert_eq!(
11840            url.as_deref(),
11841            Some("https://github.com/org/repo/commit/abc1234abc1234abc1234abc1234abc1234abc1234")
11842        );
11843    }
11844
11845    #[test]
11846    fn derive_commit_url_bitbucket_uses_commits_plural() {
11847        let url = derive_commit_url("https://bitbucket.org/org/repo.git", "deadbeef");
11848        assert_eq!(
11849            url.as_deref(),
11850            Some("https://bitbucket.org/org/repo/commits/deadbeef")
11851        );
11852    }
11853
11854    #[test]
11855    fn derive_commit_url_gitlab_uses_dash_commit() {
11856        let url = derive_commit_url("https://gitlab.example.com/org/repo.git", "cafe0000");
11857        assert_eq!(
11858            url.as_deref(),
11859            Some("https://gitlab.example.com/org/repo/-/commit/cafe0000")
11860        );
11861    }
11862
11863    #[test]
11864    fn derive_branch_url_github_uses_tree() {
11865        let url = derive_branch_url("https://github.com/org/repo.git", "main");
11866        assert_eq!(
11867            url.as_deref(),
11868            Some("https://github.com/org/repo/tree/main")
11869        );
11870    }
11871
11872    #[test]
11873    fn derive_branch_url_bitbucket_uses_branch_segment() {
11874        let url = derive_branch_url("https://bitbucket.org/org/repo.git", "develop");
11875        assert_eq!(
11876            url.as_deref(),
11877            Some("https://bitbucket.org/org/repo/branch/develop")
11878        );
11879    }
11880
11881    #[test]
11882    fn derive_branch_url_gitlab_uses_dash_tree() {
11883        let url = derive_branch_url("https://gitlab.mycompany.com/org/repo.git", "feature");
11884        assert_eq!(
11885            url.as_deref(),
11886            Some("https://gitlab.mycompany.com/org/repo/-/tree/feature")
11887        );
11888    }
11889
11890    #[test]
11891    fn derive_commit_url_invalid_url_returns_none() {
11892        let url = derive_commit_url("not-a-url", "abc123");
11893        assert!(url.is_none());
11894    }
11895
11896    #[test]
11897    fn normalize_remote_url_variants() {
11898        assert_eq!(
11899            normalize_remote_url("git@github.com:org/repo.git").as_deref(),
11900            Some("https://github.com/org/repo")
11901        );
11902        assert_eq!(
11903            normalize_remote_url("https://gitlab.com/a/b.git").as_deref(),
11904            Some("https://gitlab.com/a/b")
11905        );
11906        assert_eq!(
11907            normalize_remote_url("http://host/x").as_deref(),
11908            Some("http://host/x")
11909        );
11910        assert_eq!(normalize_remote_url("not a url"), None);
11911    }
11912
11913    #[test]
11914    fn classify_and_bucket_helpers() {
11915        assert_eq!(
11916            classify_unsupported_path("README.md"),
11917            "Documentation / text"
11918        );
11919        assert_eq!(
11920            classify_unsupported_path("pkg.json"),
11921            "JSON manifests and config"
11922        );
11923        assert_eq!(
11924            classify_unsupported_path("Cargo.toml"),
11925            "Project metadata and packaging"
11926        );
11927        assert_eq!(classify_unsupported_path("page.html"), "HTML templates");
11928        assert_eq!(classify_unsupported_path("notes.txt"), "Plain text assets");
11929        assert_eq!(
11930            classify_unsupported_path("data.xyz"),
11931            "Other unsupported text formats"
11932        );
11933        assert_eq!(
11934            classify_unsupported_path("Makefile_noext"),
11935            "Extensionless or custom text files"
11936        );
11937        // bucket_description + bucket_recommendation for each known label.
11938        for label in [
11939            "Documentation / text",
11940            "JSON manifests and config",
11941            "Project metadata and packaging",
11942            "HTML templates",
11943            "Plain text assets",
11944            "Extensionless or custom text files",
11945            "Unknown bucket",
11946        ] {
11947            assert!(!bucket_description(label).is_empty());
11948            assert!(!bucket_recommendation(label).is_empty());
11949        }
11950    }
11951
11952    #[test]
11953    fn summarize_warnings_groups_categories() {
11954        let warnings = vec![
11955            "file 'a.md': unsupported or undetected language".to_string(),
11956            "file 'b.bin': binary file skipped by default".to_string(),
11957            "file 'c.min.js': minified file skipped by policy".to_string(),
11958            "file 'big.txt': file exceeded max_file_size_bytes".to_string(),
11959        ];
11960        let rows = summarize_warnings(&warnings);
11961        assert!(!rows.is_empty(), "warnings should summarize into buckets");
11962    }
11963
11964    #[test]
11965    fn pdf_number_and_string_formatters() {
11966        assert_eq!(pdf_fmt_full(0), "0");
11967        assert!(pdf_fmt_full(1_234_567).contains('1'));
11968        // pdf_safe_str must not panic on non-ASCII / control chars.
11969        let s = pdf_safe_str("héllo\tworld\u{1F600}");
11970        assert!(!s.is_empty());
11971    }
11972
11973    #[test]
11974    fn file_url_produces_uri() {
11975        let url = file_url(Path::new("/tmp/report.html"));
11976        assert!(url.starts_with("file://") || url.contains("report.html"));
11977    }
11978
11979    #[test]
11980    fn browser_discovery_is_callable_without_panicking() {
11981        // With no SLOC_BROWSER set, discovery walks the candidate list and
11982        // returns None (no browser in the test sandbox) — exercising the loop.
11983        // FIXME: Audit that the environment access only happens in single-threaded code.
11984        unsafe { std::env::remove_var("SLOC_BROWSER") };
11985        // FIXME: Audit that the environment access only happens in single-threaded code.
11986        unsafe { std::env::remove_var("BROWSER") };
11987        let _ = discover_browser();
11988        let _ = discover_browser_from_env();
11989        #[cfg(windows)]
11990        let _ = windows_browser_candidates();
11991        #[cfg(not(windows))]
11992        let _ = linux_browser_candidates();
11993        // With a bogus SLOC_BROWSER, normalize_browser_env_path is exercised.
11994        // FIXME: Audit that the environment access only happens in single-threaded code.
11995        unsafe { std::env::set_var("SLOC_BROWSER", "/no/such/browser/path") };
11996        let _ = discover_browser_from_env();
11997        let p = normalize_browser_env_path("\"/quoted/path/chrome\"");
11998        assert!(p.to_string_lossy().contains("chrome"));
11999        // FIXME: Audit that the environment access only happens in single-threaded code.
12000        unsafe { std::env::remove_var("SLOC_BROWSER") };
12001    }
12002
12003    #[test]
12004    fn which_in_path_returns_none_for_missing() {
12005        assert!(which_in_path("definitely-not-a-real-exe-xyz123").is_none());
12006    }
12007
12008    #[test]
12009    fn write_pdf_from_html_without_browser_errors_gracefully() {
12010        // FIXME: Audit that the environment access only happens in single-threaded code.
12011        unsafe { std::env::remove_var("SLOC_BROWSER") };
12012        // FIXME: Audit that the environment access only happens in single-threaded code.
12013        unsafe { std::env::remove_var("BROWSER") };
12014        let dir = std::env::temp_dir().join("sloc_report_pdf_test");
12015        let _ = std::fs::create_dir_all(&dir);
12016        let html = dir.join("in.html");
12017        std::fs::write(&html, "<html><body>hi</body></html>").unwrap();
12018        let out = dir.join("out.pdf");
12019        // No browser present → Err, but exercises discovery + early validation.
12020        let res = write_pdf_from_html(&html, &out);
12021        // Either a real browser exists (Ok) or not (Err); both are acceptable.
12022        let _ = res;
12023        let _ = std::fs::remove_dir_all(&dir);
12024    }
12025
12026    // ── helvetica_advance ────────────────────────────────────────────────────────
12027
12028    #[test]
12029    fn helvetica_advance_uppercase_a_differs_by_weight() {
12030        assert_eq!(helvetica_advance('A', true), 722);
12031        assert_eq!(helvetica_advance('A', false), 667);
12032    }
12033
12034    #[test]
12035    fn helvetica_advance_uppercase_w_same_both_weights() {
12036        assert_eq!(helvetica_advance('W', true), 944);
12037        assert_eq!(helvetica_advance('W', false), 944);
12038    }
12039
12040    #[test]
12041    fn helvetica_advance_lowercase_i_differs_by_weight() {
12042        assert_eq!(helvetica_advance('i', true), 278);
12043        assert_eq!(helvetica_advance('i', false), 222);
12044    }
12045
12046    #[test]
12047    fn helvetica_advance_digits_are_556_both_weights() {
12048        for d in '0'..='9' {
12049            assert_eq!(helvetica_advance(d, true), 556, "bold digit {d}");
12050            assert_eq!(helvetica_advance(d, false), 556, "regular digit {d}");
12051        }
12052    }
12053
12054    #[test]
12055    fn helvetica_advance_middle_dot_is_278() {
12056        assert_eq!(helvetica_advance('\u{00B7}', true), 278);
12057        assert_eq!(helvetica_advance('\u{00B7}', false), 278);
12058    }
12059
12060    #[test]
12061    fn helvetica_advance_unknown_char_returns_nonzero_fallback() {
12062        let bold_fb = helvetica_advance('\u{1F600}', true);
12063        let reg_fb = helvetica_advance('\u{1F600}', false);
12064        assert_eq!(bold_fb, 556);
12065        assert_eq!(reg_fb, 500);
12066    }
12067
12068    // ── helvetica_width_mm ───────────────────────────────────────────────────────
12069
12070    #[test]
12071    fn helvetica_width_mm_empty_is_zero() {
12072        assert!(helvetica_width_mm("", 10.0, false).abs() < f32::EPSILON);
12073        assert!(helvetica_width_mm("", 10.0, true).abs() < f32::EPSILON);
12074    }
12075
12076    #[test]
12077    fn helvetica_width_mm_scales_linearly_with_pt_size() {
12078        let w6 = helvetica_width_mm("Hello", 6.0, false);
12079        let w12 = helvetica_width_mm("Hello", 12.0, false);
12080        assert!(
12081            2.0_f32.mul_add(-w6, w12).abs() < 1e-4,
12082            "width must be proportional to pt size"
12083        );
12084    }
12085
12086    #[test]
12087    fn helvetica_width_mm_bold_a_wider_than_regular_a() {
12088        let bold = helvetica_width_mm("A", 10.0, true);
12089        let reg = helvetica_width_mm("A", 10.0, false);
12090        assert!(
12091            bold > reg,
12092            "bold 'A' (722) must be wider than regular 'A' (667)"
12093        );
12094    }
12095
12096    #[test]
12097    fn helvetica_width_mm_single_char_matches_manual_calculation() {
12098        // 'A' regular advance = 667; width_mm = 667 * 10.0 * (25.4/72.0) / 1000.0
12099        let expected = 667.0_f32 * 10.0 * (25.4 / 72.0) / 1000.0;
12100        let got = helvetica_width_mm("A", 10.0, false);
12101        assert!((got - expected).abs() < 1e-4);
12102    }
12103}