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, 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
465    let template = ReportTemplate {
466        // Empty nonce for disk-saved reports; patch_html_nonce replaces it
467        // with the request nonce when serving from the web server.
468        nonce: String::new(),
469        title: rep.report_title.clone(),
470        browser_title: format!("Oxide-SLOC | {}", rep.report_title),
471        scan_performed_by: run.environment.ci_name.clone().unwrap_or_else(|| {
472            format!(
473                "{} / {}",
474                run.environment.initiator_username, run.environment.initiator_hostname
475            )
476        }),
477        scan_time_pst: to_pst_display(run.tool.timestamp_utc),
478        tool_version: run.tool.version.clone(),
479        is_sub_report,
480        run,
481        language_rows: run
482            .totals_by_language
483            .iter()
484            .map(|row| LanguageRow {
485                language: row.language.display_name().to_string(),
486                files: row.files,
487                total_physical_lines: row.total_physical_lines,
488                code_lines: row.code_lines,
489                comment_lines: row.comment_lines,
490                blank_lines: row.blank_lines,
491                mixed_lines_separate: row.mixed_lines_separate,
492                functions: row.functions,
493                classes: row.classes,
494                variables: row.variables,
495                imports: row.imports,
496                test_count: row.test_count,
497                test_assertion_count: row.test_assertion_count,
498                test_suite_count: row.test_suite_count,
499                test_density_str: if row.code_lines > 0 {
500                    // ratio display, precision loss acceptable
501                    #[allow(clippy::cast_precision_loss)]
502                    let density = row.test_count as f64 / row.code_lines as f64 * 1000.0;
503                    format!("{density:.1}")
504                } else {
505                    "—".to_string()
506                },
507            })
508            .collect(),
509        file_rows: run.per_file_records.iter().map(file_row_view).collect(),
510        skipped_rows: run.skipped_file_records.iter().map(file_row_view).collect(),
511        config_json,
512        lang_chart_json: build_lang_chart_json(run),
513        submodule_chart_json: build_submodule_chart_json(run),
514        scatter_chart_json: build_scatter_chart_json(run),
515        semantic_chart_json: build_semantic_chart_json(run),
516        file_size_histogram_json: build_file_size_histogram_json(run),
517        has_submodule_data: !run.submodule_summaries.is_empty(),
518        has_semantic_data: run
519            .totals_by_language
520            .iter()
521            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
522        has_coverage_data: run.per_file_records.iter().any(|f| f.coverage.is_some()),
523        has_fn_coverage: totals.coverage_functions_found > 0,
524        has_branch_coverage: totals.coverage_branches_found > 0,
525        test_files_count: run
526            .per_file_records
527            .iter()
528            .filter(|f| f.raw_line_categories.test_count > 0)
529            .count() as u64,
530        test_assertion_count: totals.test_assertion_count,
531        test_suite_count: totals.test_suite_count,
532        test_density: format_test_density(totals.code_lines, totals.test_count),
533        most_tested_lang: run
534            .totals_by_language
535            .iter()
536            .filter(|l| l.test_count > 0)
537            .max_by_key(|l| l.test_count)
538            .map_or_else(
539                || "\u{2014}".to_string(),
540                |l| l.language.display_name().to_string(),
541            ),
542        langs_with_tests: run
543            .totals_by_language
544            .iter()
545            .filter(|l| l.test_count > 0)
546            .count(),
547        cov_line_pct: coverage_pct_str(totals.coverage_lines_hit, totals.coverage_lines_found),
548        cov_fn_pct: coverage_pct_str(
549            totals.coverage_functions_hit,
550            totals.coverage_functions_found,
551        ),
552        cov_branch_pct: coverage_pct_str(
553            totals.coverage_branches_hit,
554            totals.coverage_branches_found,
555        ),
556        cov_line_class: coverage_class(totals.coverage_lines_hit, totals.coverage_lines_found),
557        cov_fn_class: coverage_class(
558            totals.coverage_functions_hit,
559            totals.coverage_functions_found,
560        ),
561        cov_branch_class: coverage_class(
562            totals.coverage_branches_hit,
563            totals.coverage_branches_found,
564        ),
565        has_run_warnings: !run.warnings.is_empty(),
566        warning_count: run.warnings.len(),
567        warning_summary_rows,
568        warning_opportunity_rows,
569        warning_console_full: build_warning_console(&run.warnings),
570        logo_text_uri,
571        small_logo_uri,
572        custom_logo_uri,
573        company_name,
574        accent_hex,
575        report_header_footer,
576        chart_js: CHART_JS,
577        run_id_short: run
578            .tool
579            .run_id
580            .split('-')
581            .next_back()
582            .unwrap_or(&run.tool.run_id)
583            .chars()
584            .take(7)
585            .collect(),
586        standalone_pdf_url: pdf_url.map(str::to_string),
587        has_style_data: run.style_summary.is_some(),
588        style_lang_count: run
589            .style_summary
590            .as_ref()
591            .map_or(0, |ss| ss.by_language.len()),
592        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
593        style_chart_json: run
594            .style_summary
595            .as_ref()
596            .map(build_style_chart_json)
597            .unwrap_or_default(),
598        style_file_json: if run.style_summary.is_some() {
599            build_style_file_json(run)
600        } else {
601            String::new()
602        },
603        style_summary: run.style_summary.clone(),
604        has_delta: delta_ctx.is_some(),
605        delta_code_added: delta_ctx.map_or(0, |d| d.delta_code_added),
606        delta_code_removed: delta_ctx.map_or(0, |d| d.delta_code_removed),
607        delta_unmodified_lines: delta_ctx.map_or(0, |d| d.delta_unmodified_lines),
608        delta_files_added: delta_ctx.map_or(0, |d| d.delta_files_added),
609        delta_files_removed: delta_ctx.map_or(0, |d| d.delta_files_removed),
610        delta_files_modified: delta_ctx.map_or(0, |d| d.delta_files_modified),
611        delta_files_unchanged: delta_ctx.map_or(0, |d| d.delta_files_unchanged),
612        prev_code_lines: delta_ctx.map_or(0, |d| d.prev_code_lines),
613        prev_scan_count: delta_ctx.map_or(0, |d| d.prev_scan_count),
614        prev_scan_label: delta_ctx
615            .map(|d| d.prev_scan_label.clone())
616            .unwrap_or_default(),
617        prev_run_id: delta_ctx
618            .and_then(|d| d.prev_run_id.clone())
619            .unwrap_or_default(),
620        git_commit_url: run
621            .git_remote_url
622            .as_deref()
623            .zip(run.git_commit_long.as_deref())
624            .and_then(|(remote, sha)| derive_commit_url(remote, sha)),
625        git_branch_url: run
626            .git_remote_url
627            .as_deref()
628            .zip(run.git_branch.as_deref())
629            .and_then(|(remote, branch)| derive_branch_url(remote, branch)),
630        has_cocomo: run.cocomo.is_some(),
631        cocomo_effort_str: run
632            .cocomo
633            .as_ref()
634            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
635        cocomo_duration_str: run
636            .cocomo
637            .as_ref()
638            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
639        cocomo_staff_str: run
640            .cocomo
641            .as_ref()
642            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
643        cocomo_ksloc_str: run
644            .cocomo
645            .as_ref()
646            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
647        cocomo_mode_label: run
648            .cocomo
649            .as_ref()
650            .map_or_else(|| "Organic".to_string(), |c| {
651                match c.mode {
652                    CocomoMode::Organic => "Organic",
653                    CocomoMode::SemiDetached => "Semi-detached",
654                    CocomoMode::Embedded => "Embedded",
655                }
656                .to_string()
657            }),
658        cocomo_mode_tooltip: run
659            .cocomo
660            .as_ref()
661            .map_or(String::new(), |c| match c.mode {
662                CocomoMode::Organic => "Organic: A small team working on a well-understood \
663                    project in a familiar environment with minimal external constraints. \
664                    Suited for internal tools, utilities, and projects with stable requirements. \
665                    Effort = 2.4 \u{00D7} KSLOC^1.05.",
666                CocomoMode::SemiDetached => "Semi-detached: A mixed team with varying levels of \
667                    experience tackling a project with moderate novelty and some rigid constraints. \
668                    Typical for compilers, transaction systems, and batch processors. \
669                    Effort = 3.0 \u{00D7} KSLOC^1.12.",
670                CocomoMode::Embedded => "Embedded: Tight hardware, software, or operational \
671                    constraints requiring significant innovation and deep integration work. \
672                    Typical for real-time control systems and safety-critical software. \
673                    Effort = 3.6 \u{00D7} KSLOC^1.20.",
674            }.to_string()),
675        uloc: run.uloc,
676        dryness_pct_str: run
677            .dryness_pct
678            .map_or(String::new(), |d| format!("{d:.1}")),
679        duplicate_group_count: run.duplicate_groups.len(),
680        has_hotspots: !hotspot_rows.is_empty(),
681        hotspot_rows,
682    };
683
684    template.render().context("failed to render HTML report")
685}
686
687/// One row of the Git Hotspots table: a file ranked by `code_lines × recent commits`.
688struct HotspotRow {
689    path: String,
690    code_lines: u64,
691    commit_count: u32,
692    last_commit_date: String,
693    score: u64,
694}
695
696/// Build the git hotspots from per-file activity (only files that carry a
697/// `commit_count` from an `--activity-window` scan), ranked by `code_lines × commits`
698/// and capped at `limit` rows. The interactive HTML report requests a larger cap (so its
699/// client-side pagination has something to page through); the fixed-height PDF page keeps
700/// the original top-15.
701fn build_hotspot_rows(run: &AnalysisRun, limit: usize) -> Vec<HotspotRow> {
702    let mut rows: Vec<HotspotRow> = run
703        .per_file_records
704        .iter()
705        .filter_map(|r| {
706            let commits = r.commit_count?;
707            let code = r.effective_counts.code_lines;
708            Some(HotspotRow {
709                path: r.relative_path.clone(),
710                code_lines: code,
711                commit_count: commits,
712                // Show the calendar date only (strip the time component of the ISO date).
713                last_commit_date: r.last_commit_date.as_deref().map_or_else(String::new, |d| {
714                    d.split('T').next().unwrap_or(d).to_string()
715                }),
716                score: code.saturating_mul(u64::from(commits)),
717            })
718        })
719        .collect();
720    rows.sort_by(|a, b| {
721        b.score
722            .cmp(&a.score)
723            .then(b.commit_count.cmp(&a.commit_count))
724    });
725    rows.truncate(limit);
726    rows
727}
728
729/// Render an HTML report and write it to `output_path`.
730///
731/// # Errors
732///
733/// Returns an error if rendering fails or the file cannot be written.
734pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
735    let html = render_html_inner(run, false, None, None)?;
736    fs::write(output_path, html)
737        .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
738}
739
740/// Write an HTML report that embeds a relative link to a pre-generated PDF.
741///
742/// When `pdf_path` is in the same directory as `output_path`, the "View PDF"
743/// button in the report opens the PDF directly (e.g. from a Jenkins HTML
744/// Publisher artifact directory) instead of calling the oxide-sloc server route.
745/// Pass `pdf_path = None` to get the same behaviour as [`write_html`].
746///
747/// # Errors
748/// Returns an error if HTML rendering or file I/O fails.
749pub fn write_html_with_pdf_link(
750    run: &AnalysisRun,
751    output_path: &Path,
752    pdf_path: Option<&Path>,
753) -> Result<()> {
754    let pdf_relative = pdf_path.and_then(|pdf| {
755        let html_dir = output_path.parent()?;
756        let pdf_dir = pdf.parent()?;
757        if html_dir == pdf_dir {
758            pdf.file_name().map(|n| n.to_string_lossy().into_owned())
759        } else {
760            None
761        }
762    });
763    let html = render_html_inner(run, false, pdf_relative.as_deref(), None)?;
764    fs::write(output_path, html)
765        .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
766}
767
768/// Launch a headless Chromium browser.
769/// When `no_sandbox` is true (set via `SLOC_BROWSER_NOSANDBOX=1`) the browser
770/// runs without the namespace sandbox — required in containers that drop `SYS_ADMIN`.
771/// Otherwise the sandbox is always enabled with no automatic fallback, so failures
772/// surface as clear errors rather than silently removing a security boundary.
773fn launch_cdp_browser(
774    browser_path: std::path::PathBuf,
775    no_sandbox: bool,
776) -> Result<headless_chrome::Browser> {
777    use headless_chrome::{Browser, LaunchOptions};
778
779    if no_sandbox {
780        return Browser::new(LaunchOptions {
781            headless: true,
782            path: Some(browser_path),
783            window_size: Some((1122, 794)),
784            sandbox: false,
785            ..Default::default()
786        })
787        .context("failed to launch browser via CDP (no-sandbox)");
788    }
789
790    // Sandboxed only — no automatic fallback to --no-sandbox.
791    // If this fails in a container, set SLOC_BROWSER_NOSANDBOX=1 to opt in explicitly.
792    Browser::new(LaunchOptions {
793        headless: true,
794        path: Some(browser_path),
795        window_size: Some((1122, 794)),
796        sandbox: true,
797        ..Default::default()
798    })
799    .map_err(|e| {
800        anyhow::anyhow!(
801            "Browser launch failed with sandbox enabled: {e:#}\n\
802             If running in a container without user namespaces (e.g. Docker with cap_drop:ALL), \
803             set SLOC_BROWSER_NOSANDBOX=1 to opt into --no-sandbox mode."
804        )
805    })
806}
807
808/// If a JS chart error was recorded on the page, print it to stderr.
809fn report_chart_error_if_any(tab: &headless_chrome::Tab) {
810    let Ok(e) = tab.evaluate("window.oxSlocChartError||''", false) else {
811        return;
812    };
813    let Some(serde_json::Value::String(msg)) = e.value else {
814        return;
815    };
816    if !msg.is_empty() {
817        eprintln!("[oxide-sloc][pdf] chart JS error (charts may be missing): {msg}");
818    }
819}
820
821/// Poll `window.oxSlocChartsReady` for up to 15 s so Chart.js canvases finish rendering.
822fn wait_for_charts_ready(tab: &headless_chrome::Tab) {
823    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
824    let mut last_cdp_err: Option<String> = None;
825    loop {
826        match tab.evaluate("!!window.oxSlocChartsReady", false) {
827            Ok(r) => {
828                last_cdp_err = None;
829                if matches!(r.value, Some(serde_json::Value::Bool(true))) {
830                    report_chart_error_if_any(tab);
831                    return;
832                }
833            }
834            Err(e) => {
835                let msg = format!("{e:#}");
836                if last_cdp_err.as_deref() != Some(&msg) {
837                    eprintln!("[oxide-sloc][pdf] CDP evaluate error (will retry): {msg}");
838                    last_cdp_err = Some(msg);
839                }
840            }
841        }
842        if std::time::Instant::now() >= deadline {
843            report_chart_error_if_any(tab);
844            break;
845        }
846        std::thread::sleep(std::time::Duration::from_millis(250));
847    }
848}
849
850/// Read the `.report-id-banner` text from the loaded page, if present and non-empty.
851fn extract_banner_text(tab: &headless_chrome::Tab) -> Option<String> {
852    let result = tab
853        .evaluate(
854            "(function(){\
855               var el=document.querySelector('.report-id-banner');\
856               return el?el.textContent.trim():null;\
857             })()",
858            false,
859        )
860        .ok()?;
861    match result.value? {
862        serde_json::Value::String(s) if !s.is_empty() => Some(s),
863        _ => None,
864    }
865}
866
867/// Read the `innerHTML` of an optional `#<id>` element supplied by the document to act as
868/// a Chrome print header/footer template. Chrome renders these in the page margin on every
869/// printed page (including a short final page), which in-flow or `position:fixed` markup
870/// cannot do reliably. Returns `None` when the element is absent or empty.
871fn extract_pdf_template(tab: &headless_chrome::Tab, id: &str) -> Option<String> {
872    // `id` is always a hard-coded constant below — no script-injection surface.
873    let js = format!(
874        "(function(){{var el=document.getElementById('{id}');return el?el.innerHTML:null;}})()"
875    );
876    let result = tab.evaluate(&js, false).ok()?;
877    match result.value? {
878        serde_json::Value::String(s) if !s.trim().is_empty() => Some(s),
879        _ => None,
880    }
881}
882
883/// Use Chrome `DevTools` Protocol to render `html_path` as a PDF at `output_path`.
884///
885/// Launches a headless Chromium-based browser at A4-landscape viewport (1122 × 794 px),
886/// waits up to 15 s for all Chart.js canvases to signal readiness via
887/// `window.oxSlocChartsReady`, then captures the page using `Page.printToPDF` via CDP.
888fn write_pdf_via_cdp(html_path: &Path, output_path: &Path) -> Result<()> {
889    use headless_chrome::types::PrintToPdfOptions;
890
891    let browser_path = discover_browser().context(
892        "no supported Chromium-based browser found; \
893         set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
894    )?;
895    eprintln!("[oxide-sloc][pdf] browser = {}", browser_path.display());
896
897    let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
898    if no_sandbox {
899        eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
900    }
901
902    let browser = launch_cdp_browser(browser_path, no_sandbox)?;
903    let tab = browser.new_tab().context("failed to open browser tab")?;
904    // Raise the per-call CDP timeout well above the 20 s default. On a loaded host
905    // (e.g. the user's own Chromium already eating several GB) just launching a second
906    // headless instance and navigating a trivial page can take 15-30 s; the old default
907    // made navigation/print time out and fall back to wkhtmltopdf, failing the export.
908    tab.set_default_timeout(std::time::Duration::from_secs(90));
909
910    let html_for_url = PathBuf::from(
911        html_path
912            .to_string_lossy()
913            .trim_start_matches(r"\\?\")
914            .to_string(),
915    );
916    let url = file_url(&html_for_url);
917    eprintln!("[oxide-sloc][pdf] url = {url}");
918
919    tab.navigate_to(&url)
920        .context("failed to navigate browser to HTML file")?;
921    tab.wait_until_navigated()
922        .context("browser navigation did not complete")?;
923
924    wait_for_charts_ready(&tab);
925
926    // Read the user-configured report identification banner from the DOM (set in step 3 of
927    // the scan configuration as `report_header_footer`).  When present, pass it as Chrome's
928    // native per-page header/footer templates so it appears in the margin on every PDF page.
929    let banner_text = extract_banner_text(&tab);
930
931    if let Some(ref t) = banner_text {
932        eprintln!("[oxide-sloc][pdf] report banner detected: {t}");
933    }
934
935    let has_banner = banner_text.is_some();
936
937    // Optional per-document native header/footer templates. A document can embed hidden
938    // `#pdf-native-header` / `#pdf-native-footer` elements; their innerHTML is handed to
939    // Chrome as print templates so a header repeats at the top and a footer is pinned to
940    // the bottom margin of every page (the Scan Delta report uses this for its per-page
941    // footer bar). A document supplying only a footer gets no top chrome.
942    let native_header = if has_banner {
943        None
944    } else {
945        extract_pdf_template(&tab, "pdf-native-header")
946    };
947    let native_footer = if has_banner {
948        None
949    } else {
950        extract_pdf_template(&tab, "pdf-native-footer")
951    };
952    let has_native_header = native_header.is_some();
953    let has_native_footer = native_footer.is_some();
954
955    // Build Chrome header/footer HTML templates from the banner text.
956    // The template is rendered in the margin area; `font-size` must be set explicitly.
957    let make_banner_tmpl = |text: &str| -> String {
958        let escaped = text
959            .replace('&', "&amp;")
960            .replace('<', "&lt;")
961            .replace('>', "&gt;")
962            .replace('"', "&quot;");
963        format!(
964            r#"<div style="font-size:10px;width:100%;text-align:center;\
965color:#fff;background:#b35428;padding:5px 0;\
966font-family:sans-serif;font-weight:700;letter-spacing:0.05em;\
967-webkit-print-color-adjust:exact;print-color-adjust:exact;">{escaped}</div>"#
968        )
969    };
970
971    // Chrome prints both a header and a footer template whenever display_header_footer is
972    // on; an empty `<span>` suppresses its default date/title/url chrome on the side we are
973    // not populating.
974    let (header_tmpl, footer_tmpl): (Option<String>, Option<String>) = if has_banner {
975        let t = banner_text.as_deref().unwrap_or_default();
976        (Some(make_banner_tmpl(t)), Some(make_banner_tmpl(t)))
977    } else if has_native_header || has_native_footer {
978        let empty = || "<span></span>".to_string();
979        (
980            Some(native_header.unwrap_or_else(empty)),
981            Some(native_footer.unwrap_or_else(empty)),
982        )
983    } else {
984        (None, None)
985    };
986
987    let display_header_footer = has_banner || has_native_header || has_native_footer;
988    // Reserve margin only on the side(s) that actually carry chrome so content keeps the
989    // most room. Banner keeps its historical top/bottom reserve.
990    let margin_top = if has_banner {
991        0.35
992    } else if has_native_header {
993        0.55
994    } else {
995        0.0
996    };
997    let margin_bottom = if has_banner {
998        0.25
999    } else if has_native_footer {
1000        0.42
1001    } else {
1002        0.0
1003    };
1004
1005    let pdf_bytes = tab
1006        .print_to_pdf(Some(PrintToPdfOptions {
1007            landscape: Some(true),
1008            print_background: Some(true),
1009            scale: Some(0.97),
1010            paper_width: Some(11.69), // A4 landscape width (inches)
1011            paper_height: Some(8.27), // A4 landscape height (inches)
1012            margin_top: Some(margin_top),
1013            margin_bottom: Some(margin_bottom),
1014            margin_left: Some(0.0),
1015            margin_right: Some(0.0),
1016            prefer_css_page_size: Some(false),
1017            display_header_footer: if display_header_footer {
1018                Some(true)
1019            } else {
1020                None
1021            },
1022            header_template: header_tmpl,
1023            footer_template: footer_tmpl,
1024            ..Default::default()
1025        }))
1026        .context("browser failed to generate PDF")?;
1027
1028    fs::write(output_path, &pdf_bytes)
1029        .with_context(|| format!("failed to write PDF to {}", output_path.display()))?;
1030
1031    eprintln!("[oxide-sloc][pdf] wrote {} bytes", pdf_bytes.len());
1032    Ok(())
1033}
1034
1035/// Locate the `wkhtmltopdf` binary on Linux and Windows.
1036///
1037/// Search order:
1038/// 1. `wkhtmltopdf` / `wkhtmltopdf.exe` anywhere in `$PATH` (covers Linux packages and
1039///    Windows installs that add the bin dir to the system PATH).
1040/// 2. Windows-only: standard MSI install locations under `Program Files` and
1041///    `Program Files (x86)`.
1042/// 3. Linux-only: absolute paths that package managers commonly use but that may not be
1043///    on the service account's `$PATH`.
1044fn discover_wkhtmltopdf() -> Option<PathBuf> {
1045    if let Some(p) = which_in_path("wkhtmltopdf") {
1046        return Some(p);
1047    }
1048
1049    #[cfg(windows)]
1050    {
1051        for var in ["ProgramFiles", "ProgramFiles(x86)"] {
1052            if let Ok(base) = std::env::var(var) {
1053                let candidate = PathBuf::from(base)
1054                    .join("wkhtmltopdf")
1055                    .join("bin")
1056                    .join("wkhtmltopdf.exe");
1057                if candidate.is_file() {
1058                    return Some(candidate);
1059                }
1060            }
1061        }
1062    }
1063
1064    #[cfg(not(windows))]
1065    for p in [
1066        "/usr/bin/wkhtmltopdf",
1067        "/usr/local/bin/wkhtmltopdf",
1068        "/opt/wkhtmltopdf/bin/wkhtmltopdf",
1069        "/snap/bin/wkhtmltopdf",
1070    ] {
1071        let candidate = PathBuf::from(p);
1072        if candidate.is_file() {
1073            return Some(candidate);
1074        }
1075    }
1076
1077    None
1078}
1079
1080/// Generate a PDF using `wkhtmltopdf` when no Chromium-based browser is available.
1081///
1082/// Works on both Linux and Windows:
1083/// - Linux: install via `dnf install wkhtmltopdf` (RHEL/CentOS) or `apt install wkhtmltopdf`
1084/// - Windows: install the MSI from <https://wkhtmltopdf.org/downloads.html>; the installer
1085///   adds `wkhtmltopdf.exe` to `Program Files\wkhtmltopdf\bin\` which is checked automatically.
1086fn write_pdf_via_wkhtmltopdf(html_path: &Path, pdf_path: &Path) -> Result<()> {
1087    eprintln!("[oxide-sloc][pdf] trying wkhtmltopdf fallback");
1088
1089    let exe = discover_wkhtmltopdf().context(
1090        "wkhtmltopdf not found. \
1091         Linux: install via 'dnf install wkhtmltopdf' or 'apt install wkhtmltopdf'. \
1092         Windows: install the MSI from https://wkhtmltopdf.org/downloads.html. \
1093         Alternatively, set SLOC_BROWSER to a Chromium-based browser executable.",
1094    )?;
1095    eprintln!("[oxide-sloc][pdf] wkhtmltopdf = {}", exe.display());
1096
1097    // Strip the extended-length prefix on Windows (\\?\) so wkhtmltopdf can parse the path.
1098    let html_normalized = PathBuf::from(
1099        html_path
1100            .to_string_lossy()
1101            .trim_start_matches(r"\\?\")
1102            .to_string(),
1103    );
1104    // file_url() handles Windows drive letters (C:\ → /C:/) and encodes special chars.
1105    let html_url = file_url(&html_normalized);
1106    eprintln!("[oxide-sloc][pdf] wkhtmltopdf url = {html_url}");
1107
1108    let pdf_str = pdf_path
1109        .to_str()
1110        .context("PDF output path contains non-UTF-8 characters")?;
1111
1112    let output = std::process::Command::new(&exe)
1113        .args([
1114            "--enable-javascript",
1115            "--javascript-delay",
1116            "2000",
1117            "--quiet",
1118            "--orientation",
1119            "Landscape",
1120            "--page-size",
1121            "A4",
1122            "--margin-top",
1123            "9",
1124            "--margin-bottom",
1125            "9",
1126            "--margin-left",
1127            "13",
1128            "--margin-right",
1129            "13",
1130            "--print-media-type",
1131            &html_url,
1132            pdf_str,
1133        ])
1134        .output()
1135        .with_context(|| format!("failed to launch wkhtmltopdf at {}", exe.display()))?;
1136
1137    if !output.status.success() {
1138        let stderr = String::from_utf8_lossy(&output.stderr);
1139        anyhow::bail!("wkhtmltopdf exited with {}: {stderr}", output.status);
1140    }
1141
1142    if !pdf_path.exists() {
1143        anyhow::bail!(
1144            "wkhtmltopdf exited successfully but {} was not created",
1145            pdf_path.display()
1146        );
1147    }
1148
1149    eprintln!("[oxide-sloc][pdf] wkhtmltopdf wrote {}", pdf_path.display());
1150    Ok(())
1151}
1152
1153struct PdfCtx<'a> {
1154    layer: &'a crate::pdf_compat::PdfLayerReference,
1155    font_reg: &'a crate::pdf_compat::IndirectFontRef,
1156    font_bold: &'a crate::pdf_compat::IndirectFontRef,
1157    w: f32,
1158    margin: f32,
1159    row_h: f32,
1160    tbl_hdr_h: f32,
1161}
1162
1163/// Fixed page geometry (landscape A4 in mm) threaded through the PDF page builders.
1164/// Bundled into one struct so the page helpers stay under the argument-count lint.
1165#[derive(Clone, Copy)]
1166struct PdfPageDims {
1167    w: f32,
1168    h: f32,
1169    margin: f32,
1170    footer_h: f32,
1171    row_h: f32,
1172    tbl_hdr_h: f32,
1173}
1174
1175#[allow(
1176    clippy::cast_precision_loss,
1177    clippy::suboptimal_flops,
1178    clippy::too_many_lines
1179)]
1180fn runtime_mode_display(mode: &str) -> &str {
1181    match mode {
1182        "serve" => "Web UI",
1183        "analyze" => "CLI",
1184        "git-scan" => "Git Scan",
1185        "git-compare" => "Git Compare",
1186        "watch" => "Watch",
1187        other => other,
1188    }
1189}
1190
1191fn pdf_render_page1_header(
1192    ctx: &PdfCtx<'_>,
1193    run: &AnalysisRun,
1194    ts: &str,
1195    title: &str,
1196    h: f32,
1197    hdr_h: f32,
1198    banner: Option<&str>,
1199) -> f32 {
1200    use crate::pdf_compat::{Color, Mm, Rgb};
1201    let hdr_y = h - hdr_h;
1202    pdf_fill_rect(
1203        ctx.layer,
1204        0.0,
1205        hdr_y,
1206        ctx.w,
1207        hdr_h,
1208        Rgb::new(0.098, 0.11, 0.15, None),
1209    );
1210    ctx.layer
1211        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1212    ctx.layer.use_text(
1213        "oxide-sloc",
1214        13.0,
1215        Mm(ctx.margin),
1216        Mm(hdr_y + 4.5),
1217        ctx.font_bold,
1218    );
1219    ctx.layer
1220        .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
1221    ctx.layer.use_text(
1222        "Code Metrics Report",
1223        9.5,
1224        Mm(54.0),
1225        Mm(hdr_y + 5.0),
1226        ctx.font_reg,
1227    );
1228    ctx.layer.use_text(
1229        pdf_safe_str(ts),
1230        8.0,
1231        Mm(ctx.w - 70.0),
1232        Mm(hdr_y + 5.0),
1233        ctx.font_reg,
1234    );
1235    // Report identification banner — white bold, centered between the two header items.
1236    if let Some(text) = banner {
1237        let safe = pdf_trunc(&pdf_safe_str(text), 40);
1238        // Approximate half-width at 9pt bold Helvetica (~0.97 mm per char) for centering.
1239        // `safe` is truncated to 40 chars, so the count is tiny; the f32 cast is exact here
1240        // and only ever feeds a millimetre layout coordinate.
1241        #[allow(
1242            clippy::cast_precision_loss,
1243            reason = "small bounded char count; sub-mm layout offset"
1244        )]
1245        let text_x = (safe.len() as f32).mul_add(-0.97, ctx.w / 2.0).max(95.0);
1246        ctx.layer
1247            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1248        ctx.layer
1249            .use_text(safe, 9.0, Mm(text_x), Mm(hdr_y + 4.5), ctx.font_bold);
1250    }
1251    let title_text_y = hdr_y - 5.5;
1252    ctx.layer
1253        .set_fill_color(Color::Rgb(Rgb::new(0.098, 0.11, 0.15, None)));
1254    ctx.layer.use_text(
1255        pdf_trunc(&pdf_safe_str(title), 55),
1256        9.5,
1257        Mm(ctx.margin),
1258        Mm(title_text_y),
1259        ctx.font_bold,
1260    );
1261    let roots_text_y = title_text_y - 5.0;
1262    // ── Left side: project path ──────────────────────────────────────────────
1263    let roots: String = run
1264        .input_roots
1265        .iter()
1266        .map(|r| pdf_safe_str(r))
1267        .collect::<Vec<_>>()
1268        .join("  ");
1269    ctx.layer
1270        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
1271    ctx.layer.use_text(
1272        pdf_trunc(&roots, 85),
1273        6.5,
1274        Mm(ctx.margin),
1275        Mm(roots_text_y),
1276        ctx.font_reg,
1277    );
1278    // ── Right side: git + environment metadata in a grouped box ─────────────
1279    pdf_render_page1_gitbox(ctx, run, title_text_y, roots_text_y);
1280    roots_text_y
1281}
1282
1283/// Render the right-side git + environment metadata box of the page-1 header.
1284fn pdf_render_page1_gitbox(
1285    ctx: &PdfCtx<'_>,
1286    run: &AnalysisRun,
1287    title_text_y: f32,
1288    roots_text_y: f32,
1289) {
1290    use crate::pdf_compat::{Color, Mm, Rgb};
1291    let mut git_parts: Vec<String> = vec![];
1292    if let Some(ref b) = run.git_branch {
1293        git_parts.push(format!("Branch: {}", pdf_safe_str(b)));
1294    }
1295    if let Some(ref c) = run.git_commit_short {
1296        git_parts.push(format!("Commit: {}", pdf_safe_str(c)));
1297    }
1298    if let Some(ref t) = run.git_nearest_tag {
1299        git_parts.push(format!("Tag: {}", pdf_safe_str(t)));
1300    }
1301    let git_str = pdf_trunc(&git_parts.join("  \u{00B7}  "), 70);
1302
1303    let initiator = run
1304        .environment
1305        .ci_name
1306        .as_deref()
1307        .unwrap_or(run.environment.initiator_username.as_str());
1308    let mode_label = runtime_mode_display(&run.environment.runtime_mode);
1309    let env_str = format!(
1310        "OS: {} / {}  \u{00B7}  User: {}  \u{00B7}  Host: {}  \u{00B7}  Source: {}",
1311        pdf_safe_str(&run.environment.operating_system),
1312        pdf_safe_str(&run.environment.architecture),
1313        pdf_safe_str(initiator),
1314        pdf_safe_str(&run.environment.initiator_hostname),
1315        mode_label,
1316    );
1317    let env_trunc = pdf_trunc(&env_str, 100);
1318
1319    // Shared right anchor — text right-edges land here; box extends pad_h mm beyond.
1320    let right_anchor = ctx.w - ctx.margin - 6.0;
1321    // Accurate widths using exact PDF Helvetica advance tables (PDF spec Appendix D).
1322    // Character-count estimates are unreliable for proportional fonts — actual per-glyph widths vary 4×.
1323    let git_w = helvetica_width_mm(&git_str, 7.5, true);
1324    let env_w = helvetica_width_mm(&env_trunc, 6.5, false);
1325    let max_w = git_w.max(env_w);
1326
1327    // Background pill with 0.6 mm simulated border for visual grouping.
1328    let pad_h: f32 = 3.5;
1329    let pad_v: f32 = 1.8;
1330    let box_left = (right_anchor - max_w - pad_h).max(ctx.w / 2.0 - pad_h);
1331    let box_right = right_anchor + pad_h;
1332    let box_width = box_right - box_left;
1333    let box_bot = roots_text_y - pad_v;
1334    let box_top = title_text_y + pad_v + 1.5;
1335    let box_height = box_top - box_bot;
1336    pdf_fill_rect(
1337        ctx.layer,
1338        box_left - 0.6,
1339        box_bot - 0.6,
1340        box_width + 1.2,
1341        box_height + 1.2,
1342        Rgb::new(0.80, 0.75, 0.68, None),
1343    );
1344    pdf_fill_rect(
1345        ctx.layer,
1346        box_left,
1347        box_bot,
1348        box_width,
1349        box_height,
1350        Rgb::new(0.97, 0.95, 0.92, None),
1351    );
1352
1353    // Git line — right-aligned to shared anchor, dark-green bold
1354    if !git_str.is_empty() {
1355        let git_x = (right_anchor - git_w).max(box_left + 2.0);
1356        ctx.layer
1357            .set_fill_color(Color::Rgb(Rgb::new(0.25, 0.42, 0.25, None)));
1358        ctx.layer.use_text(
1359            git_str.as_str(),
1360            7.5,
1361            Mm(git_x),
1362            Mm(title_text_y),
1363            ctx.font_bold,
1364        );
1365    }
1366    // Env line — same right anchor so "Source: …" right-edge aligns with "Tag: …" above
1367    let env_x = (right_anchor - env_w).max(box_left + 2.0);
1368    ctx.layer
1369        .set_fill_color(Color::Rgb(Rgb::new(0.38, 0.38, 0.38, None)));
1370    ctx.layer.use_text(
1371        env_trunc.as_str(),
1372        6.5,
1373        Mm(env_x),
1374        Mm(roots_text_y),
1375        ctx.font_reg,
1376    );
1377}
1378
1379#[allow(clippy::cast_precision_loss)]
1380fn pdf_render_summary_chips(ctx: &PdfCtx<'_>, run: &AnalysisRun, roots_text_y: f32) -> f32 {
1381    use crate::pdf_compat::{Color, Mm, Rgb};
1382    let tot = &run.summary_totals;
1383    let chip_gap: f32 = 5.0;
1384    let chip_w = 3.0f32.mul_add(-chip_gap, 2.0f32.mul_add(-ctx.margin, ctx.w)) / 4.0;
1385    let chip_h: f32 = 17.0;
1386    let row1_bot = roots_text_y - 4.0 - chip_h;
1387    let row1: [(&str, u64); 4] = [
1388        ("Code Lines", tot.code_lines),
1389        ("Comment Lines", tot.comment_lines),
1390        ("Blank Lines", tot.blank_lines),
1391        ("Physical Lines", tot.total_physical_lines),
1392    ];
1393    for (i, (label, value)) in row1.iter().enumerate() {
1394        let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1395        pdf_fill_rect(
1396            ctx.layer,
1397            cx,
1398            row1_bot,
1399            chip_w,
1400            chip_h,
1401            Rgb::new(0.945, 0.925, 0.90, None),
1402        );
1403        ctx.layer
1404            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1405        // Show the full comma-separated number (no K/M rounding) on the PDF stat cards.
1406        ctx.layer.use_text(
1407            pdf_fmt_full(*value),
1408            13.0,
1409            Mm(cx + 4.0),
1410            Mm(row1_bot + 9.0),
1411            ctx.font_bold,
1412        );
1413        ctx.layer
1414            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
1415        ctx.layer.use_text(
1416            pdf_safe_str(label),
1417            6.5,
1418            Mm(cx + 4.0),
1419            Mm(row1_bot + 3.0),
1420            ctx.font_reg,
1421        );
1422    }
1423    let row2_bot = row1_bot - 3.0 - chip_h;
1424    let row2_4th = if tot.test_count > 0 {
1425        ("Test Methods", tot.test_count)
1426    } else if tot.classes > 0 {
1427        ("Classes", tot.classes)
1428    } else {
1429        ("Mixed Lines", tot.mixed_lines_separate)
1430    };
1431    let row2: [(&str, u64); 4] = [
1432        ("Files Analyzed", tot.files_analyzed),
1433        ("Files Skipped", tot.files_skipped),
1434        ("Functions", tot.functions),
1435        row2_4th,
1436    ];
1437    for (i, (label, value)) in row2.iter().enumerate() {
1438        let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1439        pdf_fill_rect(
1440            ctx.layer,
1441            cx,
1442            row2_bot,
1443            chip_w,
1444            chip_h,
1445            Rgb::new(0.91, 0.92, 0.96, None),
1446        );
1447        ctx.layer
1448            .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.25, 0.55, None)));
1449        ctx.layer.use_text(
1450            pdf_fmt_full(*value),
1451            13.0,
1452            Mm(cx + 4.0),
1453            Mm(row2_bot + 9.0),
1454            ctx.font_bold,
1455        );
1456        ctx.layer
1457            .set_fill_color(Color::Rgb(Rgb::new(0.35, 0.35, 0.45, None)));
1458        ctx.layer.use_text(
1459            pdf_safe_str(label),
1460            6.5,
1461            Mm(cx + 4.0),
1462            Mm(row2_bot + 3.0),
1463            ctx.font_reg,
1464        );
1465    }
1466    row2_bot
1467}
1468
1469#[allow(clippy::cast_precision_loss)]
1470fn pdf_info_parts_stats(tot: &SummaryTotals) -> Vec<String> {
1471    let total = tot.total_physical_lines.max(1) as f64;
1472    let code_pct = tot.code_lines as f64 / total * 100.0;
1473    let cmt_pct = tot.comment_lines as f64 / total * 100.0;
1474    let blank_pct = tot.blank_lines as f64 / total * 100.0;
1475    let mixed_pct = tot.mixed_lines_separate as f64 / total * 100.0;
1476    let mut parts = vec![
1477        format!(
1478            "Code: {code_pct:.1}% ({} lines)",
1479            pdf_fmt_full(tot.code_lines)
1480        ),
1481        format!(
1482            "Comments: {cmt_pct:.1}% ({} lines)",
1483            pdf_fmt_full(tot.comment_lines)
1484        ),
1485        format!(
1486            "Blank: {blank_pct:.1}% ({} lines)",
1487            pdf_fmt_full(tot.blank_lines)
1488        ),
1489    ];
1490    if tot.functions > 0 {
1491        parts.push(format!("Functions: {}", pdf_fmt_full(tot.functions)));
1492    }
1493    if tot.mixed_lines_separate > 0 {
1494        parts.push(format!(
1495            "Mixed: {mixed_pct:.1}% ({} lines)",
1496            pdf_fmt_full(tot.mixed_lines_separate)
1497        ));
1498    }
1499    if tot.imports > 0 {
1500        parts.push(format!("Imports: {}", pdf_fmt_full(tot.imports)));
1501    }
1502    if tot.variables > 0 {
1503        parts.push(format!("Variables: {}", pdf_fmt_full(tot.variables)));
1504    }
1505    if tot.classes > 0 {
1506        parts.push(format!("Classes: {}", pdf_fmt_full(tot.classes)));
1507    }
1508    parts
1509}
1510
1511fn pdf_info_parts_git(run: &AnalysisRun) -> Vec<String> {
1512    let mut parts: Vec<String> = Vec::new();
1513    if let Some(ref b) = run.git_branch {
1514        parts.push(format!("Branch: {}", pdf_safe_str(b)));
1515    }
1516    if let Some(ref c) = run.git_commit_short {
1517        parts.push(format!("Commit: {}", pdf_safe_str(c)));
1518    }
1519    if let Some(ref t) = run.git_nearest_tag {
1520        parts.push(format!("Tag: {}", pdf_safe_str(t)));
1521    }
1522    if let Some(ref a) = run.git_commit_author {
1523        parts.push(format!("Author: {}", pdf_safe_str(a)));
1524    }
1525    if let Some(ref d) = run.git_commit_date {
1526        parts.push(format!("Commit Date: {}", fmt_commit_date_pt(d)));
1527    }
1528    parts
1529}
1530
1531#[allow(clippy::cast_precision_loss)]
1532fn pdf_info_parts_tests(tot: &SummaryTotals) -> Vec<String> {
1533    let mut tc: Vec<String> = Vec::new();
1534    if tot.test_count > 0 {
1535        tc.push(format!("Tests: {}", pdf_fmt_full(tot.test_count)));
1536    }
1537    if tot.test_assertion_count > 0 {
1538        tc.push(format!(
1539            "Assertions: {}",
1540            pdf_fmt_full(tot.test_assertion_count)
1541        ));
1542    }
1543    if tot.test_suite_count > 0 {
1544        tc.push(format!("Suites: {}", pdf_fmt_full(tot.test_suite_count)));
1545    }
1546    if tot.coverage_lines_found > 0 {
1547        tc.push(format!(
1548            "Line Cov: {:.1}% ({}/{})",
1549            tot.coverage_lines_hit as f64 / tot.coverage_lines_found as f64 * 100.0,
1550            pdf_fmt_full(tot.coverage_lines_hit),
1551            pdf_fmt_full(tot.coverage_lines_found)
1552        ));
1553    }
1554    if tot.coverage_functions_found > 0 {
1555        tc.push(format!(
1556            "Func Cov: {:.1}%",
1557            tot.coverage_functions_hit as f64 / tot.coverage_functions_found as f64 * 100.0
1558        ));
1559    }
1560    if tot.coverage_branches_found > 0 {
1561        tc.push(format!(
1562            "Branch Cov: {:.1}%",
1563            tot.coverage_branches_hit as f64 / tot.coverage_branches_found as f64 * 100.0
1564        ));
1565    }
1566    tc
1567}
1568
1569/// Emit one or more info lines, packing `parts` joined by "  |  " and wrapping onto a fresh
1570/// line whenever appending the next part would overflow the usable page width. Measured with
1571/// the exact Helvetica advance table so the whole line is always shown — never truncated.
1572/// Returns the y position below the last emitted line.
1573fn pdf_info_emit_line(
1574    ctx: &PdfCtx<'_>,
1575    mut y: f32,
1576    r: f32,
1577    g: f32,
1578    b: f32,
1579    parts: &[String],
1580) -> f32 {
1581    use crate::pdf_compat::{Color, Mm, Rgb};
1582    if parts.is_empty() {
1583        return y;
1584    }
1585    const SEP: &str = "  |  ";
1586    let usable = ctx.w - 2.0 * ctx.margin;
1587    ctx.layer
1588        .set_fill_color(Color::Rgb(Rgb::new(r, g, b, None)));
1589    let mut line = String::new();
1590    for part in parts {
1591        let candidate = if line.is_empty() {
1592            part.clone()
1593        } else {
1594            format!("{line}{SEP}{part}")
1595        };
1596        // Keep packing until the next part would overflow; then flush and start a new line.
1597        if !line.is_empty() && helvetica_width_mm(&candidate, 7.0, false) > usable {
1598            ctx.layer
1599                .use_text(line.as_str(), 7.0, Mm(ctx.margin), Mm(y), ctx.font_reg);
1600            y -= 5.0;
1601            line.clone_from(part);
1602        } else {
1603            line = candidate;
1604        }
1605    }
1606    ctx.layer
1607        .use_text(line.as_str(), 7.0, Mm(ctx.margin), Mm(y), ctx.font_reg);
1608    y - 5.0
1609}
1610
1611fn pdf_render_info_lines(ctx: &PdfCtx<'_>, run: &AnalysisRun, row2_bot: f32) -> f32 {
1612    let tot = &run.summary_totals;
1613    let mut y = row2_bot - 6.5;
1614    let stats = pdf_info_parts_stats(tot);
1615    y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.15, &stats);
1616    let git = pdf_info_parts_git(run);
1617    if !git.is_empty() {
1618        y = pdf_info_emit_line(ctx, y, 0.10, 0.35, 0.15, &git);
1619    }
1620    let tests = pdf_info_parts_tests(tot);
1621    if !tests.is_empty() {
1622        y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.50, &tests);
1623    }
1624    y
1625}
1626
1627#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1628fn pdf_table_render_section(
1629    ctx: &PdfCtx<'_>,
1630    x: f32,
1631    top: f32,
1632    w: f32,
1633    lbl_frac: f32,
1634    title: &str,
1635    rows: &[(&str, String)],
1636) {
1637    use crate::pdf_compat::{Color, Mm, Rgb};
1638    pdf_fill_rect(
1639        ctx.layer,
1640        x,
1641        top - ctx.tbl_hdr_h,
1642        w,
1643        ctx.tbl_hdr_h,
1644        Rgb::new(0.098, 0.11, 0.15, None),
1645    );
1646    ctx.layer
1647        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1648    ctx.layer.use_text(
1649        title,
1650        7.0,
1651        Mm(x + 2.0),
1652        Mm(top - ctx.tbl_hdr_h + 1.5),
1653        ctx.font_bold,
1654    );
1655    let y = top - ctx.tbl_hdr_h;
1656    for (ri, (lbl, val)) in rows.iter().enumerate() {
1657        let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, y);
1658        let bg = if ri % 2 == 0 {
1659            Rgb::new(0.975, 0.965, 0.95, None)
1660        } else {
1661            Rgb::new(1.0, 1.0, 1.0, None)
1662        };
1663        pdf_fill_rect(ctx.layer, x, ry, w, ctx.row_h, bg);
1664        ctx.layer
1665            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1666        ctx.layer
1667            .use_text(*lbl, 6.5, Mm(x + 2.0), Mm(ry + 1.5), ctx.font_reg);
1668        let is_dash = val == "--";
1669        let val_rgb = if is_dash {
1670            Rgb::new(0.55, 0.55, 0.55, None)
1671        } else {
1672            Rgb::new(0.12, 0.12, 0.12, None)
1673        };
1674        let val_font = if is_dash { ctx.font_reg } else { ctx.font_bold };
1675        ctx.layer.set_fill_color(Color::Rgb(val_rgb));
1676        ctx.layer.use_text(
1677            val.as_str(),
1678            6.5,
1679            Mm(x + w * lbl_frac + 2.0),
1680            Mm(ry + 1.5),
1681            val_font,
1682        );
1683    }
1684}
1685
1686#[allow(
1687    clippy::cast_precision_loss,
1688    clippy::suboptimal_flops,
1689    clippy::similar_names
1690)]
1691fn pdf_render_metric_tables(ctx: &PdfCtx<'_>, run: &AnalysisRun, tbl_top: f32) {
1692    let tot = &run.summary_totals;
1693    let half_w = (2.0f32.mul_add(-ctx.margin, ctx.w) - 4.0) / 2.0;
1694    let left_x = ctx.margin;
1695    let right_x = ctx.margin + half_w + 4.0;
1696    let lbl_frac: f32 = 0.68;
1697
1698    let files_rows: [(&str, String); 4] = [
1699        ("Files analyzed", pdf_fmt_full(tot.files_analyzed)),
1700        ("Files skipped", pdf_fmt_full(tot.files_skipped)),
1701        ("Files modified", "--".to_string()),
1702        ("Files unchanged", "--".to_string()),
1703    ];
1704    pdf_table_render_section(ctx, left_x, tbl_top, half_w, lbl_frac, "FILES", &files_rows);
1705
1706    let lc_rows: [(&str, String); 5] = [
1707        ("Physical lines", pdf_fmt_full(tot.total_physical_lines)),
1708        ("Code lines", pdf_fmt_full(tot.code_lines)),
1709        ("Comment lines", pdf_fmt_full(tot.comment_lines)),
1710        ("Blank lines", pdf_fmt_full(tot.blank_lines)),
1711        ("Mixed (separate)", pdf_fmt_full(tot.mixed_lines_separate)),
1712    ];
1713    let lc_top = tbl_top - ctx.tbl_hdr_h - (files_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1714    pdf_table_render_section(
1715        ctx,
1716        left_x,
1717        lc_top,
1718        half_w,
1719        lbl_frac,
1720        "LINE COUNTS",
1721        &lc_rows,
1722    );
1723
1724    let cs_rows: [(&str, String); 4] = [
1725        ("Functions", pdf_fmt_full(tot.functions)),
1726        ("Classes / Types", pdf_fmt_full(tot.classes)),
1727        ("Variables", pdf_fmt_full(tot.variables)),
1728        ("Imports", pdf_fmt_full(tot.imports)),
1729    ];
1730    pdf_table_render_section(
1731        ctx,
1732        right_x,
1733        tbl_top,
1734        half_w,
1735        lbl_frac,
1736        "CODE STRUCTURE",
1737        &cs_rows,
1738    );
1739
1740    let lcs_rows: [(&str, String); 4] = [
1741        ("Lines added", "--".to_string()),
1742        ("Lines removed", "--".to_string()),
1743        ("Lines modified (net)", "--".to_string()),
1744        ("Lines unmodified", "--".to_string()),
1745    ];
1746    let lcs_top = tbl_top - ctx.tbl_hdr_h - (cs_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1747    pdf_table_render_section(
1748        ctx,
1749        right_x,
1750        lcs_top,
1751        half_w,
1752        lbl_frac,
1753        "LINE CHANGE SUMMARY",
1754        &lcs_rows,
1755    );
1756}
1757
1758/// Render Tests & Coverage content **inline** on an existing page, starting at `y_start`.
1759/// Draw a full-width dark section title bar at `y` and return the Y just below it.
1760fn pdf_tc_title_bar(ctx: &PdfCtx<'_>, label: &str, y: f32) -> f32 {
1761    use crate::pdf_compat::{Color, Mm, Rgb};
1762    let tbl_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
1763    pdf_fill_rect(
1764        ctx.layer,
1765        ctx.margin,
1766        y - ctx.tbl_hdr_h,
1767        tbl_w,
1768        ctx.tbl_hdr_h,
1769        Rgb::new(0.098, 0.11, 0.15, None),
1770    );
1771    ctx.layer
1772        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1773    ctx.layer.use_text(
1774        label,
1775        7.0,
1776        Mm(ctx.margin + 2.0),
1777        Mm(y - ctx.tbl_hdr_h + 1.5),
1778        ctx.font_bold,
1779    );
1780    y - ctx.tbl_hdr_h
1781}
1782
1783/// Alternating zebra row background for PDF tables.
1784fn pdf_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
1785    use crate::pdf_compat::Rgb;
1786    if ri.is_multiple_of(2) {
1787        Rgb::new(0.975, 0.965, 0.95, None)
1788    } else {
1789        Rgb::new(1.0, 1.0, 1.0, None)
1790    }
1791}
1792
1793/// Sum a per-submodule language metric via the provided accessor.
1794fn pdf_sub_sum(
1795    sub: &sloc_core::SubmoduleSummary,
1796    f: impl Fn(&sloc_core::LanguageSummary) -> u64,
1797) -> u64 {
1798    sub.language_summaries.iter().map(f).sum()
1799}
1800
1801/// Render the four summary stat boxes (test functions/assertions/suites + line coverage).
1802#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1803fn pdf_tc_stat_boxes(ctx: &PdfCtx<'_>, run: &AnalysisRun, has_cov: bool, mut y: f32) -> f32 {
1804    use crate::pdf_compat::{Color, Mm, Rgb};
1805    let gap: f32 = 4.0;
1806    let box_h: f32 = 15.0;
1807    let box_w = (ctx.w - 2.0 * ctx.margin - 3.0 * gap) / 4.0;
1808    let line_cov_str = if has_cov {
1809        let pct = run.summary_totals.coverage_lines_hit as f64
1810            / run.summary_totals.coverage_lines_found as f64
1811            * 100.0;
1812        format!("{pct:.1}%")
1813    } else {
1814        "\u{2014}".to_string()
1815    };
1816    let box_vals: [String; 4] = [
1817        pdf_fmt_full(run.summary_totals.test_count),
1818        pdf_fmt_full(run.summary_totals.test_assertion_count),
1819        pdf_fmt_full(run.summary_totals.test_suite_count),
1820        line_cov_str,
1821    ];
1822    let box_labels: [&str; 4] = [
1823        "Test Functions",
1824        "Test Assertions",
1825        "Test Suites",
1826        "Line Coverage",
1827    ];
1828    for (i, (label, val)) in box_labels.iter().zip(box_vals.iter()).enumerate() {
1829        let bx = ctx.margin + i as f32 * (box_w + gap);
1830        let by = y - box_h;
1831        pdf_fill_rect(
1832            ctx.layer,
1833            bx,
1834            by,
1835            box_w,
1836            box_h,
1837            Rgb::new(0.97, 0.96, 0.94, None),
1838        );
1839        ctx.layer
1840            .set_fill_color(Color::Rgb(Rgb::new(0.60, 0.40, 0.22, None)));
1841        ctx.layer
1842            .use_text(val.as_str(), 9.5, Mm(bx + 3.0), Mm(by + 7.5), ctx.font_bold);
1843        ctx.layer
1844            .set_fill_color(Color::Rgb(Rgb::new(0.50, 0.44, 0.40, None)));
1845        ctx.layer
1846            .use_text(*label, 5.5, Mm(bx + 3.0), Mm(by + 2.0), ctx.font_reg);
1847    }
1848    y -= box_h + 4.0;
1849    y
1850}
1851
1852/// Render the full-width SUBMODULES table when submodule summaries are present.
1853#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1854fn pdf_tc_submodules(ctx: &PdfCtx<'_>, run: &AnalysisRun, footer_h: f32, mut y: f32) -> f32 {
1855    use crate::pdf_compat::{Color, Mm, Rgb};
1856    let subs = &run.submodule_summaries;
1857    if subs.is_empty() {
1858        return y;
1859    }
1860    let margin = ctx.margin;
1861    let row_h = ctx.row_h;
1862    let tbl_w = ctx.w - 2.0 * margin;
1863
1864    let col_name = tbl_w * 0.40;
1865    let rem = tbl_w - col_name;
1866    let col_files = rem * 0.15;
1867    let col_code = rem * 0.20;
1868    let col_tests = rem * 0.20;
1869    let col_assert = rem * 0.20;
1870
1871    let cx_files = margin + col_name;
1872    let cx_code = cx_files + col_files;
1873    let cx_tests = cx_code + col_code;
1874    let cx_assert = cx_tests + col_tests;
1875    let cx_cov = cx_assert + col_assert;
1876
1877    y = pdf_tc_title_bar(ctx, "SUBMODULES", y);
1878
1879    pdf_fill_rect(
1880        ctx.layer,
1881        margin,
1882        y - row_h,
1883        tbl_w,
1884        row_h,
1885        Rgb::new(0.25, 0.27, 0.32, None),
1886    );
1887    ctx.layer
1888        .set_fill_color(Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)));
1889    for (lbl, x) in &[
1890        ("Submodule", margin + 2.0),
1891        ("Files", cx_files + 2.0),
1892        ("Code Lines", cx_code + 2.0),
1893        ("Test Functions", cx_tests + 2.0),
1894        ("Assertions", cx_assert + 2.0),
1895        ("Line Coverage %", cx_cov + 2.0),
1896    ] {
1897        ctx.layer
1898            .use_text(*lbl, 5.5, Mm(*x), Mm(y - row_h + 1.5), ctx.font_bold);
1899    }
1900    y -= row_h;
1901
1902    for (ri, sub) in subs.iter().enumerate() {
1903        if y < footer_h + row_h {
1904            break;
1905        }
1906        let sub_tests = pdf_sub_sum(sub, |l| l.test_count);
1907        let sub_assert = pdf_sub_sum(sub, |l| l.test_assertion_count);
1908        let sub_cov_hit = pdf_sub_sum(sub, |l| l.coverage_lines_hit);
1909        let sub_cov_found = pdf_sub_sum(sub, |l| l.coverage_lines_found);
1910        let sub_cov_str = if sub_cov_found > 0 {
1911            format!("{:.1}%", sub_cov_hit as f64 / sub_cov_found as f64 * 100.0)
1912        } else {
1913            "\u{2014}".to_string()
1914        };
1915
1916        let ry = y - row_h;
1917        pdf_fill_rect(ctx.layer, margin, ry, tbl_w, row_h, pdf_row_bg(ri));
1918        ctx.layer
1919            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1920        ctx.layer.use_text(
1921            pdf_trunc(&pdf_safe_str(&sub.name), 40),
1922            5.5,
1923            Mm(margin + 2.0),
1924            Mm(ry + 1.5),
1925            ctx.font_bold,
1926        );
1927        for (val, x) in &[
1928            (pdf_fmt_full(sub.files_analyzed), cx_files + 2.0),
1929            (pdf_fmt_full(sub.code_lines), cx_code + 2.0),
1930            (pdf_fmt_full(sub_tests), cx_tests + 2.0),
1931            (pdf_fmt_full(sub_assert), cx_assert + 2.0),
1932            (sub_cov_str, cx_cov + 2.0),
1933        ] {
1934            ctx.layer
1935                .use_text(val.as_str(), 5.5, Mm(*x), Mm(ry + 1.5), ctx.font_reg);
1936        }
1937        y -= row_h;
1938    }
1939    y - 3.0
1940}
1941
1942/// Render the line/function/branch coverage gauges across the page width.
1943#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1944fn pdf_tc_gauges(ctx: &PdfCtx<'_>, run: &AnalysisRun, mut y: f32) -> f32 {
1945    use crate::pdf_compat::{Color, Mm, Rgb};
1946    let margin = ctx.margin;
1947    let gap: f32 = 4.0;
1948    let gauges: &[(&str, u64, u64)] = &[
1949        (
1950            "Line Coverage",
1951            run.summary_totals.coverage_lines_hit,
1952            run.summary_totals.coverage_lines_found,
1953        ),
1954        (
1955            "Function Coverage",
1956            run.summary_totals.coverage_functions_hit,
1957            run.summary_totals.coverage_functions_found,
1958        ),
1959        (
1960            "Branch Coverage",
1961            run.summary_totals.coverage_branches_hit,
1962            run.summary_totals.coverage_branches_found,
1963        ),
1964    ];
1965    let visible: Vec<_> = gauges.iter().filter(|(_, _, found)| *found > 0).collect();
1966    if visible.is_empty() {
1967        return y;
1968    }
1969    let count = visible.len() as f32;
1970    let gauge_h: f32 = 14.0;
1971    let gauge_w = (ctx.w - 2.0 * margin - (count - 1.0) * gap) / count;
1972    for (gi, (label, hit, found)) in visible.iter().enumerate() {
1973        let gx = margin + gi as f32 * (gauge_w + gap);
1974        let pct = *hit as f64 / *found as f64 * 100.0;
1975        let pct_str = format!("{pct:.1}%");
1976        // `pct` is a 0..=100 percentage; narrowing to f32 for a bar-width coordinate is exact
1977        // to well within sub-pixel rendering tolerance.
1978        #[allow(
1979            clippy::cast_possible_truncation,
1980            reason = "0..=100 percentage to f32 bar width"
1981        )]
1982        let bar_fill = (gauge_w - 6.0) * (pct as f32 / 100.0);
1983        let gy = y - gauge_h;
1984        pdf_fill_rect(
1985            ctx.layer,
1986            gx,
1987            gy,
1988            gauge_w,
1989            gauge_h,
1990            Rgb::new(0.975, 0.965, 0.95, None),
1991        );
1992        ctx.layer
1993            .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.15, 0.15, None)));
1994        ctx.layer
1995            .use_text(*label, 6.0, Mm(gx + 2.0), Mm(gy + 9.5), ctx.font_bold);
1996        ctx.layer
1997            .set_fill_color(Color::Rgb(Rgb::new(0.20, 0.55, 0.35, None)));
1998        ctx.layer
1999            .use_text(&pct_str, 8.0, Mm(gx + 2.0), Mm(gy + 4.5), ctx.font_bold);
2000        pdf_fill_rect(
2001            ctx.layer,
2002            gx + 3.0,
2003            gy + 1.5,
2004            gauge_w - 6.0,
2005            2.5,
2006            Rgb::new(0.86, 0.84, 0.80, None),
2007        );
2008        if bar_fill > 0.0 {
2009            pdf_fill_rect(
2010                ctx.layer,
2011                gx + 3.0,
2012                gy + 1.5,
2013                bar_fill,
2014                2.5,
2015                Rgb::new(0.20, 0.55, 0.35, None),
2016            );
2017        }
2018    }
2019    y -= gauge_h + 5.0;
2020    y
2021}
2022
2023/// Column layout for the per-file coverage table, shared by the header and row renderers.
2024struct CovCols {
2025    has_fn_cov: bool,
2026    has_br_cov: bool,
2027    col_fn_w: f32,
2028    hdr_x2: f32,
2029}
2030
2031/// Draw the PER-FILE COVERAGE title + column header bar; return `(rows_start_y, cols)`.
2032fn pdf_tc_per_file_header(
2033    ctx: &PdfCtx<'_>,
2034    has_fn_cov: bool,
2035    has_br_cov: bool,
2036    col_fn_w: f32,
2037    y: f32,
2038) -> (f32, CovCols) {
2039    use crate::pdf_compat::{Color, Mm, Rgb};
2040    let margin = ctx.margin;
2041    let col_br_w: f32 = if has_br_cov { 22.0 } else { 0.0 };
2042    let col_file_w = 2.0_f32.mul_add(-margin, ctx.w) - 22.0 - col_fn_w - col_br_w;
2043    let hdr_x2 = margin + col_file_w;
2044
2045    let y = pdf_tc_title_bar(ctx, "PER-FILE COVERAGE", y - 3.0);
2046    ctx.layer
2047        .set_fill_color(Color::Rgb(Rgb::new(0.55, 0.55, 0.55, None)));
2048    ctx.layer
2049        .use_text("Line%", 5.5, Mm(hdr_x2 + 2.0), Mm(y - 3.5), ctx.font_bold);
2050    if has_fn_cov {
2051        ctx.layer
2052            .use_text("Fn%", 5.5, Mm(hdr_x2 + 24.0), Mm(y - 3.5), ctx.font_bold);
2053    }
2054    if has_br_cov {
2055        ctx.layer.use_text(
2056            "Br%",
2057            5.5,
2058            Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2059            Mm(y - 3.5),
2060            ctx.font_bold,
2061        );
2062    }
2063    (
2064        y - ctx.row_h,
2065        CovCols {
2066            has_fn_cov,
2067            has_br_cov,
2068            col_fn_w,
2069            hdr_x2,
2070        },
2071    )
2072}
2073
2074/// Render one per-file coverage row at vertical position `ry`.
2075fn pdf_tc_per_file_row(ctx: &PdfCtx<'_>, file: &FileRecord, ri: usize, cols: &CovCols, ry: f32) {
2076    use crate::pdf_compat::{Color, Mm, Rgb};
2077    let (has_fn_cov, has_br_cov, col_fn_w, hdr_x2) =
2078        (cols.has_fn_cov, cols.has_br_cov, cols.col_fn_w, cols.hdr_x2);
2079    let Some(cov) = file.coverage.as_ref() else {
2080        return;
2081    };
2082    pdf_fill_rect(
2083        ctx.layer,
2084        ctx.margin,
2085        ry,
2086        2.0_f32.mul_add(-ctx.margin, ctx.w),
2087        ctx.row_h,
2088        pdf_row_bg(ri),
2089    );
2090    ctx.layer
2091        .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2092    let fname = pdf_trunc(
2093        &pdf_safe_str(
2094            std::path::Path::new(&file.relative_path)
2095                .file_name()
2096                .and_then(|n| n.to_str())
2097                .unwrap_or(&file.relative_path),
2098        ),
2099        52,
2100    );
2101    ctx.layer.use_text(
2102        &fname,
2103        5.5,
2104        Mm(ctx.margin + 2.0),
2105        Mm(ry + 1.5),
2106        ctx.font_reg,
2107    );
2108    ctx.layer
2109        .set_fill_color(Color::Rgb(Rgb::new(0.10, 0.42, 0.25, None)));
2110    ctx.layer.use_text(
2111        format!("{:.1}%", cov.line_pct()),
2112        5.5,
2113        Mm(hdr_x2 + 2.0),
2114        Mm(ry + 1.5),
2115        ctx.font_bold,
2116    );
2117    if has_fn_cov && cov.functions_found > 0 {
2118        ctx.layer.use_text(
2119            format!("{:.1}%", cov.function_pct()),
2120            5.5,
2121            Mm(hdr_x2 + 24.0),
2122            Mm(ry + 1.5),
2123            ctx.font_bold,
2124        );
2125    }
2126    if has_br_cov && cov.branches_found > 0 {
2127        ctx.layer.use_text(
2128            format!("{:.1}%", cov.branch_pct()),
2129            5.5,
2130            Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2131            Mm(ry + 1.5),
2132            ctx.font_bold,
2133        );
2134    }
2135}
2136
2137/// Render the PER-FILE COVERAGE table (header + rows) when coverage records exist.
2138fn pdf_tc_per_file(
2139    ctx: &PdfCtx<'_>,
2140    run: &AnalysisRun,
2141    footer_h: f32,
2142    has_fn_cov: bool,
2143    has_br_cov: bool,
2144    mut y: f32,
2145) -> f32 {
2146    let cov_files: Vec<_> = run
2147        .per_file_records
2148        .iter()
2149        .filter(|r| r.coverage.is_some())
2150        .collect();
2151    if cov_files.is_empty() {
2152        return y;
2153    }
2154    let col_fn_w: f32 = if has_fn_cov { 22.0 } else { 0.0 };
2155    let (rows_start, cols) = pdf_tc_per_file_header(ctx, has_fn_cov, has_br_cov, col_fn_w, y);
2156    y = rows_start;
2157    for (ri, file) in cov_files.iter().enumerate() {
2158        if y < footer_h + ctx.row_h {
2159            break;
2160        }
2161        let ry = y - ctx.row_h;
2162        pdf_tc_per_file_row(ctx, file, ri, &cols, ry);
2163        y -= ctx.row_h;
2164    }
2165    y
2166}
2167
2168/// Render the "no coverage data" note when no coverage is present.
2169fn pdf_tc_no_coverage_note(ctx: &PdfCtx<'_>, mut y: f32) -> f32 {
2170    use crate::pdf_compat::{Color, Mm, Rgb};
2171    let margin = ctx.margin;
2172    let note_h: f32 = 12.0;
2173    pdf_fill_rect(
2174        ctx.layer,
2175        margin,
2176        y - note_h,
2177        2.0_f32.mul_add(-margin, ctx.w),
2178        note_h,
2179        Rgb::new(0.96, 0.95, 0.93, None),
2180    );
2181    ctx.layer
2182        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.40, 0.37, None)));
2183    ctx.layer.use_text(
2184        "No code coverage data detected.",
2185        7.0,
2186        Mm(margin + 4.0),
2187        Mm(y - note_h + 7.0),
2188        ctx.font_bold,
2189    );
2190    ctx.layer.use_text(
2191        "Re-run with --lcov-path <file.info> to see per-file line, function, and branch coverage.",
2192        6.0,
2193        Mm(margin + 4.0),
2194        Mm(y - note_h + 2.5),
2195        ctx.font_reg,
2196    );
2197    y -= note_h;
2198    y
2199}
2200
2201/// Does NOT create a new page, draw a mini-header, or draw a footer — those are the caller's
2202/// responsibility. Returns the Y position immediately below the last rendered element.
2203fn pdf_render_tc_inline(ctx: &PdfCtx<'_>, run: &AnalysisRun, y_start: f32, footer_h: f32) -> f32 {
2204    let has_cov = run.summary_totals.coverage_lines_found > 0;
2205    let has_fn_cov = run.summary_totals.coverage_functions_found > 0;
2206    let has_br_cov = run.summary_totals.coverage_branches_found > 0;
2207
2208    let mut y = pdf_tc_title_bar(ctx, "TESTS & COVERAGE", y_start) - 4.0;
2209    y = pdf_tc_stat_boxes(ctx, run, has_cov, y);
2210    y = pdf_tc_submodules(ctx, run, footer_h, y);
2211
2212    if has_cov {
2213        y = pdf_tc_gauges(ctx, run, y);
2214        y = pdf_tc_per_file(ctx, run, footer_h, has_fn_cov, has_br_cov, y);
2215    } else {
2216        y = pdf_tc_no_coverage_note(ctx, y);
2217    }
2218    y
2219}
2220
2221/// Build the right-aligned per-page header metadata string shown on every continuation
2222/// page so each printed sheet is self-identifying: Run ID, git commit, and scan time.
2223fn pdf_page_header_meta(run: &AnalysisRun) -> String {
2224    let mut parts = vec![format!(
2225        "Run ID: {}",
2226        pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2227    )];
2228    if let Some(ref c) = run.git_commit_short {
2229        parts.push(format!("Commit: {}", pdf_safe_str(c)));
2230    }
2231    parts.push(to_pt_hhmm(run.tool.timestamp_utc));
2232    parts.join("  \u{00B7}  ")
2233}
2234
2235/// Draw `text` right-aligned (gray, 6.5 pt) inside a navy page-header bar whose text
2236/// baseline sits at `baseline_y`. Uses exact Helvetica advance widths for precise
2237/// right-edge alignment against the page margin.
2238fn pdf_draw_header_meta(
2239    layer: &crate::pdf_compat::PdfLayerReference,
2240    font: &crate::pdf_compat::IndirectFontRef,
2241    w: f32,
2242    margin: f32,
2243    baseline_y: f32,
2244    text: &str,
2245) {
2246    use crate::pdf_compat::{Color, Mm, Rgb};
2247    let tw = helvetica_width_mm(text, 6.5, false);
2248    let x = (w - margin - tw).max(margin + 60.0);
2249    layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2250    layer.use_text(text, 6.5, Mm(x), Mm(baseline_y), font);
2251}
2252
2253/// Draw the per-page mini header band (dark bar with "oxide-sloc", the truncated report `title`,
2254/// and right-aligned run metadata) shared by the dedicated T&C and Git Hotspots pages. `h` is the
2255/// page height and `hdr_h` the band height.
2256fn pdf_page_mini_header(ctx: &PdfCtx<'_>, h: f32, hdr_h: f32, title: &str, run: &AnalysisRun) {
2257    use crate::pdf_compat::{Color, Mm, Rgb};
2258    pdf_fill_rect(
2259        ctx.layer,
2260        0.0,
2261        h - hdr_h,
2262        ctx.w,
2263        hdr_h,
2264        Rgb::new(0.098, 0.11, 0.15, None),
2265    );
2266    ctx.layer
2267        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2268    ctx.layer.use_text(
2269        "oxide-sloc",
2270        9.0,
2271        Mm(ctx.margin),
2272        Mm(h - 5.5),
2273        ctx.font_bold,
2274    );
2275    ctx.layer
2276        .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2277    ctx.layer.use_text(
2278        pdf_trunc(&pdf_safe_str(title), 45),
2279        7.5,
2280        Mm(46.0),
2281        Mm(h - 5.5),
2282        ctx.font_reg,
2283    );
2284    pdf_draw_header_meta(
2285        ctx.layer,
2286        ctx.font_reg,
2287        ctx.w,
2288        ctx.margin,
2289        h - 5.5,
2290        &pdf_page_header_meta(run),
2291    );
2292}
2293
2294/// Draw the standard page footer band (light bar with the version/licence line) shared by the
2295/// dedicated T&C and Git Hotspots pages.
2296fn pdf_page_footer_band(ctx: &PdfCtx<'_>, footer_h: f32, version: &str) {
2297    use crate::pdf_compat::{Color, Mm, Rgb};
2298    pdf_fill_rect(
2299        ctx.layer,
2300        0.0,
2301        0.0,
2302        ctx.w,
2303        footer_h,
2304        Rgb::new(0.93, 0.91, 0.87, None),
2305    );
2306    ctx.layer
2307        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2308    ctx.layer.use_text(
2309        format!("oxide-sloc v{version}  \u{00b7}  AGPL-3.0-or-later"),
2310        6.5,
2311        Mm(ctx.margin),
2312        Mm(3.0),
2313        ctx.font_reg,
2314    );
2315}
2316
2317/// Create a dedicated "Tests & Coverage" page, render its content inline, and return the
2318/// `(page, layer, y_bottom)` tuple so `pdf_render_per_file_pages` can continue on this page.
2319#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
2320fn pdf_render_tests_coverage_page(
2321    doc: &crate::pdf_compat::PdfDocumentReference,
2322    font_reg: &crate::pdf_compat::IndirectFontRef,
2323    font_bold: &crate::pdf_compat::IndirectFontRef,
2324    run: &AnalysisRun,
2325    w: f32,
2326    h: f32,
2327    margin: f32,
2328    footer_h: f32,
2329    title: &str,
2330    version: &str,
2331) -> (
2332    crate::pdf_compat::PdfPageIndex,
2333    crate::pdf_compat::PdfLayerIndex,
2334    f32,
2335) {
2336    use crate::pdf_compat::Mm;
2337    const HDR_H: f32 = 8.0;
2338
2339    let (tc_page, tc_layer_idx) = doc.add_page(Mm(w), Mm(h), "Tests & Coverage");
2340    let layer = doc.get_page(tc_page).get_layer(tc_layer_idx);
2341    let ctx = PdfCtx {
2342        layer: &layer,
2343        font_reg,
2344        font_bold,
2345        w,
2346        margin,
2347        row_h: 5.5,
2348        tbl_hdr_h: 6.0,
2349    };
2350
2351    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
2352
2353    // T&C content inline
2354    let tc_bottom = pdf_render_tc_inline(&ctx, run, h - HDR_H - 4.0, footer_h);
2355
2356    pdf_page_footer_band(&ctx, footer_h, version);
2357
2358    (tc_page, tc_layer_idx, tc_bottom - 3.0)
2359}
2360
2361#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2362fn pdf_render_page1_footer(
2363    ctx: &PdfCtx<'_>,
2364    run: &AnalysisRun,
2365    footer_h: f32,
2366    version: &str,
2367    banner: Option<&str>,
2368) {
2369    use crate::pdf_compat::{Color, Mm, Rgb};
2370    pdf_fill_rect(
2371        ctx.layer,
2372        0.0,
2373        0.0,
2374        ctx.w,
2375        footer_h,
2376        Rgb::new(0.93, 0.91, 0.87, None),
2377    );
2378    ctx.layer
2379        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2380    // Left section.
2381    ctx.layer.use_text(
2382        format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
2383        6.5,
2384        Mm(ctx.margin),
2385        Mm(3.0),
2386        ctx.font_reg,
2387    );
2388    // Right section — github.com and Run ID, right-aligned (~1.27 mm per char at 6.5 pt).
2389    let right_text = format!(
2390        "github.com/oxide-sloc/oxide-sloc  |  Run ID: {}",
2391        pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2392    );
2393    let right_x = (ctx.w - ctx.margin - right_text.len() as f32 * 1.27).max(ctx.margin + 80.0);
2394    ctx.layer
2395        .use_text(right_text, 6.5, Mm(right_x), Mm(3.0), ctx.font_reg);
2396    // Center section — banner text, no background, oxide brand color, bold.
2397    if let Some(text) = banner {
2398        let safe = pdf_trunc(&pdf_safe_str(text), 40);
2399        // Same per-char width as the header banner (0.97 mm at 9pt bold Helvetica).
2400        let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(ctx.margin + 50.0);
2401        ctx.layer
2402            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2403        ctx.layer
2404            .use_text(safe, 9.0, Mm(text_x), Mm(2.6), ctx.font_bold);
2405    }
2406}
2407
2408fn per_file_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2409    if ri.is_multiple_of(2) {
2410        crate::pdf_compat::Rgb::new(0.975, 0.965, 0.95, None)
2411    } else {
2412        crate::pdf_compat::Rgb::new(1.0, 1.0, 1.0, None)
2413    }
2414}
2415
2416// ── Per-file page layout constants shared by the helpers below ─────────────────
2417const PDF_PERFILE_HDR_H: f32 = 8.0;
2418const PDF_PERFILE_SUB_H: f32 = 5.5;
2419// Gap between the PER-FILE DETAIL sub-bar and the column-header row.
2420// Applied on standalone per-file pages (not when sharing a page with COCOMO/T&C).
2421const PDF_PERFILE_TABLE_GAP: f32 = 3.0;
2422
2423/// Doc/font/dims context for per-file page helpers; carries `doc` instead of `layer`
2424/// because the page layer is created inside `pdf_draw_perfile_header`.
2425struct PdfPerFileCtx<'a> {
2426    doc: &'a crate::pdf_compat::PdfDocumentReference,
2427    font_reg: &'a crate::pdf_compat::IndirectFontRef,
2428    font_bold: &'a crate::pdf_compat::IndirectFontRef,
2429    w: f32,
2430    h: f32,
2431    margin: f32,
2432}
2433
2434/// Compute the `[start, end)` record slice displayed on one per-file page.
2435fn pdf_perfile_page_slice(
2436    page_idx: usize,
2437    use_continuation: bool,
2438    has_first_page: bool,
2439    fp_rows: usize,
2440    rows_per_page: usize,
2441    total_files: usize,
2442) -> (usize, usize) {
2443    if use_continuation {
2444        (0, fp_rows.min(total_files))
2445    } else if has_first_page {
2446        let s = fp_rows + (page_idx - 1) * rows_per_page;
2447        (s, (s + rows_per_page).min(total_files))
2448    } else {
2449        let s = page_idx * rows_per_page;
2450        (s, (s + rows_per_page).min(total_files))
2451    }
2452}
2453
2454/// Obtain (or create) the PDF layer for one per-file page and render its page header.
2455///
2456/// Returns `(layer, sub_top)` where `sub_top` is the y-coordinate at the bottom of the
2457/// header bar. When `use_continuation` is true the layer is taken from `first_page` and
2458/// no new header is drawn — the COCOMO page already has one.
2459#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2460fn pdf_draw_perfile_header(
2461    ctx: &PdfPerFileCtx<'_>,
2462    use_continuation: bool,
2463    first_page: Option<(
2464        crate::pdf_compat::PdfPageIndex,
2465        crate::pdf_compat::PdfLayerIndex,
2466        f32,
2467    )>,
2468    page_idx: usize,
2469    page_count: usize,
2470    banner: Option<&str>,
2471    meta: &str,
2472) -> (crate::pdf_compat::PdfLayerReference, f32) {
2473    use crate::pdf_compat::{Color, Mm, Rgb};
2474    if use_continuation {
2475        let (fp_page, fp_layer_idx, fp_top) = first_page.unwrap();
2476        let layer = ctx.doc.get_page(fp_page).get_layer(fp_layer_idx);
2477        (layer, fp_top - PDF_PERFILE_SUB_H)
2478    } else {
2479        let (pf_page, pf_layer_idx) = ctx.doc.add_page(Mm(ctx.w), Mm(ctx.h), "Content");
2480        let layer = ctx.doc.get_page(pf_page).get_layer(pf_layer_idx);
2481        let hdr_top = ctx.h - PDF_PERFILE_HDR_H;
2482        pdf_fill_rect(
2483            &layer,
2484            0.0,
2485            hdr_top,
2486            ctx.w,
2487            PDF_PERFILE_HDR_H,
2488            Rgb::new(0.098, 0.11, 0.15, None),
2489        );
2490        layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2491        layer.use_text(
2492            "oxide-sloc",
2493            9.0,
2494            Mm(ctx.margin),
2495            Mm(hdr_top + 2.5),
2496            ctx.font_bold,
2497        );
2498        layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2499        layer.use_text(
2500            "Per-File Detail",
2501            8.0,
2502            Mm(46.0),
2503            Mm(hdr_top + 2.5),
2504            ctx.font_reg,
2505        );
2506        // Right-aligned: Run ID / commit / scan time, then the page counter.
2507        let right = format!(
2508            "{meta}  \u{00B7}  Page {} of {}",
2509            page_idx + 2,
2510            page_count + 1
2511        );
2512        let right_w = helvetica_width_mm(&right, 6.5, false);
2513        let right_x = (ctx.w - ctx.margin - right_w).max(ctx.margin + 60.0);
2514        layer.use_text(right, 6.5, Mm(right_x), Mm(hdr_top + 2.5), ctx.font_reg);
2515        if let Some(text) = banner {
2516            let safe = pdf_trunc(&pdf_safe_str(text), 40);
2517            let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(80.0);
2518            layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2519            layer.use_text(safe, 9.0, Mm(text_x), Mm(hdr_top + 2.5), ctx.font_bold);
2520        }
2521        // Leave a gap between the top header bar and the PER-FILE DETAIL sub-bar.
2522        (layer, hdr_top - PDF_PERFILE_TABLE_GAP - PDF_PERFILE_SUB_H)
2523    }
2524}
2525
2526/// Render per-file data rows onto an existing PDF layer.
2527#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2528fn pdf_draw_perfile_rows(
2529    ctx: &PdfCtx<'_>,
2530    records: &[FileRecord],
2531    col_x: &[f32; 13],
2532    pf_tbl_top: f32,
2533) {
2534    use crate::pdf_compat::{Color, Mm, Rgb};
2535    for (ri, rec) in records.iter().enumerate() {
2536        let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, pf_tbl_top - ctx.tbl_hdr_h);
2537        let bg = per_file_row_bg(ri);
2538        pdf_fill_rect(
2539            ctx.layer,
2540            ctx.margin,
2541            ry,
2542            2.0f32.mul_add(-ctx.margin, ctx.w),
2543            ctx.row_h,
2544            bg,
2545        );
2546        ctx.layer
2547            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2548        let file_str = pdf_safe_str(&rec.relative_path);
2549        let lang_str = rec
2550            .language
2551            .as_ref()
2552            .map_or_else(|| "--".to_string(), |l| l.display_name().to_string());
2553        let raw = &rec.raw_line_categories;
2554        let eff = &rec.effective_counts;
2555        let cells = [
2556            pdf_trunc_end(&file_str, 110),
2557            lang_str,
2558            pdf_fmt_full(raw.total_physical_lines),
2559            pdf_fmt_full(eff.code_lines),
2560            pdf_fmt_full(eff.comment_lines),
2561            pdf_fmt_full(eff.blank_lines),
2562            pdf_fmt_full(eff.mixed_lines_separate),
2563            pdf_fmt_full(raw.functions),
2564            pdf_fmt_full(raw.classes),
2565            pdf_fmt_full(raw.variables),
2566            pdf_fmt_full(raw.imports),
2567            pdf_fmt_full(raw.test_count),
2568            pdf_fmt_full(raw.test_assertion_count),
2569        ];
2570        for (ci, cell) in cells.iter().enumerate() {
2571            ctx.layer.use_text(
2572                cell.clone(),
2573                5.5,
2574                Mm(col_x[ci] + 0.5),
2575                Mm(ry + 1.0),
2576                ctx.font_reg,
2577            );
2578        }
2579    }
2580}
2581
2582// PDF per-file page renderer — layout params are distinct; see PdfPerFileCtx for bundling.
2583#[allow(
2584    clippy::cast_precision_loss,
2585    clippy::cast_possible_truncation,
2586    clippy::cast_sign_loss,
2587    clippy::too_many_arguments,
2588    clippy::too_many_lines,
2589    clippy::suboptimal_flops
2590)]
2591fn pdf_render_per_file_pages(
2592    doc: &crate::pdf_compat::PdfDocumentReference,
2593    font_reg: &crate::pdf_compat::IndirectFontRef,
2594    font_bold: &crate::pdf_compat::IndirectFontRef,
2595    run: &AnalysisRun,
2596    w: f32,
2597    h: f32,
2598    margin: f32,
2599    footer_h: f32,
2600    row_h: f32,
2601    tbl_hdr_h: f32,
2602    title: &str,
2603    ts: &str,
2604    version: &str,
2605    banner: Option<&str>,
2606    // When COCOMO is rendered on its own page, continue the per-file table on that same page
2607    // rather than starting a new one.  Tuple: (page index, layer index, available top y-coord).
2608    first_page: Option<(
2609        crate::pdf_compat::PdfPageIndex,
2610        crate::pdf_compat::PdfLayerIndex,
2611        f32,
2612    )>,
2613) {
2614    use crate::pdf_compat::{Color, Mm, Rgb};
2615    // File column gets ~136 mm; numeric columns compressed to minimum readable width.
2616    // Column widths: File=136, Lang=14, Phys=12, Code=10, Comments=13, Blank=10, Mixed=10,
2617    //   Functions=13, Classes=11, Variables=13, Imports=11, Tests=10, Assertions=14  → total 277 mm
2618    let col_x: [f32; 13] = [
2619        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,
2620    ];
2621    let col_labels: [&str; 13] = [
2622        "File",
2623        "Language",
2624        "Physical",
2625        "Code",
2626        "Comments",
2627        "Blank",
2628        "Mixed",
2629        "Functions",
2630        "Classes",
2631        "Variables",
2632        "Imports",
2633        "Tests",
2634        "Assertions",
2635    ];
2636    let rows_per_page =
2637        ((h - PDF_PERFILE_HDR_H - PDF_PERFILE_SUB_H - PDF_PERFILE_TABLE_GAP - tbl_hdr_h - footer_h)
2638            / row_h)
2639            .floor() as usize;
2640    let total_files = run.per_file_records.len();
2641
2642    // Rows that fit on the continuation page (COCOMO already occupies the top portion).
2643    let fp_rows = match first_page {
2644        Some((_, _, fp_top)) => ((fp_top - PDF_PERFILE_SUB_H - tbl_hdr_h - footer_h) / row_h)
2645            .floor()
2646            .max(0.0) as usize,
2647        None => rows_per_page,
2648    };
2649    let page_count = if first_page.is_some() {
2650        1 + total_files.saturating_sub(fp_rows).div_ceil(rows_per_page)
2651    } else {
2652        total_files.div_ceil(rows_per_page)
2653    };
2654    let pf_ctx = PdfPerFileCtx {
2655        doc,
2656        font_reg,
2657        font_bold,
2658        w,
2659        h,
2660        margin,
2661    };
2662    let header_meta = pdf_page_header_meta(run);
2663
2664    for page_idx in 0..page_count {
2665        let use_continuation = page_idx == 0 && first_page.is_some();
2666        let (pf_layer, sub_top) = pdf_draw_perfile_header(
2667            &pf_ctx,
2668            use_continuation,
2669            first_page,
2670            page_idx,
2671            page_count,
2672            banner,
2673            &header_meta,
2674        );
2675
2676        // Sub-bar — dark navy, matching TESTS & COVERAGE / SUBMODULES section headers.
2677        pdf_fill_rect(
2678            &pf_layer,
2679            margin,
2680            sub_top,
2681            w - 2.0 * margin,
2682            PDF_PERFILE_SUB_H,
2683            Rgb::new(0.098, 0.11, 0.15, None),
2684        );
2685        pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2686        pf_layer.use_text(
2687            "PER-FILE DETAIL",
2688            7.0,
2689            Mm(margin + 2.0),
2690            Mm(sub_top + 1.5),
2691            font_bold,
2692        );
2693        if use_continuation {
2694            // On the continuation page show the project context on the right.
2695            let right = format!(
2696                "{}  |  {} files  |  {ts}",
2697                pdf_trunc(title, 30),
2698                total_files
2699            );
2700            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2701            let right_x = (w - margin - right.len() as f32 * 1.05).max(margin + 80.0);
2702            pf_layer.use_text(right, 5.5, Mm(right_x), Mm(sub_top + 1.5), font_reg);
2703        } else {
2704            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2705            pf_layer.use_text(
2706                pdf_trunc(title, 45),
2707                5.5,
2708                Mm(margin + 60.0),
2709                Mm(sub_top + 1.5),
2710                font_reg,
2711            );
2712            pf_layer.use_text(
2713                format!("{total_files} files  |  {ts}"),
2714                5.5,
2715                Mm(w - margin - 55.0),
2716                Mm(sub_top + 1.5),
2717                font_reg,
2718            );
2719        }
2720
2721        let pf_tbl_top = sub_top;
2722        pdf_fill_rect(
2723            &pf_layer,
2724            margin,
2725            pf_tbl_top - tbl_hdr_h,
2726            2.0f32.mul_add(-margin, w),
2727            tbl_hdr_h,
2728            Rgb::new(0.098, 0.11, 0.15, None),
2729        );
2730        pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2731        for (i, lbl) in col_labels.iter().enumerate() {
2732            pf_layer.use_text(
2733                *lbl,
2734                5.0,
2735                Mm(col_x[i] + 0.5),
2736                Mm(pf_tbl_top - tbl_hdr_h + 1.5),
2737                font_bold,
2738            );
2739        }
2740
2741        let (start, end) = pdf_perfile_page_slice(
2742            page_idx,
2743            use_continuation,
2744            first_page.is_some(),
2745            fp_rows,
2746            rows_per_page,
2747            total_files,
2748        );
2749        let row_ctx = PdfCtx {
2750            layer: &pf_layer,
2751            font_reg,
2752            font_bold,
2753            w,
2754            margin,
2755            row_h,
2756            tbl_hdr_h,
2757        };
2758        pdf_draw_perfile_rows(
2759            &row_ctx,
2760            &run.per_file_records[start..end],
2761            &col_x,
2762            pf_tbl_top,
2763        );
2764
2765        // Footer
2766        pdf_fill_rect(
2767            &pf_layer,
2768            0.0,
2769            0.0,
2770            w,
2771            footer_h,
2772            Rgb::new(0.93, 0.91, 0.87, None),
2773        );
2774        pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2775        pf_layer.use_text(
2776            format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
2777            6.5,
2778            Mm(margin),
2779            Mm(3.0),
2780            font_reg,
2781        );
2782        let right_text = format!(
2783            "github.com/oxide-sloc/oxide-sloc  |  Run ID: {}",
2784            pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2785        );
2786        let right_x = (w - margin - right_text.len() as f32 * 1.27).max(margin + 80.0);
2787        pf_layer.use_text(right_text, 6.5, Mm(right_x), Mm(3.0), font_reg);
2788        // Center section — banner, oxide brand color, bold.
2789        if let Some(text) = banner {
2790            let safe = pdf_trunc(&pdf_safe_str(text), 40);
2791            let text_x = (w / 2.0 - safe.len() as f32 * 0.97).max(margin + 50.0);
2792            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2793            pf_layer.use_text(safe, 9.0, Mm(text_x), Mm(2.6), font_bold);
2794        }
2795    }
2796}
2797
2798/// Draw the dark section-header bar (full usable width, `hdr_h` tall, top edge at `section_top`)
2799/// with `title` rendered in white bold at the left. Shared by every PDF report section so the
2800/// header styling stays identical across them.
2801fn pdf_section_header_bar(
2802    ctx: &PdfCtx<'_>,
2803    usable_w: f32,
2804    section_top: f32,
2805    hdr_h: f32,
2806    title: &str,
2807) {
2808    use crate::pdf_compat::{Color, Mm, Rgb};
2809    pdf_fill_rect(
2810        ctx.layer,
2811        ctx.margin,
2812        section_top - hdr_h,
2813        usable_w,
2814        hdr_h,
2815        Rgb::new(0.098, 0.11, 0.15, None),
2816    );
2817    ctx.layer
2818        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2819    ctx.layer.use_text(
2820        title,
2821        7.0,
2822        Mm(ctx.margin + 2.0),
2823        Mm(section_top - hdr_h + 1.5),
2824        ctx.font_bold,
2825    );
2826}
2827
2828/// Render the Code Style Analysis section onto page 1 of the printpdf PDF.
2829///
2830/// Draws below the metric tables: a section header, four summary chips, and a
2831/// per-language mini-table showing the top style guide and N-col compliance.
2832/// Returns the y coordinate of the bottom of the rendered section.
2833#[allow(
2834    clippy::cast_precision_loss,
2835    clippy::cast_possible_truncation,
2836    clippy::too_many_lines,
2837    clippy::suboptimal_flops
2838)]
2839fn pdf_render_style_section(ctx: &PdfCtx<'_>, ss: &StyleSummary, section_top: f32) -> f32 {
2840    use crate::pdf_compat::{Color, Mm, Rgb};
2841    const HDR_H: f32 = 5.5;
2842    const CHIP_H: f32 = 11.0;
2843    const CHIP_GAP: f32 = 4.0;
2844    const ROW_H: f32 = 5.0;
2845    const TBL_HDR_H: f32 = 5.0;
2846    const GAP: f32 = 2.5;
2847
2848    let usable_w = ctx.w - 2.0 * ctx.margin;
2849    let chip_w = (usable_w - 3.0 * CHIP_GAP) / 4.0;
2850
2851    // ── section header bar ────────────────────────────────────────────────────
2852    pdf_section_header_bar(ctx, usable_w, section_top, HDR_H, "CODE STYLE ANALYSIS");
2853    let col_label = format!("{}-Col", ss.col_threshold);
2854    ctx.layer
2855        .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
2856    ctx.layer.use_text(
2857        "Lexical heuristics",
2858        5.5,
2859        Mm(ctx.w - ctx.margin - 26.0),
2860        Mm(section_top - HDR_H + 1.5),
2861        ctx.font_reg,
2862    );
2863
2864    // ── summary chips ─────────────────────────────────────────────────────────
2865    let chips_bot = section_top - HDR_H - GAP - CHIP_H;
2866    let chip_data: [(&str, String); 4] = [
2867        ("Files Analyzed", ss.files_analyzed.to_string()),
2868        ("Language Groups", ss.by_language.len().to_string()),
2869        ("Common Indent", ss.common_indent_style.clone()),
2870        (&col_label, format!("{}%", ss.line_col_compliant_pct)),
2871    ];
2872    for (i, (label, value)) in chip_data.iter().enumerate() {
2873        let cx = (i as f32).mul_add(chip_w + CHIP_GAP, ctx.margin);
2874        pdf_fill_rect(
2875            ctx.layer,
2876            cx,
2877            chips_bot,
2878            chip_w,
2879            CHIP_H,
2880            Rgb::new(0.945, 0.925, 0.90, None),
2881        );
2882        ctx.layer
2883            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2884        ctx.layer.use_text(
2885            pdf_trunc(value, 16),
2886            10.0,
2887            Mm(cx + 3.0),
2888            Mm(chips_bot + 5.5),
2889            ctx.font_bold,
2890        );
2891        ctx.layer
2892            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
2893        ctx.layer.use_text(
2894            pdf_safe_str(label),
2895            5.5,
2896            Mm(cx + 3.0),
2897            Mm(chips_bot + 1.5),
2898            ctx.font_reg,
2899        );
2900    }
2901
2902    // ── per-language mini-table ───────────────────────────────────────────────
2903    if ss.by_language.is_empty() {
2904        return chips_bot;
2905    }
2906    let tbl_top = chips_bot - GAP;
2907
2908    // Column widths (fractions of usable_w): Family | Files | Top Guide | Score | N-Col
2909    let col_w = [0.28_f32, 0.08, 0.36, 0.14, 0.14];
2910    let col_x: Vec<f32> = col_w
2911        .iter()
2912        .scan(ctx.margin, |acc, &w| {
2913            let x = *acc;
2914            *acc += w * usable_w;
2915            Some(x)
2916        })
2917        .collect();
2918    let headers = ["Language Family", "Files", "Top Guide", "Score", &col_label];
2919
2920    pdf_fill_rect(
2921        ctx.layer,
2922        ctx.margin,
2923        tbl_top - TBL_HDR_H,
2924        usable_w,
2925        TBL_HDR_H,
2926        Rgb::new(0.098, 0.11, 0.15, None),
2927    );
2928    ctx.layer
2929        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2930    for (hi, hdr) in headers.iter().enumerate() {
2931        ctx.layer.use_text(
2932            pdf_safe_str(hdr),
2933            5.5,
2934            Mm(col_x[hi] + 2.0),
2935            Mm(tbl_top - TBL_HDR_H + 1.5),
2936            ctx.font_bold,
2937        );
2938    }
2939
2940    let mut row_y = tbl_top - TBL_HDR_H;
2941    for (ri, grp) in ss.by_language.iter().take(5).enumerate() {
2942        let ry = row_y - ROW_H;
2943        let bg = if ri % 2 == 0 {
2944            Rgb::new(0.975, 0.965, 0.95, None)
2945        } else {
2946            Rgb::new(1.0, 1.0, 1.0, None)
2947        };
2948        pdf_fill_rect(ctx.layer, ctx.margin, ry, usable_w, ROW_H, bg);
2949        ctx.layer
2950            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2951        let cells = [
2952            pdf_trunc(&grp.language_family, 26),
2953            grp.files_count.to_string(),
2954            pdf_trunc(&grp.dominant_guide, 28),
2955            format!("{}%", grp.dominant_score_pct),
2956            format!("{}%", grp.line_col_compliant_pct),
2957        ];
2958        for (ci, cell) in cells.iter().enumerate() {
2959            let is_score = ci == 3 || ci == 4;
2960            if is_score && cell != "--" {
2961                ctx.layer
2962                    .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2963            } else {
2964                ctx.layer
2965                    .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2966            }
2967            ctx.layer.use_text(
2968                pdf_safe_str(cell),
2969                6.0,
2970                Mm(col_x[ci] + 2.0),
2971                Mm(ry + 1.5),
2972                ctx.font_reg,
2973            );
2974        }
2975        row_y = ry;
2976    }
2977
2978    row_y
2979}
2980
2981/// Render the COCOMO I estimate section as a compact table on the PDF page.
2982/// Returns the bottom y-coordinate of the rendered section.
2983#[allow(clippy::cast_precision_loss)]
2984fn pdf_render_cocomo_section(ctx: &PdfCtx<'_>, run: &AnalysisRun, section_top: f32) -> f32 {
2985    use crate::pdf_compat::{Color, Mm, Rgb};
2986    const HDR_H: f32 = 5.5;
2987    const ROW_H: f32 = 13.0; // tall enough for label + value with comfortable padding
2988    const NOTE_H: f32 = 2.0; // just enough clearance for 5.5 pt descenders below the baseline
2989    const GAP: f32 = 5.0; // breathing room between data row and footnote
2990
2991    let Some(ref c) = run.cocomo else {
2992        return section_top;
2993    };
2994
2995    let mode_label = match c.mode {
2996        CocomoMode::Organic => "Organic",
2997        CocomoMode::SemiDetached => "Semi-detached",
2998        CocomoMode::Embedded => "Embedded",
2999    };
3000    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3001
3002    // Section header bar
3003    pdf_section_header_bar(
3004        ctx,
3005        usable_w,
3006        section_top,
3007        HDR_H,
3008        "CONSTRUCTIVE COST MODEL (COCOMO I) ESTIMATE",
3009    );
3010    ctx.layer
3011        .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3012    let mode_display = format!("{mode_label} mode");
3013    ctx.layer.use_text(
3014        mode_display.as_str(),
3015        5.5,
3016        Mm(ctx.w - ctx.margin - 28.0),
3017        Mm(section_top - HDR_H + 1.5),
3018        ctx.font_reg,
3019    );
3020
3021    // 4-column data row (full width, single row)
3022    let col_w = usable_w / 4.0;
3023    let row_y = section_top - HDR_H - ROW_H;
3024    let data: [(&str, String); 4] = [
3025        ("Person-months", format!("{:.2}", c.effort_person_months)),
3026        ("Schedule (months)", format!("{:.2}", c.duration_months)),
3027        ("Avg. Team Size", format!("{:.2}", c.avg_staff)),
3028        ("Input KSLOC", format!("{:.2}K", c.ksloc)),
3029    ];
3030    for (i, (label, value)) in data.iter().enumerate() {
3031        let cx = (i as f32).mul_add(col_w, ctx.margin);
3032        let bg = if i % 2 == 0 {
3033            Rgb::new(0.975, 0.965, 0.95, None)
3034        } else {
3035            Rgb::new(1.0, 1.0, 1.0, None)
3036        };
3037        pdf_fill_rect(ctx.layer, cx, row_y, col_w, ROW_H, bg);
3038        ctx.layer
3039            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3040        ctx.layer
3041            .use_text(*label, 5.5, Mm(cx + 2.0), Mm(row_y + 9.0), ctx.font_reg);
3042        ctx.layer
3043            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3044        ctx.layer.use_text(
3045            value.as_str(),
3046            10.0,
3047            Mm(cx + 2.0),
3048            Mm(row_y + 2.5),
3049            ctx.font_bold,
3050        );
3051    }
3052
3053    // Footnote
3054    let note_y = row_y - GAP;
3055    ctx.layer
3056        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3057    ctx.layer.use_text(
3058        "COCOMO I (Boehm, 1981): algorithmic model converting SLOC into effort, schedule, and team-size estimates. \
3059         Ballpark figures only - actual outcomes vary with team experience and domain complexity.",
3060        5.5,
3061        Mm(ctx.margin),
3062        Mm(note_y),
3063        ctx.font_reg,
3064    );
3065
3066    note_y - NOTE_H
3067}
3068
3069/// Draw `text` so its right edge sits at `x_right` mm, at vertical `y` mm. The caller sets the
3070/// fill colour beforehand. Uses the Helvetica advance-width table for alignment.
3071fn pdf_text_right(ctx: &PdfCtx<'_>, text: &str, pt: f32, x_right: f32, y: f32, bold: bool) {
3072    use crate::pdf_compat::Mm;
3073    let font = if bold { ctx.font_bold } else { ctx.font_reg };
3074    let w = helvetica_width_mm(text, pt, bold);
3075    ctx.layer.use_text(text, pt, Mm(x_right - w), Mm(y), font);
3076}
3077
3078/// Front-truncate `path` with a leading "..." so it fits within `budget_mm` at `pt`, keeping the
3079/// most informative tail (the filename). Returns the path unchanged when it already fits.
3080fn pdf_fit_path(path: &str, budget_mm: f32, pt: f32) -> String {
3081    if helvetica_width_mm(path, pt, false) <= budget_mm {
3082        return path.to_string();
3083    }
3084    let mut chars: Vec<char> = path.chars().collect();
3085    while !chars.is_empty() {
3086        chars.remove(0);
3087        let candidate: String = format!("...{}", chars.iter().collect::<String>());
3088        if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3089            return candidate;
3090        }
3091    }
3092    "...".to_string()
3093}
3094
3095/// Render the Git Hotspots table (files ranked by code lines x recent commits) starting at
3096/// `section_top`. Returns the Y coordinate below the rendered content. Mirrors the COCOMO
3097/// section's dark header bar and the per-file table's right-aligned numeric columns.
3098fn pdf_render_hotspots_section(ctx: &PdfCtx<'_>, rows: &[HotspotRow], section_top: f32) -> f32 {
3099    use crate::pdf_compat::{Color, Mm, Rgb};
3100    const HDR_H: f32 = 5.5;
3101    const COLHDR_H: f32 = 5.0;
3102    const ROW_H: f32 = 5.2;
3103    const NOTE_GAP: f32 = 4.0;
3104
3105    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3106
3107    // Section header bar.
3108    pdf_section_header_bar(
3109        ctx,
3110        usable_w,
3111        section_top,
3112        HDR_H,
3113        "GIT HOTSPOTS (CODE LINES x RECENT COMMITS)",
3114    );
3115
3116    // Column right edges (numeric columns are right-aligned); File fills the remaining left space.
3117    let col_last_r = ctx.w - ctx.margin;
3118    let col_score_r = col_last_r - 32.0;
3119    let col_commits_r = col_score_r - 33.0;
3120    let col_code_r = col_commits_r - 32.0;
3121    let file_x = ctx.margin + 2.0;
3122    let file_budget = (col_code_r - 26.0) - file_x;
3123
3124    // Column-header row.
3125    let chdr_y = section_top - HDR_H - COLHDR_H;
3126    pdf_fill_rect(
3127        ctx.layer,
3128        ctx.margin,
3129        chdr_y,
3130        usable_w,
3131        COLHDR_H,
3132        Rgb::new(0.90, 0.88, 0.84, None),
3133    );
3134    ctx.layer
3135        .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3136    ctx.layer
3137        .use_text("File", 6.0, Mm(file_x), Mm(chdr_y + 1.4), ctx.font_bold);
3138    pdf_text_right(ctx, "Code lines", 6.0, col_code_r, chdr_y + 1.4, true);
3139    pdf_text_right(ctx, "Commits", 6.0, col_commits_r, chdr_y + 1.4, true);
3140    pdf_text_right(ctx, "Hotspot score", 6.0, col_score_r, chdr_y + 1.4, true);
3141    pdf_text_right(ctx, "Last changed", 6.0, col_last_r, chdr_y + 1.4, true);
3142
3143    // Data rows (zebra background).
3144    let mut y = chdr_y;
3145    for (ri, hrow) in rows.iter().enumerate() {
3146        y -= ROW_H;
3147        let bg = if ri.is_multiple_of(2) {
3148            Rgb::new(0.975, 0.965, 0.95, None)
3149        } else {
3150            Rgb::new(1.0, 1.0, 1.0, None)
3151        };
3152        pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3153        // File path (front-truncated to its width budget).
3154        ctx.layer
3155            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3156        let path = pdf_fit_path(&pdf_safe_str(&hrow.path), file_budget, 6.0);
3157        ctx.layer
3158            .use_text(path, 6.0, Mm(file_x), Mm(y + 1.4), ctx.font_reg);
3159        // Numeric columns.
3160        ctx.layer
3161            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3162        pdf_text_right(
3163            ctx,
3164            &group_thousands(&hrow.code_lines.to_string()),
3165            6.0,
3166            col_code_r,
3167            y + 1.4,
3168            false,
3169        );
3170        pdf_text_right(
3171            ctx,
3172            &hrow.commit_count.to_string(),
3173            6.0,
3174            col_commits_r,
3175            y + 1.4,
3176            false,
3177        );
3178        // Hotspot score — emphasised in the oxide accent colour.
3179        ctx.layer
3180            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3181        pdf_text_right(
3182            ctx,
3183            &group_thousands(&hrow.score.to_string()),
3184            6.0,
3185            col_score_r,
3186            y + 1.4,
3187            true,
3188        );
3189        ctx.layer
3190            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3191        pdf_text_right(ctx, &hrow.last_commit_date, 6.0, col_last_r, y + 1.4, false);
3192    }
3193
3194    // Footnote.
3195    let note_y = y - NOTE_GAP;
3196    ctx.layer
3197        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3198    ctx.layer.use_text(
3199        "Files ranked by code lines x commits over the configured git activity window. \
3200         Distinct from the Compare page's scan-to-scan churn rate.",
3201        5.5,
3202        Mm(ctx.margin),
3203        Mm(note_y + 1.0),
3204        ctx.font_reg,
3205    );
3206
3207    note_y
3208}
3209
3210/// Render a dedicated "Git Hotspots" page and return its `(page, layer, y_below)` so the per-file
3211/// table can continue on the same page (mirrors `pdf_render_tests_coverage_page`).
3212#[allow(clippy::too_many_arguments)]
3213fn pdf_render_hotspots_page(
3214    doc: &crate::pdf_compat::PdfDocumentReference,
3215    font_reg: &crate::pdf_compat::IndirectFontRef,
3216    font_bold: &crate::pdf_compat::IndirectFontRef,
3217    run: &AnalysisRun,
3218    rows: &[HotspotRow],
3219    w: f32,
3220    h: f32,
3221    margin: f32,
3222    footer_h: f32,
3223    title: &str,
3224    version: &str,
3225) -> (
3226    crate::pdf_compat::PdfPageIndex,
3227    crate::pdf_compat::PdfLayerIndex,
3228    f32,
3229) {
3230    use crate::pdf_compat::Mm;
3231    const HDR_H: f32 = 8.0;
3232
3233    let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Git Hotspots");
3234    let layer = doc.get_page(page).get_layer(layer_idx);
3235    let ctx = PdfCtx {
3236        layer: &layer,
3237        font_reg,
3238        font_bold,
3239        w,
3240        margin,
3241        row_h: 5.5,
3242        tbl_hdr_h: 6.0,
3243    };
3244
3245    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3246
3247    let bottom = pdf_render_hotspots_section(&ctx, rows, h - HDR_H - 4.0);
3248
3249    pdf_page_footer_band(&ctx, footer_h, version);
3250
3251    (page, layer_idx, bottom - 3.0)
3252}
3253
3254/// Measure how tall the COCOMO + Tests & Coverage page needs to be, so a terminal
3255/// (last) page can be trimmed to its content instead of left at full landscape height
3256/// with a large empty gap below the last section.
3257///
3258/// Renders the same sections onto a throwaway, never-saved document of height `h_full`
3259/// and reads where the content ends. Layout is vertically translation-invariant, so the
3260/// trimmed height is `h_full - content_bottom + footer_h + pad`. Falls back to `h_full`
3261/// on any error so the report is always produced.
3262#[allow(clippy::too_many_arguments)]
3263fn measure_terminal_tc_page_height(
3264    run: &AnalysisRun,
3265    w: f32,
3266    h_full: f32,
3267    margin: f32,
3268    footer_h: f32,
3269    row_h: f32,
3270    tbl_hdr_h: f32,
3271    with_cocomo: bool,
3272) -> f32 {
3273    use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3274    let measure = || -> Option<f32> {
3275        let (doc, page, layer_idx) = PdfDocument::new("measure", Mm(w), Mm(h_full), "m");
3276        let font_reg = doc.add_builtin_font(BuiltinFont::Helvetica).ok()?;
3277        let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).ok()?;
3278        let layer = doc.get_page(page).get_layer(layer_idx);
3279        let ctx = PdfCtx {
3280            layer: &layer,
3281            font_reg: &font_reg,
3282            font_bold: &font_bold,
3283            w,
3284            margin,
3285            row_h,
3286            tbl_hdr_h,
3287        };
3288        // Mirror the real render's starting offsets exactly (see the cocomo/T&C branches
3289        // in `write_pdf_from_run`): an 8 mm header band, then the first section below it.
3290        let content_bottom = if with_cocomo {
3291            let cocomo_bottom = pdf_render_cocomo_section(&ctx, run, h_full - 8.0 - 6.0);
3292            pdf_render_tc_inline(&ctx, run, cocomo_bottom - 2.0, footer_h)
3293        } else {
3294            pdf_render_tc_inline(&ctx, run, h_full - 8.0 - 4.0, footer_h)
3295        };
3296        // 4 mm bottom padding below the last element, mirroring the top-of-content gap.
3297        let pad = 4.0;
3298        Some((h_full - content_bottom + footer_h + pad).clamp(60.0, h_full))
3299    };
3300    measure().unwrap_or(h_full)
3301}
3302
3303/// Render the dedicated COCOMO + Tests & Coverage page (page 2) when COCOMO did not fit
3304/// on page 1, or a standalone Tests & Coverage page otherwise. Returns the page/layer and
3305/// the Y below the last section so the per-file table can continue on the same page with
3306/// no blank-page gap. Extracted from `write_pdf_from_run` to keep that function's cognitive
3307/// complexity low; layout and output are unchanged.
3308#[allow(
3309    clippy::cast_precision_loss,
3310    clippy::cast_possible_truncation,
3311    clippy::cast_sign_loss,
3312    clippy::too_many_arguments
3313)]
3314fn pdf_render_cocomo_or_tc_page(
3315    doc: &crate::pdf_compat::PdfDocumentReference,
3316    font_reg: &crate::pdf_compat::IndirectFontRef,
3317    font_bold: &crate::pdf_compat::IndirectFontRef,
3318    run: &AnalysisRun,
3319    dims: PdfPageDims,
3320    title: &str,
3321    version: &str,
3322    cocomo_fits_page1: bool,
3323    trim_page: bool,
3324) -> (
3325    crate::pdf_compat::PdfPageIndex,
3326    crate::pdf_compat::PdfLayerIndex,
3327    f32,
3328) {
3329    use crate::pdf_compat::{Color, Mm, Mm as PdfMm, Rgb};
3330    let PdfPageDims {
3331        w,
3332        h,
3333        margin,
3334        footer_h,
3335        row_h,
3336        tbl_hdr_h,
3337    } = dims;
3338
3339    // No COCOMO on its own page — create a dedicated T&C page and start per-file from it.
3340    if run.cocomo.is_none() || cocomo_fits_page1 {
3341        let page_h = if trim_page {
3342            measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, false)
3343        } else {
3344            h
3345        };
3346        return pdf_render_tests_coverage_page(
3347            doc, font_reg, font_bold, run, w, page_h, margin, footer_h, title, version,
3348        );
3349    }
3350
3351    let page_h = if trim_page {
3352        measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, true)
3353    } else {
3354        h
3355    };
3356    let (c2_page, c2_layer_idx) = doc.add_page(Mm(w), Mm(page_h), "Content");
3357    let c2_layer = doc.get_page(c2_page).get_layer(c2_layer_idx);
3358    let c2_ctx = PdfCtx {
3359        layer: &c2_layer,
3360        font_reg,
3361        font_bold,
3362        w,
3363        margin,
3364        row_h,
3365        tbl_hdr_h,
3366    };
3367    // Small page header so the reader knows which report this is.
3368    pdf_fill_rect(
3369        &c2_layer,
3370        0.0,
3371        page_h - 8.0,
3372        w,
3373        8.0,
3374        Rgb::new(0.098, 0.11, 0.15, None),
3375    );
3376    c2_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3377    c2_layer.use_text(
3378        "oxide-sloc",
3379        9.0,
3380        PdfMm(margin),
3381        PdfMm(page_h - 5.5),
3382        font_bold,
3383    );
3384    c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3385    c2_layer.use_text(
3386        pdf_trunc(&pdf_safe_str(title), 45),
3387        7.5,
3388        PdfMm(46.0),
3389        PdfMm(page_h - 5.5),
3390        font_reg,
3391    );
3392    pdf_draw_header_meta(
3393        &c2_layer,
3394        font_reg,
3395        w,
3396        margin,
3397        page_h - 5.5,
3398        &pdf_page_header_meta(run),
3399    );
3400    let cocomo_bottom = pdf_render_cocomo_section(&c2_ctx, run, page_h - 8.0 - 6.0);
3401    // Render T&C inline on the same page immediately after COCOMO — no blank gap.
3402    let tc_bottom = pdf_render_tc_inline(&c2_ctx, run, cocomo_bottom - 2.0, footer_h);
3403    // Footer (per-file renderer will overdraw with its richer version if it starts here).
3404    pdf_fill_rect(
3405        &c2_layer,
3406        0.0,
3407        0.0,
3408        w,
3409        footer_h,
3410        Rgb::new(0.93, 0.91, 0.87, None),
3411    );
3412    c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3413    c2_layer.use_text(
3414        format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
3415        6.5,
3416        PdfMm(margin),
3417        PdfMm(3.0),
3418        font_reg,
3419    );
3420    // Pass the Y below T&C content so per-file can continue on this page without a gap.
3421    (c2_page, c2_layer_idx, tc_bottom - 3.0)
3422}
3423
3424/// Generate a PDF summary report from `AnalysisRun` data using the pure-Rust `printpdf` crate.
3425///
3426/// No external tools (Chrome, wkhtmltopdf) are required — this path is always available on
3427/// both Windows and Linux server deployments.
3428///
3429/// # Errors
3430///
3431/// Returns an error if the output directory cannot be created or the PDF file cannot be written.
3432// Casts throughout are for PDF layout coordinates and percentage ratios; precision loss is fine.
3433#[allow(
3434    clippy::cast_precision_loss,
3435    clippy::cast_possible_truncation,
3436    clippy::cast_sign_loss,
3437    clippy::too_many_lines
3438)]
3439pub fn write_pdf_from_run(run: &AnalysisRun, pdf_path: &Path) -> Result<()> {
3440    use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3441    use std::fs::File;
3442    use std::io::BufWriter;
3443
3444    const W: f32 = 297.0;
3445    const H: f32 = 210.0;
3446    const MARGIN: f32 = 10.0;
3447    const FOOTER_H: f32 = 10.0;
3448    const HDR_H: f32 = 13.5;
3449    const ROW_H: f32 = 5.5;
3450    const TBL_HDR_H: f32 = 6.0;
3451
3452    if let Some(parent) = pdf_path.parent() {
3453        fs::create_dir_all(parent)
3454            .with_context(|| format!("failed to create PDF directory {}", parent.display()))?;
3455    }
3456
3457    let title = pdf_safe_str(&run.effective_configuration.reporting.report_title);
3458    let ts = to_pt_hhmm(run.tool.timestamp_utc);
3459    let version = env!("CARGO_PKG_VERSION");
3460    let banner = run
3461        .effective_configuration
3462        .reporting
3463        .report_header_footer
3464        .as_deref();
3465
3466    let (doc, page1, layer1) =
3467        PdfDocument::new(format!("oxide-sloc: {title}"), Mm(W), Mm(H), "Content");
3468    let font_reg = doc
3469        .add_builtin_font(BuiltinFont::Helvetica)
3470        .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3471    let font_bold = doc
3472        .add_builtin_font(BuiltinFont::HelveticaBold)
3473        .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3474    let layer = doc.get_page(page1).get_layer(layer1);
3475
3476    let ctx = PdfCtx {
3477        layer: &layer,
3478        font_reg: &font_reg,
3479        font_bold: &font_bold,
3480        w: W,
3481        margin: MARGIN,
3482        row_h: ROW_H,
3483        tbl_hdr_h: TBL_HDR_H,
3484    };
3485    let roots_text_y = pdf_render_page1_header(&ctx, run, &ts, &title, H, HDR_H, banner);
3486    let row2_bot = pdf_render_summary_chips(&ctx, run, roots_text_y);
3487    let info_y = pdf_render_info_lines(&ctx, run, row2_bot);
3488    let tbl_top = info_y - 4.0;
3489    pdf_render_metric_tables(&ctx, run, tbl_top);
3490    // Style analysis section — rendered below the metric tables when data is available.
3491    // The metric tables occupy ~64.5 mm below tbl_top; leave 4 mm clearance before drawing.
3492    let after_tables_y = tbl_top - 64.5 - 4.0;
3493    let after_style_y = run.style_summary.as_ref().map_or(after_tables_y, |ss| {
3494        if after_tables_y > FOOTER_H + 12.0 {
3495            pdf_render_style_section(&ctx, ss, after_tables_y)
3496        } else {
3497            after_tables_y
3498        }
3499    });
3500    // COCOMO estimate — on page 1 if room remains, otherwise on its own page 2.
3501    // Need ~32 mm: header (5.5) + data row (13) + gap (5) + note (5) + margins (~3.5).
3502    let cocomo_fits_page1 = run.cocomo.is_some() && (after_style_y - 3.0) > FOOTER_H + 32.0;
3503    if cocomo_fits_page1 {
3504        pdf_render_cocomo_section(&ctx, run, after_style_y - 3.0);
3505    }
3506    pdf_render_page1_footer(&ctx, run, FOOTER_H, version, banner);
3507
3508    // Page-flow bookkeeping for empty-gap trimming. The per-file table continues on the
3509    // COCOMO/T&C page only when there is no Git Hotspots page in between (the Hotspots page,
3510    // when present, becomes the per-file continuation instead). A page that nothing flows
3511    // onto is trimmed to its content height to avoid a large empty gap below the last section.
3512    let hotspot_rows = build_hotspot_rows(run, 15);
3513    let has_per_file = !run.per_file_records.is_empty();
3514    let tc_page_gets_per_file = hotspot_rows.is_empty() && has_per_file;
3515    let trim_tc_page = !tc_page_gets_per_file;
3516
3517    // If COCOMO didn't fit on page 1, render it on a dedicated page 2 (with T&C inline);
3518    // otherwise render a standalone T&C page. Either way the returned page/layer/Y lets the
3519    // per-file table continue on the same page with no blank-page gap.
3520    let page_dims = PdfPageDims {
3521        w: W,
3522        h: H,
3523        margin: MARGIN,
3524        footer_h: FOOTER_H,
3525        row_h: ROW_H,
3526        tbl_hdr_h: TBL_HDR_H,
3527    };
3528    let cocomo_page_ctx = pdf_render_cocomo_or_tc_page(
3529        &doc,
3530        &font_reg,
3531        &font_bold,
3532        run,
3533        page_dims,
3534        &title,
3535        version,
3536        cocomo_fits_page1,
3537        trim_tc_page,
3538    );
3539
3540    // Git Hotspots — its own page after COCOMO/T&C, only when an --activity-window scan
3541    // collected per-file git activity. Threaded as the per-file continuation (like COCOMO)
3542    // so the per-file table flows on below it with no blank-page gap.
3543    // A Git Hotspots page is only emitted when per-file git activity exists, which means
3544    // `per_file_records` is non-empty and the per-file table always flows onto it — so it is
3545    // never a terminal page and needs no trimming (it stays full height for the per-file rows).
3546    let per_file_start = if hotspot_rows.is_empty() {
3547        Some(cocomo_page_ctx)
3548    } else {
3549        Some(pdf_render_hotspots_page(
3550            &doc,
3551            &font_reg,
3552            &font_bold,
3553            run,
3554            &hotspot_rows,
3555            W,
3556            H,
3557            MARGIN,
3558            FOOTER_H,
3559            &title,
3560            version,
3561        ))
3562    };
3563
3564    if !run.per_file_records.is_empty() {
3565        // Per-file continues on the same page as T&C / COCOMO / Hotspots — no blank page between.
3566        pdf_render_per_file_pages(
3567            &doc,
3568            &font_reg,
3569            &font_bold,
3570            run,
3571            W,
3572            H,
3573            MARGIN,
3574            FOOTER_H,
3575            ROW_H,
3576            TBL_HDR_H,
3577            &title,
3578            &ts,
3579            version,
3580            banner,
3581            per_file_start,
3582        );
3583    }
3584
3585    doc.save(&mut BufWriter::new(File::create(pdf_path).with_context(
3586        || format!("cannot create PDF at {}", pdf_path.display()),
3587    )?))
3588    .map_err(|e| anyhow::anyhow!("printpdf save error: {e}"))?;
3589
3590    Ok(())
3591}
3592
3593/// Per-character advance widths for the PDF built-in Helvetica and Helvetica-Bold fonts
3594/// (1/1000 em units, PDF spec Appendix D). Used to right-align text without a layout engine.
3595///
3596/// Each row is `(glyph, bold_advance, regular_advance)`, a verbatim transcription of the PDF
3597/// spec width tables. Keeping both weights on one row per glyph preserves the spec mapping for
3598/// audit while expressing it as data rather than two parallel `match` arms. Digits (`'0'..='9'`,
3599/// 556 in both weights) are handled in `helvetica_advance` and intentionally omitted here.
3600const HELVETICA_WIDTHS: &[(char, u32, u32)] = &[
3601    (' ', 278, 278),
3602    ('!', 333, 278),
3603    ('"', 474, 355),
3604    ('#', 556, 556),
3605    ('$', 556, 556),
3606    ('%', 889, 889),
3607    ('&', 722, 667),
3608    ('\'', 278, 222),
3609    ('(', 333, 333),
3610    (')', 333, 333),
3611    ('*', 389, 389),
3612    ('+', 584, 584),
3613    (',', 278, 278),
3614    ('-', 333, 333),
3615    ('.', 278, 278),
3616    ('/', 278, 278),
3617    (':', 333, 278),
3618    (';', 333, 278),
3619    ('<', 584, 584),
3620    ('=', 584, 584),
3621    ('>', 584, 584),
3622    ('?', 556, 472),
3623    ('@', 975, 1015),
3624    ('A', 722, 667),
3625    ('B', 722, 667),
3626    ('C', 722, 722),
3627    ('D', 722, 722),
3628    ('E', 667, 667),
3629    ('F', 611, 611),
3630    ('G', 778, 778),
3631    ('H', 722, 722),
3632    ('I', 278, 278),
3633    ('J', 556, 500),
3634    ('K', 722, 667),
3635    ('L', 611, 556),
3636    ('M', 833, 833),
3637    ('N', 722, 722),
3638    ('O', 778, 778),
3639    ('P', 667, 667),
3640    ('Q', 778, 778),
3641    ('R', 722, 722),
3642    ('S', 667, 667),
3643    ('T', 611, 611),
3644    ('U', 722, 722),
3645    ('V', 667, 667),
3646    ('W', 944, 944),
3647    ('X', 667, 667),
3648    ('Y', 611, 611),
3649    ('Z', 611, 611),
3650    ('[', 333, 278),
3651    ('\\', 278, 278),
3652    (']', 333, 278),
3653    ('^', 584, 469),
3654    ('_', 556, 556),
3655    ('`', 278, 222),
3656    ('a', 556, 556),
3657    ('b', 611, 556),
3658    ('c', 556, 500),
3659    ('d', 611, 556),
3660    ('e', 556, 556),
3661    ('f', 333, 278),
3662    ('g', 611, 556),
3663    ('h', 611, 556),
3664    ('i', 278, 222),
3665    ('j', 278, 222),
3666    ('k', 556, 500),
3667    ('l', 278, 222),
3668    ('m', 889, 833),
3669    ('n', 611, 556),
3670    ('o', 611, 556),
3671    ('p', 611, 556),
3672    ('q', 611, 556),
3673    ('r', 389, 333),
3674    ('s', 556, 500),
3675    ('t', 333, 278),
3676    ('u', 611, 556),
3677    ('v', 556, 500),
3678    ('w', 778, 722),
3679    ('x', 556, 500),
3680    ('y', 556, 500),
3681    ('z', 500, 500),
3682    ('\u{00B7}', 278, 278), // middle dot (Latin-1 0xB7) — used as section separator
3683];
3684
3685/// Advance width (1/1000 em) for `ch` in Helvetica (`bold` selects the bold weight). Looks up
3686/// `HELVETICA_WIDTHS`; digits are a uniform 556, and unknown glyphs fall back to the average
3687/// advance for the weight (556 bold, 500 regular).
3688fn helvetica_advance(ch: char, bold: bool) -> u32 {
3689    if ch.is_ascii_digit() {
3690        return 556;
3691    }
3692    for &(glyph, bold_w, regular_w) in HELVETICA_WIDTHS {
3693        if glyph == ch {
3694            return if bold { bold_w } else { regular_w };
3695        }
3696    }
3697    if bold {
3698        556
3699    } else {
3700        500
3701    }
3702}
3703
3704/// Convert a string to mm given a font size (pt) and bold flag, using exact PDF Helvetica metrics.
3705//
3706// Rendered strings are length-bounded, so the glyph-unit sum is far below f32's 2^23
3707// exact-integer ceiling; the cast feeds a millimetre layout width where any rounding is
3708// sub-pixel.
3709#[allow(
3710    clippy::cast_precision_loss,
3711    reason = "bounded glyph-unit sum to mm width"
3712)]
3713fn helvetica_width_mm(text: &str, pt: f32, bold: bool) -> f32 {
3714    let units: u32 = text.chars().map(|ch| helvetica_advance(ch, bold)).sum();
3715    // 1 unit = (pt × 25.4 mm/in ÷ 72 pt/in) / 1000.
3716    units as f32 * pt * (25.4 / 72.0) / 1000.0
3717}
3718
3719fn pdf_fill_rect(
3720    layer: &crate::pdf_compat::PdfLayerReference,
3721    x: f32,
3722    y: f32,
3723    w: f32,
3724    h: f32,
3725    color: crate::pdf_compat::Rgb,
3726) {
3727    layer.fill_rect(x, y, w, h, color);
3728}
3729
3730fn pdf_safe_str(s: &str) -> String {
3731    let mut out = String::with_capacity(s.len());
3732    for c in s.chars() {
3733        match c {
3734            // Common Unicode punctuation → readable ASCII equivalents
3735            '\u{2014}' | '\u{2013}' => out.push_str(" - "), // em dash / en dash
3736            '\u{2026}' => out.push_str("..."),              // ellipsis
3737            '\u{2018}' | '\u{2019}' => out.push('\''),      // curly single quotes
3738            '\u{201C}' | '\u{201D}' => out.push('"'),       // curly double quotes
3739            '\u{00B7}' | '\u{2022}' => out.push('-'),       // middle dot / bullet
3740            '\u{00A0}' => out.push(' '),                    // non-breaking space
3741            c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
3742            _ => {} // drop truly unprintable non-ASCII rather than emitting '?'
3743        }
3744    }
3745    out
3746}
3747
3748fn pdf_trunc(s: &str, max: usize) -> String {
3749    if s.len() <= max {
3750        s.to_string()
3751    } else {
3752        format!("{}...", &s[..max.saturating_sub(3)])
3753    }
3754}
3755
3756// Show the tail of a string — prepend "..." when truncated so the meaningful end is visible.
3757// Used for file paths where the filename/leaf matters more than the leading directories.
3758fn pdf_trunc_end(s: &str, max: usize) -> String {
3759    if s.len() <= max {
3760        s.to_string()
3761    } else {
3762        format!("...{}", &s[s.len() - max.saturating_sub(3)..])
3763    }
3764}
3765
3766fn pdf_fmt_full(n: u64) -> String {
3767    // Comma-separated full number: 15319 → "15,319", 1374 → "1,374"
3768    let s = n.to_string();
3769    let mut out = String::with_capacity(s.len() + s.len() / 3);
3770    for (i, ch) in s.chars().rev().enumerate() {
3771        if i > 0 && i % 3 == 0 {
3772            out.push(',');
3773        }
3774        out.push(ch);
3775    }
3776    out.chars().rev().collect()
3777}
3778
3779/// Launch a headless Chromium-based browser to print `html_path` as a PDF to `pdf_path`.
3780///
3781/// Tries CDP (headless Chrome) first; falls back to `wkhtmltopdf` when no Chromium-based
3782/// browser is found on the server.
3783///
3784/// # Errors
3785///
3786/// Returns an error if no PDF tool (Chromium or wkhtmltopdf) is available, the tool fails
3787/// to start, or the PDF file is not produced within the timeout.
3788pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
3789    eprintln!("[oxide-sloc][pdf] starting");
3790
3791    let absolute_html = html_path
3792        .canonicalize()
3793        .with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
3794    // canonicalize() on Windows prepends \\?\ (extended-length path prefix) — strip it for display.
3795    eprintln!(
3796        "[oxide-sloc][pdf] html = {}",
3797        absolute_html.to_string_lossy().trim_start_matches(r"\\?\")
3798    );
3799
3800    let absolute_pdf = if pdf_path.is_absolute() {
3801        pdf_path.to_path_buf()
3802    } else {
3803        std::env::current_dir()
3804            .context("failed to resolve current working directory")?
3805            .join(pdf_path)
3806    };
3807    eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
3808
3809    if let Some(parent) = absolute_pdf.parent() {
3810        fs::create_dir_all(parent).with_context(|| {
3811            format!("failed to create PDF output directory {}", parent.display())
3812        })?;
3813    }
3814
3815    match write_pdf_via_cdp(&absolute_html, &absolute_pdf) {
3816        Ok(()) => {}
3817        Err(cdp_err) => {
3818            eprintln!("[oxide-sloc][pdf] CDP failed ({cdp_err:#}), trying wkhtmltopdf fallback");
3819            write_pdf_via_wkhtmltopdf(&absolute_html, &absolute_pdf).with_context(|| {
3820                format!(
3821                    "PDF generation failed via both CDP ({cdp_err:#}) and wkhtmltopdf. \
3822                     Install a Chromium-based browser (Chrome, Edge, Brave) or wkhtmltopdf \
3823                     on the server, or set SLOC_BROWSER to the browser executable path."
3824                )
3825            })?;
3826        }
3827    }
3828
3829    eprintln!("[oxide-sloc][pdf] done");
3830    Ok(())
3831}
3832
3833fn normalize_browser_env_path(raw: &str) -> PathBuf {
3834    let trimmed = raw.trim();
3835    #[cfg(windows)]
3836    {
3837        let bytes = trimmed.as_bytes();
3838        if bytes.len() >= 3
3839            && bytes[0] == b'/'
3840            && bytes[2] == b'/'
3841            && bytes[1].is_ascii_alphabetic()
3842        {
3843            let drive = (bytes[1] as char).to_ascii_uppercase();
3844            let rest = &trimmed[3..];
3845            return PathBuf::from(format!("{drive}:/{rest}"));
3846        }
3847    }
3848    PathBuf::from(trimmed)
3849}
3850
3851fn discover_browser_from_env() -> Option<PathBuf> {
3852    for var_name in ["SLOC_BROWSER", "BROWSER"] {
3853        if let Ok(path) = std::env::var(var_name) {
3854            let candidate = normalize_browser_env_path(&path);
3855            if candidate.is_file() {
3856                return Some(candidate);
3857            }
3858        }
3859    }
3860    None
3861}
3862
3863fn discover_browser() -> Option<PathBuf> {
3864    if let Some(p) = discover_browser_from_env() {
3865        return Some(p);
3866    }
3867
3868    let names = [
3869        "chromium",
3870        "chromium-browser",
3871        "google-chrome",
3872        "google-chrome-stable",
3873        "microsoft-edge",
3874        "msedge",
3875        "brave",
3876        "brave-browser",
3877        "vivaldi",
3878        "opera",
3879        "opera-stable",
3880    ];
3881
3882    for name in names {
3883        if let Some(path) = which_in_path(name) {
3884            return Some(path);
3885        }
3886    }
3887
3888    #[cfg(windows)]
3889    {
3890        for candidate in windows_browser_candidates() {
3891            if candidate.is_file() {
3892                return Some(candidate);
3893            }
3894        }
3895    }
3896
3897    // Absolute path fallbacks for Linux servers where the browser may not be
3898    // in $PATH (e.g. installed via snap, flatpak, or a minimal systemd service env).
3899    #[cfg(not(windows))]
3900    {
3901        for candidate in linux_browser_candidates() {
3902            if candidate.is_file() {
3903                return Some(candidate);
3904            }
3905        }
3906
3907        // Final fallback: ask the shell's `which` so we catch browsers installed
3908        // in non-standard locations that weren't found via PATH or static paths.
3909        if let Some(path) = which_subprocess(&[
3910            "chromium-browser",
3911            "chromium",
3912            "google-chrome",
3913            "google-chrome-stable",
3914            "microsoft-edge",
3915            "brave-browser",
3916        ]) {
3917            return Some(path);
3918        }
3919    }
3920
3921    None
3922}
3923
3924/// Push the Chrome/Edge/Brave/Vivaldi `Application`-layout executable paths under `base` onto
3925/// `paths`. These four share the same per-base directory layout; Opera differs and is added by
3926/// the caller.
3927#[cfg(windows)]
3928fn push_chromium_app_browsers(paths: &mut Vec<PathBuf>, base: &Path) {
3929    paths.push(
3930        base.join("Google")
3931            .join("Chrome")
3932            .join("Application")
3933            .join("chrome.exe"),
3934    );
3935    paths.push(
3936        base.join("Microsoft")
3937            .join("Edge")
3938            .join("Application")
3939            .join("msedge.exe"),
3940    );
3941    paths.push(
3942        base.join("BraveSoftware")
3943            .join("Brave-Browser")
3944            .join("Application")
3945            .join("brave.exe"),
3946    );
3947    paths.push(base.join("Vivaldi").join("Application").join("vivaldi.exe"));
3948}
3949
3950#[cfg(windows)]
3951fn windows_browser_candidates() -> Vec<PathBuf> {
3952    let mut paths = Vec::new();
3953
3954    let program_files = std::env::var_os("ProgramFiles");
3955    let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
3956    let local_app_data = std::env::var_os("LocalAppData");
3957
3958    for base in [program_files, program_files_x86].into_iter().flatten() {
3959        let base = PathBuf::from(base);
3960        push_chromium_app_browsers(&mut paths, &base);
3961        paths.push(base.join("Opera").join("launcher.exe"));
3962        paths.push(base.join("Opera GX").join("launcher.exe"));
3963    }
3964
3965    if let Some(base) = local_app_data {
3966        let base = PathBuf::from(base);
3967        push_chromium_app_browsers(&mut paths, &base);
3968        paths.push(base.join("Programs").join("Opera").join("launcher.exe"));
3969        paths.push(base.join("Programs").join("Opera GX").join("launcher.exe"));
3970    }
3971
3972    paths
3973}
3974
3975#[cfg(not(windows))]
3976fn linux_browser_candidates() -> Vec<PathBuf> {
3977    vec![
3978        // snap (Ubuntu, common on servers)
3979        PathBuf::from("/snap/bin/chromium"),
3980        PathBuf::from("/snap/bin/chromium-browser"),
3981        // standard apt/dnf paths
3982        PathBuf::from("/usr/bin/chromium"),
3983        PathBuf::from("/usr/bin/chromium-browser"),
3984        PathBuf::from("/usr/bin/google-chrome"),
3985        PathBuf::from("/usr/bin/google-chrome-stable"),
3986        PathBuf::from("/usr/bin/microsoft-edge"),
3987        PathBuf::from("/usr/bin/microsoft-edge-stable"),
3988        PathBuf::from("/usr/bin/brave-browser"),
3989        PathBuf::from("/usr/bin/brave-browser-stable"),
3990        // package-managed library locations (Ubuntu 20.04, Debian)
3991        PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
3992        PathBuf::from("/usr/lib/chromium/chromium"),
3993        PathBuf::from("/usr/lib/chromium/chrome"),
3994        // manual / opt installs
3995        PathBuf::from("/opt/google/chrome/google-chrome"),
3996        PathBuf::from("/opt/google/chrome-beta/google-chrome"),
3997        PathBuf::from("/opt/google/chrome-unstable/google-chrome"),
3998        // local installs
3999        PathBuf::from("/usr/local/bin/chromium"),
4000        PathBuf::from("/usr/local/bin/chromium-browser"),
4001        PathBuf::from("/usr/local/bin/google-chrome"),
4002        // flatpak wrapper scripts
4003        PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
4004        PathBuf::from("/usr/share/flatpak/exports/bin/org.chromium.Chromium"),
4005    ]
4006}
4007
4008/// Ask the shell for a browser that may be in PATH but not in the hardcoded list above.
4009/// Used as a last resort when all static-path checks have failed.
4010#[cfg(not(windows))]
4011fn which_subprocess(names: &[&str]) -> Option<PathBuf> {
4012    for name in names {
4013        if let Ok(out) = std::process::Command::new("which").arg(name).output() {
4014            if out.status.success() {
4015                let s = String::from_utf8_lossy(&out.stdout);
4016                let path = PathBuf::from(s.trim());
4017                if path.is_file() {
4018                    return Some(path);
4019                }
4020            }
4021        }
4022    }
4023    None
4024}
4025
4026fn which_in_path(exe: &str) -> Option<PathBuf> {
4027    let path_var = std::env::var_os("PATH")?;
4028    for dir in std::env::split_paths(&path_var) {
4029        let candidate = dir.join(exe);
4030        if candidate.is_file() {
4031            return Some(candidate);
4032        }
4033        #[cfg(windows)]
4034        {
4035            let candidate = dir.join(format!("{exe}.exe"));
4036            if candidate.is_file() {
4037                return Some(candidate);
4038            }
4039        }
4040    }
4041    None
4042}
4043
4044fn file_url(path: &Path) -> String {
4045    let raw = path.to_string_lossy().replace('\\', "/");
4046    let normalized = if raw.starts_with('/') {
4047        raw
4048    } else {
4049        format!("/{raw}")
4050    };
4051
4052    let mut encoded = String::with_capacity(normalized.len() + 8);
4053    for byte in normalized.bytes() {
4054        match byte {
4055            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
4056                encoded.push(byte as char);
4057            }
4058            _ => {
4059                let _ = write!(encoded, "%{byte:02X}");
4060            }
4061        }
4062    }
4063
4064    format!("file://{encoded}")
4065}
4066
4067fn file_row_view(file: &FileRecord) -> FileRow {
4068    FileRow {
4069        relative_path: file.relative_path.clone(),
4070        language: file.language.map_or_else(
4071            || "-".into(),
4072            |language| language.display_name().to_string(),
4073        ),
4074        total_physical_lines: file.raw_line_categories.total_physical_lines,
4075        code_lines: file.effective_counts.code_lines,
4076        comment_lines: file.effective_counts.comment_lines,
4077        blank_lines: file.effective_counts.blank_lines,
4078        mixed_lines_separate: file.effective_counts.mixed_lines_separate,
4079        functions: file.raw_line_categories.functions,
4080        classes: file.raw_line_categories.classes,
4081        variables: file.raw_line_categories.variables,
4082        imports: file.raw_line_categories.imports,
4083        test_count: file.raw_line_categories.test_count,
4084        test_assertion_count: file.raw_line_categories.test_assertion_count,
4085        test_suite_count: file.raw_line_categories.test_suite_count,
4086        line_cov_pct: file
4087            .coverage
4088            .as_ref()
4089            .map(|c| format!("{:.1}", c.line_pct()))
4090            .unwrap_or_default(),
4091        fn_cov_pct: file
4092            .coverage
4093            .as_ref()
4094            .filter(|c| c.functions_found > 0)
4095            .map(|c| format!("{:.1}", c.function_pct()))
4096            .unwrap_or_default(),
4097        branch_cov_pct: file
4098            .coverage
4099            .as_ref()
4100            .filter(|c| c.branches_found > 0)
4101            .map(|c| format!("{:.1}", c.branch_pct()))
4102            .unwrap_or_default(),
4103        cov_lines_detail: file.coverage.as_ref().map_or_else(String::new, |c| {
4104            format!("{}/{}", c.lines_hit, c.lines_found)
4105        }),
4106        status: format!("{:?}", file.status),
4107        status_class: format!("{:?}", file.status).to_ascii_lowercase(),
4108        warnings: if file.warnings.is_empty() {
4109            String::new()
4110        } else {
4111            file.warnings.join("; ")
4112        },
4113    }
4114}
4115
4116fn is_pacific_dst_report(dt: DateTime<Utc>) -> bool {
4117    use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Weekday};
4118    let year = dt.year();
4119    let nth_sun = |month: u32, n: u32, hour: u32| {
4120        let mut count = 0u32;
4121        let mut day = 1u32;
4122        loop {
4123            let d = NaiveDate::from_ymd_opt(year, month, day).expect("valid");
4124            if d.weekday() == Weekday::Sun {
4125                count += 1;
4126                if count == n {
4127                    return Utc.from_utc_datetime(
4128                        &d.and_time(NaiveTime::from_hms_opt(hour, 0, 0).expect("valid")),
4129                    );
4130                }
4131            }
4132            day += 1;
4133        }
4134    };
4135    let dst_start = nth_sun(3, 2, 10);
4136    let dst_end = nth_sun(11, 1, 9);
4137    dt >= dst_start && dt < dst_end
4138}
4139
4140fn to_pst_display(dt: DateTime<Utc>) -> String {
4141    let (offset, label) = if is_pacific_dst_report(dt) {
4142        (
4143            FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4144            "PDT",
4145        )
4146    } else {
4147        (
4148            FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4149            "PST",
4150        )
4151    };
4152    format!(
4153        "{} {label}",
4154        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
4155    )
4156}
4157
4158/// Format a UTC `DateTime` as "YYYY-MM-DD HH:MM PDT/PST" (no seconds).
4159fn to_pt_hhmm(dt: DateTime<Utc>) -> String {
4160    let (offset, label) = if is_pacific_dst_report(dt) {
4161        (
4162            FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4163            "PDT",
4164        )
4165    } else {
4166        (
4167            FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4168            "PST",
4169        )
4170    };
4171    format!(
4172        "{} {label}",
4173        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M")
4174    )
4175}
4176
4177/// Parse an RFC 3339 / ISO 8601 git commit date string and reformat it as
4178/// "YYYY-MM-DD HH:MM PDT/PST", converting from the embedded offset to Pacific time.
4179fn fmt_commit_date_pt(s: &str) -> String {
4180    use chrono::DateTime as ChronoDateTime;
4181    ChronoDateTime::parse_from_rfc3339(s).map_or_else(
4182        |_| s.replace('T', " "),
4183        |dt| to_pt_hhmm(dt.with_timezone(&Utc)),
4184    )
4185}
4186
4187fn build_warning_console(warnings: &[String]) -> String {
4188    if warnings.is_empty() {
4189        return "No top-level warnings.".to_string();
4190    }
4191
4192    warnings
4193        .iter()
4194        .enumerate()
4195        .map(|(index, warning)| {
4196            format!(
4197                "[{index:03}] {warning}",
4198                index = index + 1,
4199                warning = warning
4200            )
4201        })
4202        .collect::<Vec<_>>()
4203        .join("\n")
4204}
4205
4206fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
4207    let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
4208    for warning in warnings {
4209        let key = if warning.contains("unsupported or undetected language") {
4210            "Unsupported or undetected text formats"
4211        } else if warning.contains("file exceeded max_file_size_bytes") {
4212            "Large files skipped by size limit"
4213        } else if warning.contains("binary file skipped by default") {
4214            "Binary assets skipped"
4215        } else if warning.contains("minified file skipped by policy") {
4216            "Minified files skipped by policy"
4217        } else if warning.contains("vendor file skipped by policy") {
4218            "Vendor files skipped by policy"
4219        } else if warning.contains("best effort") || warning.contains("unclosed string literal") {
4220            "Best-effort parse results"
4221        } else {
4222            "Other warnings"
4223        };
4224        *counts.entry(key).or_default() += 1;
4225    }
4226
4227    counts
4228        .into_iter()
4229        .map(|(label, count)| {
4230            let (tone_class, detail) = match label {
4231                "Unsupported or undetected text formats" => (
4232                    "tone-neutral",
4233                    "These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
4234                ),
4235                "Large files skipped by size limit" => (
4236                    "tone-warn",
4237                    "Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
4238                ),
4239                "Binary assets skipped" => (
4240                    "tone-neutral",
4241                    "Binary bundles are excluded from source counting unless you explicitly opt into them.",
4242                ),
4243                "Minified files skipped by policy" => (
4244                    "tone-warn",
4245                    "Generated and minified assets are being filtered out to avoid inflating code totals.",
4246                ),
4247                "Vendor files skipped by policy" => (
4248                    "tone-neutral",
4249                    "Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
4250                ),
4251                "Best-effort parse results" => (
4252                    "tone-danger",
4253                    "These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
4254                ),
4255                _ => (
4256                    "tone-danger",
4257                    "Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
4258                ),
4259            };
4260
4261            WarningSummaryRow {
4262                label: label.to_string(),
4263                count,
4264                tone_class: tone_class.to_string(),
4265                detail: detail.to_string(),
4266            }
4267        })
4268        .collect()
4269}
4270
4271/// Classify an unsupported-language warning path into a named bucket.
4272fn classify_unsupported_path(path: &str) -> &'static str {
4273    let ext_lc = Path::new(path)
4274        .extension()
4275        .and_then(|e| e.to_str())
4276        .map(str::to_ascii_lowercase)
4277        .unwrap_or_default();
4278
4279    if ext_lc == "md"
4280        || path.ends_with("README")
4281        || path.ends_with("README.md")
4282        || path.ends_with("LICENSE")
4283    {
4284        "Documentation / text"
4285    } else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
4286        "JSON manifests and config"
4287    } else if ext_lc == "toml"
4288        || path.ends_with("MANIFEST.in")
4289        || path.ends_with("requirements.txt")
4290    {
4291        "Project metadata and packaging"
4292    } else if ext_lc == "html" {
4293        "HTML templates"
4294    } else if ext_lc == "txt" {
4295        "Plain text assets"
4296    } else if ext_lc.is_empty() {
4297        "Extensionless or custom text files"
4298    } else {
4299        "Other unsupported text formats"
4300    }
4301}
4302
4303/// Map a bucket label to its recommendation string.
4304fn bucket_recommendation(label: &str) -> String {
4305    match label {
4306        "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(),
4307        "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(),
4308        "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(),
4309        "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(),
4310        "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(),
4311        "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(),
4312        _ => "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(),
4313    }
4314}
4315
4316/// Short human-readable description of what each bucket means.
4317fn bucket_description(label: &str) -> String {
4318    match label {
4319        "Documentation / text" => {
4320            "README, LICENSE, and markdown files — not source code.".to_string()
4321        }
4322        "JSON manifests and config" => {
4323            "JSON configuration or manifest files (package.json, lockfiles, etc.).".to_string()
4324        }
4325        "Project metadata and packaging" => {
4326            "TOML, requirements.txt, and MANIFEST.in files that describe package metadata."
4327                .to_string()
4328        }
4329        "HTML templates" => {
4330            "HTML files not covered by the built-in HTML analyzer (unexpected extension or path)."
4331                .to_string()
4332        }
4333        "Plain text assets" => "Plain .txt files that have no analyzable structure.".to_string(),
4334        "Extensionless or custom text files" => {
4335            "Files with no extension that could not be language-detected.".to_string()
4336        }
4337        _ => "Files with an unrecognized extension or format.".to_string(),
4338    }
4339}
4340
4341fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
4342    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4343    let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
4344
4345    for warning in warnings {
4346        if !warning.contains("unsupported or undetected language") {
4347            continue;
4348        }
4349
4350        let path = warning
4351            .split_once(':')
4352            .map(|(path, _)| path.trim())
4353            .unwrap_or_default();
4354        if path.is_empty() {
4355            continue;
4356        }
4357
4358        let bucket = classify_unsupported_path(path);
4359        *counts.entry(bucket.to_string()).or_default() += 1;
4360
4361        let ex = examples.entry(bucket.to_string()).or_default();
4362        if ex.len() < 3 {
4363            let basename = Path::new(path)
4364                .file_name()
4365                .and_then(|n| n.to_str())
4366                .unwrap_or(path)
4367                .to_string();
4368            if !ex.contains(&basename) {
4369                ex.push(basename);
4370            }
4371        }
4372    }
4373
4374    let mut rows = counts.into_iter().collect::<Vec<_>>();
4375    rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4376
4377    rows.into_iter()
4378        .map(|(label, count)| {
4379            let recommendation = bucket_recommendation(&label);
4380            let bucket_description = bucket_description(&label);
4381            let example_files = examples.remove(&label).unwrap_or_default();
4382            WarningOpportunityRow {
4383                label,
4384                count,
4385                recommendation,
4386                example_files,
4387                bucket_description,
4388            }
4389        })
4390        .collect()
4391}
4392
4393#[derive(Debug, Clone)]
4394struct LanguageRow {
4395    language: String,
4396    files: u64,
4397    total_physical_lines: u64,
4398    code_lines: u64,
4399    comment_lines: u64,
4400    blank_lines: u64,
4401    mixed_lines_separate: u64,
4402    functions: u64,
4403    classes: u64,
4404    variables: u64,
4405    imports: u64,
4406    test_count: u64,
4407    test_assertion_count: u64,
4408    test_suite_count: u64,
4409    test_density_str: String,
4410}
4411
4412#[derive(Debug, Clone)]
4413struct FileRow {
4414    relative_path: String,
4415    language: String,
4416    total_physical_lines: u64,
4417    code_lines: u64,
4418    comment_lines: u64,
4419    blank_lines: u64,
4420    mixed_lines_separate: u64,
4421    functions: u64,
4422    classes: u64,
4423    variables: u64,
4424    imports: u64,
4425    test_count: u64,
4426    test_assertion_count: u64,
4427    test_suite_count: u64,
4428    /// Line coverage percentage, e.g. "96.7" — empty string when no coverage data.
4429    line_cov_pct: String,
4430    /// Function coverage percentage — empty string when no coverage data.
4431    fn_cov_pct: String,
4432    /// Branch coverage percentage — empty string when no branch coverage data.
4433    branch_cov_pct: String,
4434    /// Lines hit out of lines found, e.g. "142/156" — empty string when no coverage data.
4435    cov_lines_detail: String,
4436    status: String,
4437    status_class: String,
4438    warnings: String,
4439}
4440
4441#[derive(Debug, Clone)]
4442struct WarningSummaryRow {
4443    label: String,
4444    count: usize,
4445    tone_class: String,
4446    detail: String,
4447}
4448
4449#[derive(Debug, Clone)]
4450struct WarningOpportunityRow {
4451    label: String,
4452    count: usize,
4453    recommendation: String,
4454    /// Up to 3 example file names (basename only) that triggered this bucket.
4455    example_files: Vec<String>,
4456    /// Short description of what this bucket means for the user.
4457    bucket_description: String,
4458}
4459
4460#[derive(Template)]
4461#[template(
4462    source = r##"<!doctype html>
4463<html lang="en">
4464<head>
4465  <meta charset="utf-8" />
4466  <meta name="viewport" content="width=device-width, initial-scale=1" />
4467  <title>{{ browser_title }}</title>
4468  <link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
4469  <style nonce="{{ nonce }}">
4470    :root {
4471      --radius: 18px;
4472      --bg: #f5efe8;
4473      --surface: rgba(255,255,255,0.82);
4474      --surface-2: #fbf7f2;
4475      --surface-3: #efe6dc;
4476      --line: #e6d0bf;
4477      --line-strong: #dcb89f;
4478      --text: #43342d;
4479      --muted: #7b675b;
4480      --muted-2: #a08777;
4481      --nav: #b85d33;
4482      --nav-2: #7a371b;
4483      --accent: #6f9bff;
4484      --accent-2: #4a78ee;
4485      --oxide: #d37a4c;
4486      --oxide-2: #b35428;
4487      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
4488      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
4489      --good-bg: #e8f5ed;
4490      --good-text: #1a8f47;
4491      --warn-bg: #fff4dc;
4492      --warn-text: #9a6d00;
4493      --danger-bg: #fdebec;
4494      --danger-text: #cc4b4b;
4495      --info-bg: #f2f6ff;
4496      --info-text: #4467d8;
4497    }
4498    {% if let Some(hex) = accent_hex %}
4499    :root, body.dark-theme { --accent: {{ hex }}; --accent-2: {{ hex }}; }
4500    {% endif %}
4501    body.dark-theme {
4502      --bg: #1b1511;
4503      --surface: #261c17;
4504      --surface-2: #2d221d;
4505      --surface-3: #372922;
4506      --line: #524238;
4507      --line-strong: #6c5649;
4508      --text: #f5ece6;
4509      --muted: #c7b7aa;
4510      --muted-2: #aa9485;
4511      --nav: #b85d33;
4512      --nav-2: #7a371b;
4513      --accent: #6f9bff;
4514      --accent-2: #4a78ee;
4515      --oxide: #d37a4c;
4516      --oxide-2: #b35428;
4517      --shadow: 0 18px 42px rgba(0,0,0,0.28);
4518      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
4519      --good-bg: #163927;
4520      --good-text: #8fe2a8;
4521      --warn-bg: #3c2d11;
4522      --warn-text: #f3cb75;
4523      --danger-bg: #3d1f1f;
4524      --danger-text: #ff9f9f;
4525      --info-bg: #202e55;
4526      --info-text: #a9c1ff;
4527    }
4528    * { box-sizing: border-box; }
4529    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); }
4530    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
4531    .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); }
4532    .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; }
4533    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
4534    .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)); }
4535    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4536    .background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
4537    .code-particles { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4538    .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; }
4539    @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)); } }
4540    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
4541    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
4542    .brand-subtitle { color: rgba(255,255,255,0.72); font-size: 11px; line-height: 1.2; margin-top: 2px; letter-spacing: 0.01em; }
4543    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
4544    .nav-project-pill, .nav-pill, .theme-toggle, .header-button {
4545      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;
4546    }
4547    .nav-project-pill { pointer-events: auto; width: 100%; max-width: 300px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4548    .nav-project-label { color: rgba(255,255,255,0.72); text-transform: uppercase; letter-spacing: 0.09em; font-size: 10px; font-weight: 800; }
4549    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 13px; }
4550    .nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:nowrap; min-width:0; }
4551    @media (max-width: 1400px) { .nav-status { gap: 6px; } .header-button, .theme-toggle { padding: 0 10px; } }
4552    @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; } }
4553    .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; }
4554    .theme-toggle:hover, .header-button:hover { background: rgba(255,255,255,0.18); transform: translateY(-1px); }
4555    .theme-toggle { width: 38px; justify-content:center; padding:0; }
4556    .nav-dropdown-wrap { position: relative; }
4557    .nav-dropdown-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 6px; }
4558    .nav-dropdown-trigger { }
4559    .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); }
4560    .nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
4561    .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; }
4562    .nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
4563    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
4564    .theme-toggle .icon-sun { display:none; }
4565    body.dark-theme .theme-toggle .icon-sun { display:block; }
4566    body.dark-theme .theme-toggle .icon-moon { display:none; }
4567    .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;}
4568    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
4569    .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);}
4570    .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;}
4571    .settings-close:hover{color:var(--text);background:var(--surface-2);}
4572    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
4573    .settings-modal-body{padding:14px 16px 16px;}
4574    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
4575    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
4576    .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;}
4577    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
4578    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
4579    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
4580    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
4581    .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;}
4582    .tz-select:focus{border-color:var(--oxide);}
4583    .page { max-width: 1720px; margin: 0 auto; padding: 32px 24px 40px; }
4584    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
4585    /* Uniform two-row card strip. JS pads the card count to even (revealing a
4586       reserve card when odd) and sets the column count to n/2, so the cards form
4587       exactly two full rows with every column aligned and every card the same
4588       width — no oversized card, no empty trailing cell. */
4589    .summary-grid { display:grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap:10px; align-items:stretch; }
4590    .panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
4591    .panel { padding: 20px; }
4592    .metric { padding: 11px 12px 20px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 70px; }
4593    .metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
4594    .metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
4595    .section-kicker { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
4596    .metric-value { margin-top: 6px; }
4597    .metric-big { display:block; font-size: 20px; font-weight: 900; color: var(--oxide); line-height: 1.15; letter-spacing: -0.02em; }
4598    .metric-exact { position: absolute; bottom: 6px; right: 10px; font-size: 12px; font-weight: 600; color: var(--muted); font-family: ui-monospace, monospace; }
4599    .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); }
4600    .metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
4601    .metric:hover .metric-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); }
4602    .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); }
4603    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
4604    .hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
4605    .run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
4606    @media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
4607    @media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
4608    .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; }
4609    .run-id-chip[data-copy] { cursor:pointer; }
4610    a.run-id-chip-link { text-decoration:none; cursor:pointer; }
4611    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); }
4612    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
4613    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
4614    .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; }
4615    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
4616    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
4617    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
4618    .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; }
4619    body.dark-theme .submodule-state-badge { color:var(--accent); background:rgba(111,155,255,0.13); border-color:rgba(111,155,255,0.28); }
4620    .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; }
4621    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4622    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4623    a.run-id-chip-link:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4624    .chip-copy-icon { display:inline-block; margin-left:5px; font-size:10px; opacity:0.55; vertical-align:middle; }
4625    .chip-label-icon { display:inline-block; vertical-align:middle; margin-right:3px; margin-top:-1px; opacity:0.8; }
4626    .chip-popout-icon { display:inline-block; vertical-align:middle; margin-left:4px; opacity:0.6; flex-shrink:0; }
4627    .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; }
4628    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
4629    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
4630    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
4631    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
4632    .subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
4633    .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%; }
4634    .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; }
4635    .meta-chip:last-child { border-right:none; }
4636    .meta-chip b { color:var(--text); font-weight:700; }
4637    .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); }
4638    body.dark-theme .prev-scan-banner { box-shadow:0 4px 16px rgba(0,0,0,0.3); }
4639    .prev-scan-banner-empty { flex-direction:row; align-items:center; gap:8px; font-size:13px; color:var(--muted); font-style:italic; }
4640    .prev-scan-banner-top { display:flex; flex-direction:column; gap:4px; }
4641    .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; }
4642    .prev-scan-ts { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4643    .prev-scan-count { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4644    .prev-scan-summary { font-size:13px; font-weight:600; color:var(--text); }
4645    .prev-scan-summary b { font-weight:900; }
4646    .delta-neutral-text { color:var(--muted); }
4647    .delta-up { color:#2a6846; }
4648    .delta-down { color:#b23030; }
4649    body.dark-theme .delta-up { color:#5aba8a; }
4650    body.dark-theme .delta-down { color:#e07070; }
4651    .delta-card-row { display:grid; grid-template-columns:repeat(7,1fr); gap:12px; width:100%; }
4652    @media(max-width:1000px){ .delta-card-row { grid-template-columns:repeat(4,1fr); } }
4653    @media(max-width:540px){ .delta-card-row { grid-template-columns:repeat(2,1fr); } }
4654    .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); }
4655    .delta-card-inline:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.22); z-index:10; }
4656    body.dark-theme .delta-card-inline { box-shadow:0 2px 8px rgba(0,0,0,0.2); }
4657    body.dark-theme .delta-card-inline:hover { box-shadow:0 12px 32px rgba(0,0,0,0.55); }
4658    .delta-card-val { font-size:20px; font-weight:900; color:var(--oxide); line-height:1.2; }
4659    .delta-card-val.pos { color:#2a6846; }
4660    .delta-card-val.neg { color:#b23030; }
4661    .delta-card-val.mod { color:#7a5a10; }
4662    body.dark-theme .delta-card-val.pos { color:#5aba8a; }
4663    body.dark-theme .delta-card-val.neg { color:#e07070; }
4664    body.dark-theme .delta-card-val.mod { color:#d4a843; }
4665    .delta-card-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4666    .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); }
4667    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4668    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4669    .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); }
4670    .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); }
4671    body.dark-theme .delta-panel-link { box-shadow:0 2px 6px rgba(0,0,0,0.2); }
4672    body.dark-theme .delta-panel-link:hover { box-shadow:0 6px 18px rgba(0,0,0,0.45); }
4673    .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; }
4674    .toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
4675    .toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
4676    .search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
4677    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
4678    .pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
4679    .pill.good { background: var(--good-bg); color: var(--good-text); }
4680    .pill.info { background: var(--info-bg); color: var(--info-text); }
4681    .export-group { display:flex; gap:6px; align-items:center; }
4682    .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; }
4683    .export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
4684    .page-size-row { display:flex; align-items:center; gap:6px; }
4685    .page-size-label { font-size:13px; color:var(--muted); font-weight:600; }
4686    .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; }
4687    .page-count-label { font-size:12px; color:var(--muted); white-space:nowrap; }
4688    .pagination-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:10px 0 2px; }
4689    .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; }
4690    .pager-btn:hover:not(:disabled) { background:var(--accent); color:#fff; border-color:var(--accent); }
4691    .pager-btn:disabled { opacity:.4; cursor:default; }
4692    .pager-info { font-size:13px; color:var(--muted); font-weight:600; min-width:120px; text-align:center; }
4693    .pager-edge { font-size:12px; padding:5px 10px; }
4694    .pager-jump-wrap { font-size:13px; color:var(--muted); font-weight:600; display:flex; align-items:center; gap:5px; white-space:nowrap; }
4695    .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; }
4696    .pager-jump::-webkit-inner-spin-button,.pager-jump::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
4697    .table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
4698    /* Clip wrapper: hides the scrollbar track that hangs 8px past the right edge */
4699    .table-shell-clip { overflow: hidden !important; max-height: none !important; }
4700    /* Skipped-files scroll pane: auto so no phantom space when content is short */
4701    #skipped-shell { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--surface-2); }
4702    #per-file-table tbody tr:last-child td, #skipped-table tbody tr:last-child td { border-bottom: none; }
4703    #skipped-shell::-webkit-scrollbar { width: 8px; }
4704    #skipped-shell::-webkit-scrollbar-track { background: var(--surface-2); }
4705    #skipped-shell::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
4706    table { width: 100%; border-collapse: collapse; font-size: 14px; }
4707    th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
4708    th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
4709    /* Per-file detail table — auto layout so File column sizes to content */
4710    .table-resizable { table-layout: auto; }
4711    .table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
4712    .table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4713    .table-resizable td.mono { overflow: visible; text-overflow: unset; white-space: nowrap; }
4714    #skipped-table { table-layout: fixed; width: 100%; }
4715    #skipped-table th, #skipped-table td { padding: 7px 8px; }
4716    #skipped-table td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4717    /* Hotspots table — overflow visible so header tooltips can escape the cell */
4718    #hotspots-table th { overflow: visible; }
4719    .hs-hint { color: var(--muted); font-style: italic; }
4720    /* Column-header explainer tooltip (shared visual language with .stat-chip-tip) */
4721    .col-tip { position: absolute; top: calc(100% + 9px); left: 0; z-index: 60; width: max-content; max-width: 270px;
4722      background: var(--text); color: var(--bg); padding: 9px 12px; border-radius: 9px;
4723      font-size: 11.5px; font-weight: 500; line-height: 1.5; letter-spacing: normal; text-transform: none;
4724      white-space: normal; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.22);
4725      opacity: 0; pointer-events: none; transition: opacity .18s ease; }
4726    .col-tip.col-tip-r { left: auto; right: 0; }
4727    .col-tip strong { color: var(--bg); }
4728    .col-tip::after { content: ''; position: absolute; bottom: 100%; left: 16px;
4729      border: 6px solid transparent; border-bottom-color: var(--text); }
4730    .col-tip.col-tip-r::after { left: auto; right: 16px; }
4731    #hotspots-table th:hover .col-tip { opacity: 1; }
4732    /* Column resize handle */
4733    .col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
4734    .col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
4735    #per-file-table { table-layout: fixed; width: 100%; min-width: 0; }
4736    #per-file-table th, #per-file-table td { padding: 8px 6px; }
4737    /* File column: pinned, truncates long paths */
4738    #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; }
4739    #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; }
4740    #per-file-table th:nth-child(2) { width: 6%; }
4741    /* 12 numeric columns share the remaining 68%: 26+6+12×5.67≈98% total */
4742    #per-file-table th:nth-child(n+3) { width: 5.67%; }
4743    /* Override mono class overflow so file paths truncate */
4744    #per-file-table td.mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4745    #per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
4746    body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
4747    /* Language breakdown: auto layout with resizable columns — headers size to content */
4748    #lang-breakdown-table { width: 100%; min-width: 760px; }
4749    #lang-breakdown-table th, #lang-breakdown-table td { padding: 8px 6px; font-size: 13px; }
4750    /* Skipped table: extend truncation to all cells, not just the first column */
4751    #skipped-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
4752    /* Support opportunities table: fixed layout so Category/Count columns don't grow */
4753    .support-table { table-layout: fixed; width: 100%; }
4754    .support-table th:first-child { width: 20%; }
4755    .support-table th:nth-child(2) { width: 6%; }
4756    .support-table th:nth-child(3) { width: 24%; }
4757    .support-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
4758    /* Description and example columns must wrap — content can be long */
4759    .support-table td:nth-child(3) { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; line-height: 1.45; }
4760    .support-table td:last-child { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; }
4761    .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; }
4762    .support-recommendation { color: var(--muted); font-size: 11px; margin: 6px 0 0; line-height: 1.5; }
4763    .num-col { text-align: right !important; }
4764    tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
4765    body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
4766    tr:last-child td { border-bottom: none; }
4767    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4768    .small { color: var(--muted); font-size: 13px; }
4769    .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; }
4770    .status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
4771    .status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
4772    .status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
4773    .stack { display:grid; gap:22px; }
4774    .summary-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
4775    @media(max-width:800px) { .summary-strip { grid-template-columns:repeat(2,1fr) !important; } }
4776    .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); }
4777    .test-density-num { font-size:22px; font-weight:900; color:var(--oxide); line-height:1; }
4778    .test-density-meta { display:flex; flex-direction:column; gap:2px; }
4779    .test-density-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4780    .test-density-sub { font-size:11px; color:var(--muted-2); }
4781    .test-density-badge { margin-left:auto; padding:4px 12px; border-radius:999px; font-size:12px; font-weight:700; }
4782    .test-density-badge.good { background:var(--good-bg); color:var(--good-text); }
4783    .test-density-badge.warn { background:var(--warn-bg); color:var(--warn-text); }
4784    .test-density-badge.danger { background:var(--danger-bg); color:var(--danger-text); }
4785    .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(68,103,216,0.18); color:var(--info-text); font-size:13px; line-height:1.5; }
4786    .info-callout-icon { flex:0 0 auto; font-size:15px; margin-top:1px; }
4787    .info-callout code { background:rgba(68,103,216,0.12); border-radius:4px; padding:1px 5px; font-size:12px; }
4788    body.dark-theme .info-callout { background:rgba(100,130,255,0.09); border-color:rgba(100,130,255,0.22); }
4789    .empty-state-row td { text-align:center; padding:20px; color:var(--muted-2); font-size:13px; font-style:italic; }
4790    .cov-gauge-row { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-bottom:18px; }
4791    @media(max-width:700px) { .cov-gauge-row { grid-template-columns:1fr; } }
4792    .cov-gauge-card { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; display:flex; flex-direction:column; gap:8px; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); min-width:0; }
4793    .cov-gauge-card:hover { transform:translateY(-3px); box-shadow:0 10px 28px rgba(77,44,20,0.15); }
4794    .cov-gauge-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4795    .cov-gauge-val { font-size:32px; font-weight:900; line-height:1; }
4796    .cov-gauge-track { height:8px; border-radius:4px; background:var(--line); overflow:hidden; }
4797    .cov-gauge-fill { height:100%; border-radius:4px; transition:width .5s ease; }
4798    .cov-gauge-sub { font-size:11px; color:var(--muted); }
4799    .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); }
4800    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
4801    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
4802    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4803    .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); }
4804    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4805    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4806    .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; }
4807    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
4808    .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); }
4809    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
4810    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
4811    .report-stack { display:grid; gap: 18px; align-items:start; }
4812    pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
4813    .warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
4814    .sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
4815    .warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
4816    .warning-card { padding: 10px 12px; }
4817    .warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
4818    .warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
4819    .tone-neutral .count { color: var(--text); }
4820    .tone-warn .count { color: var(--warn-text); }
4821    .tone-danger .count { color: var(--danger-text); }
4822    .tone-neutral .warning-count { color: var(--oxide); }
4823    .tone-warn .warning-count { color: var(--warn-text); }
4824    .tone-danger .warning-count { color: var(--danger-text); }
4825    .support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
4826    .support-table th { cursor: default; }
4827    details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
4828    summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
4829    details > div { padding: 0 16px 16px; }
4830    .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; }
4831    .warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
4832    .warning-console.hidden { display:none; }
4833    @media (max-width: 1200px) {
4834      .warning-grid { grid-template-columns: 1fr 1fr; }
4835    }
4836    @media (max-width: 960px) {
4837      .top-nav-inner { grid-template-columns: 1fr; }
4838      .nav-project-slot, .nav-status { justify-content:flex-start; }
4839      .warning-grid, .report-stack { grid-template-columns: 1fr; }
4840      .hero-top { flex-direction: column; }
4841      .search { min-width: 100%; width: 100%; }
4842    }
4843    @media (max-width: 640px) {
4844      .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
4845    }
4846    /* ── Report header / footer identification banner ─────────────────── */
4847    .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; }
4848    .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; }
4849    body.has-report-banner .top-nav { top: 27px; }
4850    body.has-report-banner { padding-bottom: 27px; }
4851    /* ── Print & PDF export ──────────────────────────────────────────── */
4852    @page { size: A4 landscape; margin: 0.35in 0.5in; }
4853
4854    @media print {
4855      *, *::before, *::after {
4856        -webkit-print-color-adjust: exact !important;
4857        print-color-adjust: exact !important;
4858        box-sizing: border-box !important;
4859      }
4860
4861      html, body {
4862        background: #f5efe8 !important;
4863        min-height: auto !important;
4864        width: 100% !important;
4865      }
4866
4867      /* Report id banner — fixed position repeats the banner on every printed page */
4868      .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; }
4869      .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; }
4870      body.has-report-banner .top-nav { top: 0 !important; }
4871      body.has-report-banner { padding-bottom: 0 !important; }
4872      /* Hide interactive UI-chrome; keep section heading text visible */
4873      .top-nav, .hero-actions,
4874      .background-watermarks, #code-particles,
4875      .header-button, .theme-toggle,
4876      .nav-dropdown-wrap, .config-actions,
4877      .warnings-show-link, .warning-console-actions,
4878      .toolbar .pill-row, .toolbar .export-group,
4879      input[type="search"], button { display: none !important; }
4880      /* Show toolbar as a plain block so h2 headings are visible */
4881      .toolbar { display: block !important; margin-bottom: 8px !important; }
4882      .toolbar-left { display: block !important; }
4883
4884      /* Remove page-level layout constraints */
4885      .page {
4886        max-width: none !important;
4887        width: 100% !important;
4888        padding: 0 !important;
4889        margin: 0 !important;
4890      }
4891
4892      .panel, .hero, .section,
4893      .saved-report-shell, .saved-panel, .report-shell, .stack {
4894        max-width: none !important;
4895        width: 100% !important;
4896        box-shadow: none !important;
4897        border: 1px solid #ddd !important;
4898        border-radius: 10px !important;
4899        margin-bottom: 10px !important;
4900        overflow: visible !important;
4901      }
4902
4903      /* Force grids to their full-width column counts regardless of viewport */
4904      .summary-grid {
4905        display: grid !important;
4906        grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
4907        gap: 10px !important;
4908      }
4909
4910      .warning-grid {
4911        display: grid !important;
4912        grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
4913        gap: 8px !important;
4914      }
4915
4916      .report-stack {
4917        display: grid !important;
4918        gap: 12px !important;
4919        align-items: start !important;
4920      }
4921
4922      /* Metric cards */
4923      .metric {
4924        box-shadow: none !important;
4925        border: 1px solid #e0d0c0 !important;
4926        border-radius: 8px !important;
4927        break-inside: avoid !important;
4928        padding: 10px 12px 22px !important;
4929        min-height: 0 !important;
4930      }
4931
4932      .metric-big { font-size: 20px !important; }
4933      .metric-exact { font-size: 10px !important; bottom: 5px !important; right: 8px !important; }
4934      .metric-label { font-size: 10px !important; }
4935
4936      /* Page break control — small atomic cards stay together; large panels
4937         and tables flow freely so they never force blank pages. */
4938      .metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
4939      .hero, .panel, .stack { break-inside: auto !important; }
4940      section { break-inside: auto !important; }
4941      /* Keep each chart panel whole — browser moves it to the next page rather than
4942         slicing through the middle of a canvas. */
4943      .chart-section { break-inside: avoid !important; }
4944      /* Keep the summary grid on the same page as the hero header when possible */
4945      .summary-grid { break-before: avoid !important; }
4946      /* Section headings never orphan at the bottom of a page */
4947      h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
4948      /* Keep the first few rows of a table with the header */
4949      thead { break-after: avoid !important; }
4950
4951      /* Language charts — table layout is inherently side-by-side */
4952      #lang-overview-charts table { display: inline-table !important; }
4953      #lang-overview-charts td { vertical-align: top !important; }
4954
4955      /* Tables */
4956      .table-shell {
4957        max-height: none !important;
4958        overflow: visible !important;
4959        width: 100% !important;
4960        break-inside: auto !important;
4961      }
4962
4963      table {
4964        width: 100% !important;
4965        table-layout: auto !important;
4966        font-size: 10px !important;
4967        border-collapse: collapse !important;
4968        orphans: 4 !important;
4969        widows: 4 !important;
4970      }
4971
4972      /* Remove the screen-layout min-width so tables scale to page width */
4973      #per-file-table, #skipped-table { min-width: 0 !important; }
4974      /* Release sticky column positioning (not meaningful on paper) */
4975      #per-file-table th:first-child,
4976      #per-file-table td:first-child { position: static !important; }
4977      /* Show ALL rows — JS pagination hides rows via inline style; !important overrides it */
4978      #per-file-table tbody tr, #skipped-table tbody tr, #hotspots-table tbody tr { display: table-row !important; }
4979      /* Hide pagination controls — not interactive in PDF */
4980      .page-size-row, .pagination-bar { display: none !important; }
4981      /* Header tooltips and the interaction hint are screen-only */
4982      .col-tip { display: none !important; }
4983      .hs-hint { display: none !important; }
4984
4985      thead { display: table-header-group; }
4986      tr { break-inside: avoid !important; }
4987
4988      th {
4989        position: relative !important;
4990        font-size: 9px !important;
4991        font-weight: 700 !important;
4992        color: #333 !important;
4993        padding: 5px 8px !important;
4994        background: rgba(211,122,76,0.18) !important;
4995        white-space: normal !important;
4996      }
4997      /* Resize handles are screen-only — hide them in print */
4998      .col-resize-handle { display: none !important; }
4999      /* Sort indicators are redundant on paper */
5000      .sort-indicator { display: none !important; }
5001
5002      td {
5003        white-space: normal !important;
5004        overflow-wrap: anywhere !important;
5005        word-break: break-word !important;
5006        padding: 5px 8px !important;
5007        font-size: 10px !important;
5008        border-bottom: 1px solid #e8d8c8 !important;
5009      }
5010
5011      pre, code {
5012        white-space: pre-wrap !important;
5013        overflow-wrap: anywhere !important;
5014        word-break: break-word !important;
5015        font-size: 9px !important;
5016        max-height: none !important;
5017      }
5018
5019      .warning-card {
5020        box-shadow: none !important;
5021        border: 1px solid #ddd !important;
5022        break-inside: avoid !important;
5023        padding: 10px !important;
5024      }
5025
5026      .hero-top { flex-direction: row !important; }
5027
5028      .run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
5029      .run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
5030      .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; }
5031      .meta-chip { flex: 1 !important; justify-content: center !important; font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
5032      .meta-chip:last-child { border-right: none !important; }
5033
5034      .report-footer {
5035        border-top: 1px solid #ccc !important;
5036        margin-top: 12px !important;
5037        font-size: 10px !important;
5038      }
5039
5040      /* Collapse all <details> in print except the warnings block */
5041      details { border: 1px solid #ddd !important; border-radius: 8px !important; }
5042      details > summary { display: block !important; font-size: 10px !important; }
5043      details > div { display: none !important; }
5044      .warning-console { display: none !important; }
5045      .warning-console-actions { display: none !important; }
5046      /* Always expand the run-warnings details in PDF */
5047      details.warnings-details > div { display: block !important; }
5048      details.warnings-details .warning-console {
5049        display: block !important;
5050        max-height: none !important;
5051        overflow: visible !important;
5052        font-size: 8px !important;
5053        white-space: pre-wrap !important;
5054        word-break: break-all !important;
5055      }
5056      details.warnings-details .code-block-toolbar { display: none !important; }
5057
5058      /* Pill badges */
5059      .pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
5060
5061      /* Support opportunities table */
5062      .support-table td:first-child { font-weight: 600; font-size: 10px !important; }
5063
5064      /* Hide canvas-based interactive chart sections; replaced by pre-rendered variants */
5065      .chart-section { display: none !important; }
5066      .charts-grid   { display: none !important; }
5067      /* Pre-rendered chart variants — no forced page break; flow naturally after hero section */
5068      .pdf-variants-root { display: block !important; padding: 0 !important; }
5069      .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; }
5070      .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; }
5071      .pdf-variant-grid { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
5072      /* Single-column chart (scatter, etc.) — centre and constrain width in print */
5073      .pdf-variant-grid.single-col { grid-template-columns: 1fr !important; }
5074      .pdf-variant-grid.single-col .pdf-variant-panel { max-width: 62% !important; margin: 0 auto !important; }
5075      .pdf-variant-panel { break-inside: avoid !important; }
5076      .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; }
5077      .pdf-variant-img { width: 100% !important; height: auto !important; display: block !important; border-radius: 5px !important; border: 1px solid #ddd !important; }
5078    }
5079
5080
5081    .warnings-show-link {
5082      display: inline-flex;
5083      align-items: center;
5084      gap: 8px;
5085      padding: 8px 12px;
5086      border-radius: 10px;
5087      border: 1px solid rgba(111, 144, 255, 0.35);
5088      background: #eef3ff;
5089      color: #2f5fe3 !important;
5090      font-weight: 800;
5091      text-decoration: none;
5092      box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
5093    }
5094
5095    body.dark-theme .warnings-show-link {
5096      background: #1c2847;
5097      color: #a9c1ff !important;
5098      border-color: rgba(169, 193, 255, 0.32);
5099    }
5100
5101    .effective-config-note {
5102      margin: 8px 0 0;
5103      color: var(--muted);
5104      font-size: 14px;
5105      line-height: 1.6;
5106    }
5107    .config-actions { display: flex; gap: 8px; flex-shrink: 0; }
5108    .config-pre-wrap { position: relative; }
5109    .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; }
5110    body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
5111    .code-block-toolbar { display:flex; justify-content:flex-end; margin-bottom:6px; }
5112    .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; }
5113    .code-copy-btn:hover { background: rgba(184,93,51,0.08); color: var(--oxide-2); border-color: rgba(184,93,51,0.30); }
5114    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); }
5115    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); }
5116
5117
5118    .page {
5119      position: relative;
5120      z-index: 1;
5121    }
5122    .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; }
5123
5124    /* ── Chart controls & containers ───────────────────────────────────── */
5125    .chart-section { }
5126    .chart-controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
5127    .chart-controls label { font-size:13px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:6px; }
5128    .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; }
5129    .chart-select:focus { border-color:var(--accent); }
5130    .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; }
5131    .chart-expand-btn:hover { background:var(--surface-2); color:var(--text); }
5132    .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; }
5133    .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); }
5134    .chart-modal-title { font-size:15px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--text); margin:0 0 2px; display:block; }
5135    .chart-modal-subtitle { font-size:13px; font-weight:600; color:var(--muted); margin:0 0 16px; display:block; letter-spacing:.02em; }
5136    .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; }
5137    .chart-modal-close:hover { opacity:.7; }
5138    .chart-modal-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin:0 0 16px; padding-right:44px; }
5139    .chart-modal-header .chart-modal-title { flex:1 1 auto; margin:0; min-width:0; }
5140    body.dark-theme .chart-modal { background:var(--surface); }
5141    .chart-container { width:100%; overflow:visible; }
5142    .charts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
5143    .charts-grid > .panel { margin:0; min-width:0; display:flex; flex-direction:column; }
5144    .charts-grid .chart-section > div { display:flex; flex-direction:column; flex:1; }
5145    .charts-grid .chart-container { flex:1; min-height:180px; }
5146    .chart-pre { min-height:72px; }
5147    @media (max-width:820px) { .charts-grid { grid-template-columns:1fr; } }
5148    .r-lang-overview { display:flex; gap:40px; align-items:center; justify-content:center; flex-wrap:wrap; padding:8px 0 16px; }
5149    .r-lang-overview-cell { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 1 280px; max-width:480px; }
5150    .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; }
5151    .r-lang-overview svg { display:block; max-width:100%; height:auto; }
5152    .rchit { cursor:pointer; transition:opacity .17s,filter .17s,transform .17s; transform-box:fill-box; transform-origin:center center; }
5153    .rchit:hover { filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18)); transform:scale(1.05); }
5154    .lang-bar-row { cursor:pointer; transition:transform .2s cubic-bezier(.34,1.56,.64,1); }
5155    .lang-bar-row:hover { transform:translateY(-2px); }
5156    .lang-bar-row .rchit:hover { filter:none; transform:none; }
5157    .lang-bar-row:hover .rchit { filter:brightness(1.12); transform:scaleY(1.22); }
5158    #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; }
5159    .chart-tab-bar { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
5160    .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; }
5161    .chart-tab:hover { background:var(--surface-3); color:var(--text); }
5162    .chart-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
5163    .chart-locked-card { display:none; padding:20px 24px; border-radius:14px; background:var(--info-bg); border:1px solid rgba(111,144,255,0.28); color:var(--info-text); font-size:14px; line-height:1.6; }
5164    .chart-locked-card a { color:var(--accent-2); font-weight:700; }
5165    .chart-locked-card h3 { margin:0 0 6px; font-size:15px; }
5166
5167    /* Print: hide interactive controls; keep SVGs; show locked card for history mode */
5168    @media print {
5169      .chart-controls, .chart-tab-bar { display:none !important; }
5170      /* Single-column grid: each chart gets full page width, renders shorter, fits on one page */
5171      .charts-grid { grid-template-columns: 1fr !important; gap: 10px !important; }
5172      /* Cap canvas height so a single chart never overflows a landscape page */
5173      canvas { max-width: 100% !important; max-height: 280px !important; }
5174      .chart-container { width: 100% !important; overflow: visible !important; }
5175      .chart-container svg { max-height:300px !important; }
5176      /* chart-locked-card: do NOT force display:block — let JS-set visibility carry
5177         into print (hidden in normal mode; visible only when history mode is active).
5178         When it does show, use readable dark text instead of accent blue. */
5179      .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; }
5180      .chart-locked-card h3 { color:#222 !important; font-size:13px !important; }
5181      .chart-locked-card a, .chart-locked-card strong { color:#1a4fa0 !important; }
5182      .chart-locked-card code { background:rgba(0,0,0,0.08) !important; padding:1px 4px !important; border-radius:3px !important; }
5183    }
5184
5185    /* PDF-only chart variants container — hidden on screen, rendered in print */
5186    .pdf-variants-root{display:none;}
5187    .pdf-variant-group{margin-bottom:12px;background:#faf6f0;border:1px solid #e0d0c0;border-radius:10px;padding:12px 14px;}
5188    .pdf-variant-group-title{font-size:15px;font-weight:800;color:#3d2d26;margin:0 0 8px;padding-bottom:5px;border-bottom:2px solid #d37a4c;}
5189    .pdf-variant-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
5190    /* Solo charts (one per row) — constrained width, centred */
5191    .pdf-variant-grid.single-col{grid-template-columns:1fr;}
5192    .pdf-variant-grid.single-col .pdf-variant-panel{max-width:62%;margin:0 auto;}
5193    .pdf-variant-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b675b;margin:0 0 3px;}
5194    .pdf-variant-img{width:100%;height:auto;display:block;border-radius:6px;border:1px solid #ddd;}
5195
5196    #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%);}
5197    #rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
5198    /* Drifting color blobs — transform/opacity only (GPU composited, no per-frame repaint) */
5199    .rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
5200    .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;}
5201    .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;}
5202    .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;}
5203    @keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
5204    @keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
5205    @keyframes rpt-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(-7vw,8vw,0) scale(1.22);}}
5206    body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
5207    body.dark-theme .rpt-bg-blob{opacity:.36;}
5208    .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;}
5209    @keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
5210    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);}
5211    /* Logo is static — no bounce (kept GPU-cheap) */
5212    .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;}
5213    .rpt-spinner-wrap{position:relative;width:90px;height:90px;}
5214    .rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
5215    .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));}
5216    @keyframes rpt-spin{to{transform:rotate(360deg);}}
5217    .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;}
5218    body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
5219    body.dark-theme .rpt-spinner-pct{color:#e8932f;}
5220    .rpt-load-divider{width:54px;height:1px;background:linear-gradient(90deg,transparent,rgba(196,92,16,.22),transparent);}
5221    .rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
5222    .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;}
5223    @keyframes rpt-text-shimmer{to{background-position:-220% center;}}
5224    .rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
5225    .rpt-dot:nth-child(2){animation-delay:.28s;}
5226    .rpt-dot:nth-child(3){animation-delay:.56s;}
5227    @keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
5228    .rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
5229    .rpt-status-in{animation:rpt-status-pop .38s ease both;}
5230    @keyframes rpt-status-pop{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
5231    .rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
5232    .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;}
5233    body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
5234    .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;}
5235    .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;}
5236    .rpt-feed-line::before{content:'>';color:#c45c10;font-weight:700;}
5237    @keyframes rpt-feed-in{from{opacity:0;transform:translateX(-6px);}to{opacity:.82;transform:none;}}
5238    body.dark-theme .rpt-feed{color:rgba(204,172,150,.62);}
5239    body.dark-theme .rpt-load-divider{background:linear-gradient(90deg,transparent,rgba(196,92,16,.28),transparent);}
5240    @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;}}
5241    /* ── Code Style Analysis section ── */
5242    .style-guide-grid{display:grid;gap:10px;}
5243    .style-guide-row{display:grid;grid-template-columns:140px 1fr 52px;align-items:center;gap:10px;padding:6px 8px;border-radius:8px;cursor:default;position:relative;transition:transform .18s ease,box-shadow .18s ease,background .18s ease;}
5244    .style-guide-row:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(77,44,20,0.18);background:var(--surface-2);}
5245    .style-guide-label{font-size:12px;font-weight:800;color:var(--text);text-align:right;white-space:nowrap;}
5246    .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);}
5247    .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;}
5248    .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;}
5249    .style-guide-row:hover .style-guide-fill{filter:brightness(1.12);}
5250    .style-guide-score{font-size:12px;font-weight:800;color:var(--oxide);text-align:right;white-space:nowrap;}
5251    .style-guide-desc{font-size:10px;color:var(--muted);margin-top:2px;grid-column:2/3;}
5252    .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);}
5253    .style-bar-tip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:var(--text);}
5254    .style-guide-row:hover .style-bar-tip{opacity:1;}
5255    .style-metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:18px 0 0;}
5256    @media(max-width:800px){.style-metrics-strip{grid-template-columns:repeat(2,1fr);}}
5257    .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);}
5258    .style-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
5259    .style-chip-val{font-size:18px;font-weight:900;color:var(--oxide);}
5260    .style-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:3px;}
5261    .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;}
5262    .style-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
5263    .style-chip:hover .style-chip-tip{opacity:1;}
5264    .style-file-table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed;}
5265    .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;}
5266    .style-file-table th:hover{background:var(--surface-2);color:var(--text);}
5267    .style-sort-ind{display:inline-block;margin-left:4px;font-size:9px;opacity:.4;vertical-align:middle;}
5268    .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);}
5269    .style-file-table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
5270    .style-file-table tr:hover td{background:var(--surface-2);}
5271    .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;}
5272    .style-score-fill{position:absolute;left:0;top:0;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));}
5273    .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;}
5274    a.style-badge:hover{background:var(--oxide);color:#fff !important;transform:translateY(-1px);box-shadow:0 3px 10px rgba(77,44,20,.24);}
5275    .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(68,103,216,0.18);}
5276    .style-lang-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
5277    .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;}
5278    .style-lang-tab:hover{background:var(--surface-2);}
5279    .style-lang-tab.active{background:var(--oxide);color:#fff;border-color:var(--oxide);}
5280    .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;}
5281    .style-row-warn td{background:rgba(178,48,48,0.06)!important;}
5282    .style-row-warn td:first-child{border-left:3px solid #b23030;}
5283    .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;}
5284    .style-sig-more:hover{background:var(--oxide);color:#fff;}
5285    .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;}
5286    .style-sig-info-btn:hover{color:var(--oxide);}
5287    .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;}
5288    .style-sig-pop-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
5289    .style-sig-pop-row{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);}
5290    .style-sig-pop-row:last-child{border-bottom:none;}
5291    .style-sig-pop-key{color:var(--muted);font-weight:700;white-space:nowrap;flex-shrink:0;}
5292    .style-sig-pop-val{color:var(--text);}
5293    .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;}
5294    .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;}
5295    .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;}
5296    .style-sig-info-close:hover{color:var(--text);}
5297    .style-sig-info-grid{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin-top:14px;font-size:13px;}
5298    .style-sig-info-name{color:var(--oxide);font-weight:700;padding:2px 0;}
5299    .style-sig-info-desc{color:var(--text);padding:2px 0;}
5300    body.dark-theme .style-guide-track{background:var(--surface-3);}
5301    body.dark-theme .style-chip{background:var(--surface-2);}
5302    body.dark-theme .style-file-table th{background:var(--surface-3);}
5303    body.dark-theme .style-heuristic-note{border-color:rgba(100,130,255,0.22);}
5304    body.dark-theme .style-lang-tab{background:var(--surface-2);color:var(--text);}
5305    body.dark-theme .style-lang-tab.active{background:var(--oxide);color:#fff;}
5306    body.dark-theme .style-sig-chip{background:var(--surface-3);color:var(--muted);}
5307    body.dark-theme .style-sig-pop{background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.4);}
5308    body.dark-theme .style-sig-info-modal{background:var(--surface);}
5309    .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;}
5310    .sig-tip.visible{opacity:1;}
5311    .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);}
5312    .sig-tip-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:rgba(240,235,228,.5);margin-bottom:5px;}
5313    .sig-tip-row{display:flex;gap:8px;align-items:baseline;}
5314    .sig-tip-k{color:#e07b3a;font-weight:700;white-space:nowrap;flex-shrink:0;}
5315    .sig-tip-v{color:#f0ebe4;}
5316</style>
5317<script nonce="{{ nonce }}">{{ chart_js|safe }}</script>
5318</head>
5319<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
5320  <div id="rpt-loading-overlay" aria-live="polite" aria-label="Loading report">
5321    <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
5322    <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
5323    <div class="rpt-bg-blob rpt-blob-c" aria-hidden="true"></div>
5324    <div class="rpt-load-card">
5325      <img src="{{ small_logo_uri }}" alt="oxide-sloc" class="rpt-load-logo" />
5326      <div class="rpt-spinner-wrap">
5327        <div class="rpt-spinner-track"></div>
5328        <div class="rpt-spinner"></div>
5329        <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
5330      </div>
5331      <div class="rpt-load-divider"></div>
5332      <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>
5333      <div class="rpt-status" id="rpt-status">Initializing analysis engine</div>
5334      <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
5335      <div class="rpt-feed" id="rpt-feed" aria-hidden="true"></div>
5336    </div>
5337  </div>
5338  <script nonce="{{ nonce }}">
5339  (function(){
5340    var ov=document.getElementById('rpt-loading-overlay');if(!ov)return;
5341    var statusEl=document.getElementById('rpt-status'),bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),feed=document.getElementById('rpt-feed');
5342    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'];
5343    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'];
5344    var mi=0,li=0,prog=0,ready=false,start=Date.now(),MIN=1700;
5345    function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
5346    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++;}
5347    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);}
5348    nextMsg();addLog();setProg(6);
5349    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);
5350    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);}
5351    window.__rptFinish=function(){ready=true;setTimeout(done,Math.max(0,MIN-(Date.now()-start)));};
5352  })();
5353  </script>
5354  <div class="background-watermarks" aria-hidden="true">
5355    <img src="{{ logo_text_uri }}" alt="" />
5356    <img src="{{ logo_text_uri }}" alt="" />
5357    <img src="{{ logo_text_uri }}" alt="" />
5358    <img src="{{ logo_text_uri }}" alt="" />
5359    <img src="{{ logo_text_uri }}" alt="" />
5360    <img src="{{ logo_text_uri }}" alt="" />
5361    <img src="{{ logo_text_uri }}" alt="" />
5362    <img src="{{ logo_text_uri }}" alt="" />
5363    <img src="{{ logo_text_uri }}" alt="" />
5364    <img src="{{ logo_text_uri }}" alt="" />
5365    <img src="{{ logo_text_uri }}" alt="" />
5366    <img src="{{ logo_text_uri }}" alt="" />
5367  </div>
5368  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5369  {% if let Some(banner) = report_header_footer %}
5370  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
5371  {% endif %}
5372  <div class="top-nav">
5373    <div class="top-nav-inner">
5374      <a class="brand" href="/" data-local-brand="1">
5375        {% if let Some(uri) = custom_logo_uri %}
5376        <img class="brand-logo" src="{{ uri }}" alt="logo" />
5377        {% else %}
5378        <img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
5379        {% endif %}
5380        <div class="brand-copy">
5381          {% if let Some(name) = company_name %}
5382          <div class="brand-title">{{ name }}</div>
5383          {% else %}
5384          <div class="brand-title">OxideSLOC</div>
5385          {% endif %}
5386          <div class="brand-subtitle">Saved HTML report</div>
5387        </div>
5388      </a>
5389      <div class="nav-project-slot">
5390        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ title }}</span></div>
5391      </div>
5392      <div class="nav-status">
5393        <button type="button" class="header-button" data-copy-link>Copy link</button>
5394        <button type="button" class="header-button" data-share-report>Share</button>
5395        <div class="nav-dropdown-wrap">
5396          <button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
5397          <div class="nav-dropdown-menu">
5398            <button type="button" class="nav-dropdown-item" data-export-csv>Export CSV</button>
5399            <button type="button" class="nav-dropdown-item" data-export-xls>Export Excel</button>
5400          </div>
5401        </div>
5402        <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>
5403        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5404          <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>
5405        </button>
5406        <button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
5407          <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>
5408          <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>
5409        </button>
5410      </div>
5411    </div>
5412  </div>
5413
5414  <div class="page">
5415    <section class="hero panel">
5416      <div class="hero-top">
5417        <div>
5418          <div class="section-kicker">Saved report artifact</div>
5419          <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
5420            <h1>{{ title }}</h1>
5421            <span class="run-id-short-badge" title="Short run ID \u2014 matches the ID shown in View Reports">{{ run_id_short }}</span>
5422          </div>
5423        </div>
5424      </div>
5425      <div class="run-id-row">
5426            <span class="run-id-chip" data-copy="{{ run.tool.run_id }}">
5427              <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>
5428              <span class="run-id-chip-value">{{ run.tool.run_id }}</span>
5429              <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
5430            </span>
5431            {% if let Some(long_commit) = run.git_commit_long %}
5432            {% if let Some(commit_url) = git_commit_url %}
5433            <a class="run-id-chip run-id-chip-link" href="{{ commit_url }}" target="_blank" rel="noopener noreferrer" title="Open commit in source control">
5434              <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>
5435              <span class="run-id-chip-value">{{ long_commit }}</span>
5436              <span class="chip-tooltip">Opens commit in source control — new tab</span>
5437            </a>
5438            {% else %}
5439            <span class="run-id-chip" data-copy="{{ long_commit }}">
5440              <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>
5441              <span class="run-id-chip-value">{{ long_commit }}</span>
5442              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
5443            </span>
5444            {% endif %}
5445            {% else %}
5446            <span class="run-id-chip muted-chip">
5447              <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>
5448              <span class="run-id-chip-value">Not detected</span>
5449              <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
5450            </span>
5451            {% endif %}
5452            {% if let Some(branch) = run.git_branch %}
5453            {% if let Some(branch_url) = git_branch_url %}
5454            <a class="run-id-chip run-id-chip-link" href="{{ branch_url }}" target="_blank" rel="noopener noreferrer" title="Open branch in source control">
5455              <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>
5456              <span class="run-id-chip-value">{{ branch }}</span>
5457              <span class="chip-tooltip">Opens branch in source control — new tab</span>
5458            </a>
5459            {% else %}
5460            <span class="run-id-chip">
5461              <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>
5462              <span class="run-id-chip-value">{{ branch }}</span>
5463              <span class="chip-tooltip">Git branch scanned for this report</span>
5464            </span>
5465            {% endif %}
5466            {% else %}
5467            {% if is_sub_report %}
5468            <span class="run-id-chip">
5469              <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>
5470              <span class="run-id-chip-value"><span class="submodule-state-badge">detached HEAD</span></span>
5471              <span class="chip-tooltip">Submodules are pinned to a specific commit — no branch ref</span>
5472            </span>
5473            {% else %}
5474            <span class="run-id-chip muted-chip">
5475              <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>
5476              <span class="run-id-chip-value">Not detected</span>
5477              <span class="chip-tooltip">No Git branch was found for this scan</span>
5478            </span>
5479            {% endif %}
5480            {% endif %}
5481            {% if let Some(author) = run.git_commit_author %}
5482            <span class="run-id-chip" data-author="{{ author }}">
5483              <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>
5484              <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
5485              <span class="chip-tooltip">Author of the most recent commit in this repository</span>
5486            </span>
5487            {% else %}
5488            <span class="run-id-chip muted-chip">
5489              <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>
5490              <span class="run-id-chip-value">Not detected</span>
5491              <span class="chip-tooltip">No commit author was found for this scan</span>
5492            </span>
5493            {% endif %}
5494      </div>
5495
5496      <div class="meta">
5497        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
5498        <span class="meta-chip">Scanned <b>{{ scan_time_pst }}</b></span>
5499        <span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
5500        <span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
5501        <span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
5502      </div>
5503
5504      {% if has_delta %}
5505      <div class="prev-scan-banner" aria-label="Changes vs. previous scan">
5506        <div class="prev-scan-banner-top">
5507          <div class="prev-scan-meta">
5508            <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>
5509            {% 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 %}
5510            <span class="prev-scan-ts">{{ prev_scan_label }}</span>
5511            {% if prev_scan_count > 0 %}
5512            <span class="prev-scan-count">&#xb7; {{ prev_scan_count }} scan{% if prev_scan_count != 1 %}s{% endif %} total</span>
5513            {% endif %}
5514          </div>
5515          <div class="prev-scan-summary">
5516            Code before: <b data-raw="{{ prev_code_lines }}">{{ prev_code_lines }}</b>
5517            &nbsp;&rarr;&nbsp;
5518            Code now: <b data-raw="{{ run.summary_totals.code_lines }}">{{ run.summary_totals.code_lines }}</b>
5519            &nbsp;&#xb7;&nbsp;
5520            <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>
5521            &nbsp;
5522            <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>
5523          </div>
5524        </div>
5525        <div class="delta-card-row">
5526          <div class="delta-card-inline {% if delta_code_added > 0 %}pos{% endif %}">
5527            <div class="delta-card-val pos">+{{ delta_code_added|commas }}</div>
5528            <div class="delta-card-lbl">Lines added</div>
5529            <div class="delta-card-tip">Code lines added since {{ prev_scan_label }}</div>
5530          </div>
5531          <div class="delta-card-inline {% if delta_code_removed > 0 %}neg{% endif %}">
5532            <div class="delta-card-val neg">&minus;{{ delta_code_removed|commas }}</div>
5533            <div class="delta-card-lbl">Lines removed</div>
5534            <div class="delta-card-tip">Code lines removed since {{ prev_scan_label }}</div>
5535          </div>
5536          <div class="delta-card-inline">
5537            <div class="delta-card-val">{{ delta_unmodified_lines|commas }}</div>
5538            <div class="delta-card-lbl">Unmodified lines</div>
5539            <div class="delta-card-tip">Code lines unchanged since {{ prev_scan_label }}</div>
5540          </div>
5541          <div class="delta-card-inline {% if delta_files_modified > 0 %}mod{% endif %}">
5542            <div class="delta-card-val mod">{{ delta_files_modified|commas }}</div>
5543            <div class="delta-card-lbl">Files modified</div>
5544            <div class="delta-card-tip">Files with at least one line changed</div>
5545          </div>
5546          <div class="delta-card-inline {% if delta_files_added > 0 %}pos{% endif %}">
5547            <div class="delta-card-val pos">{{ delta_files_added|commas }}</div>
5548            <div class="delta-card-lbl">Files added</div>
5549            <div class="delta-card-tip">New files added since {{ prev_scan_label }}</div>
5550          </div>
5551          <div class="delta-card-inline {% if delta_files_removed > 0 %}neg{% endif %}">
5552            <div class="delta-card-val neg">{{ delta_files_removed|commas }}</div>
5553            <div class="delta-card-lbl">Files removed</div>
5554            <div class="delta-card-tip">Files deleted since {{ prev_scan_label }}</div>
5555          </div>
5556          <div class="delta-card-inline">
5557            <div class="delta-card-val">{{ delta_files_unchanged|commas }}</div>
5558            <div class="delta-card-lbl">Files unchanged</div>
5559            <div class="delta-card-tip">Files with no changes since {{ prev_scan_label }}</div>
5560          </div>
5561        </div>
5562      </div>
5563      {% else %}
5564      <div class="prev-scan-banner prev-scan-banner-empty" aria-label="No previous scan">
5565        <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>
5566        No previous scan found for this project &#x2014; this report is the baseline.
5567      </div>
5568      {% endif %}
5569
5570      <div class="summary-grid">
5571        <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>
5572        <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>
5573        <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>
5574        <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>
5575        <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>
5576        <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>
5577        <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>
5578        <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>
5579        <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>
5580        <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>
5581        <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>
5582        <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>
5583        {% 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 %}
5584        {% 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 %}
5585        {% 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 %}
5586        {% 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 %}
5587        {% 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 %}
5588        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
5589             odd, so the strip always has an even number of cards that fill exactly
5590             two aligned rows (no oversized card, no empty trailing cell). -->
5591        <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>
5592      </div>
5593    </section>
5594
5595    <!-- ── PDF-only pre-rendered chart variants (hidden on screen) ─────── -->
5596    <div id="pdf-variants" class="pdf-variants-root"></div>
5597
5598    <div class="report-stack">
5599      <!-- ── Chart row 1: Overview + Composition ───────────────────────── -->
5600      <div class="charts-grid">
5601        <section class="panel stack chart-section">
5602          <div>
5603            <div class="toolbar">
5604              <div class="toolbar-left"><h2>Project Overview</h2></div>
5605              <button class="chart-expand-btn" id="overview-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5606            </div>
5607            <div class="chart-pre">
5608            <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>
5609            <div class="chart-controls">
5610              <label>Y Axis:
5611                <select class="chart-select" id="overview-y-axis">
5612                  <option value="code">Code Lines</option>
5613                  <option value="comments">Comment Lines</option>
5614                  <option value="blanks">Blank Lines</option>
5615                  <option value="physical">Total Physical Lines</option>
5616                  <option value="files">File Count</option>
5617                </select>
5618              </label>
5619              <label>X Axis / Mode:
5620                <select class="chart-select" id="overview-x-mode">
5621                  <option value="languages">Languages</option>
5622                  {% if has_submodule_data %}<option value="submodules">Submodules</option>{% endif %}
5623                  <option value="history-commits">Per Commit (Web UI)</option>
5624                  <option value="history-tags">Per Tag (Web UI)</option>
5625                  <option value="history-releases">Per Release (Web UI)</option>
5626                  <option value="history-repos">Other Repos (Web UI)</option>
5627                </select>
5628              </label>
5629            </div>
5630            </div>
5631            <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>
5632            <div class="chart-locked-card" id="overview-chart-locked">
5633              <h3>Historical trend requires the web UI</h3>
5634              <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>
5635            </div>
5636          </div>
5637        </section>
5638
5639        <section class="panel stack chart-section">
5640          <div>
5641            <div class="toolbar">
5642              <div class="toolbar-left"><h2>Language Composition</h2></div>
5643              <button class="chart-expand-btn" id="comp-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5644            </div>
5645            <div class="chart-pre">
5646            <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>
5647            <div class="chart-tab-bar">
5648              <button type="button" class="chart-tab active" data-comp-tab="absolute">Absolute Lines</button>
5649              <button type="button" class="chart-tab" data-comp-tab="pct">Composition %</button>
5650            </div>
5651            </div>
5652            <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>
5653          </div>
5654        </section>
5655      </div>
5656
5657      <!-- ── Chart row 2: Scatter + Semantic ───────────────────────────── -->
5658      <div class="charts-grid">
5659        <section class="panel stack chart-section">
5660          <div>
5661            <div class="toolbar">
5662              <div class="toolbar-left"><h2>Files vs Code Lines</h2></div>
5663              <button class="chart-expand-btn" id="scatter-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5664            </div>
5665            <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>
5666            <div id="scatter-chart" class="chart-container" style="position:relative;height:224px;"><canvas id="canvas-scatter"></canvas></div>
5667          </div>
5668        </section>
5669
5670        <section class="panel stack chart-section">
5671          <div>
5672            <div class="toolbar">
5673              <div class="toolbar-left"><h2>Semantic Metrics</h2></div>
5674              {% if has_semantic_data %}
5675              <button class="chart-expand-btn" id="semantic-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5676              {% endif %}
5677            </div>
5678            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Detected structural elements per language. Select a metric to explore.</p>
5679            {% if has_semantic_data %}
5680            <div class="chart-controls">
5681              <label>Metric:
5682                <select class="chart-select" id="semantic-metric">
5683                  <option value="functions">Functions</option>
5684                  <option value="classes">Classes / Types</option>
5685                  <option value="variables">Variables</option>
5686                  <option value="imports">Imports</option>
5687                  <option value="tests">Tests</option>
5688                </select>
5689              </label>
5690            </div>
5691            <div id="semantic-chart" class="chart-container" style="position:relative;height:234px;"><canvas id="canvas-semantic"></canvas></div>
5692            {% else %}
5693            <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;">
5694              <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>
5695            </div>
5696            {% endif %}
5697          </div>
5698        </section>
5699        <section class="panel stack chart-section">
5700          <div>
5701            <div class="toolbar">
5702              <div class="toolbar-left"><h2>Comment Density</h2></div>
5703              <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5704            </div>
5705            <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>
5706            <div id="density-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-density"></canvas></div>
5707          </div>
5708        </section>
5709
5710        <section class="panel stack chart-section">
5711          <div>
5712            <div class="toolbar">
5713              <div class="toolbar-left"><h2>File Size Distribution</h2></div>
5714              <button class="chart-expand-btn" id="filesize-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5715            </div>
5716            <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>
5717            <div id="filesize-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-filesize"></canvas></div>
5718          </div>
5719        </section>
5720      </div>
5721
5722      <!-- ── Tests & Coverage ──────────────────────────────────────────── -->
5723      <section class="panel stack">
5724        <div>
5725          <div class="toolbar">
5726            <div class="toolbar-left"><h2>Tests &amp; Coverage</h2></div>
5727            {% if has_coverage_data %}<div class="pill-row"><span class="pill good">LCOV coverage data present</span></div>{% endif %}
5728          </div>
5729          <div class="summary-strip">
5730            <div class="stat-chip">
5731              <div class="stat-chip-val" data-fmt="{{ run.summary_totals.test_count }}">{{ run.summary_totals.test_count|commas }}</div>
5732              <div class="stat-chip-label">Test Functions</div>
5733              <div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div>
5734              <span class="stat-chip-exact">{{ run.summary_totals.test_count|commas }}</span>
5735            </div>
5736            <div class="stat-chip">
5737              <div class="stat-chip-val" data-fmt="{{ test_assertion_count }}">{{ test_assertion_count|commas }}</div>
5738              <div class="stat-chip-label">Assertions</div>
5739              <div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div>
5740              <span class="stat-chip-exact">{{ test_assertion_count|commas }}</span>
5741            </div>
5742            <div class="stat-chip">
5743              <div class="stat-chip-val" data-fmt="{{ test_suite_count }}">{{ test_suite_count|commas }}</div>
5744              <div class="stat-chip-label">Test Suites</div>
5745              <div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div>
5746              <span class="stat-chip-exact">{{ test_suite_count|commas }}</span>
5747            </div>
5748            <div class="stat-chip">
5749              <div class="stat-chip-val">{{ test_files_count|commas }} / {{ run.summary_totals.files_analyzed|commas }}</div>
5750              <div class="stat-chip-label">Test Files</div>
5751              <div class="stat-chip-tip">Files containing at least one detected test definition out of total analyzed files</div>
5752            </div>
5753          </div>
5754          <div class="summary-strip" style="margin-top:0;">
5755            <div class="stat-chip">
5756              <div class="stat-chip-val">{{ test_density }}</div>
5757              <div class="stat-chip-label">Tests per 1K SLOC</div>
5758              <div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div>
5759            </div>
5760            <div class="stat-chip">
5761              <div class="stat-chip-val" style="font-size:15px;word-break:break-word;line-height:1.2;">{{ most_tested_lang }}</div>
5762              <div class="stat-chip-label">Most Tested Language</div>
5763              <div class="stat-chip-tip">Language with the highest absolute test function count</div>
5764            </div>
5765            <div class="stat-chip">
5766              <div class="stat-chip-val">{{ langs_with_tests }}</div>
5767              <div class="stat-chip-label">Languages with Tests</div>
5768              <div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div>
5769            </div>
5770            <div class="stat-chip">
5771              {% 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 %}
5772              <div class="stat-chip-label">Line Coverage</div>
5773              <div class="stat-chip-tip">Overall line coverage from LCOV data — run with --lcov-path to populate</div>
5774            </div>
5775          </div>
5776          {% if has_coverage_data %}
5777          <div class="cov-gauge-row">
5778            <div class="cov-gauge-card">
5779              <div class="cov-gauge-label">Line Coverage</div>
5780              <div class="cov-gauge-val" style="color:var(--{{ cov_line_class }}-text);">{{ cov_line_pct }}%</div>
5781              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_line_pct }}%;background:var(--{{ cov_line_class }}-text);"></div></div>
5782              <div class="cov-gauge-sub">Lines hit / instrumented</div>
5783            </div>
5784            {% if has_fn_coverage %}
5785            <div class="cov-gauge-card">
5786              <div class="cov-gauge-label">Function Coverage</div>
5787              <div class="cov-gauge-val" style="color:var(--{{ cov_fn_class }}-text);">{{ cov_fn_pct }}%</div>
5788              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_fn_pct }}%;background:var(--{{ cov_fn_class }}-text);"></div></div>
5789              <div class="cov-gauge-sub">Functions hit / found</div>
5790            </div>
5791            {% endif %}
5792            {% if has_branch_coverage %}
5793            <div class="cov-gauge-card">
5794              <div class="cov-gauge-label">Branch Coverage</div>
5795              <div class="cov-gauge-val" style="color:var(--{{ cov_branch_class }}-text);">{{ cov_branch_pct }}%</div>
5796              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_branch_pct }}%;background:var(--{{ cov_branch_class }}-text);"></div></div>
5797              <div class="cov-gauge-sub">Branches hit / found</div>
5798            </div>
5799            {% endif %}
5800          </div>
5801          {% endif %}
5802          <div class="table-shell" style="margin-top:16px;">
5803            <table data-sort-table style="min-width:560px;">
5804              <thead>
5805                <tr>
5806                  <th data-sort-type="text">Language</th>
5807                  <th data-sort-type="number">Test Fns</th>
5808                  <th data-sort-type="number">Assertions</th>
5809                  <th data-sort-type="number">Suites</th>
5810                  <th data-sort-type="text">Density (per 1K SLOC)</th>
5811                </tr>
5812              </thead>
5813              <tbody>
5814                {% for row in language_rows %}
5815                {% if row.test_count > 0 || row.test_assertion_count > 0 %}
5816                <tr>
5817                  <td>{{ row.language }}</td>
5818                  <td>{{ row.test_count|commas }}</td>
5819                  <td>{{ row.test_assertion_count|commas }}</td>
5820                  <td>{{ row.test_suite_count|commas }}</td>
5821                  <td>{{ row.test_density_str }}</td>
5822                </tr>
5823                {% endif %}
5824                {% endfor %}
5825                {% if run.summary_totals.test_count == 0 && test_assertion_count == 0 %}
5826                <tr class="empty-state-row"><td colspan="5">No test functions or assertions detected in this scan</td></tr>
5827                {% endif %}
5828              </tbody>
5829            </table>
5830          </div>
5831          {% if has_coverage_data %}
5832          <div class="table-shell" style="margin-top:16px;">
5833            <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
5834              <h3 style="margin:0;font-size:14px;font-weight:800;color:var(--text);">Per-File Coverage</h3>
5835              <span class="pill good" style="font-size:10px;">{{ file_rows.len() }} files with data</span>
5836            </div>
5837            <table data-sort-table style="min-width:560px;">
5838              <thead>
5839                <tr>
5840                  <th data-sort-type="text">File</th>
5841                  <th data-sort-type="number">Line Cov %</th>
5842                  <th data-sort-type="text">Lines Hit / Found</th>
5843                  {% if has_fn_coverage %}<th data-sort-type="number">Fn Cov %</th>{% endif %}
5844                  {% if has_branch_coverage %}<th data-sort-type="number">Branch Cov %</th>{% endif %}
5845                </tr>
5846              </thead>
5847              <tbody>
5848                {% for row in file_rows %}
5849                {% if !row.line_cov_pct.is_empty() %}
5850                <tr>
5851                  <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>
5852                  <td class="num-col">{{ row.line_cov_pct }}%</td>
5853                  <td class="num-col" style="font-size:11px;color:var(--muted);">{{ row.cov_lines_detail }}</td>
5854                  {% if has_fn_coverage %}<td class="num-col">{% if !row.fn_cov_pct.is_empty() %}{{ row.fn_cov_pct }}%{% else %}&mdash;{% endif %}</td>{% endif %}
5855                  {% if has_branch_coverage %}<td class="num-col">{% if !row.branch_cov_pct.is_empty() %}{{ row.branch_cov_pct }}%{% else %}&mdash;{% endif %}</td>{% endif %}
5856                </tr>
5857                {% endif %}
5858                {% endfor %}
5859                {% if file_rows.is_empty() %}
5860                <tr class="empty-state-row"><td colspan="5">No per-file coverage data available</td></tr>
5861                {% endif %}
5862              </tbody>
5863            </table>
5864          </div>
5865          {% else %}
5866          <div class="info-callout">
5867            <span class="info-callout-icon">&#x2139;&#xFE0F;</span>
5868            <span>No code coverage detected. Re-run with <code>--lcov-path coverage.info</code> to see line, function, and branch coverage here.</span>
5869          </div>
5870          {% endif %}
5871        </div>
5872      </section>
5873
5874      <!-- ── Multi-Language Code Style Analysis ───────────────────────── -->
5875      {% if has_style_data %}
5876      {% if let Some(ss) = style_summary %}
5877      <section class="panel stack">
5878        <div>
5879          <div class="toolbar">
5880            <div class="toolbar-left"><h2>Code Style Analysis</h2></div>
5881            <div class="pill-row"><span class="pill info">{{ style_lang_count }} language group(s) &#xB7; Lexical heuristics</span></div>
5882          </div>
5883          <div class="style-heuristic-note">
5884            <span class="info-callout-icon">&#x2139;&#xFE0F;</span>
5885            <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>
5886          </div>
5887          <!-- Summary chips -->
5888          <div class="style-metrics-strip">
5889            <div class="style-chip">
5890              <div class="style-chip-val">{{ ss.files_analyzed }}</div>
5891              <div class="style-chip-label">Files Analyzed</div>
5892              <div class="style-chip-tip">Total files with style data</div>
5893            </div>
5894            <div class="style-chip">
5895              <div class="style-chip-val">{{ style_lang_count }}</div>
5896              <div class="style-chip-label">Language Groups</div>
5897              <div class="style-chip-tip">Distinct language families detected</div>
5898            </div>
5899            <div class="style-chip">
5900              <div class="style-chip-val">{{ ss.common_indent_style }}</div>
5901              <div class="style-chip-label">Common Indent</div>
5902              <div class="style-chip-tip">Most prevalent indentation across all files</div>
5903            </div>
5904            <div class="style-chip">
5905              <div class="style-chip-val">{{ ss.line_col_compliant_pct }}%</div>
5906              <div class="style-chip-label">{{ ss.col_threshold }}-Col Compliant</div>
5907              <div class="style-chip-tip">Files where &le;5% of lines exceed {{ ss.col_threshold }} chars</div>
5908            </div>
5909          </div>
5910          <!-- Language selector tab strip -->
5911          <div style="margin-top:20px;">
5912            <div style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:10px;">Style Guide Adherence by Language</div>
5913            <div id="style-lang-tabs" class="style-lang-tabs"></div>
5914            <div class="style-guide-grid" id="style-guide-bars"></div>
5915          </div>
5916          <!-- Per-file style table -->
5917          <div style="margin-top:22px;">
5918            <div class="toolbar" style="margin-bottom:8px;">
5919              <div class="toolbar-left">
5920                <span style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);">Per-File Style Details</span>
5921                <input id="sft-search" class="search" type="search" placeholder="Filter files, languages, guides..." style="margin-left:12px;" />
5922                <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>
5923              </div>
5924            </div>
5925            <div class="table-scroll-wrap">
5926              <table class="style-file-table" id="style-file-table">
5927                <thead>
5928                  <tr>
5929                    <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>
5930                    <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>
5931                    <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>
5932                    <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>
5933                    <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>
5934                    <th style="width:13%;" title="Hover a row to see all signals \u2014 signal name and detected value.">Signals</th>
5935                  </tr>
5936                </thead>
5937                <tbody id="style-file-tbody">
5938                  <tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">Loading...</td></tr>
5939                </tbody>
5940              </table>
5941            </div>
5942            <div id="sft-pagination" class="pagination-bar">
5943              <button id="sft-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
5944              <button id="sft-prev" class="pager-btn" disabled>&#8592; Prev</button>
5945              <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>
5946              <span id="sft-page-info" class="pager-info"></span>
5947              <button id="sft-next" class="pager-btn">Next &#8594;</button>
5948              <button id="sft-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
5949            </div>
5950          </div>
5951        </div>
5952      </section>
5953      {% endif %}
5954      {% endif %}
5955
5956      <!-- ── Submodule Breakdown (2-column, conditional) ─────────────── -->
5957      {% if has_submodule_data %}
5958      <div class="charts-grid">
5959        <section class="panel stack chart-section">
5960          <div>
5961            <div class="toolbar">
5962              <div class="toolbar-left"><h2>Submodule Breakdown</h2></div>
5963              <button class="chart-expand-btn" id="sub-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5964            </div>
5965            <div class="chart-controls">
5966              <label>Y Axis:
5967                <select class="chart-select" id="sub-y-axis">
5968                  <option value="code">Code Lines</option>
5969                  <option value="comment">Comment Lines</option>
5970                  <option value="blank">Blank Lines</option>
5971                  <option value="physical">Total Physical Lines</option>
5972                  <option value="files">File Count</option>
5973                </select>
5974              </label>
5975              <label>Sort:
5976                <select class="chart-select" id="sub-sort">
5977                  <option value="desc">Value ↓</option>
5978                  <option value="asc">Value ↑</option>
5979                  <option value="name">Name A→Z</option>
5980                </select>
5981              </label>
5982            </div>
5983            <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>
5984          </div>
5985        </section>
5986        <section class="panel stack chart-section">
5987          <div>
5988            <div class="toolbar">
5989              <div class="toolbar-left"><h2>Submodule Composition</h2></div>
5990              <button class="chart-expand-btn" id="sub-comp-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5991            </div>
5992            <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>
5993            <div id="submodule-donut" style="width:100%;padding:4px 0;overflow:hidden;"></div>
5994          </div>
5995        </section>
5996      </div>
5997      {% endif %}
5998
5999      {% if has_cocomo %}
6000      <section class="panel" id="cocomo-section">
6001        <div class="toolbar">
6002          <div class="toolbar-left">
6003            <h2>Constructive Cost Model &mdash; COCOMO I</h2>
6004            <span class="cocomo-mode-pill-wrap" style="margin-left:12px;">
6005              <span class="pill" style="background:var(--surface-3);color:var(--muted);border:1px solid var(--line);font-size:11px;">{{ cocomo_mode_label }} mode</span>
6006              <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
6007            </span>
6008          </div>
6009        </div>
6010        <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
6011          <div class="stat-chip">
6012            <div class="stat-chip-label">Person-months</div>
6013            <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
6014            <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>
6015          </div>
6016          <div class="stat-chip">
6017            <div class="stat-chip-label">Schedule (months)</div>
6018            <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
6019            <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>
6020          </div>
6021          <div class="stat-chip">
6022            <div class="stat-chip-label">Avg. Team Size</div>
6023            <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
6024            <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>
6025          </div>
6026          <div class="stat-chip">
6027            <div class="stat-chip-label">Input KSLOC</div>
6028            <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
6029            <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>
6030          </div>
6031        </div>
6032        <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>
6033      </section>
6034      {% endif %}
6035
6036      {% if has_hotspots %}
6037      <section class="panel stack" id="hotspots-section">
6038        <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>
6039        <p style="font-size:13px;color:var(--muted);padding:4px 4px 10px;line-height:1.6;">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. <span class="hs-hint">Click a column header to sort; drag its right edge to resize; hover a header for what it means.</span></p>
6040        <div class="table-shell">
6041          <table id="hotspots-table" data-sort-table class="table-resizable hotspots-table">
6042            <colgroup><col><col><col><col><col></colgroup>
6043            <thead><tr>
6044              <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>
6045              <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>
6046              <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>
6047              <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>
6048              <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>
6049            </tr></thead>
6050            <tbody>
6051            {% for h in hotspot_rows %}
6052              <tr>
6053                <td class="mono" title="{{ h.path }}">{{ h.path }}</td>
6054                <td class="num-col">{{ h.code_lines|commas }}</td>
6055                <td class="num-col">{{ h.commit_count }}</td>
6056                <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ h.score|commas }}</td>
6057                <td class="num-col" style="color:var(--muted);">{{ h.last_commit_date }}</td>
6058              </tr>
6059            {% endfor %}
6060            </tbody>
6061          </table>
6062        </div>
6063        <div id="hotspots-pagination" class="pagination-bar">
6064          <button id="hs-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6065          <button id="hs-prev" class="pager-btn" disabled>&#8592; Prev</button>
6066          <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>
6067          <span id="hs-page-info" class="pager-info"></span>
6068          <button id="hs-next" class="pager-btn">Next &#8594;</button>
6069          <button id="hs-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6070        </div>
6071      </section>
6072      {% endif %}
6073
6074      <section class="panel stack">
6075        <div>
6076          <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>
6077          <div id="report-lang-overview" style="margin:0 0 16px;"></div>
6078          <div class="table-shell">
6079            <table id="lang-breakdown-table" data-sort-table class="table-resizable">
6080              <colgroup>
6081                <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6082              </colgroup>
6083              <thead>
6084                <tr>
6085                  <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6086                  <th data-sort-type="number" class="num-col">Files<div class="col-resize-handle"></div></th>
6087                  <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6088                  <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6089                  <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6090                  <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6091                  <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6092                  <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6093                  <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6094                  <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6095                  <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6096                  <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6097                  <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6098                  <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6099                </tr>
6100              </thead>
6101              <tbody>
6102                {% for row in language_rows %}
6103                <tr>
6104                  <td title="{{ row.language }}">{{ row.language }}</td>
6105                  <td class="num-col">{{ row.files|commas }}</td>
6106                  <td class="num-col">{{ row.total_physical_lines|commas }}</td>
6107                  <td class="num-col">{{ row.code_lines|commas }}</td>
6108                  <td class="num-col">{{ row.comment_lines|commas }}</td>
6109                  <td class="num-col">{{ row.blank_lines|commas }}</td>
6110                  <td class="num-col">{{ row.mixed_lines_separate|commas }}</td>
6111                  <td class="num-col">{{ row.functions|commas }}</td>
6112                  <td class="num-col">{{ row.classes|commas }}</td>
6113                  <td class="num-col">{{ row.variables|commas }}</td>
6114                  <td class="num-col">{{ row.imports|commas }}</td>
6115                  <td class="num-col">{{ row.test_count|commas }}</td>
6116                  <td class="num-col">{{ row.test_assertion_count|commas }}</td>
6117                  <td class="num-col">{{ row.test_suite_count|commas }}</td>
6118                </tr>
6119                {% endfor %}
6120              </tbody>
6121            </table>
6122          </div>
6123        </div>
6124      </section>
6125
6126      <section class="panel stack">
6127        <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>
6128        <div class="table-shell table-shell-clip">
6129        <div id="per-file-shell">
6130          <table id="per-file-table" data-sort-table class="table-resizable">
6131            <colgroup>
6132              <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6133            </colgroup>
6134            <thead>
6135              <tr>
6136                <th data-sort-type="text">File<div class="col-resize-handle"></div></th>
6137                <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6138                <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6139                <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6140                <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6141                <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6142                <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6143                <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6144                <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6145                <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6146                <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6147                <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6148                <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6149                <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6150                {% 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 %}
6151              </tr>
6152            </thead>
6153            <tbody>
6154              {% for row in file_rows %}
6155              <tr>
6156                <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6157                <td title="{{ row.language }}">{{ row.language }}</td>
6158                <td class="num-col">{{ row.total_physical_lines }}</td>
6159                <td class="num-col">{{ row.code_lines }}</td>
6160                <td class="num-col">{{ row.comment_lines }}</td>
6161                <td class="num-col">{{ row.blank_lines }}</td>
6162                <td class="num-col">{{ row.mixed_lines_separate }}</td>
6163                <td class="num-col">{{ row.functions }}</td>
6164                <td class="num-col">{{ row.classes }}</td>
6165                <td class="num-col">{{ row.variables }}</td>
6166                <td class="num-col">{{ row.imports }}</td>
6167                <td class="num-col">{{ row.test_count }}</td>
6168                <td class="num-col">{{ row.test_assertion_count }}</td>
6169                <td class="num-col">{{ row.test_suite_count }}</td>
6170                {% if has_coverage_data %}<td class="num-col">{{ row.line_cov_pct }}</td><td class="num-col">{{ row.fn_cov_pct }}</td>{% endif %}
6171              </tr>
6172              {% endfor %}
6173            </tbody>
6174          </table>
6175        </div>
6176        </div>
6177        <div id="per-file-pagination" class="pagination-bar">
6178          <button id="pf-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6179          <button id="pf-prev" class="pager-btn" disabled>&#8592; Prev</button>
6180          <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>
6181          <span id="pf-page-info" class="pager-info"></span>
6182          <button id="pf-next" class="pager-btn">Next &#8594;</button>
6183          <button id="pf-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6184        </div>
6185      </section>
6186
6187      <section class="panel stack">
6188        <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>
6189        <div class="table-shell table-shell-clip" style="margin-top:6px;">
6190        <div id="skipped-shell">
6191          <table id="skipped-table" data-sort-table class="table-resizable">
6192            <thead>
6193              <tr>
6194                <th data-sort-type="text" style="width:42%">File</th>
6195                <th data-sort-type="text" style="width:20%">Status</th>
6196                <th data-sort-type="text" style="width:38%">Warnings</th>
6197              </tr>
6198            </thead>
6199            <tbody>
6200              {% for row in skipped_rows %}
6201              <tr>
6202                <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6203                <td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
6204                <td class="small" title="{{ row.warnings }}">{{ row.warnings }}</td>
6205              </tr>
6206              {% endfor %}
6207            </tbody>
6208          </table>
6209        </div>
6210        </div>
6211        <div id="skipped-pagination" class="pagination-bar">
6212          <button id="sk-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6213          <button id="sk-prev" class="pager-btn" disabled>&#8592; Prev</button>
6214          <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>
6215          <span id="sk-page-info" class="pager-info"></span>
6216          <button id="sk-next" class="pager-btn">Next &#8594;</button>
6217          <button id="sk-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6218        </div>
6219      </section>
6220
6221      <section class="panel stack">
6222        <div>
6223          <div class="toolbar">
6224            <div class="toolbar-left"><h2>Diagnostics &amp; Configuration</h2></div>
6225            {% 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 %}
6226          </div>
6227          <p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
6228        </div>
6229
6230        {% if !is_sub_report %}
6231        <div style="margin-top:-14px;">
6232          <h3 style="margin:0 0 4px;">Warnings overview</h3>
6233          <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>
6234          {% if !has_run_warnings %}
6235            <div class="pill good">No top-level warnings.</div>
6236          {% else %}
6237            <div class="table-shell">
6238              <table class="support-table">
6239                <thead>
6240                  <tr><th style="width:30%;">Category</th><th style="width:8%;">Count</th><th>What this means</th></tr>
6241                </thead>
6242                <tbody>
6243                  {% for row in warning_summary_rows %}
6244                  <tr class="{{ row.tone_class }}">
6245                    <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6246                    <td class="warning-count" style="font-weight:800;">{{ row.count }}</td>
6247                    <td class="small" style="color:var(--muted);">{{ row.detail }}</td>
6248                  </tr>
6249                  {% endfor %}
6250                </tbody>
6251              </table>
6252            </div>
6253          {% endif %}
6254        </div>
6255
6256        <div>
6257          <h3 style="margin:0 0 4px;">Skipped file categories</h3>
6258          <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>
6259          {% if warning_opportunity_rows.is_empty() %}
6260            <div class="pill good">No unsupported text-format buckets detected.</div>
6261          {% else %}
6262          <div class="table-shell">
6263            <table class="support-table">
6264              <thead>
6265                <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>
6266              </thead>
6267              <tbody>
6268                {% for row in warning_opportunity_rows %}
6269                <tr>
6270                  <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6271                  <td style="font-weight:800;color:var(--oxide);">{{ row.count }}</td>
6272                  <td class="small" style="color:var(--muted);">{{ row.bucket_description }}</td>
6273                  <td>
6274                    {% if !row.example_files.is_empty() %}
6275                    <div style="margin-bottom:6px;">
6276                      {% for f in row.example_files %}<span class="support-example-file">{{ f }}</span> {% endfor %}
6277                      {% 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 %}
6278                    </div>
6279                    {% endif %}
6280                    <p class="support-recommendation">{{ row.recommendation }}</p>
6281                  </td>
6282                </tr>
6283                {% endfor %}
6284              </tbody>
6285            </table>
6286          </div>
6287          {% endif %}
6288        </div>
6289
6290        <div>
6291          <details open class="warnings-details">
6292            <summary>Detailed run warnings ({{ warning_count }})</summary>
6293            <div>
6294              <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>
6295              {% if !has_run_warnings %}
6296                <div class="pill good">No top-level warnings.</div>
6297              {% else %}
6298                <div class="code-block-toolbar">
6299                  <button type="button" class="code-copy-btn" id="warning-console-copy-btn" aria-label="Copy warnings">Copy</button>
6300                </div>
6301                <pre class="warning-console" id="warning-console-full" style="max-height:210px;">{{ warning_console_full }}</pre>
6302              {% endif %}
6303            </div>
6304          </details>
6305        </div>
6306        {% endif %}
6307
6308        <div>
6309          <details open>
6310            <summary>Effective configuration</summary>
6311            <div>
6312              <div style="display:flex;gap:8px;margin-bottom:10px;">
6313                <button type="button" class="export-btn" data-copy-config>Copy</button>
6314                <button type="button" class="export-btn" data-download-config>Download</button>
6315              </div>
6316              <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>
6317              <div class="config-pre-wrap">
6318                <div class="code-block-toolbar">
6319                  <button type="button" class="code-copy-btn" id="config-inline-copy-btn" aria-label="Copy configuration">Copy</button>
6320                </div>
6321                <pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
6322              </div>
6323            </div>
6324          </details>
6325        </div>
6326      </section>
6327    </div>
6328  </div>
6329
6330  <div id="r-tt" aria-hidden="true"></div>
6331  <script nonce="{{ nonce }}">
6332    // Hide "View PDF" button and block brand-link navigation when opened as a local file
6333    (function () {
6334      var pdfBtn = document.getElementById('nav-view-pdf-btn');
6335      if (pdfBtn && window.location.protocol === 'file:') {
6336        pdfBtn.style.display = 'none';
6337      }
6338      var brand = document.querySelector('a[data-local-brand]');
6339      if (brand && window.location.protocol === 'file:') {
6340        brand.addEventListener('click', function (e) { e.preventDefault(); });
6341      }
6342    })();
6343
6344    (function () {
6345      var body = document.body;
6346      var storageKey = 'oxide-sloc-theme';
6347      var themeToggle = document.querySelector('[data-theme-toggle]');
6348      var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
6349      var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
6350      var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
6351
6352      function applyTheme(theme) {
6353        body.classList.toggle('dark-theme', theme === 'dark');
6354      }
6355
6356      function currentTheme() {
6357        return body.classList.contains('dark-theme') ? 'dark' : 'light';
6358      }
6359
6360      try {
6361        var saved = localStorage.getItem(storageKey);
6362        if (saved === 'dark' || saved === 'light') {
6363          applyTheme(saved);
6364        }
6365      } catch (e) {}
6366
6367      if (themeToggle) {
6368        themeToggle.addEventListener('click', function () {
6369          var next = currentTheme() === 'dark' ? 'light' : 'dark';
6370          applyTheme(next);
6371          try { localStorage.setItem(storageKey, next); } catch (e) {}
6372        });
6373      }
6374
6375      function copyText(value) {
6376        if (!value) return;
6377        if (navigator.clipboard && navigator.clipboard.writeText) {
6378          navigator.clipboard.writeText(value).catch(function () {});
6379        }
6380      }
6381
6382      copyLinkButtons.forEach(function (button) {
6383        button.addEventListener('click', function () {
6384          copyText(window.location.href);
6385        });
6386      });
6387
6388      shareButtons.forEach(function (button) {
6389        button.addEventListener('click', function () {
6390          if (navigator.share) {
6391            navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
6392          } else {
6393            copyText(window.location.href);
6394          }
6395        });
6396      });
6397
6398      printButtons.forEach(function (button) {
6399        button.addEventListener('click', function () {
6400          window.print();
6401        });
6402      });
6403
6404      // "View PDF" nav button.
6405      // Priority order:
6406      //  1. data-standalone-pdf attr — pre-generated PDF in the same directory
6407      //     (set when oxide-sloc CLI was invoked with both --html-out and
6408      //     --pdf-out). Opens the file directly; works in Jenkins HTML Publisher.
6409      //  2. Server route (/runs/pdf/<id>) — oxide-sloc web server generates
6410      //     the PDF on demand via headless Chrome. Checked via HEAD request.
6411      //  3. Neither available — inform the user how to generate a PDF via CLI.
6412      var pdfNavBtn = document.getElementById('nav-view-pdf-btn');
6413      if (pdfNavBtn) {
6414        pdfNavBtn.addEventListener('click', function (e) {
6415          e.preventDefault();
6416          var standaloneUrl = pdfNavBtn.getAttribute('data-standalone-pdf');
6417          if (standaloneUrl) {
6418            window.open(standaloneUrl, '_blank', 'noopener');
6419            return;
6420          }
6421          var serverUrl = pdfNavBtn.getAttribute('href');
6422          var xhr = new XMLHttpRequest();
6423          xhr.open('HEAD', serverUrl, true);
6424          xhr.onreadystatechange = function () {
6425            if (xhr.readyState === 4) {
6426              if (xhr.status >= 200 && xhr.status < 300) {
6427                window.open(serverUrl, '_blank', 'noopener');
6428              } else {
6429                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.');
6430              }
6431            }
6432          };
6433          xhr.onerror = function () {
6434            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.');
6435          };
6436          xhr.send();
6437        });
6438      }
6439
6440      var copyConfigBtn = document.querySelector('[data-copy-config]');
6441      var downloadConfigBtn = document.querySelector('[data-download-config]');
6442      var configBlock = document.getElementById('config-json-block');
6443      var inlineCopyBtn = document.getElementById('config-inline-copy-btn');
6444      function handleConfigCopy(btn) {
6445        if (!btn || !configBlock) return;
6446        btn.addEventListener('click', function (e) {
6447          e.stopPropagation();
6448          copyText(configBlock.textContent);
6449          var orig = btn.textContent;
6450          btn.textContent = 'Copied!';
6451          setTimeout(function () { btn.textContent = orig; }, 1600);
6452        });
6453      }
6454      handleConfigCopy(copyConfigBtn);
6455      handleConfigCopy(inlineCopyBtn);
6456
6457      var warnCopyBtn = document.getElementById('warning-console-copy-btn');
6458      var warnBlock = document.getElementById('warning-console-full');
6459      if (warnCopyBtn && warnBlock) {
6460        warnCopyBtn.addEventListener('click', function () {
6461          copyText(warnBlock.textContent);
6462          var orig = warnCopyBtn.textContent;
6463          warnCopyBtn.textContent = 'Copied!';
6464          setTimeout(function () { warnCopyBtn.textContent = orig; }, 1600);
6465        });
6466      }
6467
6468      if (downloadConfigBtn && configBlock) {
6469        downloadConfigBtn.addEventListener('click', function (e) {
6470          e.stopPropagation();
6471          var blob = new Blob([configBlock.textContent], { type: 'application/json' });
6472          var url = URL.createObjectURL(blob);
6473          var a = document.createElement('a');
6474          a.href = url; a.download = 'effective-config.json';
6475          document.body.appendChild(a); a.click();
6476          document.body.removeChild(a);
6477          setTimeout(function () { URL.revokeObjectURL(url); }, 200);
6478        });
6479      }
6480
6481      function detectType(value) {
6482        // Strip thousands separators so comma-formatted numbers (e.g. "121,542")
6483        // still sort numerically rather than lexicographically.
6484        var v = value.trim().replace(/,/g, '');
6485        return /^-?\d+(?:\.\d+)?$/.test(v) ? parseFloat(v) : value.trim().toLowerCase();
6486      }
6487
6488      document.querySelectorAll('[data-sort-table]').forEach(function (table) {
6489        var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
6490        var allMarkers = [];
6491        headers.forEach(function (th, idx) {
6492          var direction = 1;
6493          var marker = document.createElement('span');
6494          marker.className = 'sort-indicator';
6495          marker.textContent = ' \u2195';
6496          th.style.cursor = 'pointer';
6497          th.appendChild(marker);
6498          allMarkers.push(marker);
6499          th.addEventListener('click', function (e) {
6500            if (e.target.closest && e.target.closest('.col-resize-handle')) return;
6501            var tbody = table.tBodies[0];
6502            var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
6503            rows.sort(function (a, b) {
6504              var av = detectType((a.children[idx].textContent || '').trim());
6505              var bv = detectType((b.children[idx].textContent || '').trim());
6506              if (av < bv) return -1 * direction;
6507              if (av > bv) return 1 * direction;
6508              return 0;
6509            });
6510            rows.forEach(function (row) { tbody.appendChild(row); });
6511            allMarkers.forEach(function(m) { m.textContent = ' \u2195'; });
6512            direction = direction * -1;
6513            marker.textContent = direction === -1 ? ' \u2191' : ' \u2193';
6514            table.dispatchEvent(new CustomEvent('sloc-sorted'));
6515          });
6516        });
6517      });
6518
6519      // ── Column resize for all table-resizable tables ──────────────────────────
6520      (function() {
6521        document.querySelectorAll('.table-resizable').forEach(function(table) {
6522          var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
6523          var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
6524          ths.forEach(function(th, i) {
6525            var handle = th.querySelector('.col-resize-handle');
6526            if (!handle || !cols[i]) return;
6527            var startX, startW;
6528            handle.addEventListener('mousedown', function(e) {
6529              e.stopPropagation(); e.preventDefault();
6530              startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
6531              handle.classList.add('dragging');
6532              function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
6533              function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
6534              document.addEventListener('mousemove', onMove);
6535              document.addEventListener('mouseup', onUp);
6536            });
6537          });
6538        });
6539      })();
6540
6541      document.querySelectorAll('[data-table-filter]').forEach(function (input) {
6542        var table = document.getElementById(input.getAttribute('data-table-filter'));
6543        if (!table) return;
6544        var filterTimer = null;
6545        var rowCache = null;
6546        input.addEventListener('input', function () {
6547          clearTimeout(filterTimer);
6548          var q = input.value.toLowerCase();
6549          filterTimer = setTimeout(function () {
6550            if (!rowCache) {
6551              rowCache = Array.prototype.map.call(table.tBodies[0].rows, function (row) {
6552                return { row: row, text: row.textContent.toLowerCase() };
6553              });
6554            }
6555            rowCache.forEach(function (item) {
6556              item.row.style.display = q === '' || item.text.indexOf(q) >= 0 ? '' : 'none';
6557            });
6558          }, 200);
6559        });
6560      });
6561
6562      // ── Per-file table pagination ────────────────────────────────────────────
6563      (function () {
6564        var table = document.getElementById('per-file-table');
6565        if (!table) return;
6566        var tbody = table.tBodies[0];
6567        var searchInput = document.getElementById('per-file-search');
6568        var pageSizeSelect = document.getElementById('per-file-page-size');
6569        var firstBtn = document.getElementById('pf-first');
6570        var prevBtn = document.getElementById('pf-prev');
6571        var nextBtn = document.getElementById('pf-next');
6572        var lastBtn = document.getElementById('pf-last');
6573        var pageInfo = document.getElementById('pf-page-info');
6574        var jumpInput = document.getElementById('pf-page-jump');
6575        var pageTotal = document.getElementById('pf-page-total');
6576        var countLabel = document.getElementById('per-file-count-label');
6577        var filteredRows = [];
6578        var currentPage = 1;
6579        var totalAll = tbody.rows.length;
6580
6581        function getPageSize() {
6582          var v = pageSizeSelect ? pageSizeSelect.value : '20';
6583          return v === 'all' ? Infinity : parseInt(v, 10);
6584        }
6585
6586        function applyFilter() {
6587          var q = searchInput ? searchInput.value.toLowerCase() : '';
6588          var rows = Array.prototype.slice.call(tbody.rows);
6589          filteredRows = q === '' ? rows : rows.filter(function (row) {
6590            return row.textContent.toLowerCase().indexOf(q) >= 0;
6591          });
6592          currentPage = 1;
6593          render();
6594        }
6595
6596        function render() {
6597          var ps = getPageSize();
6598          var total = filteredRows.length;
6599          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6600          if (currentPage > totalPages) currentPage = totalPages;
6601          if (currentPage < 1) currentPage = 1;
6602          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6603          var end = ps === Infinity ? total : Math.min(start + ps, total);
6604          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6605          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6606          if (pageInfo) {
6607            if (total === 0) {
6608              pageInfo.textContent = 'No results';
6609            } else if (ps === Infinity) {
6610              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6611            } else {
6612              pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6613            }
6614          }
6615          if (countLabel) {
6616            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6617          }
6618          var edgeDisabled = ps === Infinity;
6619          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6620          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6621          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6622          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6623          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6624          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6625        }
6626
6627        if (searchInput) {
6628          var filterTimer = null;
6629          searchInput.addEventListener('input', function () {
6630            clearTimeout(filterTimer);
6631            filterTimer = setTimeout(applyFilter, 200);
6632          });
6633        }
6634        if (pageSizeSelect) {
6635          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6636        }
6637        if (firstBtn) {
6638          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6639        }
6640        if (prevBtn) {
6641          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6642        }
6643        if (nextBtn) {
6644          nextBtn.addEventListener('click', function () {
6645            var ps = getPageSize();
6646            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6647            if (currentPage < totalPages) { currentPage++; render(); }
6648          });
6649        }
6650        if (lastBtn) {
6651          lastBtn.addEventListener('click', function () {
6652            var ps = getPageSize();
6653            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6654            render();
6655          });
6656        }
6657        if (jumpInput) {
6658          function pfJump() {
6659            var ps = getPageSize();
6660            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6661            var v = parseInt(jumpInput.value, 10);
6662            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6663          }
6664          jumpInput.addEventListener('change', pfJump);
6665          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') pfJump(); });
6666        }
6667        table.addEventListener('sloc-sorted', function () { applyFilter(); });
6668        window._pfPaginationReset = function () { currentPage = 1; applyFilter(); };
6669        applyFilter();
6670      })();
6671
6672      // ── Skipped-files table pagination ───────────────────────────────────────
6673      (function () {
6674        var table = document.getElementById('skipped-table');
6675        if (!table) return;
6676        var tbody = table.tBodies[0];
6677        var searchInput = document.getElementById('skipped-search');
6678        var pageSizeSelect = document.getElementById('skipped-page-size');
6679        var firstBtn = document.getElementById('sk-first');
6680        var prevBtn = document.getElementById('sk-prev');
6681        var nextBtn = document.getElementById('sk-next');
6682        var lastBtn = document.getElementById('sk-last');
6683        var pageInfo = document.getElementById('sk-page-info');
6684        var jumpInput = document.getElementById('sk-page-jump');
6685        var pageTotal = document.getElementById('sk-page-total');
6686        var countLabel = document.getElementById('skipped-count-label');
6687        var filteredRows = [];
6688        var currentPage = 1;
6689        var totalAll = tbody.rows.length;
6690
6691        function getPageSize() {
6692          var v = pageSizeSelect ? pageSizeSelect.value : '10';
6693          return v === 'all' ? Infinity : parseInt(v, 10);
6694        }
6695
6696        function applyFilter() {
6697          var q = searchInput ? searchInput.value.toLowerCase() : '';
6698          var rows = Array.prototype.slice.call(tbody.rows);
6699          filteredRows = q === '' ? rows : rows.filter(function (row) {
6700            return row.textContent.toLowerCase().indexOf(q) >= 0;
6701          });
6702          currentPage = 1;
6703          render();
6704        }
6705
6706        function render() {
6707          var ps = getPageSize();
6708          var total = filteredRows.length;
6709          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6710          if (currentPage > totalPages) currentPage = totalPages;
6711          if (currentPage < 1) currentPage = 1;
6712          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6713          var end = ps === Infinity ? total : Math.min(start + ps, total);
6714          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6715          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6716          if (pageInfo) {
6717            if (total === 0) {
6718              pageInfo.textContent = 'No results';
6719            } else if (ps === Infinity) {
6720              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6721            } else {
6722              pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6723            }
6724          }
6725          if (countLabel) {
6726            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6727          }
6728          var edgeDisabled = ps === Infinity;
6729          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6730          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6731          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6732          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6733          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6734          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6735        }
6736
6737        if (searchInput) {
6738          var filterTimer = null;
6739          searchInput.addEventListener('input', function () {
6740            clearTimeout(filterTimer);
6741            filterTimer = setTimeout(applyFilter, 200);
6742          });
6743        }
6744        if (pageSizeSelect) {
6745          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6746        }
6747        if (firstBtn) {
6748          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6749        }
6750        if (prevBtn) {
6751          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6752        }
6753        if (nextBtn) {
6754          nextBtn.addEventListener('click', function () {
6755            var ps = getPageSize();
6756            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6757            if (currentPage < totalPages) { currentPage++; render(); }
6758          });
6759        }
6760        if (lastBtn) {
6761          lastBtn.addEventListener('click', function () {
6762            var ps = getPageSize();
6763            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6764            render();
6765          });
6766        }
6767        if (jumpInput) {
6768          function skJump() {
6769            var ps = getPageSize();
6770            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6771            var v = parseInt(jumpInput.value, 10);
6772            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6773          }
6774          jumpInput.addEventListener('change', skJump);
6775          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') skJump(); });
6776        }
6777        table.addEventListener('sloc-sorted', function () { applyFilter(); });
6778        applyFilter();
6779      })();
6780
6781      // ── Hotspots table pagination ────────────────────────────────────────────
6782      (function () {
6783        var table = document.getElementById('hotspots-table');
6784        if (!table) return;
6785        var tbody = table.tBodies[0];
6786        var searchInput = document.getElementById('hotspots-search');
6787        var pageSizeSelect = document.getElementById('hotspots-page-size');
6788        var firstBtn = document.getElementById('hs-first');
6789        var prevBtn = document.getElementById('hs-prev');
6790        var nextBtn = document.getElementById('hs-next');
6791        var lastBtn = document.getElementById('hs-last');
6792        var pageInfo = document.getElementById('hs-page-info');
6793        var jumpInput = document.getElementById('hs-page-jump');
6794        var pageTotal = document.getElementById('hs-page-total');
6795        var countLabel = document.getElementById('hotspots-count-label');
6796        var filteredRows = [];
6797        var currentPage = 1;
6798        var totalAll = tbody.rows.length;
6799
6800        function getPageSize() {
6801          var v = pageSizeSelect ? pageSizeSelect.value : '15';
6802          return v === 'all' ? Infinity : parseInt(v, 10);
6803        }
6804
6805        function applyFilter() {
6806          var q = searchInput ? searchInput.value.toLowerCase() : '';
6807          var rows = Array.prototype.slice.call(tbody.rows);
6808          filteredRows = q === '' ? rows : rows.filter(function (row) {
6809            return row.textContent.toLowerCase().indexOf(q) >= 0;
6810          });
6811          currentPage = 1;
6812          render();
6813        }
6814
6815        function render() {
6816          var ps = getPageSize();
6817          var total = filteredRows.length;
6818          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6819          if (currentPage > totalPages) currentPage = totalPages;
6820          if (currentPage < 1) currentPage = 1;
6821          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6822          var end = ps === Infinity ? total : Math.min(start + ps, total);
6823          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6824          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6825          if (pageInfo) {
6826            if (total === 0) {
6827              pageInfo.textContent = 'No results';
6828            } else if (ps === Infinity) {
6829              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6830            } else {
6831              pageInfo.textContent = (start + 1) + '–' + end + ' of ' + total.toLocaleString() + ' files';
6832            }
6833          }
6834          if (countLabel) {
6835            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6836          }
6837          var edgeDisabled = ps === Infinity;
6838          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6839          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6840          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6841          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6842          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6843          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6844        }
6845
6846        if (searchInput) {
6847          var filterTimer = null;
6848          searchInput.addEventListener('input', function () {
6849            clearTimeout(filterTimer);
6850            filterTimer = setTimeout(applyFilter, 200);
6851          });
6852        }
6853        if (pageSizeSelect) {
6854          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6855        }
6856        if (firstBtn) {
6857          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6858        }
6859        if (prevBtn) {
6860          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6861        }
6862        if (nextBtn) {
6863          nextBtn.addEventListener('click', function () {
6864            var ps = getPageSize();
6865            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6866            if (currentPage < totalPages) { currentPage++; render(); }
6867          });
6868        }
6869        if (lastBtn) {
6870          lastBtn.addEventListener('click', function () {
6871            var ps = getPageSize();
6872            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6873            render();
6874          });
6875        }
6876        if (jumpInput) {
6877          function hsJump() {
6878            var ps = getPageSize();
6879            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6880            var v = parseInt(jumpInput.value, 10);
6881            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6882          }
6883          jumpInput.addEventListener('change', hsJump);
6884          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') hsJump(); });
6885        }
6886        table.addEventListener('sloc-sorted', function () { applyFilter(); });
6887        applyFilter();
6888      })();
6889    })();
6890
6891    (function randomizeWatermarks() {
6892      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6893      if (!wms.length) return;
6894      var placed = [];
6895      function tooClose(t, l) {
6896        for (var i = 0; i < placed.length; i++) {
6897          var dt = Math.abs(placed[i][0] - t);
6898          var dl = Math.abs(placed[i][1] - l);
6899          if (dt < 18 && dl < 18) return true;
6900        }
6901        return false;
6902      }
6903      function pick(leftBias) {
6904        for (var attempt = 0; attempt < 40; attempt++) {
6905          var t = Math.random() * 90;
6906          var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
6907          if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
6908        }
6909        var fb = [Math.random() * 90, Math.random() * 95];
6910        placed.push(fb);
6911        return fb;
6912      }
6913      var half = Math.floor(wms.length / 2);
6914      wms.forEach(function (img, i) {
6915        var pos = pick(i < half);
6916        var sz = Math.floor(Math.random() * 80 + 110);
6917        var rot = (Math.random() * 360).toFixed(1);
6918        var op = (Math.random() * 0.07 + 0.10).toFixed(2);
6919        img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
6920      });
6921    })();
6922
6923    (function spawnCodeParticles() {
6924      var container = document.getElementById('code-particles');
6925      if (!container) return;
6926      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'];
6927      for (var i = 0; i < 38; i++) {
6928        (function (idx) {
6929          var el = document.createElement('span');
6930          el.className = 'code-particle';
6931          el.textContent = snippets[idx % snippets.length];
6932          var left = Math.random() * 94 + 2;
6933          var top = Math.random() * 88 + 6;
6934          var dur = (Math.random() * 10 + 9).toFixed(1);
6935          var delay = (Math.random() * 18).toFixed(1);
6936          var rot = (Math.random() * 26 - 13).toFixed(1);
6937          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6938          el.style.left = left.toFixed(1) + '%';
6939          el.style.top = top.toFixed(1) + '%';
6940          el.style.setProperty('--rot', rot + 'deg');
6941          el.style.setProperty('--op', op);
6942          el.style.animationDuration = dur + 's';
6943          el.style.animationDelay = '-' + delay + 's';
6944          container.appendChild(el);
6945        })(i);
6946      }
6947    })();
6948    // ── Metric number formatting ─────────────────────────────────────────────
6949    (function () {
6950      function fmtBig(n) {
6951        if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
6952        if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
6953        return n.toLocaleString();
6954      }
6955      function fmtExact(n) { return n.toLocaleString(); }
6956      document.querySelectorAll('[data-metric-value]').forEach(function (el) {
6957        var n = parseInt(el.getAttribute('data-metric-value'), 10);
6958        if (isNaN(n)) return;
6959        var big = el.querySelector('.metric-big');
6960        var exact = el.querySelector('.metric-exact');
6961        if (big) big.textContent = fmtBig(n);
6962        if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
6963      });
6964      var densityCard = document.querySelector('[data-metric-density]');
6965      if (densityCard) {
6966        var phys = 0, code = 0;
6967        document.querySelectorAll('[data-metric-value]').forEach(function (el) {
6968          var lbl = el.querySelector('.metric-label');
6969          if (!lbl) return;
6970          var t = lbl.textContent.trim().toLowerCase();
6971          var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
6972          if (t === 'physical lines') phys = v;
6973          if (t === 'code') code = v;
6974        });
6975        var pct = phys > 0 ? (code / phys * 100) : 0;
6976        var big = densityCard.querySelector('.metric-big');
6977        var exact = densityCard.querySelector('.metric-exact');
6978        if (big) big.textContent = pct.toFixed(1) + '%';
6979        if (exact) exact.textContent = '';
6980      }
6981      (function(){
6982        var g=document.querySelector('.summary-grid');if(!g)return;
6983        var pad=g.querySelector('.metric-pad');
6984        var real=Array.prototype.slice.call(g.querySelectorAll('.metric')).filter(function(el){return el!==pad;});
6985        if(!real.length)return;
6986        function upd(){
6987          // Pad the strip to an EVEN card count so a true CSS grid lays it out as
6988          // exactly two full rows with every column aligned and every card the
6989          // same size. When the real-card count is odd, reveal the reserve
6990          // "Assertions" pad card; otherwise keep it hidden.
6991          var n=real.length;
6992          if(pad){ if(n%2===1){pad.style.display='';n++;} else {pad.style.display='none';} }
6993          var perRow=window.innerWidth<=640?2:Math.ceil(n/2);
6994          g.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';
6995        }
6996        upd();window.addEventListener('resize',upd);
6997      })();
6998      (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);}})();
6999    })();
7000    // ── Info chip interactivity ───────────────────────────────────────────────
7001    (function() {
7002      document.querySelectorAll('.run-id-chip[data-copy]').forEach(function(chip) {
7003        chip.addEventListener('click', function() {
7004          var val = chip.getAttribute('data-copy');
7005          var tt = chip.querySelector('.chip-tooltip');
7006          var orig = tt ? tt.textContent : '';
7007          if (!navigator.clipboard) return;
7008          navigator.clipboard.writeText(val).then(function() {
7009            chip.classList.add('chip-copied-flash');
7010            if (tt) tt.textContent = 'Copied!';
7011            setTimeout(function() {
7012              chip.classList.remove('chip-copied-flash');
7013              if (tt) tt.textContent = orig;
7014            }, 1100);
7015          });
7016        });
7017      });
7018      document.querySelectorAll('.run-id-chip[data-author]').forEach(function(chip) {
7019        var author = chip.getAttribute('data-author');
7020        var el = chip.querySelector('.author-handle');
7021        if (el) el.textContent = '/' + author.replace(/\s+/g, '');
7022      });
7023    })();
7024    // ── Export helpers ────────────────────────────────────────────────────────
7025    function _slocUnh(s){var e=document.createElement('div');e.innerHTML=s;return e.textContent;}
7026    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 }}"};
7027    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
7028    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7029    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);}
7030    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;');}
7031    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');}
7032    function slocXlsMulti(fname,sheets){
7033      var enc=new TextEncoder();
7034      var CT=[];
7035      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;}
7036      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;}
7037      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
7038      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
7039      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7040      var ss=[],si={};
7041      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
7042      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;}
7043      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
7044      // 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(@)
7045      var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
7046        +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
7047        +'<fonts count="3">'
7048          +'<font><sz val="11"/><name val="Calibri"/></font>'
7049          +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
7050          +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
7051        +'</fonts>'
7052        +'<fills count="4">'
7053          +'<fill><patternFill patternType="none"/></fill>'
7054          +'<fill><patternFill patternType="gray125"/></fill>'
7055          +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
7056          +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
7057        +'</fills>'
7058        +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
7059        +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
7060        +'<cellXfs count="7">'
7061          +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
7062          +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7063          +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
7064          +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7065          +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
7066          +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
7067          +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
7068        +'</cellXfs>'
7069        +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
7070        +'</styleSheet>';
7071      var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
7072      function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
7073      sheets.forEach(function(sh,sheetIdx){
7074        var rx='<row r="1">';
7075        sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
7076        rx+='</row>';
7077        var rn=2;
7078        sh.rows.forEach(function(row){
7079          if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
7080          if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
7081            rx+='<row r="'+rn+'">';
7082            rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
7083            for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
7084            rx+='</row>';rn++;return;
7085          }
7086          rx+='<row r="'+rn+'">';
7087          row.forEach(function(cell,c){
7088            var ref=colRef(c,rn);
7089            if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
7090            if(typeof cell==='object'&&cell!==null){
7091              var cv=cell.v,cs=cell.s!=null?cell.s:0;
7092              if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
7093              else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
7094              return;
7095            }
7096            if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
7097            rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
7098          });
7099          rx+='</row>';rn++;
7100        });
7101        var cw='';
7102        if(sh.colWidths&&sh.colWidths.length>0){
7103          cw='<cols>';
7104          sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
7105          cw+='</cols>';
7106        }
7107        var tblParts='';
7108        if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
7109          tableCounter++;
7110          var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
7111          var tRef='A1:'+colNm(colCount)+rowCount;
7112          tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7113            +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
7114            +'<autoFilter ref="'+tRef+'"/>'
7115            +'<tableColumns count="'+colCount+'">'
7116            +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
7117            +'</tableColumns>'
7118            +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
7119            +'</table>';
7120          wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7121            +'<Relationships xmlns="'+pns+'relationships">'
7122            +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
7123            +'</Relationships>';
7124          tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
7125        }
7126        wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
7127          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
7128          +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
7129      });
7130      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>';
7131      var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
7132      var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
7133      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>';
7134      var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
7135      var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
7136      var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
7137      wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
7138        +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
7139      var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
7140      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};
7141      var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
7142      sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
7143      Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
7144      Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
7145      var zparts=[],zcds=[],zoff=0,znf=0;
7146      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++;});
7147      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
7148      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]);
7149      var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
7150      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
7151      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
7152      zout.set(new Uint8Array(ea),zpos);
7153      slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
7154    }
7155    window.resetPerFileTable = function() {
7156      var tbl = document.getElementById('per-file-table');
7157      if (!tbl) return;
7158      var shell = tbl.closest('.table-shell');
7159      if (shell) shell.scrollLeft = 0;
7160      Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
7161      Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7162      if (window._pfPaginationReset) window._pfPaginationReset();
7163      var si = document.getElementById('per-file-search');
7164      if (si) si.value = '';
7165    };
7166    var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
7167    var _titleSlug="{{ title }}".replace(/[^a-zA-Z0-9\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'');
7168    var _commitSlug="{% if let Some(c) = run.git_commit_short %}{{ c }}{% endif %}";
7169    var _exportSlug='per-file_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7170    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;}
7171    window.exportReportCsv=function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());};
7172    window.exportReportXls=function(){
7173      var fname='report_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'')+'.xlsx';
7174      function sec(v){return[{_sec:true,v:v}];}
7175      function B(v){return{v:v,s:4};}
7176      function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
7177      // Table cells render with thousands separators (1,656,153) via the |commas
7178      // filter; Number() on that string is NaN, which would store the value as text
7179      // (green-triangle warning, left-aligned). Strip separators so numeric cells
7180      // become real numbers and align correctly. Non-numeric text is left untouched.
7181      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;}
7182      function pnum(v){var t=String(v==null?'':v).replace(/,/g,'').trim();return /^-?\d+(\.\d+)?$/.test(t)?Number(t):0;}
7183      var dens=_SLOC_META.physicalLines>0?(_SLOC_META.codeLines/_SLOC_META.physicalLines*100).toFixed(1)+'%':'0%';
7184      var sumRows=[
7185        sec('RUN INFORMATION'),
7186        [B('Run ID'),_SLOC_META.runId,''],
7187        [B('Git Commit'),_SLOC_META.gitCommit,''],
7188        [B('Branch'),_SLOC_META.branch,''],
7189        [B('Last Commit By'),_SLOC_META.lastCommitBy,''],
7190        [B('Scan By'),_SLOC_META.scanBy,''],
7191        [B('Scanned'),_SLOC_META.scanned,''],
7192        [B('OS'),_SLOC_META.os,''],
7193        [B('Files Analyzed'),N(_SLOC_META.filesAnalyzed),'Total source files included in this analysis'],
7194        [B('Files Skipped'),N(_SLOC_META.filesSkipped),'Files excluded (binary, unsupported, or policy-filtered)'],
7195        [],
7196        sec('CODE METRICS'),
7197        [B('Physical Lines'),N(_SLOC_META.physicalLines),'Total lines including code, comments, and blanks'],
7198        [B('Code Lines'),N(_SLOC_META.codeLines),'Lines containing executable source code'],
7199        [B('Comments'),N(_SLOC_META.commentLines),'Lines consisting entirely of comments or documentation'],
7200        [B('Blank Lines'),N(_SLOC_META.blankLines),'Empty or whitespace-only lines'],
7201        [B('Mixed Separate'),N(_SLOC_META.mixedSeparate),'Lines with both code and trailing comment, counted separately'],
7202        [B('Functions'),N(_SLOC_META.functions),'Best-effort count of function/method definitions'],
7203        [B('Classes / Types'),N(_SLOC_META.classes),'Best-effort count of class, struct, interface definitions'],
7204        [B('Variables'),N(_SLOC_META.variables),'Best-effort count of variable and constant declarations'],
7205        [B('Imports'),N(_SLOC_META.imports),'Best-effort count of import, include, module-use statements'],
7206        [B('Tests'),N(_SLOC_META.tests),'Best-effort count of test cases (GTest, PyTest, JUnit, etc.)'],
7207        [B('Code Density'),{v:dens,s:6},'Percentage of physical lines that contain executable source code'],
7208        [B('Tool Version'),'oxide-sloc '+_SLOC_META.toolVersion,''],
7209      ];
7210      var langHdrs=['Language','Files','Physical Lines','Code Lines','Comments','Blank Lines','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7211      var langRows=[];
7212      document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7213        var tds=tr.querySelectorAll('td');
7214        var row=[];
7215        Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>0?numify(v):v);});
7216        langRows.push(row);
7217      });
7218      var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7219      var pfRows=[];
7220      document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){
7221        var tds=tr.querySelectorAll('td');
7222        if(tds.length<11)return;
7223        var row=[];
7224        Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>=2?numify(v):v);});
7225        pfRows.push(row);
7226      });
7227      var skHdrs=['File','Status','Warnings'];
7228      var skRows=[];
7229      document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){
7230        var tds=tr.querySelectorAll('td');
7231        if(tds.length<3)return;
7232        skRows.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);
7233      });
7234      var covHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
7235      var covRows=[];
7236      document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7237        var tds=tr.querySelectorAll('td');
7238        if(tds.length<4)return;
7239        var phys=pnum(tds[2].textContent);
7240        var code=pnum(tds[3].textContent);
7241        var densStr=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
7242        var row=[tds[0].textContent.trim(),pnum(tds[1].textContent),phys,code,{v:densStr,s:6}];
7243        for(var i=7;i<Math.min(tds.length,14);i++){row.push(numify(tds[i].textContent.trim()));}
7244        covRows.push(row);
7245      });
7246      slocXlsMulti(fname,[
7247        {name:'Summary',hdrs:['Field / Metric','Value','Description'],rows:sumRows,colWidths:[22,45,55],isKv:true},
7248        {name:'Language Breakdown',hdrs:langHdrs,rows:langRows,colWidths:[16,8,14,12,12,12,8,10,10,10,10,8,10,8]},
7249        {name:'Per-File Detail',hdrs:pfHdrs,rows:pfRows,colWidths:[50,12,12,12,12,10,8,10,10,10,10,8,10,8]},
7250        {name:'Code Coverage',hdrs:covHdrs,rows:covRows,colWidths:[18,7,14,12,13,11,10,10,10,8,11,12]},
7251        {name:'Skipped Files',hdrs:skHdrs,rows:skRows,colWidths:[60,25,50]}
7252      ]);
7253    };
7254    Array.prototype.slice.call(document.querySelectorAll('[data-export-csv]')).forEach(function(btn){btn.addEventListener('click',function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());});});
7255    Array.prototype.slice.call(document.querySelectorAll('[data-export-xls]')).forEach(function(btn){btn.addEventListener('click',window.exportReportXls);});
7256    Array.prototype.slice.call(document.querySelectorAll('[data-reset-table]')).forEach(function(btn){btn.addEventListener('click',window.resetPerFileTable);});
7257    var _skippedRh=['File','Status','Warnings'];
7258    var _skippedSlug='skipped_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7259    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;}
7260    (function(){var b=document.getElementById('skipped-export-csv');if(b)b.addEventListener('click',function(){slocCsv(_skippedSlug+'.csv',_skippedRh,getSkippedExportRows());});})();
7261    (function(){var b=document.getElementById('skipped-export-xls');if(b)b.addEventListener('click',function(){slocXls(_skippedSlug+'.xlsx','Skipped Files',_skippedRh,getSkippedExportRows());});})();
7262    // ── Chart.js initialization ───────────────────────────────────────────────
7263    // Deferred so the browser can repaint (dismiss the loading overlay) before
7264    // the canvas/SVG chart work blocks the main thread.
7265    requestAnimationFrame(function() {
7266    try {
7267    (function() {
7268      var D = {{ lang_chart_json|safe }};
7269      var SUB_D = {{ submodule_chart_json|safe }};
7270      var SCAT_D = {{ scatter_chart_json|safe }};
7271      var SEM_D = {{ semantic_chart_json|safe }};
7272      var HIST_D = {{ file_size_histogram_json|safe }};
7273      if (!D || !D.length) return;
7274
7275      var PALETTE = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030',
7276                     '#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082',
7277                     '#D0743C','#5BA8A0','#8B3A8B','#3D7A3D','#AA5500','#005599'];
7278      var OX = '#C45C10', GN = '#2A6846', GY = '#BBBBBB';
7279      var ALL_CHARTS = [];
7280      function hexAlpha(hex, a) {
7281        var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
7282        return 'rgba('+r+','+g+','+b+','+a+')';
7283      }
7284
7285      function fmt(n) {
7286        var v = Number(n), a = Math.abs(v);
7287        if (a >= 1e6) return (v/1e6).toFixed(1).replace(/\.0$/,'') + 'M';
7288        if (a >= 1e4) return Math.round(v/1e3) + 'K';
7289        return v.toLocaleString();
7290      }
7291      function isDark() { return document.body.classList.contains('dark-theme'); }
7292      function clr() {
7293        return isDark()
7294          ? { text: '#d4c5b8', grid: 'rgba(255,255,255,0.10)' }
7295          : { text: '#43342d', grid: '#e6d0bf' };
7296      }
7297      // Inline Chart.js plugin: draws a permanent value label on each bar / bubble.
7298      // fmtFn(rawValue, datasetIndex, pointIndex) → string | null
7299      // anchor: 'top' = above vertical bar, 'end' = right of horizontal bar, 'bubble' = above bubble
7300      function makeDlPlugin(fmtFn, anchor) {
7301        return {
7302          afterDatasetsDraw: function(chart) {
7303            var ctx = chart.ctx;
7304            var tc = clr().text;
7305            chart.data.datasets.forEach(function(ds, di) {
7306              var meta = chart.getDatasetMeta(di);
7307              meta.data.forEach(function(el, idx) {
7308                var label = fmtFn(ds.data[idx], di, idx);
7309                if (label == null || label === '') return;
7310                ctx.save();
7311                ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7312                ctx.fillStyle = tc;
7313                if (anchor === 'top') {
7314                  ctx.textAlign = 'center';
7315                  ctx.textBaseline = 'bottom';
7316                  ctx.fillText(String(label), el.x, el.y - 3);
7317                } else if (anchor === 'end') {
7318                  ctx.textAlign = 'left';
7319                  ctx.textBaseline = 'middle';
7320                  ctx.fillText(String(label), el.x + 5, el.y);
7321                } else {
7322                  ctx.textAlign = 'center';
7323                  ctx.textBaseline = 'bottom';
7324                  var r = (el.options && el.options.radius) ? el.options.radius : 10;
7325                  ctx.fillText(String(label), el.x, el.y - r - 3);
7326                }
7327                ctx.restore();
7328              });
7329            });
7330          }
7331        };
7332      }
7333      function makeStackedEndPlugin(fmtFn) {
7334        return {
7335          afterDatasetsDraw: function(chart) {
7336            var ctx = chart.ctx;
7337            var tc = clr().text;
7338            var nDs = chart.data.datasets.length;
7339            if (nDs === 0) return;
7340            var lastMeta = chart.getDatasetMeta(nDs - 1);
7341            lastMeta.data.forEach(function(el, idx) {
7342              var total = 0;
7343              chart.data.datasets.forEach(function(ds) { total += ds.data[idx] || 0; });
7344              var label = fmtFn(total, idx);
7345              if (label == null || label === '') return;
7346              ctx.save();
7347              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7348              ctx.fillStyle = tc;
7349              ctx.textAlign = 'left';
7350              ctx.textBaseline = 'middle';
7351              ctx.fillText(String(label), el.x + 5, el.y);
7352              ctx.restore();
7353            });
7354          }
7355        };
7356      }
7357
7358      function wireDonutLegend(svg) {
7359        if(!svg) return;
7360        var paths=svg.querySelectorAll('path[data-lang]');
7361        function hl(lang){for(var i=0;i<paths.length;i++){if(paths[i].getAttribute('data-lang')===lang){paths[i].style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.25))';paths[i].style.transform='scale(1.05)';paths[i].style.opacity='1';}else{paths[i].style.opacity='0.32';paths[i].style.filter='none';paths[i].style.transform='none';}}}
7362        function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
7363        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();});
7364        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();});
7365        svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
7366      }
7367      function wireMixLegend(svg) {
7368        if(!svg) return;
7369        var legGs=svg.querySelectorAll('g[data-kind]');
7370        var allRects=svg.querySelectorAll('rect[data-kind]');
7371        if(!legGs.length) return;
7372        function hlKind(kind) {
7373          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';}}
7374          for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}
7375        }
7376        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='';}}
7377        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]);}
7378      }
7379
7380      // ── Language overview: SVG donut + horizontal stacked bars ───────────────
7381      (function() {
7382        var el = document.getElementById('report-lang-overview');
7383        if (!el || !D || !D.length) return;
7384        var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7385        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7386        function px(n){return Math.round(n);}
7387        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;')+'"';}
7388        var tot = D.reduce(function(a,d){return a+d.code;},0)||1;
7389        // Donut — height matches the stacked-bar chart so both panels align
7390        var rHb_d=28;
7391        var DH=Math.max(220,D.length*rHb_d+32);
7392        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48,legX=208,DW=395;
7393        var legCount=D.length;
7394        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
7395        var legYStart=Math.round((DH-legCount*legSpacing)/2);
7396        var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7397        if(D.length===1){
7398          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
7399          ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+PALETTE[0]+'" stroke-width="'+rsw+'"/>';
7400        } else {
7401          var smalls=[];
7402          var ang=-Math.PI/2;
7403          D.forEach(function(d,i){
7404            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
7405            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
7406            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
7407            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
7408            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
7409            var pct=Math.round(d.code/tot*100);
7410            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"/>';
7411            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text 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]});}
7412            ang+=sw;
7413          });
7414          // Small slices (<5%) get outside labels positioned near each slice's own
7415          // angular position (a slice on the left gets its label/leader on the left),
7416          // then nudged apart horizontally so text never overlaps. Leader lines point
7417          // from each slice to its label. Horizontal text keeps long names legible;
7418          // the whole SVG scales up in Full View so these stay readable there too.
7419          if(smalls.length){
7420            smalls.sort(function(a,b){return a.mAng-b.mAng;});
7421            var sPad=6,sRowY=11;
7422            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)));});
7423            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;}
7424            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
7425            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
7426            smalls.forEach(function(sm){
7427              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
7428              ds+='<line 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;"/>';
7429              ds+='<text x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="pointer-events:none;">'+esc(sm.txt)+'</text>';
7430            });
7431          }
7432        }
7433        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
7434        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
7435        D.forEach(function(d,i){
7436          var ly=legYStart+i*legSpacing;
7437          var pctL=Math.round(d.code/tot*100);
7438          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
7439          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
7440          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
7441          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
7442          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(PALETTE[i%PALETTE.length])+'"/>';
7443          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
7444          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>';
7445          ds+='</g>';
7446        });
7447        ds+='</svg>';
7448        // Horizontal stacked-bar chart
7449        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
7450        var LW=108,BW=260,svgW=LW+BW+68;
7451        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
7452        var barBH=Math.min(32,Math.round(barRhb*0.7));
7453        var SH=DH;
7454        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
7455        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">';
7456        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
7457        // it cannot fit legibly even at the 6.5 floor (labels shrink to fit instead
7458        // of disappearing; the SVG scales up in Full View so small fonts stay legible).
7459        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;}
7460        D.forEach(function(d,i){
7461          var y=barTopPad+i*barRhb,x=LW;
7462          var phys=d.physical||d.code+d.comments+d.blanks;
7463          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
7464          var lmid=y+barBH/2+4;
7465          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
7466          bs+='<g class="lang-bar-row">';
7467          // Hit area ends just past the total label so empty space to the right of the
7468          // bar does not trigger the tooltip — only the name, bar and total are hot.
7469          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
7470          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
7471          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>';
7472          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;}
7473          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;}
7474          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>';}
7475          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>';
7476          bs+='</g>';
7477        });
7478        var ly=SH-14;
7479        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
7480        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
7481        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
7482        var totAll=totC+totCm+totBl||1;
7483        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
7484        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7485        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7486        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7487        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7488        bs+='<g data-kind="code" style="cursor:pointer;">'
7489          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
7490          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
7491          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
7492          +'</g>';
7493        bs+='<g data-kind="comment" style="cursor:pointer;">'
7494          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
7495          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
7496          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
7497          +'</g>';
7498        bs+='<g data-kind="blank" style="cursor:pointer;">'
7499          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
7500          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
7501          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
7502          +'</g>';
7503        bs+='</svg>';
7504        el.innerHTML='<div class="r-lang-overview">'+
7505          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
7506          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
7507        '</div>';
7508        wireDonutLegend(el.querySelector('svg'));
7509        wireMixLegend(el.querySelectorAll('svg')[1]);
7510      })();
7511
7512      // Shared cursor helper: pointer over data elements, default elsewhere.
7513      // Added to options.onHover on every Chart.js instance.
7514      // Legend items handled separately via legend.onHover / legend.onLeave.
7515      function chartCursor(e, els) {
7516        var t = e.native && e.native.target;
7517        if (t) t.style.cursor = els.length ? 'pointer' : 'default';
7518      }
7519      function legendCursorOn(e) { var t=e.native&&e.native.target; if(t)t.style.cursor='pointer'; }
7520      function legendCursorOff(e){ var t=e.native&&e.native.target; if(t)t.style.cursor='default'; }
7521      // Pushes a right-positioned legend away from the plot by `gap` px. Chart.js
7522      // (v4) places a right legend flush against the plot area: fit() reserves the
7523      // legend box width and _draw() lays items out from `this.left + padding`, so
7524      // the column hugs the bubbles. We reserve `gap` extra width in fit() (which
7525      // shrinks the plot by `gap`), then translate the canvas right by `gap` while
7526      // the legend draws so the column lands in that reserved space — clear of the
7527      // plot. The legendHitBoxes (used only for hover hit-testing, not drawing) are
7528      // shifted by the same `gap` so hover targets stay aligned with what's drawn.
7529      function legendGapPlugin(gap) {
7530        return {
7531          id: 'legendGap',
7532          beforeInit: function(chart) {
7533            var lg = chart.legend; if (!lg) return;
7534            var origFit = lg.fit, origDraw = lg.draw;
7535            lg.fit = function() { origFit.call(this); this.width += gap; this._needGap = true; };
7536            lg.draw = function() {
7537              if (this._needGap && this.legendHitBoxes) {
7538                this.legendHitBoxes.forEach(function(h){ h.left += gap; });
7539                this._needGap = false;
7540              }
7541              var ctx = this.ctx;
7542              ctx.save();
7543              ctx.translate(gap, 0);
7544              origDraw.call(this);
7545              ctx.restore();
7546            };
7547          }
7548        };
7549      }
7550
7551      // ── Project Overview bar ─────────────────────────────────────────────────
7552      var projChart = null;
7553      (function() {
7554        var ySel = document.getElementById('overview-y-axis');
7555        var xSel = document.getElementById('overview-x-mode');
7556        var el = document.getElementById('overview-chart');
7557        var lockedEl = document.getElementById('overview-chart-locked');
7558        var wrap = document.getElementById('canvas-proj-wrap');
7559        var canvas = document.getElementById('canvas-proj');
7560        if (!canvas || !ySel || !xSel) return;
7561        var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines',
7562                         physical:'Physical Lines', files:'Files', comment:'Comment Lines', blank:'Blank Lines' };
7563        function getData() {
7564          var yKey = ySel.value, mode = xSel.value;
7565          var src = mode === 'submodules' ? SUB_D : D;
7566          var lKey = mode === 'submodules' ? 'name' : 'lang';
7567          var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7568          return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7569        }
7570        function renderOverview() {
7571          var mode = xSel.value, isHist = mode.indexOf('history') === 0;
7572          if (el) el.style.display = isHist ? 'none' : 'block';
7573          if (lockedEl) lockedEl.style.display = isHist ? 'block' : 'none';
7574          if (isHist) return;
7575          var r = getData();
7576          var c = clr();
7577          if (wrap) wrap.style.height = Math.max(200, Math.min(432, r.sorted.length * 29 + 60)) + 'px';
7578          if (projChart) {
7579            projChart.data.labels = r.sorted.map(function(d){return d[r.lKey];});
7580            projChart.data.datasets[0].data = r.sorted.map(function(d){return d[r.yKey]||0;});
7581            projChart.data.datasets[0].backgroundColor = r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7582            projChart.data.datasets[0].label = r.yLabel;
7583            projChart.options.scales.x.title.text = r.yLabel;
7584            projChart.update('none'); return;
7585          }
7586          projChart = new Chart(canvas, {
7587            type: 'bar',
7588            data: {
7589              labels: r.sorted.map(function(d){return d[r.lKey];}),
7590              datasets: [{ label: r.yLabel,
7591                data: r.sorted.map(function(d){return d[r.yKey]||0;}),
7592                backgroundColor: r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7593                borderRadius: 3 }]
7594            },
7595            options: {
7596              indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7597              onHover: chartCursor,
7598              animation: { duration: 500, easing: 'easeOutQuart' },
7599              layout: { padding: { right: 64 } },
7600              scales: {
7601                x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
7602                     title: { display: true, text: r.yLabel, color: c.text } },
7603                y: { grid: { display: false }, ticks: { color: c.text } }
7604              },
7605              plugins: {
7606                legend: { display: false },
7607                tooltip: {
7608                  callbacks: {
7609                    title: function(items) { return items.length ? items[0].label : ''; },
7610                    label: function(ctx) {
7611                      return '  ' + ctx.dataset.label + ': ' + Number(ctx.parsed.x).toLocaleString();
7612                    }
7613                  }
7614                }
7615              }
7616            },
7617            plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7618          });
7619          ALL_CHARTS.push(projChart);
7620        }
7621        ySel.addEventListener('change', renderOverview);
7622        xSel.addEventListener('change', renderOverview);
7623        renderOverview();
7624
7625        var overviewExpandBtn = document.getElementById('overview-expand-btn');
7626        if (overviewExpandBtn) {
7627          overviewExpandBtn.addEventListener('click', function() {
7628            var r = getData();
7629            var n = r.sorted.length || 1;
7630            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
7631            var modalH = Math.min(Math.max(480, n * 29 + 96), maxH);
7632            var overlay = document.createElement('div');
7633            overlay.className = 'chart-modal-overlay';
7634            overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;">'
7635              + '<button class="chart-modal-close" aria-label="Close">&times;</button>'
7636              + '<div class="chart-modal-header">'
7637              + '<span class="chart-modal-title">Project Overview \u2014 Full View</span>'
7638              + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
7639              + '<select id="ov-modal-y" class="chart-select">'
7640              + '<option value="code">Code Lines</option>'
7641              + '<option value="comments">Comment Lines</option>'
7642              + '<option value="blanks">Blank Lines</option>'
7643              + '<option value="physical">Total Physical Lines</option>'
7644              + '<option value="files">File Count</option>'
7645              + '</select></label>'
7646              + (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:'
7647              + '<select id="ov-modal-x" class="chart-select">'
7648              + '<option value="languages">Languages</option>'
7649              + '<option value="submodules">Submodules</option>'
7650              + '</select></label>' : '')
7651              + '</div>'
7652              + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-proj-modal"></canvas></div></div>';
7653            document.body.appendChild(overlay);
7654            overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
7655            overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
7656            var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines', physical:'Physical Lines', files:'Files' };
7657            var modalYSel = document.getElementById('ov-modal-y');
7658            var modalXSel = document.getElementById('ov-modal-x');
7659            if (modalYSel) modalYSel.value = ySel ? ySel.value : 'code';
7660            if (modalXSel && xSel) modalXSel.value = (xSel.value === 'languages' || xSel.value === 'submodules') ? xSel.value : 'languages';
7661            var modalCanvas = document.getElementById('canvas-proj-modal');
7662            if (!modalCanvas) return;
7663            var c = clr();
7664            function getModalData() {
7665              var yKey = modalYSel ? modalYSel.value : 'code';
7666              var mode = modalXSel ? modalXSel.value : 'languages';
7667              var src = mode === 'submodules' ? SUB_D : D;
7668              var lKey = mode === 'submodules' ? 'name' : 'lang';
7669              var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7670              return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7671            }
7672            var ovModalChart = null;
7673            function renderOverviewModal() {
7674              var r2 = getModalData();
7675              if (ovModalChart) {
7676                ovModalChart.data.labels = r2.sorted.map(function(d){return d[r2.lKey];});
7677                ovModalChart.data.datasets[0].data = r2.sorted.map(function(d){return d[r2.yKey]||0;});
7678                ovModalChart.data.datasets[0].backgroundColor = r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7679                ovModalChart.data.datasets[0].label = r2.yLabel;
7680                ovModalChart.options.scales.x.title.text = r2.yLabel;
7681                ovModalChart.update('none'); return;
7682              }
7683              ovModalChart = new Chart(modalCanvas, {
7684                type: 'bar',
7685                data: {
7686                  labels: r2.sorted.map(function(d){return d[r2.lKey];}),
7687                  datasets: [{ label: r2.yLabel,
7688                    data: r2.sorted.map(function(d){return d[r2.yKey]||0;}),
7689                    backgroundColor: r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7690                    borderRadius: 3 }]
7691                },
7692                options: {
7693                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7694                  onHover: chartCursor,
7695                  animation: { duration: 500, easing: 'easeOutQuart' },
7696                  layout: { padding: { right: 64 } },
7697                  scales: {
7698                    x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
7699                         title:{display:true, text:r2.yLabel, color:c.text} },
7700                    y: { grid:{display:false}, ticks:{color:c.text} }
7701                  },
7702                  plugins: {
7703                    legend:{display:false},
7704                    tooltip:{callbacks:{
7705                      title:function(items){return items.length?items[0].label:'';},
7706                      label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x).toLocaleString();}
7707                    }}
7708                  }
7709                },
7710                plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7711              });
7712            }
7713            renderOverviewModal();
7714            if (modalYSel) modalYSel.addEventListener('change', renderOverviewModal);
7715            if (modalXSel) modalXSel.addEventListener('change', renderOverviewModal);
7716          });
7717        }
7718      })();
7719
7720      // ── Language Composition (SVG — matches /runs/result behaviour) ──────────
7721      (function() {
7722        var el = document.getElementById('comp-svg-container');
7723        if (!el || !D || !D.length) return;
7724        var cData = D.slice(0, 15);
7725        var cMode = 'absolute';
7726        var CX = OX, CG = GN, CB = '#BBBBBB';
7727        var CFONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7728        function cEsc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7729        function cPx(n){return Math.round(n);}
7730        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;')+'"';}
7731        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if it
7732        // cannot fit legibly even at the 6.5 floor (labels shrink to fit rather than
7733        // disappear; the SVG scales up in Full View so small fonts stay legible).
7734        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;}
7735        function cLT(l,v){return ' data-ttl="'+l+'" data-ttv="'+v.replace(/"/g,'&quot;')+'"';}
7736        function renderCompSVG() {
7737          var isPct = cMode === 'pct';
7738          var totC=cData.reduce(function(a,d){return a+(d.code||0);},0);
7739          var totCm=cData.reduce(function(a,d){return a+(d.comments||0);},0);
7740          var totBl=cData.reduce(function(a,d){return a+(d.blanks||0);},0);
7741          var totAll=totC+totCm+totBl||1;
7742          var svgW=Math.max(320,el.offsetWidth||540);
7743          var LW=108,legendH=24,topPad=4;
7744          var MIN_SVG_H=220;
7745          var rHb=Math.min(80,Math.max(26,Math.floor((MIN_SVG_H-legendH-topPad-10)/cData.length)));
7746          var bH=Math.min(38,Math.round(rHb*0.68));
7747          var BW=Math.max(120,svgW-LW-84);
7748          var SH=Math.max(MIN_SVG_H,cData.length*rHb+legendH+topPad+10);
7749          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">';
7750          if(isPct){
7751            cData.forEach(function(d,i){
7752              var t2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
7753              var cW=(d.code||0)/t2*BW,cmW=(d.comments||0)/t2*BW,blW=(d.blanks||0)/t2*BW;
7754              var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7755              var lmid=y+Math.floor(bH/2)+4;
7756              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||t2);
7757              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>';
7758              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;}
7759              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;}
7760              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>';}
7761              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>';
7762            });
7763          } else {
7764            var maxT=Math.max.apply(null,cData.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);})) || 1;
7765            cData.forEach(function(d,i){
7766              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
7767              var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7768              var lmid=y+Math.floor(bH/2)+4;
7769              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));
7770              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>';
7771              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;}
7772              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;}
7773              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>';}
7774              var phys=d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0);
7775              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>';
7776            });
7777          }
7778          var ly=SH-legendH+4;
7779          var ttC=cLT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7780          var ttCm=cLT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7781          var ttBl=cLT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7782          var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7783          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>';
7784          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>';
7785          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>';
7786          s+='</svg>';
7787          el.innerHTML=s;
7788          wireMixLegend(el.querySelector('svg'));
7789        }
7790        document.querySelectorAll('[data-comp-tab]').forEach(function(btn){
7791          btn.addEventListener('click', function(){
7792            document.querySelectorAll('[data-comp-tab]').forEach(function(b){b.classList.remove('active');});
7793            btn.classList.add('active');
7794            cMode=btn.getAttribute('data-comp-tab');
7795            renderCompSVG();
7796          });
7797        });
7798        renderCompSVG();
7799        window.addEventListener('resize', renderCompSVG);
7800      })();
7801
7802      // Custom HTML legend for the bubble chart: balanced columns (~15 rows max
7803      // per column, evenly distributed) placed to the right of the canvas. Chart.js'
7804      // native right-legend fills one column to full height then dumps the rest into
7805      // a tiny second column (and clips when space is tight) — this gives even columns
7806      // and never hides languages. Hover mirrors the old dim/highlight behaviour.
7807      //
7808      // Must run BEFORE `new Chart(canvas, …)` so Chart.js' resize observer binds to
7809      // the inner wrapper (not the full-width host). Returns a holder whose `.chart`
7810      // field the caller assigns once the chart exists, so hover can drive it.
7811      // expandBtnId: when set (compact card), the legend is capped to what fits in
7812      // 2 columns at the available height and a trailing "+N more" row links to Full
7813      // View. When null (Full View itself) every language is shown across (up to) 2
7814      // tall columns. Languages are ordered by code lines so the compact view keeps
7815      // the biggest ones; colours/hover still key off each language's original index.
7816      function attachScatterLegend(canvas, expandBtnId) {
7817        var holder = { chart: null };
7818        var host = canvas && canvas.parentNode;
7819        if (!host) return holder;
7820        host.style.display = 'flex';
7821        host.style.alignItems = 'center';
7822        host.style.gap = '12px';
7823        var cwrap = document.createElement('div');
7824        cwrap.style.cssText = 'position:relative;flex:1 1 auto;min-width:0;height:100%;';
7825        host.insertBefore(cwrap, canvas);
7826        cwrap.appendChild(canvas);
7827
7828        var n = SCAT_D.length;
7829        var availH = Math.max(120, host.clientHeight || 224);
7830        var rowsFit = Math.max(2, Math.floor(availH / 18));   // readable pitch
7831        // Never more than 2 columns; compact view truncates to fit, Full View shows all.
7832        var truncated = expandBtnId ? (n > 2 * rowsFit) : false;
7833        var realShown = truncated ? (2 * rowsFit - 1) : n;
7834        var totalItems = truncated ? (2 * rowsFit) : n;
7835        // Split into 2 equal columns once a single column would exceed ~18 rows, even
7836        // when the (tall) Full-View modal could fit them all in one column.
7837        var cols = totalItems > Math.min(rowsFit, 18) ? 2 : 1;
7838        var perCol = Math.ceil(totalItems / cols);
7839        var rowH = Math.max(14, Math.min(30, Math.floor(availH / perCol)));
7840
7841        // Order by code lines desc so the compact view keeps the biggest languages.
7842        var order = SCAT_D.map(function(_, i){ return i; })
7843          .sort(function(a, b){ return (SCAT_D[b].code || 0) - (SCAT_D[a].code || 0); });
7844
7845        var leg = document.createElement('div');
7846        leg.style.cssText = 'flex:0 0 auto;display:grid;grid-auto-flow:column;'
7847          + 'grid-template-rows:repeat(' + perCol + ',' + rowH + 'px);column-gap:18px;'
7848          + 'align-content:center;font-size:12px;line-height:1;';
7849        function setHi(idx) {
7850          var chart = holder.chart; if (!chart) return;
7851          chart.data.datasets.forEach(function(ds, i) {
7852            var b = PALETTE[i % PALETTE.length];
7853            ds.backgroundColor = i === idx ? b + 'b8' : b + '20';
7854            ds.borderColor = i === idx ? b : b + '30';
7855          });
7856          chart.setActiveElements([{ datasetIndex: idx, index: 0 }]);
7857          chart.update();
7858        }
7859        function clearHi() {
7860          var chart = holder.chart; if (!chart) return;
7861          chart.data.datasets.forEach(function(ds, i) {
7862            var b = PALETTE[i % PALETTE.length];
7863            ds.backgroundColor = b + 'b8';
7864            ds.borderColor = b;
7865          });
7866          chart.setActiveElements([]);
7867          chart.update('none');
7868        }
7869        function addItem(swColor, label, idx, isMore) {
7870          var it = document.createElement('div');
7871          it.style.cssText = 'display:flex;align-items:center;gap:7px;white-space:nowrap;'
7872            + ((idx != null || isMore) ? 'cursor:pointer;' : '');
7873          var sw = document.createElement('span');
7874          sw.style.cssText = 'width:22px;height:12px;border-radius:2px;flex:0 0 auto;background:'
7875            + swColor + ';' + (isMore ? 'opacity:0.45;' : '');
7876          var tx = document.createElement('span');
7877          tx.textContent = label;
7878          if (isMore) { tx.style.fontStyle = 'italic'; tx.style.opacity = '0.8'; }
7879          it.appendChild(sw); it.appendChild(tx);
7880          if (idx != null) {
7881            it.addEventListener('mouseenter', function(){ setHi(idx); });
7882            it.addEventListener('mouseleave', clearHi);
7883          }
7884          if (isMore) {
7885            it.addEventListener('click', function(){
7886              var b = document.getElementById(expandBtnId); if (b) b.click();
7887            });
7888          }
7889          leg.appendChild(it);
7890        }
7891        for (var k = 0; k < realShown; k++) {
7892          var oi = order[k];
7893          addItem(PALETTE[oi % PALETTE.length], SCAT_D[oi].lang, oi, false);
7894        }
7895        if (truncated) addItem('#9a8c82', '+' + (n - realShown) + ' more — Full View', null, true);
7896        host.appendChild(leg);
7897        return holder;
7898      }
7899
7900      // ── Scatter / Bubble chart ────────────────────────────────────────────────
7901      (function() {
7902        var canvas = document.getElementById('canvas-scatter');
7903        if (!canvas || !SCAT_D || !SCAT_D.length) return;
7904        var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
7905        var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
7906        var c = clr();
7907        var legHolder = attachScatterLegend(canvas, 'scatter-expand-btn');
7908        var chart = new Chart(canvas, {
7909          type: 'bubble',
7910          data: {
7911            datasets: SCAT_D.map(function(d, i) {
7912              return {
7913                label: d.lang,
7914                data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
7915                backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
7916                borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
7917                hoverBorderWidth: 2
7918              };
7919            })
7920          },
7921          options: {
7922            responsive: true, maintainAspectRatio: false,
7923            onHover: chartCursor,
7924            animation: { duration: 500, easing: 'easeOutQuart' },
7925            layout: { padding: { top: 44, right: 12 } },
7926            scales: {
7927              x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6,
7928                   grid: { color: c.grid },
7929                   ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } },
7930                   title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
7931              y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} },
7932                   title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
7933            },
7934            plugins: {
7935              legend: { display: false },
7936              tooltip: {
7937                callbacks: {
7938                  title: function(items) { return items.length ? items[0].dataset.label : ''; },
7939                  label: function(ctx){
7940                    var d = SCAT_D[ctx.datasetIndex];
7941                    return [
7942                      '  Files analyzed: ' + fmt(d.files),
7943                      '  Code lines: ' + Number(d.code).toLocaleString(),
7944                      '  Physical lines: ' + Number(d.physical).toLocaleString()
7945                    ];
7946                  }
7947                }
7948              }
7949            }
7950          },
7951          plugins: [(function(){return{afterDatasetsDraw:function(chart){
7952            var ctx=chart.ctx,tc=clr().text,ca=chart.chartArea;
7953            chart.data.datasets.forEach(function(ds,di){
7954              var meta=chart.getDatasetMeta(di),d=SCAT_D[di];if(!d)return;
7955              meta.data.forEach(function(el){
7956                var r=(el.options&&el.options.radius)?el.options.radius:10;
7957                var codeStr=fmt(d.code);
7958                // render in layout.padding.top space — clamp only to canvas top, not chartArea.top
7959                var ty2=Math.max(14,el.y-r-3);
7960                var ty1=Math.max(1,ty2-14);
7961                // label always centred directly on bubble — padding.right gives room at the edge
7962                ctx.save();ctx.fillStyle=tc;ctx.textBaseline='bottom';ctx.textAlign='center';
7963                ctx.font='800 11px Inter,ui-sans-serif,sans-serif';
7964                ctx.fillText(d.lang,el.x,ty1);
7965                ctx.font='700 10px Inter,ui-sans-serif,sans-serif';
7966                ctx.fillText(codeStr,el.x,ty2);
7967                ctx.restore();
7968              });
7969            });
7970          }};})()]
7971        });
7972        ALL_CHARTS.push(chart);
7973        legHolder.chart = chart;
7974      })();
7975
7976      // ── Submodule breakdown ──────────────────────────────────────────────────
7977      // No-op plugins: hover row-dimming was removed because the flashing row
7978      // background looked out of place vs. every other chart. Kept as empty stubs
7979      // so the (inline + Full View) chart configs that reference them stay valid.
7980      var rowDimPlugin = {};
7981      var barJumpPlugin = {};
7982      var subChart = null;
7983      (function() {
7984        if (!SUB_D || !SUB_D.length) return;
7985        var subYSel = document.getElementById('sub-y-axis');
7986        var subSortSel = document.getElementById('sub-sort');
7987        var wrap = document.getElementById('canvas-sub-wrap');
7988        var canvas = document.getElementById('canvas-sub');
7989        if (!canvas) return;
7990        var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines',
7991                         physical:'Physical Lines', files:'Files' };
7992        var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
7993        function renderSubmodule() {
7994          var yKey = subYSel ? subYSel.value : 'code';
7995          var sortMode = subSortSel ? subSortSel.value : 'desc';
7996          var data = SUB_D.slice();
7997          if (sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
7998          else if (sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
7999          else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8000          data = data.slice(0, 30);
8001          var c = clr();
8002          var col = SUB_COLS[yKey] || OX;
8003          if (wrap) wrap.style.height = Math.max(200, Math.min(540, data.length * 28 + 60)) + 'px';
8004          if (subChart) {
8005            subChart.data.labels = data.map(function(d){return d.name;});
8006            subChart.data.datasets[0].data = data.map(function(d){return d[yKey]||0;});
8007            subChart.data.datasets[0].backgroundColor = col;
8008            subChart.data.datasets[0].label = Y_LABELS[yKey]||yKey;
8009            subChart.options.scales.x.title.text = Y_LABELS[yKey]||yKey;
8010            subChart.update('none'); return;
8011          }
8012          subChart = new Chart(canvas, {
8013            type: 'bar',
8014            data: {
8015              labels: data.map(function(d){return d.name;}),
8016              datasets: [{ label: Y_LABELS[yKey]||yKey,
8017                data: data.map(function(d){return d[yKey]||0;}),
8018                backgroundColor: col, hoverBackgroundColor: col === OX ? '#d97020' : col,
8019                borderRadius: 3 }]
8020            },
8021            options: {
8022              indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8023              onHover: chartCursor,
8024              animation: { duration: 500, easing: 'easeOutQuart' },
8025              transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8026              layout: { padding: { right: 64 } },
8027              scales: {
8028                x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8029                     title: { display: true, text: Y_LABELS[yKey]||yKey, color: c.text } },
8030                y: { grid: { display: false }, ticks: { color: c.text } }
8031              },
8032              plugins: {
8033                legend: { display: false },
8034                tooltip: {
8035                  callbacks: {
8036                    title: function(items) { return items.length ? items[0].label : ''; },
8037                    label: function(ctx){
8038                      var d = data[ctx.dataIndex] || {};
8039                      return [
8040                        '  Code: ' + Number(d.code||0).toLocaleString(),
8041                        '  Comments: ' + Number(d.comment||0).toLocaleString(),
8042                        '  Blanks: ' + Number(d.blank||0).toLocaleString(),
8043                        '  Physical: ' + Number(d.physical||0).toLocaleString(),
8044                        '  Files: ' + fmt(d.files||0)
8045                      ];
8046                    }
8047                  }
8048                }
8049              }
8050            },
8051            plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end'), barJumpPlugin]
8052          });
8053          ALL_CHARTS.push(subChart);
8054        }
8055        if (subYSel) subYSel.addEventListener('change', renderSubmodule);
8056        if (subSortSel) subSortSel.addEventListener('change', renderSubmodule);
8057        renderSubmodule();
8058      })();
8059
8060      // ── Submodule composition: stacked horizontal bar (Chart.js) ─────────────
8061      var subCompChart = null;
8062      // Plugin: draw value label inside each visible segment of a stacked horizontal bar.
8063      var segLabelPlugin = {
8064        afterDatasetsDraw: function(chart) {
8065          var ctx = chart.ctx, nDs = chart.data.datasets.length;
8066          var tc = clr().text;
8067          for (var di = 0; di < nDs; di++) {
8068            var meta = chart.getDatasetMeta(di);
8069            if (meta.hidden) continue;
8070            meta.data.forEach(function(el, idx) {
8071              var v = chart.data.datasets[di].data[idx] || 0;
8072              if (!v) return;
8073              var w = Math.abs(el.x - el.base);
8074              if (w < 28) return; // too narrow to show label
8075              ctx.save();
8076              ctx.font = '600 10px Inter,ui-sans-serif,sans-serif';
8077              ctx.fillStyle = di === 0 ? '#fff' : (di === 1 ? '#fff' : '#555');
8078              ctx.textAlign = 'center';
8079              ctx.textBaseline = 'middle';
8080              ctx.fillText(fmt(v), el.base + w / 2, el.y);
8081              ctx.restore();
8082            });
8083          }
8084        }
8085      };
8086      (function() {
8087        var el = document.getElementById('submodule-donut');
8088        if (!el || !SUB_D || !SUB_D.length) return;
8089        var data = SUB_D.slice().sort(function(a,b){
8090          return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));
8091        }).slice(0, 15);
8092        var h = Math.max(150, Math.min(540, data.length * 40 + 90));
8093        el.style.height = h + 'px';
8094        el.style.position = 'relative';
8095        var cv = document.createElement('canvas');
8096        cv.id = 'canvas-sub-comp';
8097        el.innerHTML = '';
8098        el.appendChild(cv);
8099        var c = clr();
8100        subCompChart = new Chart(cv, {
8101          type: 'bar',
8102          data: {
8103            labels: data.map(function(d){ return d.name; }),
8104            datasets: [
8105              { label: 'Code',     data: data.map(function(d){ return d.code||0; }),    backgroundColor: OX, hoverBackgroundColor: '#d97020', borderRadius: 0, borderSkipped: false },
8106              { label: 'Comments', data: data.map(function(d){ return d.comment||0; }), backgroundColor: GN, hoverBackgroundColor: '#3a8a5e', borderRadius: 0, borderSkipped: false },
8107              { label: 'Blank',    data: data.map(function(d){ return d.blank||0; }),   backgroundColor: GY, hoverBackgroundColor: '#999',    borderRadius: 0, borderSkipped: false }
8108            ]
8109          },
8110          options: {
8111            indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8112            onHover: chartCursor,
8113            animation: { duration: 500, easing: 'easeOutQuart' },
8114            transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8115            layout: { padding: { right: 56 } },
8116            scales: {
8117              x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){ return fmt(v); } } },
8118              y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8119            },
8120            plugins: {
8121              legend: {
8122                position: 'bottom',
8123                labels: { color: c.text, usePointStyle: true, pointStyle: 'rect', font: { size: 11, weight: '700' }, padding: 16 },
8124                onHover: function(e, item, leg) {
8125                  legendCursorOn(e);
8126                  var ch = leg.chart, di = item.datasetIndex;
8127                  var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8128                  ch.data.datasets.forEach(function(ds, i) {
8129                    ds.backgroundColor = i===di ? orig[i] : hexAlpha(orig[i], 0.15);
8130                    ds.hoverBackgroundColor = i===di ? hov[i] : hexAlpha(orig[i], 0.15);
8131                  });
8132                  // show tooltip on first bar row with all datasets (index mode)
8133                  var n = ch.data.datasets.length, ae = [];
8134                  for (var ii = 0; ii < n; ii++) { ae.push({ datasetIndex: ii, index: 0 }); }
8135                  var fp = ch.getDatasetMeta(di).data[0];
8136                  ch.setActiveElements([{ datasetIndex: di, index: 0 }]);
8137                  ch.tooltip.setActiveElements(ae, fp ? { x: fp.x, y: fp.y } : { x: 0, y: 0 });
8138                  ch.update();
8139                },
8140                onLeave: function(e, item, leg) {
8141                  legendCursorOff(e);
8142                  var ch = leg.chart;
8143                  var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8144                  ch.data.datasets.forEach(function(ds, i) { ds.backgroundColor = orig[i]; ds.hoverBackgroundColor = hov[i]; });
8145                  ch.setActiveElements([]);
8146                  ch.tooltip.setActiveElements([], {});
8147                  ch.update('none');
8148                }
8149              },
8150              tooltip: {
8151                mode: 'index',
8152                callbacks: {
8153                  title: function(items){ return items.length ? items[0].label : ''; },
8154                  label: function(ctx){
8155                    var v = ctx.parsed.x || 0;
8156                    return '  ' + ctx.dataset.label + ': ' + Number(v).toLocaleString();
8157                  },
8158                  footer: function(items){
8159                    var tot = items.reduce(function(s,i){ return s + (i.parsed.x||0); }, 0);
8160                    return 'Total: ' + Number(tot).toLocaleString();
8161                  }
8162                }
8163              }
8164            }
8165          },
8166          plugins: [makeStackedEndPlugin(function(v){ return fmt(v); }), segLabelPlugin, rowDimPlugin]
8167        });
8168        ALL_CHARTS.push(subCompChart);
8169      })();
8170
8171      // ── Semantic Metrics ─────────────────────────────────────────────────────
8172      (function() {
8173        if (!SEM_D || !SEM_D.length) return;
8174        var semSel = document.getElementById('semantic-metric');
8175        var canvas = document.getElementById('canvas-semantic');
8176        if (!canvas) return;
8177        var SEM_LABELS = { functions:'Functions', classes:'Classes / Types', variables:'Variables',
8178                           imports:'Imports', tests:'Tests' };
8179        var SEM_COLS = { functions:OX, classes:'#4472C4', variables:GN, imports:'#805099', tests:'#B23030' };
8180        var SEM_HCOLS = { functions:'#d97020', classes:'#5a8ad8', variables:'#3a8a5e', imports:'#9a68b3', tests:'#cc4545' };
8181        var semChart = null;
8182        function renderSemantic() {
8183          var mKey = semSel ? semSel.value : 'functions';
8184          var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
8185          var c = clr();
8186          var col = SEM_COLS[mKey] || OX;
8187          var hCol = SEM_HCOLS[mKey] || '#d97020';
8188          if (semChart) {
8189            semChart.data.labels = data.map(function(d){return d.lang;});
8190            semChart.data.datasets[0].data = data.map(function(d){return d[mKey]||0;});
8191            semChart.data.datasets[0].backgroundColor = col;
8192            semChart.data.datasets[0].hoverBackgroundColor = hCol;
8193            semChart.data.datasets[0].label = SEM_LABELS[mKey]||mKey;
8194            semChart.update('none'); return;
8195          }
8196          semChart = new Chart(canvas, {
8197            type: 'bar',
8198            data: {
8199              labels: data.map(function(d){return d.lang;}),
8200              datasets: [{ label: SEM_LABELS[mKey]||mKey,
8201                data: data.map(function(d){return d[mKey]||0;}),
8202                backgroundColor: col, hoverBackgroundColor: hCol,
8203                borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8204            },
8205            options: {
8206              responsive: true, maintainAspectRatio: false,
8207              onHover: chartCursor,
8208              animation: { duration: 500, easing: 'easeOutQuart' },
8209              transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8210              layout: { padding: { top: 18 } },
8211              scales: {
8212                x: { grid: { display: false }, ticks: { color: c.text } },
8213                y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8214              },
8215              plugins: {
8216                legend: { display: false },
8217                tooltip: {
8218                  callbacks: {
8219                    title: function(items) { return items.length ? items[0].label : ''; },
8220                    label: function(ctx) {
8221                      var d = data[ctx.dataIndex] || {};
8222                      var lines = ['  ' + (SEM_LABELS[mKey]||mKey) + ': ' + Number(ctx.parsed.y).toLocaleString()];
8223                      var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== mKey && (d[k]||0) > 0; });
8224                      others.forEach(function(k) {
8225                        lines.push('  ' + SEM_LABELS[k] + ': ' + Number(d[k]||0).toLocaleString());
8226                      });
8227                      return lines;
8228                    }
8229                  }
8230                }
8231              }
8232            },
8233            plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8234          });
8235          ALL_CHARTS.push(semChart);
8236        }
8237        if (semSel) semSel.addEventListener('change', renderSemantic);
8238        renderSemantic();
8239
8240        var semExpandBtn = document.getElementById('semantic-expand-btn');
8241        if (semExpandBtn) {
8242          semExpandBtn.addEventListener('click', function() {
8243            var mKey = semSel ? semSel.value : 'functions';
8244            var n = SEM_D.length || 1;
8245            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8246            var modalH = Math.min(Math.max(400, n * 46 + 96), maxH);
8247            var overlay = document.createElement('div');
8248            overlay.className = 'chart-modal-overlay';
8249            var semOptHtml = '<option value="functions">Functions</option>'
8250              + '<option value="classes">Classes / Types</option>'
8251              + '<option value="variables">Variables</option>'
8252              + '<option value="imports">Imports</option>'
8253              + '<option value="tests">Tests</option>';
8254            var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">Semantic Metrics \u2014 Full View</span>'
8255              + '<select class="chart-select" id="sem-modal-metric">' + semOptHtml + '</select></div>';
8256            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>';
8257            document.body.appendChild(overlay);
8258            overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
8259            overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
8260            var modalSel = document.getElementById('sem-modal-metric');
8261            if (modalSel) modalSel.value = mKey;
8262            var modalCanvas = document.getElementById('canvas-semantic-modal');
8263            var semModalChart = null;
8264            function renderSemModal(key) {
8265              if (semModalChart) { semModalChart.destroy(); semModalChart = null; }
8266              if (!modalCanvas) return;
8267              var data = SEM_D.slice().sort(function(a,b){return (b[key]||0)-(a[key]||0);});
8268              var c = clr();
8269              var col = SEM_COLS[key] || OX;
8270              var hcol = SEM_HCOLS[key] || '#d97020';
8271              semModalChart = new Chart(modalCanvas, {
8272                type: 'bar',
8273                data: {
8274                  labels: data.map(function(d){return d.lang;}),
8275                  datasets: [{ label: SEM_LABELS[key]||key, data: data.map(function(d){return d[key]||0;}),
8276                    backgroundColor: col, hoverBackgroundColor: hcol,
8277                    borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8278                },
8279                options: {
8280                  responsive: true, maintainAspectRatio: false,
8281                  onHover: chartCursor,
8282                  animation: { duration: 500, easing: 'easeOutQuart' },
8283                  transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8284                  layout: { padding: { top: 18 } },
8285                  scales: {
8286                    x: { grid: { display: false }, ticks: { color: c.text } },
8287                    y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8288                  },
8289                  plugins: { legend: { display: false }, tooltip: { callbacks: {
8290                    title: function(items){return items.length?items[0].label:'';},
8291                    label: function(ctx){
8292                      var d = data[ctx.dataIndex] || {};
8293                      var lines = ['  '+(SEM_LABELS[key]||key)+': '+Number(ctx.parsed.y).toLocaleString()];
8294                      var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== key && (d[k]||0) > 0; });
8295                      others.forEach(function(k){ lines.push('  '+SEM_LABELS[k]+': '+Number(d[k]||0).toLocaleString()); });
8296                      return lines;
8297                    }
8298                  }}}
8299                },
8300                plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8301              });
8302            }
8303            renderSemModal(mKey);
8304            if (modalSel) modalSel.addEventListener('change', function() { renderSemModal(this.value); });
8305          });
8306        }
8307      })();
8308
8309      // ── Comment Density: comments / (code + comments) per language ──────────
8310      (function() {
8311        var canvas = document.getElementById('canvas-density');
8312        if (!canvas || !D || !D.length) return;
8313        var data = D.slice().sort(function(a,b){
8314          var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8315          var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8316          return db-da;
8317        });
8318        var labels = data.map(function(d){return d.lang;});
8319        var densities = data.map(function(d){
8320          var sig=(d.code||0)+(d.comments||0);
8321          return sig>0?Math.round((d.comments||0)/sig*1000)/10:0;
8322        });
8323        var wrap = canvas.parentElement;
8324        if (wrap) wrap.style.height = Math.max(150, Math.min(500, data.length*29+36))+'px';
8325        var c = clr();
8326        var densChart = new Chart(canvas, {
8327          type: 'bar',
8328          data: {
8329            labels: labels,
8330            datasets: [{ label: 'Comment %',
8331              data: densities,
8332              backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8333              borderRadius: 4
8334            }]
8335          },
8336          options: {
8337            indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8338            onHover: chartCursor,
8339            animation: { duration: 500, easing: 'easeOutQuart' },
8340            layout: { padding: { right: 42 } },
8341            scales: {
8342              x: { min: 0, max: 100,
8343                   grid: { color: c.grid },
8344                   ticks: { color: c.text, callback: function(v){return v+'%';} },
8345                   title: { display: true, text: 'Comment %', color: c.text } },
8346              y: { grid: { display: false }, ticks: { color: c.text } }
8347            },
8348            plugins: {
8349              legend: { display: false },
8350              tooltip: { callbacks: {
8351                title: function(items){return items.length?items[0].label:'';},
8352                label: function(ctx){
8353                  var d=data[ctx.dataIndex]||{};
8354                  var sig=(d.code||0)+(d.comments||0);
8355                  return ['  Comment ratio: '+ctx.parsed.x+'%',
8356                          '  Comments: '+Number(d.comments||0).toLocaleString(),
8357                          '  Significant lines: '+Number(sig).toLocaleString()];
8358                }
8359              }}
8360            }
8361          },
8362          plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8363        });
8364        ALL_CHARTS.push(densChart);
8365      })();
8366
8367      // ── File Size Distribution histogram ──────────────────────────────────────
8368      (function() {
8369        var canvas = document.getElementById('canvas-filesize');
8370        if (!canvas || !HIST_D || !HIST_D.length) return;
8371        var labels = HIST_D.map(function(d){return d.label;});
8372        var counts = HIST_D.map(function(d){return d.count||0;});
8373        var total = counts.reduce(function(a,b){return a+b;},0);
8374        var c = clr();
8375        var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8376        var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8377        var fsChart = new Chart(canvas, {
8378          type: 'bar',
8379          data: {
8380            labels: labels,
8381            datasets: [{ label: 'Files',
8382              data: counts,
8383              backgroundColor: fsBg,
8384              hoverBackgroundColor: fsHv,
8385              borderRadius: 6,
8386              borderWidth: 0,
8387              hoverBorderWidth: 0
8388            }]
8389          },
8390          options: {
8391            responsive: true, maintainAspectRatio: false,
8392            onHover: chartCursor,
8393            animation: { duration: 500, easing: 'easeOutQuart' },
8394            transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8395            layout: { padding: { top: 18 } },
8396            scales: {
8397              x: { grid: { display: false }, ticks: { color: c.text, font: { size: 11 } } },
8398              y: { beginAtZero: true,
8399                   grid: { color: c.grid },
8400                   ticks: { color: c.text, precision: 0 },
8401                   title: { display: true, text: 'File Count', color: c.text } }
8402            },
8403            plugins: {
8404              legend: { display: false },
8405              tooltip: { callbacks: {
8406                label: function(ctx) {
8407                  var n = ctx.parsed.y;
8408                  var pct = total > 0 ? Math.round(n/total*1000)/10 : 0;
8409                  return ['  Files: '+n, '  Share: '+pct+'%'];
8410                }
8411              }}
8412            }
8413          },
8414          plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8415        });
8416        ALL_CHARTS.push(fsChart);
8417      })();
8418
8419      // ── Expand button handlers ────────────────────────────────────────────────
8420      (function() {
8421        function makeOverlay(title, h, subtitle, ctrlHtml) {
8422          var overlay = document.createElement('div');
8423          overlay.className = 'chart-modal-overlay';
8424          var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8425          var hAttr = 'height:' + Math.min(h || 696, maxH) + 'px;';
8426          var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
8427          var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">' + title + '</span>' + (ctrlHtml || '') + '</div>';
8428          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>';
8429          document.body.appendChild(overlay);
8430          overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8431          overlay.addEventListener('click', function(e){ if(e.target === overlay) document.body.removeChild(overlay); });
8432          return document.getElementById('modal-expand-canvas');
8433        }
8434
8435        // Language Composition
8436        (function(){
8437          var btn = document.getElementById('comp-expand-btn');
8438          if(!btn) return;
8439          btn.addEventListener('click', function(){
8440            var activeTab = document.querySelector('[data-comp-tab].active');
8441            var compMode = activeTab ? activeTab.getAttribute('data-comp-tab') : 'absolute';
8442            var ctrlHtml = '<select class="chart-select" id="comp-modal-mode">'
8443              + '<option value="absolute">Absolute Lines</option>'
8444              + '<option value="pct">100% Normalized</option>'
8445              + '</select>';
8446            var canvas = makeOverlay('Language Composition \u2014 Full View', undefined, null, ctrlHtml);
8447            if(!canvas) return;
8448            var modalMode = document.getElementById('comp-modal-mode');
8449            if(modalMode) modalMode.value = compMode;
8450            var compModalChart = null;
8451            function renderCompModal(mode) {
8452              if(compModalChart) { compModalChart.destroy(); compModalChart = null; }
8453              var data = D.slice(0, 15);
8454              var c = clr(), isPct = mode === 'pct';
8455              var tot = function(d){ return (d.code||0)+(d.comments||0)+(d.blanks||0)||1; };
8456              var codeD = data.map(function(d){ return isPct ? (d.code||0)/tot(d)*100 : d.code||0; });
8457              var cmD   = data.map(function(d){ return isPct ? (d.comments||0)/tot(d)*100 : d.comments||0; });
8458              var blD   = data.map(function(d){ return isPct ? (d.blanks||0)/tot(d)*100 : d.blanks||0; });
8459              var tickCb = isPct ? function(v){return v.toFixed(0)+'%';} : function(v){return fmt(v);};
8460              compModalChart = new Chart(canvas, {
8461                type: 'bar',
8462                data: {
8463                  labels: data.map(function(d){ return d.lang; }),
8464                  datasets: [
8465                    { label:'Code',     data: codeD, backgroundColor: OX, borderRadius: 3 },
8466                    { label:'Comments', data: cmD,   backgroundColor: GN, borderRadius: 3 },
8467                    { label:'Blanks',   data: blD,   backgroundColor: GY, borderRadius: 3 }
8468                  ]
8469                },
8470                options: {
8471                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8472                  layout: { padding: { right: 64 } },
8473                  scales: {
8474                    x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: tickCb } },
8475                    y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8476                  },
8477                  plugins: { legend: { position: 'bottom', labels: { color: c.text } } }
8478                },
8479                plugins: [makeStackedEndPlugin(function(total, idx) {
8480                  if (isPct) return '';
8481                  var d = data[idx]; return fmt(Math.round(d && d.physical ? d.physical : total));
8482                })]
8483              });
8484            }
8485            renderCompModal(compMode);
8486            if(modalMode) modalMode.addEventListener('change', function(){ renderCompModal(this.value); });
8487          });
8488        })();
8489
8490        // Files vs Code Lines (Scatter)
8491        (function(){
8492          var btn = document.getElementById('scatter-expand-btn');
8493          if(!btn || !SCAT_D || !SCAT_D.length) return;
8494          btn.addEventListener('click', function(){
8495            var canvas = makeOverlay('Files vs Code Lines \u2014 Full View', undefined, 'File count vs SLOC per language');
8496            if(!canvas) return;
8497            var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8498            var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8499            var c = clr();
8500            var scLegHolder = attachScatterLegend(canvas);
8501            var scExpand = new Chart(canvas, {
8502              type: 'bubble',
8503              data: {
8504                datasets: SCAT_D.map(function(d, i) {
8505                  return {
8506                    label: d.lang,
8507                    data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8508                    backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8509                    borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8510                    hoverBorderWidth: 2
8511                  };
8512                })
8513              },
8514              options: {
8515                responsive: true, maintainAspectRatio: false,
8516                onHover: chartCursor,
8517                animation: { duration: 500, easing: 'easeOutQuart' },
8518                layout: { padding: { top: 44, right: 12 } },
8519                scales: {
8520                  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 } } },
8521                  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 } } }
8522                },
8523                plugins: {
8524                  legend: { display: false },
8525                  tooltip: { callbacks: {
8526                    title: function(items){ return items.length ? items[0].dataset.label : ''; },
8527                    label: function(ctx){
8528                      var d = SCAT_D[ctx.datasetIndex];
8529                      return ['  Files analyzed: '+fmt(d.files), '  Code lines: '+Number(d.code).toLocaleString(), '  Physical lines: '+Number(d.physical).toLocaleString()];
8530                    }
8531                  }}
8532                }
8533              },
8534              plugins: [(function(){return{afterDatasetsDraw:function(chart){
8535                var ctx=chart.ctx,tc=clr().text,ca=chart.chartArea;
8536                chart.data.datasets.forEach(function(ds,di){
8537                  var meta=chart.getDatasetMeta(di),d=SCAT_D[di];if(!d)return;
8538                  meta.data.forEach(function(el){
8539                    var r=(el.options&&el.options.radius)?el.options.radius:10;
8540                    var codeStr=fmt(d.code);
8541                    var ty2=Math.max(14,el.y-r-3);
8542                    var ty1=Math.max(1,ty2-14);
8543                    ctx.save();ctx.fillStyle=tc;ctx.textBaseline='bottom';ctx.textAlign='center';
8544                    ctx.font='800 11px Inter,ui-sans-serif,sans-serif';
8545                    ctx.fillText(d.lang,el.x,ty1);
8546                    ctx.font='700 10px Inter,ui-sans-serif,sans-serif';
8547                    ctx.fillText(codeStr,el.x,ty2);
8548                    ctx.restore();
8549                  });
8550                });
8551              }};})()]
8552            });
8553            scLegHolder.chart = scExpand;
8554          });
8555        })();
8556
8557        // Comment Density
8558        (function(){
8559          var btn = document.getElementById('density-expand-btn');
8560          if(!btn) return;
8561          btn.addEventListener('click', function(){
8562            var data = D.slice().sort(function(a,b){
8563              var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8564              var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8565              return db-da;
8566            });
8567            var h = Math.min(Math.max(672, data.length * 46 + 96), Math.max(400, Math.floor(window.innerHeight * 0.82) - 130));
8568            var canvas = makeOverlay('Comment Density \u2014 Full View', h, 'Comment ratio per language');
8569            if(!canvas) return;
8570            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; });
8571            var c = clr();
8572            new Chart(canvas, {
8573              type: 'bar',
8574              data: {
8575                labels: data.map(function(d){return d.lang;}),
8576                datasets: [{ label: 'Comment %', data: densities,
8577                  backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius: 4 }]
8578              },
8579              options: {
8580                indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8581                animation: { duration: 500, easing: 'easeOutQuart' },
8582                layout: { padding: { right: 42 } },
8583                scales: {
8584                  x: { min:0, max:100, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return v+'%';}} },
8585                  y: { grid:{display:false}, ticks:{color:c.text} }
8586                },
8587                plugins: { legend:{display:false}, tooltip:{callbacks:{
8588                  title:function(items){return items.length?items[0].label:'';},
8589                  label:function(ctx){
8590                    var d=data[ctx.dataIndex]||{};
8591                    var sig=(d.code||0)+(d.comments||0);
8592                    return ['  Comment ratio: '+ctx.parsed.x.toFixed(1)+'%',
8593                            '  Comments: '+Number(d.comments||0).toLocaleString(),
8594                            '  Significant lines: '+Number(sig).toLocaleString()];
8595                  }
8596                }}}
8597              },
8598              plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8599            });
8600          });
8601        })();
8602
8603        // File Size Distribution
8604        (function(){
8605          var btn = document.getElementById('filesize-expand-btn');
8606          if(!btn || !HIST_D || !HIST_D.length) return;
8607          btn.addEventListener('click', function(){
8608            var canvas = makeOverlay('File Size Distribution \u2014 Full View', undefined, 'File count per SLOC bucket');
8609            if(!canvas) return;
8610            var labels = HIST_D.map(function(d){return d.label;});
8611            var counts = HIST_D.map(function(d){return d.count||0;});
8612            var total = counts.reduce(function(a,b){return a+b;},0);
8613            var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8614            var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8615            var c = clr();
8616            new Chart(canvas, {
8617              type: 'bar',
8618              data: {
8619                labels: labels,
8620                datasets: [{ label: 'Files', data: counts,
8621                  backgroundColor: fsBg, hoverBackgroundColor: fsHv,
8622                  borderRadius: 6, borderWidth: 0, hoverBorderWidth: 0 }]
8623              },
8624              options: {
8625                responsive: true, maintainAspectRatio: false,
8626                animation: { duration: 500, easing: 'easeOutQuart' },
8627                transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8628                layout: { padding: { top: 18 } },
8629                scales: {
8630                  x: { grid:{display:false}, ticks:{color:c.text} },
8631                  y: { beginAtZero:true, grid:{color:c.grid}, ticks:{color:c.text, precision:0}, title:{display:true, text:'File Count', color:c.text} }
8632                },
8633                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+'%']; }}} }
8634              },
8635              plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8636            });
8637          });
8638        })();
8639
8640        // Submodule Breakdown — Full View with live Y Axis + Sort controls
8641        (function(){
8642          var btn = document.getElementById('sub-expand-btn');
8643          if(!btn || !SUB_D || !SUB_D.length) return;
8644          btn.addEventListener('click', function(){
8645            var subYSel = document.getElementById('sub-y-axis');
8646            var subSortSel = document.getElementById('sub-sort');
8647            var initY = subYSel ? subYSel.value : 'code';
8648            var initSort = subSortSel ? subSortSel.value : 'desc';
8649            var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines', physical:'Physical Lines', files:'Files' };
8650            var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8651            var SUB_HCOLS = { code:'#d97020', comment:'#3a8a5e', blank:'#999', physical:'#5a8ad8', files:'#9a68b3' };
8652            var n = Math.min(SUB_D.length, 30);
8653            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8654            var modalH = Math.min(Math.max(480, n * 36 + 96), maxH);
8655            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:'
8656              + '<select class="chart-select" id="sub-modal-y">'
8657              + '<option value="code">Code Lines</option>'
8658              + '<option value="comment">Comment Lines</option>'
8659              + '<option value="blank">Blank Lines</option>'
8660              + '<option value="physical">Physical Lines</option>'
8661              + '<option value="files">File Count</option>'
8662              + '</select></label>'
8663              + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8664              + '<select class="chart-select" id="sub-modal-sort">'
8665              + '<option value="desc">Value \u2193</option>'
8666              + '<option value="asc">Value \u2191</option>'
8667              + '<option value="name">Name A\u2192Z</option>'
8668              + '</select></label>';
8669            var canvas = makeOverlay('Submodule Breakdown \u2014 Full View', modalH, null, ctrlHtml);
8670            if(!canvas) return;
8671            var modalY = document.getElementById('sub-modal-y');
8672            var modalSort = document.getElementById('sub-modal-sort');
8673            if(modalY) modalY.value = initY;
8674            if(modalSort) modalSort.value = initSort;
8675            var subModalChart = null;
8676            function renderSubModal(yKey, sortMode) {
8677              if(subModalChart) { subModalChart.destroy(); subModalChart = null; }
8678              var data = SUB_D.slice();
8679              if(sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8680              else if(sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8681              else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8682              data = data.slice(0, 30);
8683              var c = clr(), col = SUB_COLS[yKey]||OX, hcol = SUB_HCOLS[yKey]||'#d97020';
8684              subModalChart = new Chart(canvas, {
8685                type: 'bar',
8686                data: {
8687                  labels: data.map(function(d){return d.name;}),
8688                  datasets: [{ label: Y_LABELS[yKey]||yKey,
8689                    data: data.map(function(d){return d[yKey]||0;}),
8690                    backgroundColor: col, hoverBackgroundColor: hcol, borderRadius: 3 }]
8691                },
8692                options: {
8693                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8694                  onHover: chartCursor,
8695                  animation: { duration: 500, easing: 'easeOutQuart' },
8696                  transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8697                  layout: { padding: { right: 72 } },
8698                  scales: {
8699                    x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
8700                         title:{display:true, text:Y_LABELS[yKey]||yKey, color:c.text} },
8701                    y: { grid:{display:false}, ticks:{color:c.text} }
8702                  },
8703                  plugins: {
8704                    legend:{display:false},
8705                    tooltip: { callbacks: {
8706                      title: function(items){return items.length?items[0].label:'';},
8707                      label: function(ctx){
8708                        var d = data[ctx.dataIndex]||{};
8709                        return ['  Code: '+Number(d.code||0).toLocaleString(),
8710                                '  Comments: '+Number(d.comment||0).toLocaleString(),
8711                                '  Blanks: '+Number(d.blank||0).toLocaleString(),
8712                                '  Physical: '+Number(d.physical||0).toLocaleString(),
8713                                '  Files: '+fmt(d.files||0)];
8714                      }
8715                    }}
8716                  }
8717                },
8718                plugins: [makeDlPlugin(function(v){return fmt(v||0);}, 'end'), barJumpPlugin]
8719              });
8720            }
8721            renderSubModal(initY, initSort);
8722            if(modalY) modalY.addEventListener('change', function(){ renderSubModal(this.value, modalSort ? modalSort.value : 'desc'); });
8723            if(modalSort) modalSort.addEventListener('change', function(){ renderSubModal(modalY ? modalY.value : 'code', this.value); });
8724          });
8725        })();
8726
8727        // Submodule Composition — Full View (Chart.js with sort control)
8728        (function(){
8729          var btn = document.getElementById('sub-comp-expand-btn');
8730          if(!btn || !SUB_D || !SUB_D.length) return;
8731          btn.addEventListener('click', function(){
8732            var n = Math.min(SUB_D.length, 20);
8733            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8734            var modalH = Math.min(Math.max(400, n * 40 + 90), maxH);
8735            var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8736              + '<select class="chart-select" id="sub-comp-modal-sort">'
8737              + '<option value="desc">Total Lines \u2193</option>'
8738              + '<option value="asc">Total Lines \u2191</option>'
8739              + '<option value="name">Name A\u2192Z</option>'
8740              + '</select></label>';
8741            var canvas = makeOverlay('Submodule Composition \u2014 Full View', modalH, null, ctrlHtml);
8742            if(!canvas) return;
8743            var modalSort = document.getElementById('sub-comp-modal-sort');
8744            var scModalChart = null;
8745            function renderSCModal(sortMode) {
8746              if(scModalChart) { scModalChart.destroy(); scModalChart = null; }
8747              var data = SUB_D.slice();
8748              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));});
8749              else if(sortMode==='name') data.sort(function(a,b){return a.name.localeCompare(b.name);});
8750              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));});
8751              data = data.slice(0, 20);
8752              var c = clr();
8753              scModalChart = new Chart(canvas, {
8754                type: 'bar',
8755                data: {
8756                  labels: data.map(function(d){ return d.name; }),
8757                  datasets: [
8758                    { label:'Code',     data:data.map(function(d){return d.code||0;}),    backgroundColor:OX, hoverBackgroundColor:'#d97020', borderRadius:0, borderSkipped:false },
8759                    { label:'Comments', data:data.map(function(d){return d.comment||0;}), backgroundColor:GN, hoverBackgroundColor:'#3a8a5e', borderRadius:0, borderSkipped:false },
8760                    { label:'Blank',    data:data.map(function(d){return d.blank||0;}),   backgroundColor:GY, hoverBackgroundColor:'#999',    borderRadius:0, borderSkipped:false }
8761                  ]
8762                },
8763                options: {
8764                  indexAxis:'y', responsive:true, maintainAspectRatio:false,
8765                  onHover: chartCursor,
8766                  animation: { duration: 500, easing: 'easeOutQuart' },
8767                  transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8768                  layout:{ padding:{ right:72 } },
8769                  scales: {
8770                    x:{ stacked:true, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}} },
8771                    y:{ stacked:true, grid:{display:false}, ticks:{color:c.text} }
8772                  },
8773                  plugins: {
8774                    legend:{ position:'bottom', labels:{color:c.text, usePointStyle:true, pointStyle:'rect', font:{size:11,weight:'700'}, padding:16},
8775                      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(); },
8776                      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'); }
8777                    },
8778                    tooltip:{ mode:'index', callbacks:{
8779                      title:function(items){return items.length?items[0].label:'';},
8780                      label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
8781                      footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}
8782                    }}
8783                  }
8784                },
8785                plugins: [makeStackedEndPlugin(function(v){return fmt(v);}), segLabelPlugin, rowDimPlugin]
8786              });
8787            }
8788            renderSCModal('desc');
8789            if(modalSort) modalSort.addEventListener('change', function(){ renderSCModal(this.value); });
8790          });
8791        })();
8792
8793        // Language overview (donut + line-mix) — clone both SVGs side-by-side
8794        (function(){
8795          var btn = document.getElementById('lang-overview-expand-btn');
8796          if(!btn) return;
8797          btn.addEventListener('click', function(){
8798            var src = document.getElementById('report-lang-overview');
8799            if(!src) return;
8800            var overlay = document.createElement('div');
8801            overlay.className = 'chart-modal-overlay';
8802            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>';
8803            document.body.appendChild(overlay);
8804            overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8805            overlay.addEventListener('click', function(e){ if(e.target===overlay) document.body.removeChild(overlay); });
8806            var wrap = document.getElementById('lang-overview-modal-wrap');
8807            if(wrap) {
8808              wrap.innerHTML = src.innerHTML;
8809              var svgs = wrap.querySelectorAll('svg');
8810              for(var i=0;i<svgs.length;i++){
8811                svgs[i].removeAttribute('width');
8812                svgs[i].removeAttribute('height');
8813                svgs[i].style.cssText='display:block;width:100%;height:auto;';
8814              }
8815              var ov = wrap.querySelector('.r-lang-overview');
8816              if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
8817              var cells = wrap.querySelectorAll('.r-lang-overview-cell');
8818              if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
8819              if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
8820              wireDonutLegend(wrap.querySelector('svg'));
8821              wireMixLegend(wrap.querySelectorAll('svg')[1]);
8822              requestAnimationFrame(function(){
8823                var ss=wrap.querySelectorAll('svg');
8824                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%;';}}
8825              });
8826            }
8827          });
8828        })();
8829      })();
8830
8831      // ── Dark mode sync ────────────────────────────────────────────────────────
8832      document.querySelectorAll('[data-theme-toggle]').forEach(function(btn) {
8833        btn.addEventListener('click', function() {
8834          setTimeout(function() {
8835            var c = clr();
8836            ALL_CHARTS.forEach(function(chart) {
8837              if (chart.options.scales) {
8838                Object.keys(chart.options.scales).forEach(function(k) {
8839                  var ax = chart.options.scales[k];
8840                  if (ax.grid) ax.grid.color = c.grid;
8841                  if (ax.ticks) ax.ticks.color = c.text;
8842                  if (ax.title) ax.title.color = c.text;
8843                });
8844              }
8845              if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels)
8846                chart.options.plugins.legend.labels.color = c.text;
8847              chart.update('none');
8848            });
8849          }, 60);
8850        });
8851      });
8852
8853      // ── Pre-render all chart variants for PDF export ──────────────────────────
8854      (function() {
8855        var root = document.getElementById('pdf-variants');
8856        if (!root) return;
8857
8858        // Plugin: fill a light background behind every off-screen chart so the PNG
8859        // is opaque — without this Chart.js canvases are transparent and render as
8860        // blank white boxes in print.
8861        var PDF_BG = {
8862          id: 'pdfBg',
8863          beforeDraw: function(ch) {
8864            var ctx = ch.canvas.getContext('2d');
8865            ctx.save();
8866            ctx.globalCompositeOperation = 'destination-over';
8867            ctx.fillStyle = '#faf6f0';
8868            ctx.fillRect(0, 0, ch.canvas.width, ch.canvas.height);
8869            ctx.restore();
8870          }
8871        };
8872
8873        // Off-screen Chart.js render → PNG data-URL → destroy chart
8874        function snap(type, data, opts, w, h) {
8875          var c = document.createElement('canvas');
8876          c.width = w || 900; c.height = h || 280;
8877          var ch = new Chart(c, {
8878            type: type, data: data,
8879            options: Object.assign({}, opts, {
8880              animation: false, responsive: false, devicePixelRatio: 1,
8881              // Breathing room so labels never clip at the canvas edge
8882              layout: { padding: { top: 10, right: 18, bottom: 10, left: 10 } }
8883            }),
8884            plugins: [PDF_BG]
8885          });
8886          var png = c.toDataURL('image/png');
8887          ch.destroy();
8888          return png;
8889        }
8890
8891        function mkPanel(label, imgSrc) {
8892          var d = document.createElement('div'); d.className = 'pdf-variant-panel';
8893          if (label) {
8894            var lbl = document.createElement('div');
8895            lbl.className = 'pdf-variant-label'; lbl.textContent = label;
8896            d.appendChild(lbl);
8897          }
8898          if (imgSrc) {
8899            var img = document.createElement('img');
8900            img.className = 'pdf-variant-img'; img.src = imgSrc;
8901            d.appendChild(img);
8902          }
8903          return d;
8904        }
8905
8906        function mkGroup(title) {
8907          var g = document.createElement('div'); g.className = 'pdf-variant-group';
8908          var h = document.createElement('h2'); h.className = 'pdf-variant-group-title'; h.textContent = title;
8909          g.appendChild(h);
8910          var grid = document.createElement('div'); grid.className = 'pdf-variant-grid';
8911          g.appendChild(grid);
8912          return { group: g, grid: grid };
8913        }
8914
8915        var tc = '#43342d', gc = 'rgba(0,0,0,0.07)';
8916
8917        // ── Project Overview — 4 Y-axis variants ─────────────────────────────────
8918        var pgProj = mkGroup('Project Overview');
8919        var projVariants = [
8920          { label:'Code Lines',     fn:function(d){return d.code||0;} },
8921          { label:'Comment Lines',  fn:function(d){return d.comments||0;} },
8922          { label:'Physical Lines', fn:function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0);} },
8923          { label:'File Count',     fn:function(d){return d.files||0;} }
8924        ];
8925        projVariants.forEach(function(y) {
8926          var sorted = D.slice().sort(function(a,b){return y.fn(b)-y.fn(a);});
8927          var h = Math.max(110, Math.min(360, sorted.length*18+40));
8928          var png = snap('bar', {
8929            labels: sorted.map(function(d){return d.lang;}),
8930            datasets:[{ label:y.label, data:sorted.map(y.fn),
8931                        backgroundColor:sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius:3 }]
8932          }, {
8933            indexAxis:'y',
8934            scales:{
8935              x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
8936              y:{grid:{display:false},ticks:{color:tc}}
8937            },
8938            plugins:{legend:{display:false}}
8939          }, 900, h);
8940          pgProj.grid.appendChild(mkPanel(y.label, png));
8941        });
8942        root.appendChild(pgProj.group);
8943
8944        // ── Language Composition — Absolute Lines + Composition % ────────────────
8945        var pgComp = mkGroup('Language Composition');
8946        var cData = D.slice(0,15);
8947        var totFn = function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0)||1;};
8948        var compH = Math.max(110, Math.min(340, cData.length*18+50));
8949        [{id:'absolute',label:'Absolute Lines',isPct:false},{id:'pct',label:'Composition %',isPct:true}]
8950          .forEach(function(m) {
8951            var pct = m.isPct;
8952            var png = snap('bar', {
8953              labels: cData.map(function(d){return d.lang;}),
8954              datasets:[
8955                {label:'Code',     data:cData.map(function(d){return pct?(d.code||0)/totFn(d)*100:d.code||0;}),    backgroundColor:OX,borderRadius:3},
8956                {label:'Comments', data:cData.map(function(d){return pct?(d.comments||0)/totFn(d)*100:d.comments||0;}),backgroundColor:GN,borderRadius:3},
8957                {label:'Blanks',   data:cData.map(function(d){return pct?(d.blanks||0)/totFn(d)*100:d.blanks||0;}),  backgroundColor:GY,borderRadius:3}
8958              ]
8959            }, {
8960              indexAxis:'y',
8961              scales:{
8962                x:{stacked:true,grid:{color:gc},ticks:{color:tc,callback:pct?function(v){return v.toFixed(0)+'%';}:function(v){return fmt(v);}}},
8963                y:{stacked:true,grid:{display:false},ticks:{color:tc}}
8964              },
8965              plugins:{legend:{position:'bottom',labels:{color:tc}}}
8966            }, 900, compH);
8967            pgComp.grid.appendChild(mkPanel(m.label, png));
8968          });
8969        root.appendChild(pgComp.group);
8970
8971        // ── Files vs Code Lines — render off-screen (bubble chart, single-col centred) ─
8972        if (SCAT_D && SCAT_D.length) {
8973          var pgScat = mkGroup('Files vs Code Lines');
8974          pgScat.grid.classList.add('single-col'); // CSS class drives centering in print
8975          var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical||0;})) || 1;
8976          var scatPng = snap('bubble', {
8977            datasets: SCAT_D.map(function(d, i) {
8978              return {
8979                label: d.lang,
8980                data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt((d.physical||0)/maxP)*20)) }],
8981                backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8982                borderColor: PALETTE[i % PALETTE.length], borderWidth: 1
8983              };
8984            })
8985          }, {
8986            scales: {
8987              x: { grid:{color:gc}, ticks:{color:tc}, title:{display:true, text:'Files Analyzed', color:tc} },
8988              y: { grid:{color:gc}, ticks:{color:tc, callback:function(v){return fmt(v);}}, title:{display:true, text:'Code Lines', color:tc} }
8989            },
8990            plugins: { legend:{position:'right', labels:{color:tc, boxWidth:12}} }
8991          }, 900, 260);
8992          pgScat.grid.appendChild(mkPanel('Files \u00d7 Code Lines (bubble size \u221d physical lines)', scatPng));
8993          root.appendChild(pgScat.group);
8994        }
8995
8996        // ── Semantic Metrics — up to 5 metrics, skip empty ones ─────────────────
8997        if (SEM_D && SEM_D.length) {
8998          var pgSem = mkGroup('Semantic Metrics');
8999          var SL={functions:'Functions',classes:'Classes / Types',variables:'Variables',imports:'Imports',tests:'Tests'};
9000          var SC={functions:OX,classes:'#4472C4',variables:GN,imports:'#805099',tests:'#B23030'};
9001          Object.keys(SL).forEach(function(mKey) {
9002            var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
9003            if (!data.some(function(d){return (d[mKey]||0)>0;})) return;
9004            var semH = 210; // vertical bar — fixed height; width drives layout, not row count
9005            var png = snap('bar', {
9006              labels: data.map(function(d){return d.lang;}),
9007              datasets:[{label:SL[mKey],data:data.map(function(d){return d[mKey]||0;}),backgroundColor:SC[mKey],borderRadius:4}]
9008            }, {
9009              scales:{
9010                x:{grid:{display:false},ticks:{color:tc}},
9011                y:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}}}
9012              },
9013              plugins:{legend:{display:false}}
9014            }, 900, semH);
9015            pgSem.grid.appendChild(mkPanel(SL[mKey], png));
9016          });
9017          root.appendChild(pgSem.group);
9018        }
9019
9020        // ── Submodule Breakdown — 3 Y-axis variants + donut SVG clone ────────────
9021        if (SUB_D && SUB_D.length) {
9022          var pgSub = mkGroup('Submodule Breakdown');
9023          [{key:'code',label:'Code Lines',col:OX},{key:'comment',label:'Comment Lines',col:GN},{key:'files',label:'File Count',col:'#805099'}]
9024            .forEach(function(y) {
9025              var data = SUB_D.slice().sort(function(a,b){return (b[y.key]||0)-(a[y.key]||0);}).slice(0,30);
9026              if (!data.length) return;
9027              var subH = Math.max(100, Math.min(420, data.length*16+30));
9028              var png = snap('bar', {
9029                labels: data.map(function(d){return d.name;}),
9030                datasets:[{label:y.label,data:data.map(function(d){return d[y.key]||0;}),backgroundColor:y.col,borderRadius:3}]
9031              }, {
9032                indexAxis:'y',
9033                scales:{
9034                  x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9035                  y:{grid:{display:false},ticks:{color:tc}}
9036                },
9037                plugins:{legend:{display:false}}
9038              }, 900, subH);
9039              pgSub.grid.appendChild(mkPanel(y.label, png));
9040            });
9041          var donutEl = document.getElementById('submodule-donut');
9042          if (donutEl && donutEl.innerHTML.trim()) {
9043            var dp = document.createElement('div'); dp.className = 'pdf-variant-panel';
9044            var dl = document.createElement('div'); dl.className = 'pdf-variant-label'; dl.textContent = 'Distribution';
9045            dp.appendChild(dl);
9046            var dw = document.createElement('div'); dw.style.cssText = 'display:flex;justify-content:center;';
9047            dw.innerHTML = donutEl.innerHTML; dp.appendChild(dw);
9048            pgSub.grid.appendChild(dp);
9049          }
9050          root.appendChild(pgSub.group);
9051        }
9052      })();
9053    })();
9054    window.oxSlocChartsReady = true;
9055    } catch(e) { window.oxSlocChartError = String(e); window.oxSlocChartsReady = true; }
9056    }); // end requestAnimationFrame
9057    // Safety net: if rAF never fires (headless browsers throttle it), mark ready
9058    // unconditionally so the PDF capture does not wait the full 15 s.
9059    setTimeout(function() { if (!window.oxSlocChartsReady) window.oxSlocChartsReady = true; }, 3000);
9060    // ── SVG tooltip delegation ───────────────────────────────────────────────
9061    (function(){
9062      var tt = document.getElementById('r-tt');
9063      if (!tt) return;
9064      function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
9065      function show(e, html) { tt.innerHTML=html; tt.style.display='block'; move(e); }
9066      function hide() { tt.style.display='none'; }
9067      function move(e) {
9068        var x=e.clientX+16, y=e.clientY-12;
9069        var r=tt.getBoundingClientRect();
9070        if (x+r.width>window.innerWidth-8) x=e.clientX-r.width-8;
9071        if (y+r.height>window.innerHeight-8) y=e.clientY-r.height-8;
9072        tt.style.left=x+'px'; tt.style.top=y+'px';
9073      }
9074      document.addEventListener('mouseover', function(e) {
9075        var t=e.target;
9076        while(t&&t.getAttribute){
9077          var l=t.getAttribute('data-ttl');
9078          if(l!==null){ show(e,'<strong>'+escH(l)+'</strong><br>'+escH(t.getAttribute('data-ttv')||'').replace(/\n/g,'<br>')); return; }
9079          t=t.parentNode;
9080        }
9081      });
9082      document.addEventListener('mouseout', function(e) {
9083        var t=e.target;
9084        while(t&&t.getAttribute){
9085          if(t.getAttribute('data-ttl')!==null){ hide(); return; }
9086          t=t.parentNode;
9087        }
9088      });
9089      document.addEventListener('mousemove', function(e) {
9090        if(tt.style.display!=='none') move(e);
9091      });
9092      window.addEventListener('blur', function() { hide(); });
9093      document.addEventListener('visibilitychange', function() { if(document.hidden) hide(); });
9094    })();
9095    // Auto-populate title on any td that is visually truncated but has no explicit title
9096    requestAnimationFrame(function() {
9097      document.querySelectorAll('td').forEach(function(td) {
9098        if (!td.title && td.scrollWidth > td.clientWidth) {
9099          td.title = td.textContent.trim();
9100        }
9101      });
9102    });
9103
9104  </script>
9105  <script nonce="{{ nonce }}">
9106  (function(){
9107    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'}];
9108    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);});}
9109    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
9110    function init(){
9111      var btn=document.getElementById('settings-btn');if(!btn)return;
9112      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9113      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>';
9114      document.body.appendChild(m);
9115      var g=document.getElementById('scheme-grid');
9116      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);});
9117      var cl=document.getElementById('settings-close');
9118      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);
9119      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');});
9120      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
9121      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
9122    }
9123    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9124  }());
9125  </script>
9126  <script nonce="{{ nonce }}">
9127  (function(){
9128    // Format delta card unmodified-lines value with comma separators
9129    Array.prototype.slice.call(document.querySelectorAll('.delta-card-inline[data-raw] .delta-card-val')).forEach(function(el){
9130      var raw=parseInt(el.parentNode.getAttribute('data-raw'),10);
9131      if(!isNaN(raw))el.textContent=raw.toLocaleString();
9132    });
9133    // Format code-before / code-now numbers in the prev-scan summary line
9134    Array.prototype.slice.call(document.querySelectorAll('.prev-scan-summary [data-raw]')).forEach(function(el){
9135      var raw=parseInt(el.getAttribute('data-raw'),10);
9136      if(!isNaN(raw))el.textContent=raw.toLocaleString();
9137    });
9138  }());
9139  </script>
9140  {% if has_style_data %}
9141  <script nonce="{{ nonce }}">
9142  (function(){
9143    var CHART_DATA = {{ style_chart_json|safe }};
9144    var FILE_DATA  = {{ style_file_json|safe }};
9145    var SCORE_THRESHOLD = {{ style_score_threshold }};
9146    var activeLang = CHART_DATA.length ? CHART_DATA[0].family : '';
9147    var sftSortKey = '';
9148    var sftSortDir = 1;
9149    function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
9150    // Official style guide URLs — covers every guide produced by the language analysers
9151    var GUIDE_URLS = {
9152      'PEP 8':'https://peps.python.org/pep-0008/',
9153      'PEP 8 (99-col)':'https://peps.python.org/pep-0008/',
9154      'Black':'https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html',
9155      'Google Python':'https://google.github.io/styleguide/pyguide.html',
9156      'Effective Go':'https://go.dev/doc/effective_go',
9157      'Uber Go':'https://github.com/uber-go/guide/blob/master/style.md',
9158      'Google Go':'https://google.github.io/styleguide/go/',
9159      'LLVM':'https://llvm.org/docs/CodingStandards.html',
9160      'Google':'https://google.github.io/styleguide/cppguide.html',
9161      'Mozilla':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/',
9162      'Microsoft':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9163      'WebKit':'https://webkit.org/code-style-guidelines/',
9164      'rustfmt defaults':'https://doc.rust-lang.org/rustfmt/',
9165      'Mozilla Rust':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/coding-style-rust.html',
9166      'Rust API Guidelines':'https://rust-lang.github.io/api-guidelines/',
9167      'Relaxed (120-col)':'https://doc.rust-lang.org/rustfmt/',
9168      'Airbnb':'https://airbnb.io/javascript/',
9169      'Google JS':'https://google.github.io/styleguide/jsguide.html',
9170      'Standard.js':'https://standardjs.com/',
9171      'Prettier':'https://prettier.io/docs/en/options.html',
9172      'Airbnb TS':'https://airbnb.io/javascript/',
9173      'Google TS':'https://google.github.io/styleguide/tsguide.html',
9174      'Angular':'https://angular.dev/style-guide',
9175      'Microsoft TS':'https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines',
9176      'Google Java':'https://google.github.io/styleguide/javaguide.html',
9177      'Oracle/Sun':'https://www.oracle.com/java/technologies/javase/codeconventions-contents.html',
9178      'Spring':'https://github.com/spring-projects/spring-framework/wiki/Code-Style',
9179      'JetBrains':'https://www.jetbrains.com/help/idea/code-style.html',
9180      'Android':'https://source.android.com/docs/setup/contribute/code-style',
9181      'Google Kotlin':'https://developer.android.com/kotlin/style-guide',
9182      'Apache Groovy':'https://groovy-lang.org/style-guide.html',
9183      'Gradle DSL':'https://docs.gradle.org/current/userguide/groovy_build_script_primer.html',
9184      'Scala Style Guide':'https://docs.scala-lang.org/style/',
9185      'Lightbend':'https://docs.scala-lang.org/style/',
9186      'Spark':'https://spark.apache.org/contributing.html',
9187      'RuboCop':'https://docs.rubocop.org/rubocop/',
9188      'Airbnb Ruby':'https://github.com/airbnb/ruby',
9189      'Standard Ruby':'https://github.com/standardrb/standard',
9190      'Microsoft .NET':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9191      'Google C#':'https://google.github.io/styleguide/csharp-style.html',
9192      'StyleCop':'https://github.com/DotNetAnalyzers/StyleCopAnalyzers',
9193      'Microsoft F#':'https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting',
9194      'FSharp.Formatting':'https://fsprojects.github.io/FSharp.Formatting/'
9195    };
9196    // Human-readable descriptions for each guide shown in bar tooltips
9197    var GUIDE_DESC = {
9198      'PEP 8':'4-space | 79-col | Python style standard',
9199      'PEP 8 (99-col)':'4-space | 99-col | relaxed line limit',
9200      'Black':'4-space | 88-col | double quotes enforced',
9201      'Google Python':'4-space | 80-col | double quotes preferred',
9202      'Effective Go':'tabs | ~80-col | gofmt standard',
9203      'Uber Go':'tabs | 120-col max',
9204      'Google Go':'tabs | 80-col',
9205      'LLVM':'2-space | 80-col | C/C++ LLVM project style',
9206      'Google':'2-space | 80-col | Google C++ style',
9207      'Mozilla':'4-space | 80-col | Firefox codebase style',
9208      'Microsoft':'4-space | Allman braces | C++ Win32 style',
9209      'WebKit':'4-space | 80-col | WebKit engine style',
9210      'rustfmt defaults':'4-space | 100-col | official Rust formatter',
9211      'Mozilla Rust':'4-space | 100-col | Firefox Rust style',
9212      'Rust API Guidelines':'4-space | naming + docs conventions',
9213      'Relaxed (120-col)':'4-space | 120-col | relaxed line limit',
9214      'Airbnb':'2-space | single quotes | no semicolons opt',
9215      'Google JS':'2-space | 80-col | single quotes',
9216      'Standard.js':'2-space | no semicolons | single quotes',
9217      'Prettier':'2-space | 80-col | double quotes | semicolons',
9218      'Airbnb TS':'2-space | single quotes | TypeScript variant',
9219      'Google TS':'2-space | 80-col | single quotes | TypeScript',
9220      'Angular':'2-space | Angular team TypeScript conventions',
9221      'Microsoft TS':'4-space | TypeScript compiler team style',
9222      'Google Java':'2-space | 100-col | Google Java guide',
9223      'Oracle/Sun':'4-space | 80-col | original Java conventions',
9224      'Spring':'4-space | Spring Framework code style',
9225      'JetBrains':'4-space | IntelliJ default Java style',
9226      'Android':'4-space | 100-col | AOSP Java style',
9227      'Google Kotlin':'4-space | 100-col | Android Kotlin style',
9228      'Apache Groovy':'4-space | Apache Groovy style',
9229      'Gradle DSL':'4-space | Gradle build script conventions',
9230      'Scala Style Guide':'2-space | 100-col | official Scala style',
9231      'Lightbend':'2-space | Lightbend/Akka Scala style',
9232      'Spark':'2-space | Apache Spark Scala style',
9233      'RuboCop':'2-space | 120-col | community Ruby style',
9234      'Airbnb Ruby':'2-space | 80-col | Airbnb Ruby guide',
9235      'Standard Ruby':'2-space | 80-col | StandardRB formatter',
9236      'Microsoft .NET':'4-space | Allman braces | .NET C# style',
9237      'Google C#':'2-space | Google C# style guide',
9238      'StyleCop':'4-space | StyleCop analyzer rules',
9239      'Microsoft F#':'4-space | official F# formatting guide',
9240      'FSharp.Formatting':'4-space | FSharp.Formatting conventions'
9241    };
9242    function renderBars(family){
9243      var wrap=document.getElementById('style-guide-bars');
9244      if(!wrap)return;
9245      wrap.innerHTML='';
9246      var grp=null;
9247      for(var i=0;i<CHART_DATA.length;i++){if(CHART_DATA[i].family===family){grp=CHART_DATA[i];break;}}
9248      if(!grp||!grp.guides.length)return;
9249      grp.guides.forEach(function(d){
9250        var isTop=(d.guide===grp.dominant);
9251        var row=document.createElement('div');row.className='style-guide-row';
9252        // Hover tooltip showing guide name + score + description
9253        var tip=document.createElement('div');tip.className='style-bar-tip';
9254        var desc=GUIDE_DESC[d.guide]||'';
9255        tip.textContent=d.guide+': '+d.score+'%'+(desc?' \u00b7 '+desc:'');
9256        var lbl=document.createElement('div');lbl.className='style-guide-label';
9257        lbl.textContent=d.guide;
9258        if(isTop)lbl.style.color='var(--oxide)';
9259        var track=document.createElement('div');track.className='style-guide-track';
9260        var fill=document.createElement('div');fill.className='style-guide-fill';
9261        fill.style.width='0%';
9262        var pct=document.createElement('div');pct.className='style-guide-score';
9263        pct.textContent=d.score+'%';
9264        if(isTop)pct.style.color='var(--oxide)';
9265        track.appendChild(fill);
9266        row.appendChild(tip);
9267        row.appendChild(lbl);row.appendChild(track);row.appendChild(pct);
9268        wrap.appendChild(row);
9269        setTimeout(function(f,s){return function(){f.style.width=s+'%';};}(fill,d.score),60);
9270      });
9271    }
9272    function initTabs(){
9273      var tabsWrap=document.getElementById('style-lang-tabs');
9274      if(!tabsWrap||!CHART_DATA.length)return;
9275      CHART_DATA.forEach(function(grp){
9276        var btn=document.createElement('button');
9277        btn.className='style-lang-tab'+(grp.family===activeLang?' active':'');
9278        btn.textContent=grp.family+' ('+grp.files+')';
9279        btn.onclick=function(){
9280          activeLang=grp.family;
9281          var tabs=tabsWrap.querySelectorAll('.style-lang-tab');
9282          for(var i=0;i<tabs.length;i++)tabs[i].className='style-lang-tab';
9283          btn.className='style-lang-tab active';
9284          renderBars(activeLang);
9285        };
9286        tabsWrap.appendChild(btn);
9287      });
9288      renderBars(activeLang);
9289    }
9290    function buildGuideHtml(guide){
9291      if(!guide||guide==='\u2014'||guide==='Unknown')return'<span style="color:var(--muted);">\u2014</span>';
9292      var url=GUIDE_URLS[guide];
9293      var desc=GUIDE_DESC[guide]||'';
9294      var tipText='Open official '+guide+' documentation'+(desc?' \u00b7 '+desc:'');
9295      if(url){return'<a href="'+escH(url)+'" target="_blank" rel="noopener" class="style-badge">'+escH(guide)+'</a>';}
9296      return'<span class="style-badge">'+escH(guide)+'</span>';
9297    }
9298    function buildSigsHtml(sigs){
9299      if(!sigs||!sigs.length)return'<span style="color:var(--muted);">\u2014</span>';
9300      var html='';
9301      var visible=sigs.slice(0,2);
9302      var rest=sigs.slice(2);
9303      visible.forEach(function(s){html+='<span class="style-sig-chip">'+escH(s.v)+'</span>';});
9304      if(rest.length){html+='<span style="color:var(--muted);font-size:11px;margin-left:2px;">\u22EF</span>';}
9305      return html;
9306    }
9307    var _sigPop=null;
9308    window.showSigPop=function(btn,ev){
9309      ev.stopPropagation();
9310      if(_sigPop){var prev=_sigPop;_sigPop=null;prev.remove();if(btn._ownPop===prev)return;}
9311      var sigs;try{sigs=JSON.parse(btn.getAttribute('data-sigs'));}catch(e){return;}
9312      var pop=document.createElement('div');
9313      pop.className='style-sig-pop';
9314      pop.setAttribute('role','tooltip');
9315      var inner='<div class="style-sig-pop-title">All Signals</div>';
9316      sigs.forEach(function(s){
9317        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>';
9318      });
9319      pop.innerHTML=inner;
9320      document.body.appendChild(pop);
9321      _sigPop=pop;
9322      btn._ownPop=pop;
9323      var r=btn.getBoundingClientRect();
9324      var pw=pop.offsetWidth||220;
9325      var left=r.left;
9326      if(left+pw>window.innerWidth-8)left=window.innerWidth-pw-8;
9327      if(left<8)left=8;
9328      var top=r.bottom+6;
9329      if(top+(pop.offsetHeight||120)>window.innerHeight-8)top=r.top-(pop.offsetHeight||120)-6;
9330      pop.style.left=left+'px';
9331      pop.style.top=top+'px';
9332      function dismiss(e){if(!pop.contains(e.target)){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9333      function dismissKey(e){if(e.key==='Escape'){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9334      setTimeout(function(){document.addEventListener('click',dismiss);document.addEventListener('keydown',dismissKey);},0);
9335    }
9336    var sftRows=[];
9337    var sftFilteredRows=[];
9338    var sftCurrentPage=1;
9339    function sftGetPageSize(){
9340      var sel=document.getElementById('sft-page-size');
9341      var v=sel?sel.value:'20';
9342      return v==='all'?Infinity:parseInt(v,10);
9343    }
9344    function sftApplyFilter(){
9345      var inp=document.getElementById('sft-search');
9346      var q=inp?inp.value.toLowerCase():'';
9347      var sorted=sftRows.slice();
9348      if(sftSortKey){
9349        sorted.sort(function(a,b){
9350          if(sftSortKey==='score'){var av=a.score||0,bv=b.score||0;return sftSortDir*(av-bv);}
9351          var av=String(a[sftSortKey]||'').toLowerCase(),bv=String(b[sftSortKey]||'').toLowerCase();
9352          return av<bv?-1*sftSortDir:av>bv?1*sftSortDir:0;
9353        });
9354      }
9355      sftFilteredRows=q===''?sorted:sorted.filter(function(f){
9356        return (f.path||'').toLowerCase().indexOf(q)>=0
9357          ||(f.lang||'').toLowerCase().indexOf(q)>=0
9358          ||(f.guide||'').toLowerCase().indexOf(q)>=0
9359          ||(f.indent||'').toLowerCase().indexOf(q)>=0;
9360      });
9361      sftCurrentPage=1;
9362      renderSftTable();
9363    }
9364    function renderSftTable(){
9365      var tbody=document.getElementById('style-file-tbody');
9366      if(!tbody)return;
9367      var ps=sftGetPageSize();
9368      var total=sftFilteredRows.length;
9369      var totalAll=sftRows.length;
9370      var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(total/ps));
9371      if(sftCurrentPage>totalPages)sftCurrentPage=totalPages;
9372      if(sftCurrentPage<1)sftCurrentPage=1;
9373      var start=ps===Infinity?0:(sftCurrentPage-1)*ps;
9374      var end=ps===Infinity?total:Math.min(start+ps,total);
9375      var page=sftFilteredRows.slice(start,end);
9376      var html='';
9377      page.forEach(function(f){
9378        var barW=Math.round(f.score);
9379        var guide=f.guide&&f.guide!=='Unknown'?f.guide:'';
9380        var badge=guide?buildGuideHtml(guide):'<span style="color:var(--muted);">\u2014</span>';
9381                var sigHtml=buildSigsHtml(f.signals);
9382        var rowClass=SCORE_THRESHOLD>0&&f.score<SCORE_THRESHOLD?' class="style-row-warn"':'';
9383        html+='<tr'+rowClass+'>'
9384          +'<td title="'+escH(f.path)+'">'+escH(f.path.replace(/^.*[\/\\]/,''))+'</td>'
9385          +'<td>'+escH(f.lang)+'</td>'
9386          +'<td>'+escH(f.indent)+'</td>'
9387          +'<td class="guide-cell" data-gtip="'+(guide?(escH(guide)+(GUIDE_DESC[guide]?' \u00b7 '+escH(GUIDE_DESC[guide]):'')):'')+'">' +badge+'</td>'
9388          +'<td><span class="style-score-bar"><span class="style-score-fill" style="width:'+barW+'%"></span></span>'+f.score+'%</td>'
9389          +'<td class="sig-cell" data-sigs="'+escH(JSON.stringify(f.signals||[]))+'">'+sigHtml+'</td>'
9390          +'</tr>';
9391      });
9392      tbody.innerHTML=html||'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9393      var pageInfo=document.getElementById('sft-page-info');
9394      var firstBtn=document.getElementById('sft-first');
9395      var prevBtn=document.getElementById('sft-prev');
9396      var nextBtn=document.getElementById('sft-next');
9397      var lastBtn=document.getElementById('sft-last');
9398      var jumpInput=document.getElementById('sft-page-jump');
9399      var pageTotal=document.getElementById('sft-page-total');
9400      var countLabel=document.getElementById('sft-count-label');
9401      if(pageInfo){
9402        if(total===0){pageInfo.textContent='No results';}
9403        else if(ps===Infinity){pageInfo.textContent='All '+total.toLocaleString()+' files';}
9404        else{pageInfo.textContent=(start+1)+'\u2013'+end+' of '+total.toLocaleString()+' files';}
9405      }
9406      if(countLabel){countLabel.textContent=(total<totalAll&&total>0)?'('+total.toLocaleString()+' matching)':'';}
9407      var edgeOff=ps===Infinity;
9408      if(firstBtn)firstBtn.disabled=sftCurrentPage<=1||edgeOff;
9409      if(prevBtn)prevBtn.disabled=sftCurrentPage<=1||edgeOff;
9410      if(nextBtn)nextBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9411      if(lastBtn)lastBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9412      if(jumpInput){jumpInput.value=sftCurrentPage;jumpInput.max=totalPages;jumpInput.disabled=edgeOff;}
9413      if(pageTotal)pageTotal.textContent=totalPages.toLocaleString();
9414    }
9415    function initStyleTable(){
9416      if(!FILE_DATA.length){
9417        var tb=document.getElementById('style-file-tbody');
9418        if(tb)tb.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9419        return;
9420      }
9421      sftRows=FILE_DATA.slice();
9422      sftFilteredRows=sftRows.slice();
9423      sftApplyFilter();
9424      // Signal & guide cell tooltip (appears above hovered cell, arrow points down)
9425      var chipTipEl=document.createElement('div');
9426      chipTipEl.className='sig-tip';
9427      document.body.appendChild(chipTipEl);
9428      function _showSigTip(html,cell){
9429        chipTipEl.innerHTML=html;
9430        chipTipEl.style.display='block';
9431        var r=cell.getBoundingClientRect();
9432        var tw=chipTipEl.offsetWidth||220;
9433        var th=chipTipEl.offsetHeight||80;
9434        var cx=r.left+r.width/2;
9435        var left=cx-tw/2;
9436        if(left<8)left=8;
9437        if(left+tw>window.innerWidth-8)left=window.innerWidth-tw-8;
9438        var arrowPct=Math.round((cx-left)/tw*100);
9439        if(arrowPct<10)arrowPct=10;
9440        if(arrowPct>90)arrowPct=90;
9441        chipTipEl.style.setProperty('--sig-tip-ax',arrowPct+'%');
9442        var top=r.top-th-12;
9443        if(top<8)top=r.bottom+8;
9444        chipTipEl.style.left=left+'px';
9445        chipTipEl.style.top=top+'px';
9446        chipTipEl.classList.add('visible');
9447      }
9448      function _hideSigTip(){
9449        chipTipEl.classList.remove('visible');
9450        chipTipEl.style.display='none';
9451      }
9452      function _buildSigHtml(cell){
9453        var sigs;try{sigs=JSON.parse(cell.getAttribute('data-sigs'));}catch(ex){return null;}
9454        if(!sigs||!sigs.length)return null;
9455        var html='<div class="sig-tip-hd">Signals</div>';
9456        sigs.forEach(function(s){
9457          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>';
9458        });
9459        return html;
9460      }
9461      function _buildGuideHtml(cell){
9462        var tip=cell.getAttribute('data-gtip')||'';
9463        if(!tip)return null;
9464        var parts=tip.split(' \u00b7 ',2);
9465        var html='<div class="sig-tip-hd">'+escH(parts[0])+'</div>';
9466        if(parts[1])html+='<div class="sig-tip-v" style="font-size:11px;">'+escH(parts[1])+'</div>';
9467        return html;
9468      }
9469      var sigTbl=document.getElementById('style-file-table');
9470      if(sigTbl){
9471        sigTbl.addEventListener('mouseover',function(e){
9472          var sc=e.target.closest?e.target.closest('.sig-cell'):null;
9473          var gc=e.target.closest?e.target.closest('.guide-cell'):null;
9474          if(sc){var h=_buildSigHtml(sc);if(h)_showSigTip(h,sc);return;}
9475          if(gc){var h=_buildGuideHtml(gc);if(h){var badge=gc.querySelector('.style-badge')||gc;_showSigTip(h,badge);}return;}
9476          _hideSigTip();
9477        });
9478        sigTbl.addEventListener('mouseleave',function(){
9479          _hideSigTip();
9480        });
9481        sigTbl.addEventListener('mouseout',function(e){
9482          if(!e.relatedTarget||!sigTbl.contains(e.relatedTarget))_hideSigTip();
9483        });
9484      }
9485      // Wire up sortable column headers
9486      var ths=document.querySelectorAll('#style-file-table thead th[data-sort-key]');
9487      for(var i=0;i<ths.length;i++){(function(th){
9488        th.style.cursor='pointer';
9489        th.addEventListener('click',function(){
9490          var key=th.getAttribute('data-sort-key');
9491          if(sftSortKey===key){sftSortDir*=-1;}else{sftSortKey=key;sftSortDir=1;}
9492          for(var j=0;j<ths.length;j++){
9493            ths[j].classList.remove('sft-sort-asc','sft-sort-desc');
9494            var ind=ths[j].querySelector('.style-sort-ind');
9495            if(ind)ind.textContent='\u25BE';
9496          }
9497          th.classList.add(sftSortDir===1?'sft-sort-asc':'sft-sort-desc');
9498          var tind=th.querySelector('.style-sort-ind');
9499          if(tind)tind.textContent=sftSortDir===1?'\u25B2':'\u25BC';
9500          sftApplyFilter();
9501        });
9502      })(ths[i]);}
9503      var searchInput=document.getElementById('sft-search');
9504      if(searchInput){
9505        var sftTimer=null;
9506        searchInput.addEventListener('input',function(){clearTimeout(sftTimer);sftTimer=setTimeout(sftApplyFilter,200);});
9507      }
9508      var pageSel=document.getElementById('sft-page-size');
9509      if(pageSel){pageSel.addEventListener('change',function(){sftCurrentPage=1;renderSftTable();});}
9510      var sftFirstBtn=document.getElementById('sft-first');
9511      var sftPrevBtn=document.getElementById('sft-prev');
9512      var sftNextBtn=document.getElementById('sft-next');
9513      var sftLastBtn=document.getElementById('sft-last');
9514      var sftJumpInput=document.getElementById('sft-page-jump');
9515      if(sftFirstBtn){sftFirstBtn.addEventListener('click',function(){sftCurrentPage=1;renderSftTable();});}
9516      if(sftPrevBtn){sftPrevBtn.addEventListener('click',function(){if(sftCurrentPage>1){sftCurrentPage--;renderSftTable();}});}
9517      if(sftNextBtn){sftNextBtn.addEventListener('click',function(){
9518        var ps=sftGetPageSize();
9519        var totalPages=ps===Infinity?1:Math.ceil(sftFilteredRows.length/ps);
9520        if(sftCurrentPage<totalPages){sftCurrentPage++;renderSftTable();}
9521      });}
9522      if(sftLastBtn){sftLastBtn.addEventListener('click',function(){
9523        var ps=sftGetPageSize();
9524        sftCurrentPage=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9525        renderSftTable();
9526      });}
9527      if(sftJumpInput){
9528        function sftJump(){
9529          var ps=sftGetPageSize();
9530          var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9531          var v=parseInt(sftJumpInput.value,10);
9532          if(!isNaN(v)){sftCurrentPage=Math.max(1,Math.min(v,totalPages));renderSftTable();}
9533        }
9534        sftJumpInput.addEventListener('change',sftJump);
9535        sftJumpInput.addEventListener('keydown',function(e){if(e.key==='Enter')sftJump();});
9536      }
9537    }
9538    function initSigInfoBtn(){
9539      var btn=document.getElementById('sig-info-btn');
9540      if(!btn)return;
9541      btn.addEventListener('click',function(){
9542        var overlay=document.createElement('div');
9543        overlay.className='style-sig-info-overlay';
9544        var GLOSSARY=[
9545          ['Quote Style','Dominant string quote character used in the file (single quotes, double quotes, or mixed)'],
9546          ['Indentation','Leading-whitespace style detected: Tabs, 2-Space, 4-Space, 8-Space, or Mixed'],
9547          ['Brace Style','Opening brace placement: K\u0026R / Attach (same line as statement) or Allman (own line)'],
9548          ['Semicolons','Whether statement-ending semicolons are present (JS/TS). \u201cNone detected\u201d means ASI-style.'],
9549          ['Variable Declarations','Preferred declaration keyword: const/let vs var (JS), short := vs var (Go)'],
9550          ['Function Naming','Dominant function naming convention: snake_case or CamelCase'],
9551          ['Type Hints','Whether Python PEP 484 type annotations (:Type, ->Type) are used in the file'],
9552          ['Wildcard Imports','Presence of import * wildcard import statements (Java/Kotlin)'],
9553          ['Pointer Style','Pointer/reference alignment in C/C++: *var (name-attached) or Type* (type-attached)'],
9554          ['Arrow Functions','Count of arrow function => expressions detected in JS/TS files'],
9555          ['Max Line Length','Character length of the longest line found in the file'],
9556          ['Error Handling','Presence of Go-style if err != nil error-checking patterns'],
9557          ['Type Inference','Whether the C# var keyword is used for implicit type inference'],
9558          ['Frozen String Literal','Whether the # frozen_string_literal: true pragma is present (Ruby)'],
9559          ['Space Before Paren','Spacing convention before opening parentheses in control structures (C/C++)'],
9560          ['Include Guard','Whether #pragma once is used as a header include guard (C/C++)']
9561        ];
9562        var rows='';
9563        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>';});
9564        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>';
9565        document.body.appendChild(overlay);
9566        overlay.addEventListener('click',function(e){if(e.target===overlay||e.target.classList.contains('style-sig-info-close')){overlay.remove();}});
9567      });
9568    }
9569    function init(){initTabs();initStyleTable();initSigInfoBtn();}
9570    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9571  }());
9572  </script>
9573  {% endif %}
9574  <script nonce="{{ nonce }}">
9575  (function(){
9576    var params=new URLSearchParams(location.search);
9577    if(params.get('autoprint')!=='1')return;
9578    var overlay=document.createElement('div');
9579    overlay.id='autoprint-overlay';
9580    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;';
9581    overlay.innerHTML='<div style="font-size:20px;font-weight:800;color:var(--text,#1a1a1a);">Preparing PDF\u2026</div>'
9582      +'<div style="font-size:13px;color:var(--muted,#666);">Use your browser\u2019s print dialog \u2192 <strong>Save as PDF</strong>.</div>'
9583      +'<div style="width:200px;height:4px;border-radius:2px;background:rgba(0,0,0,0.1);overflow:hidden;">'
9584      +'<div id="autoprint-bar" style="height:100%;width:0%;background:#e07b3a;transition:width 1.5s ease;border-radius:2px;"></div></div>';
9585    document.body.appendChild(overlay);
9586    setTimeout(function(){var b=document.getElementById('autoprint-bar');if(b)b.style.width='80%';},50);
9587    var deadline=Date.now()+12000;
9588    function tryPrint(){
9589      if(window.oxSlocChartsReady||Date.now()>deadline){
9590        var b=document.getElementById('autoprint-bar');
9591        if(b)b.style.width='100%';
9592        setTimeout(function(){
9593          overlay.style.display='none';
9594          window.print();
9595        },350);
9596      } else {
9597        setTimeout(tryPrint,150);
9598      }
9599    }
9600    if(document.readyState==='loading'){
9601      document.addEventListener('DOMContentLoaded',function(){setTimeout(tryPrint,250);});
9602    } else {
9603      setTimeout(tryPrint,250);
9604    }
9605    window.addEventListener('afterprint',function(){overlay.remove();});
9606  }());
9607  </script>
9608  <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>
9609  {% if let Some(banner) = report_header_footer %}
9610  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
9611  {% endif %}
9612</body>
9613</html>"##,
9614    ext = "html"
9615)]
9616// Template structs need many bool fields to pass Askama rendering flags.
9617// Fields are consumed by the Askama proc-macro; clippy cannot trace that usage.
9618#[allow(clippy::struct_excessive_bools, dead_code)]
9619struct ReportTemplate<'a> {
9620    nonce: String,
9621    title: String,
9622    browser_title: String,
9623    scan_performed_by: String,
9624    scan_time_pst: String,
9625    tool_version: String,
9626    is_sub_report: bool,
9627    run: &'a AnalysisRun,
9628    language_rows: Vec<LanguageRow>,
9629    file_rows: Vec<FileRow>,
9630    skipped_rows: Vec<FileRow>,
9631    config_json: String,
9632    lang_chart_json: String,
9633    submodule_chart_json: String,
9634    scatter_chart_json: String,
9635    semantic_chart_json: String,
9636    file_size_histogram_json: String,
9637    has_submodule_data: bool,
9638    has_semantic_data: bool,
9639    has_coverage_data: bool,
9640    has_fn_coverage: bool,
9641    has_branch_coverage: bool,
9642    test_files_count: u64,
9643    test_assertion_count: u64,
9644    test_suite_count: u64,
9645    test_density: String,
9646    most_tested_lang: String,
9647    langs_with_tests: usize,
9648    cov_line_pct: String,
9649    cov_fn_pct: String,
9650    cov_branch_pct: String,
9651    cov_line_class: String,
9652    cov_fn_class: String,
9653    cov_branch_class: String,
9654    has_run_warnings: bool,
9655    warning_count: usize,
9656    warning_summary_rows: Vec<WarningSummaryRow>,
9657    warning_opportunity_rows: Vec<WarningOpportunityRow>,
9658    warning_console_full: String,
9659    logo_text_uri: String,
9660    small_logo_uri: String,
9661    /// Data-URI for a custom logo, or None to show the default `OxideSLOC` logo.
9662    custom_logo_uri: Option<String>,
9663    /// Optional company/team name shown instead of "`OxideSLOC`" in the nav header.
9664    company_name: Option<String>,
9665    /// CSS hex accent colour override (e.g. `#3b82f6`), or None for the default.
9666    accent_hex: Option<String>,
9667    /// Text for the header/footer identification banner on every report page.
9668    report_header_footer: Option<String>,
9669    chart_js: &'static str,
9670    run_id_short: String,
9671    /// When the HTML was generated alongside a PDF (e.g. via CLI with both
9672    /// `--html-out` and `--pdf-out`), this holds the relative URL to that PDF.
9673    /// The "View PDF" button navigates directly to it instead of the server route.
9674    standalone_pdf_url: Option<String>,
9675    /// Direct link to the commit on the hosting forge (GitHub, Bitbucket, GitLab, …).
9676    /// `None` when the remote URL is absent or unrecognised.
9677    git_commit_url: Option<String>,
9678    /// Direct link to the branch on the hosting forge.
9679    /// `None` when the remote URL or branch is absent/unrecognised.
9680    git_branch_url: Option<String>,
9681    /// Whether any style data was collected.
9682    has_style_data: bool,
9683    /// Number of language groups in the style summary (0 when none).
9684    style_lang_count: usize,
9685    /// Files scoring below this threshold are highlighted in the per-file table. 0 = off.
9686    style_score_threshold: u8,
9687    /// Serialised JSON for the multi-language style-guide chart (empty string when none).
9688    style_chart_json: String,
9689    /// Serialised JSON for the per-file style table (empty string when none).
9690    style_file_json: String,
9691    /// Aggregate style summary, cloned from `AnalysisRun::style_summary`.
9692    style_summary: Option<StyleSummary>,
9693    /// True when a previous-scan delta was provided (shows the delta panel).
9694    has_delta: bool,
9695    delta_code_added: i64,
9696    delta_code_removed: i64,
9697    delta_unmodified_lines: i64,
9698    delta_files_added: usize,
9699    delta_files_removed: usize,
9700    delta_files_modified: usize,
9701    delta_files_unchanged: usize,
9702    prev_code_lines: u64,
9703    prev_scan_count: usize,
9704    prev_scan_label: String,
9705    prev_run_id: String,
9706    /// Whether a COCOMO estimate is available.
9707    has_cocomo: bool,
9708    /// Pre-formatted COCOMO effort string (e.g. "14.32 person-months").
9709    cocomo_effort_str: String,
9710    /// Pre-formatted COCOMO schedule string (e.g. "6.18 months").
9711    cocomo_duration_str: String,
9712    /// Pre-formatted COCOMO average team-size string (e.g. "2.32").
9713    cocomo_staff_str: String,
9714    /// Pre-formatted KSLOC input for COCOMO (e.g. "12.53").
9715    cocomo_ksloc_str: String,
9716    /// Display label for the COCOMO mode (e.g. "Organic").
9717    cocomo_mode_label: String,
9718    /// Tooltip text explaining the selected COCOMO mode.
9719    cocomo_mode_tooltip: String,
9720    /// Unique Lines of Code across all analyzed files.
9721    uloc: u64,
9722    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty string.
9723    dryness_pct_str: String,
9724    /// Number of duplicate file groups detected.
9725    duplicate_group_count: usize,
9726    /// True when an `--activity-window` scan attached per-file git activity.
9727    has_hotspots: bool,
9728    /// Top-N files by `code_lines × recent commits` (empty unless activity was collected).
9729    hotspot_rows: Vec<HotspotRow>,
9730}
9731
9732// ─────────────────────────────────────────────────────────────────────────────
9733// CSV export
9734// ─────────────────────────────────────────────────────────────────────────────
9735
9736fn csv_escape(s: &str) -> String {
9737    if s.contains(',') || s.contains('"') || s.contains('\n') {
9738        format!("\"{}\"", s.replace('"', "\"\""))
9739    } else {
9740        s.to_string()
9741    }
9742}
9743
9744/// Write a two-section CSV: language summary followed by per-file detail.
9745///
9746/// # Errors
9747///
9748/// Returns an error if the file cannot be written.
9749pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
9750    let mut out = String::new();
9751
9752    // ── Section 1: Summary ──────────────────────────────────────────────────
9753    out.push_str("# Summary\r\n");
9754    out.push_str("Metric,Value\r\n");
9755    let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
9756    let _ = write!(
9757        out,
9758        "Timestamp,{}\r\n",
9759        csv_escape(
9760            &run.tool
9761                .timestamp_utc
9762                .format("%Y-%m-%d %H:%M:%S UTC")
9763                .to_string()
9764        )
9765    );
9766    let _ = write!(
9767        out,
9768        "Report Title,{}\r\n",
9769        csv_escape(&run.effective_configuration.reporting.report_title)
9770    );
9771    let _ = write!(
9772        out,
9773        "Files Analyzed,{}\r\n",
9774        run.summary_totals.files_analyzed
9775    );
9776    let _ = write!(
9777        out,
9778        "Files Skipped,{}\r\n",
9779        run.summary_totals.files_skipped
9780    );
9781    let _ = write!(
9782        out,
9783        "Physical Lines,{}\r\n",
9784        run.summary_totals.total_physical_lines
9785    );
9786    let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
9787    let _ = write!(
9788        out,
9789        "Comment Lines,{}\r\n",
9790        run.summary_totals.comment_lines
9791    );
9792    let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
9793    let _ = write!(
9794        out,
9795        "Mixed Lines (separate),{}\r\n",
9796        run.summary_totals.mixed_lines_separate
9797    );
9798
9799    // ── Section 2: Language breakdown ───────────────────────────────────────
9800    out.push_str("\r\n# By Language\r\n");
9801    out.push_str(
9802        "Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
9803    );
9804    for lang in &run.totals_by_language {
9805        let _ = write!(
9806            out,
9807            "{},{},{},{},{},{},{}\r\n",
9808            csv_escape(lang.language.display_name()),
9809            lang.files,
9810            lang.total_physical_lines,
9811            lang.code_lines,
9812            lang.comment_lines,
9813            lang.blank_lines,
9814            lang.mixed_lines_separate,
9815        );
9816    }
9817
9818    // ── Section 3: Per-file detail (if present) ─────────────────────────────
9819    write_csv_per_file_section(&mut out, run);
9820
9821    fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
9822}
9823
9824/// Append the per-file detail section to a CSV buffer. No-op when there are no per-file records.
9825fn write_csv_per_file_section(out: &mut String, run: &AnalysisRun) {
9826    if run.per_file_records.is_empty() {
9827        return;
9828    }
9829    // Only emit the git-activity columns when an --activity-window scan populated them.
9830    let has_activity = run
9831        .per_file_records
9832        .iter()
9833        .any(|r| r.commit_count.is_some());
9834    out.push_str("\r\n# Per File\r\n");
9835    out.push_str(
9836        "Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor",
9837    );
9838    if has_activity {
9839        out.push_str(",Commits,Last Changed");
9840    }
9841    out.push_str("\r\n");
9842    for rec in &run.per_file_records {
9843        let _ = write!(
9844            out,
9845            "{},{},{},{},{},{},{},{},{},{}",
9846            csv_escape(&rec.relative_path),
9847            csv_escape(
9848                &rec.language
9849                    .map(|l| l.display_name().to_string())
9850                    .unwrap_or_default()
9851            ),
9852            rec.size_bytes,
9853            rec.effective_counts.code_lines,
9854            rec.effective_counts.comment_lines,
9855            rec.effective_counts.blank_lines,
9856            rec.raw_line_categories.total_physical_lines,
9857            rec.generated,
9858            rec.minified,
9859            rec.vendor,
9860        );
9861        if has_activity {
9862            let _ = write!(
9863                out,
9864                ",{},{}",
9865                rec.commit_count.map(|c| c.to_string()).unwrap_or_default(),
9866                csv_escape(rec.last_commit_date.as_deref().unwrap_or("")),
9867            );
9868        }
9869        out.push_str("\r\n");
9870    }
9871}
9872
9873/// Write a diff/delta as CSV.
9874///
9875/// # Errors
9876///
9877/// Returns an error if the file cannot be written.
9878pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
9879    let s = &cmp.summary;
9880    let mut out = String::new();
9881
9882    out.push_str("# Diff Summary\r\n");
9883    out.push_str("Metric,Value\r\n");
9884    let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
9885    let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
9886    let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
9887    let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
9888    let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
9889    let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
9890    let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
9891    let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
9892    let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
9893    let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
9894
9895    out.push_str("\r\n# File Deltas\r\n");
9896    out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
9897    for f in &cmp.file_deltas {
9898        let status = match f.status {
9899            sloc_core::FileChangeStatus::Added => "Added",
9900            sloc_core::FileChangeStatus::Removed => "Removed",
9901            sloc_core::FileChangeStatus::Modified => "Modified",
9902            sloc_core::FileChangeStatus::Unchanged => "Unchanged",
9903        };
9904        let _ = write!(
9905            out,
9906            "{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
9907            status,
9908            csv_escape(&f.relative_path),
9909            csv_escape(f.language.as_deref().unwrap_or("")),
9910            f.baseline_code,
9911            f.current_code,
9912            f.code_delta,
9913            f.baseline_comment,
9914            f.current_comment,
9915            f.comment_delta,
9916            f.baseline_blank,
9917            f.current_blank,
9918            f.blank_delta,
9919            f.total_delta,
9920        );
9921    }
9922
9923    fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
9924}
9925
9926// ─────────────────────────────────────────────────────────────────────────────
9927// XLSX export — self-contained, no external crates required.
9928//
9929// An .xlsx file is a ZIP archive containing a set of XML files.  We write the
9930// ZIP with the STORE (uncompressed) method so we only need a CRC-32 routine
9931// and straightforward byte-level framing — both implemented inline below.
9932// ─────────────────────────────────────────────────────────────────────────────
9933
9934fn crc32(data: &[u8]) -> u32 {
9935    let mut crc: u32 = 0xffff_ffff;
9936    for &b in data {
9937        crc ^= u32::from(b);
9938        for _ in 0..8 {
9939            crc = if crc & 1 == 0 {
9940                crc >> 1
9941            } else {
9942                (crc >> 1) ^ 0xedb8_8320
9943            };
9944        }
9945    }
9946    !crc
9947}
9948
9949struct ZipEntry {
9950    name: Vec<u8>,
9951    data: Vec<u8>,
9952    crc: u32,
9953    offset: u32,
9954}
9955
9956#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by caller
9957fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
9958    let crc = crc32(&data);
9959    let offset = buf.len() as u32;
9960    let name_bytes = name.as_bytes().to_vec();
9961    let size = data.len() as u32;
9962
9963    // Local file header (signature 0x04034b50)
9964    buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
9965    buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
9966    buf.extend_from_slice(&0u16.to_le_bytes()); // flags
9967    buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
9968    buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
9969    buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
9970    buf.extend_from_slice(&crc.to_le_bytes());
9971    buf.extend_from_slice(&size.to_le_bytes()); // compressed size
9972    buf.extend_from_slice(&size.to_le_bytes()); // uncompressed size
9973    buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
9974    buf.extend_from_slice(&0u16.to_le_bytes()); // extra field length
9975    buf.extend_from_slice(&name_bytes);
9976    buf.extend_from_slice(&data);
9977
9978    entries.push(ZipEntry {
9979        name: name_bytes,
9980        data,
9981        crc,
9982        offset,
9983    });
9984}
9985
9986#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by ZIP spec limits
9987fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
9988    let central_start = buf.len() as u32;
9989
9990    for e in entries {
9991        let size = e.data.len() as u32;
9992        buf.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); // central dir sig
9993        buf.extend_from_slice(&20u16.to_le_bytes()); // version made by
9994        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
9995        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
9996        buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
9997        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
9998        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
9999        buf.extend_from_slice(&e.crc.to_le_bytes());
10000        buf.extend_from_slice(&size.to_le_bytes());
10001        buf.extend_from_slice(&size.to_le_bytes());
10002        buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
10003        buf.extend_from_slice(&0u16.to_le_bytes()); // extra
10004        buf.extend_from_slice(&0u16.to_le_bytes()); // comment
10005        buf.extend_from_slice(&0u16.to_le_bytes()); // disk start
10006        buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
10007        buf.extend_from_slice(&0u32.to_le_bytes()); // external attrs
10008        buf.extend_from_slice(&e.offset.to_le_bytes());
10009        buf.extend_from_slice(&e.name);
10010    }
10011
10012    let central_size = buf.len() as u32 - central_start;
10013    let n = entries.len() as u16;
10014
10015    // End of central directory record
10016    buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
10017    buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
10018    buf.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir
10019    buf.extend_from_slice(&n.to_le_bytes()); // entries on this disk
10020    buf.extend_from_slice(&n.to_le_bytes()); // total entries
10021    buf.extend_from_slice(&central_size.to_le_bytes());
10022    buf.extend_from_slice(&central_start.to_le_bytes());
10023    buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
10024
10025    buf
10026}
10027
10028fn xml_escape(s: &str) -> String {
10029    s.replace('&', "&amp;")
10030        .replace('<', "&lt;")
10031        .replace('>', "&gt;")
10032        .replace('"', "&quot;")
10033        .replace('\'', "&apos;")
10034}
10035
10036/// Build a worksheet XML with the given header row and data rows.
10037// ── XLSX style-index constants ──────────────────────────────────────────────
10038// Indices into the <cellXfs> table in styles.xml.
10039// 0 = default (unused placeholder)
10040// 1 = HEADER   bold white text, navy fill (#283790), all-side thin border, centered
10041// 2 = BODY     normal text, white fill, thin border
10042// 3 = BODY_ALT normal text, cream fill (#F5EFE8), thin border  (alternating rows)
10043// 4 = NUM      #,##0, right-aligned, white fill, thin border
10044// 5 = NUM_ALT  #,##0, right-aligned, cream fill, thin border   (alternating rows)
10045// 6 = KV_KEY   bold navy text (#283790), warm-surface fill (#FBF7F2), thin border
10046// 7 = KV_VAL   normal text, white fill, thin border  (key-value sheets: Summary)
10047const XLS_HEADER: u32 = 1;
10048const XLS_BODY: u32 = 2;
10049const XLS_BODY_ALT: u32 = 3;
10050const XLS_NUM: u32 = 4;
10051const XLS_NUM_ALT: u32 = 5;
10052const XLS_KV_KEY: u32 = 6;
10053const XLS_KV_VAL: u32 = 7;
10054
10055struct XlSheet<'a> {
10056    name: &'a str,
10057    tab_color: &'a str, // AARRGGBB hex without '#', e.g. "FF283790"
10058    headers: &'a [&'a str],
10059    rows: Vec<Vec<String>>,
10060    col_widths: Vec<f64>, // per-column character widths; last entry used for overflow cols
10061    is_kv: bool,          // key-value layout (Summary): col A = key style, no autofilter
10062}
10063
10064#[allow(clippy::cast_possible_truncation)] // n % 26 fits in u8 by construction
10065fn xl_col_name(idx: usize) -> String {
10066    let mut n = idx + 1;
10067    let mut s = String::new();
10068    while n > 0 {
10069        n -= 1;
10070        s.insert(0, char::from(b'A' + (n % 26) as u8));
10071        n /= 26;
10072    }
10073    s
10074}
10075
10076const fn xl_styles() -> &'static str {
10077    "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
10078<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
10079<numFmts count=\"1\">\
10080<numFmt numFmtId=\"164\" formatCode=\"#,##0\"/>\
10081</numFmts>\
10082<fonts count=\"3\">\
10083<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\
10084<font><b/><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font>\
10085<font><b/><sz val=\"11\"/><color rgb=\"FF283790\"/><name val=\"Calibri\"/></font>\
10086</fonts>\
10087<fills count=\"5\">\
10088<fill><patternFill patternType=\"none\"/></fill>\
10089<fill><patternFill patternType=\"gray125\"/></fill>\
10090<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF283790\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10091<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFF5EFE8\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10092<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFBF7F2\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10093</fills>\
10094<borders count=\"2\">\
10095<border><left/><right/><top/><bottom/><diagonal/></border>\
10096<border>\
10097<left style=\"thin\"><color rgb=\"FFD0B8A0\"/></left>\
10098<right style=\"thin\"><color rgb=\"FFD0B8A0\"/></right>\
10099<top style=\"thin\"><color rgb=\"FFD0B8A0\"/></top>\
10100<bottom style=\"thin\"><color rgb=\"FFD0B8A0\"/></bottom>\
10101<diagonal/>\
10102</border>\
10103</borders>\
10104<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\
10105<cellXfs count=\"8\">\
10106<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\
10107<xf numFmtId=\"0\" fontId=\"1\" fillId=\"2\" borderId=\"1\" xfId=\"0\" \
10108applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10109<alignment horizontal=\"center\" vertical=\"center\"/></xf>\
10110<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10111<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" applyFill=\"1\" applyBorder=\"1\"/>\
10112<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" \
10113applyNumberFormat=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10114<alignment horizontal=\"right\"/></xf>\
10115<xf numFmtId=\"164\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" \
10116applyNumberFormat=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10117<alignment horizontal=\"right\"/></xf>\
10118<xf numFmtId=\"0\" fontId=\"2\" fillId=\"4\" borderId=\"1\" xfId=\"0\" \
10119applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\"/>\
10120<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10121</cellXfs>\
10122</styleSheet>"
10123}
10124
10125fn xl_sheet_xml(sheet: &XlSheet<'_>) -> Vec<u8> {
10126    let ncols = sheet.headers.len();
10127    let ndata = sheet.rows.len();
10128    let last_col = xl_col_name(ncols.saturating_sub(1));
10129    let last_row = ndata + 1;
10130    let range = format!("A1:{last_col}{last_row}");
10131
10132    let mut xml = String::with_capacity(4096 + ndata * 256);
10133    let _ = write!(
10134        xml,
10135        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10136         <worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
10137         <sheetPr><tabColor rgb=\"{tc}\"/></sheetPr>\n\
10138         <dimension ref=\"{rng}\"/>\n\
10139         <sheetViews><sheetView workbookViewId=\"0\">\
10140         <pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>\
10141         <selection pane=\"bottomLeft\" activeCell=\"A2\" sqref=\"A2\"/>\
10142         </sheetView></sheetViews>\n\
10143         <sheetFormatPr defaultRowHeight=\"15\"/>\n",
10144        tc = sheet.tab_color,
10145        rng = range,
10146    );
10147
10148    xl_write_col_widths(&mut xml, &sheet.col_widths, ncols);
10149    xml.push_str("<sheetData>\n");
10150    xl_write_header_row(&mut xml, sheet.headers);
10151    xl_write_data_rows(&mut xml, &sheet.rows, sheet.is_kv);
10152    xml.push_str("</sheetData>\n");
10153    if !sheet.is_kv && ncols > 0 {
10154        let _ = writeln!(xml, "<autoFilter ref=\"{range}\"/>");
10155    }
10156    xml.push_str("</worksheet>");
10157    xml.into_bytes()
10158}
10159
10160fn xl_write_col_widths(xml: &mut String, col_widths: &[f64], ncols: usize) {
10161    if col_widths.is_empty() {
10162        return;
10163    }
10164    let default_w = *col_widths.last().unwrap_or(&10.0);
10165    xml.push_str("<cols>\n");
10166    for ci in 0..ncols {
10167        let w = col_widths.get(ci).copied().unwrap_or(default_w);
10168        let _ = writeln!(
10169            xml,
10170            "  <col min=\"{n}\" max=\"{n}\" width=\"{w:.1}\" customWidth=\"1\"/>",
10171            n = ci + 1
10172        );
10173    }
10174    xml.push_str("</cols>\n");
10175}
10176
10177fn xl_write_header_row(xml: &mut String, headers: &[&str]) {
10178    let _ = write!(xml, "<row r=\"1\" ht=\"18\" customHeight=\"1\">");
10179    for (ci, &h) in headers.iter().enumerate() {
10180        let _ = write!(
10181            xml,
10182            "<c r=\"{}1\" t=\"inlineStr\" s=\"{}\"><is><t>{}</t></is></c>",
10183            xl_col_name(ci),
10184            XLS_HEADER,
10185            xml_escape(h),
10186        );
10187    }
10188    xml.push_str("</row>\n");
10189}
10190
10191const fn xl_cell_style(is_kv: bool, ci: usize, is_num: bool, is_alt: bool) -> u32 {
10192    if is_kv {
10193        if ci == 0 {
10194            XLS_KV_KEY
10195        } else if is_num {
10196            XLS_NUM
10197        } else {
10198            XLS_KV_VAL
10199        }
10200    } else if is_num {
10201        if is_alt {
10202            XLS_NUM_ALT
10203        } else {
10204            XLS_NUM
10205        }
10206    } else if is_alt {
10207        XLS_BODY_ALT
10208    } else {
10209        XLS_BODY
10210    }
10211}
10212
10213fn xl_write_data_rows(xml: &mut String, rows: &[Vec<String>], is_kv: bool) {
10214    for (ri, row) in rows.iter().enumerate() {
10215        let row_num = ri + 2;
10216        let is_alt = ri % 2 == 1;
10217        let _ = write!(xml, "<row r=\"{row_num}\">");
10218        for (ci, cell) in row.iter().enumerate() {
10219            let cell_ref = format!("{}{}", xl_col_name(ci), row_num);
10220            let is_num = !cell.is_empty() && cell.parse::<f64>().is_ok();
10221            let s = xl_cell_style(is_kv, ci, is_num, is_alt);
10222            if is_num {
10223                let _ = write!(
10224                    xml,
10225                    "<c r=\"{cell_ref}\" s=\"{s}\"><v>{}</v></c>",
10226                    xml_escape(cell)
10227                );
10228            } else {
10229                let _ = write!(
10230                    xml,
10231                    "<c r=\"{cell_ref}\" t=\"inlineStr\" s=\"{s}\"><is><t>{}</t></is></c>",
10232                    xml_escape(cell),
10233                );
10234            }
10235        }
10236        xml.push_str("</row>\n");
10237    }
10238}
10239
10240fn build_xlsx(sheets: &[XlSheet<'_>]) -> Vec<u8> {
10241    let mut buf: Vec<u8> = Vec::new();
10242    let mut entries: Vec<ZipEntry> = Vec::new();
10243
10244    // ── [Content_Types].xml ─────────────────────────────────────────────────
10245    let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10246    ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
10247    ct.push_str("  <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
10248    ct.push_str("  <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
10249    ct.push_str("  <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
10250    ct.push_str("  <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
10251    for (i, _) in sheets.iter().enumerate() {
10252        let _ = writeln!(
10253            ct,
10254            "  <Override PartName=\"/xl/worksheets/sheet{}.xml\" \
10255             ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
10256            i + 1
10257        );
10258    }
10259    ct.push_str("</Types>");
10260    zip_add(
10261        &mut entries,
10262        &mut buf,
10263        "[Content_Types].xml",
10264        ct.into_bytes(),
10265    );
10266
10267    // ── _rels/.rels ─────────────────────────────────────────────────────────
10268    let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10269<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
10270  <Relationship Id=\"rId1\" \
10271  Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" \
10272  Target=\"xl/workbook.xml\"/>\n\
10273</Relationships>";
10274    zip_add(
10275        &mut entries,
10276        &mut buf,
10277        "_rels/.rels",
10278        rels.as_bytes().to_vec(),
10279    );
10280
10281    // ── xl/workbook.xml ──────────────────────────────────────────────────────
10282    let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10283    wb.push_str(
10284        "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
10285         xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
10286    );
10287    wb.push_str("  <sheets>\n");
10288    for (i, sheet) in sheets.iter().enumerate() {
10289        let _ = writeln!(
10290            wb,
10291            "    <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
10292            xml_escape(sheet.name),
10293            i + 1,
10294            i + 1
10295        );
10296    }
10297    wb.push_str("  </sheets>\n</workbook>");
10298    zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
10299
10300    // ── xl/_rels/workbook.xml.rels ───────────────────────────────────────────
10301    let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10302    wbr.push_str(
10303        "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
10304    );
10305    for (i, _) in sheets.iter().enumerate() {
10306        let _ = writeln!(
10307            wbr,
10308            "  <Relationship Id=\"rId{}\" \
10309             Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
10310             Target=\"worksheets/sheet{}.xml\"/>",
10311            i + 1,
10312            i + 1
10313        );
10314    }
10315    let _ = writeln!(
10316        wbr,
10317        "  <Relationship Id=\"rId{}\" \
10318         Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
10319         Target=\"styles.xml\"/>",
10320        sheets.len() + 1
10321    );
10322    wbr.push_str("</Relationships>");
10323    zip_add(
10324        &mut entries,
10325        &mut buf,
10326        "xl/_rels/workbook.xml.rels",
10327        wbr.into_bytes(),
10328    );
10329
10330    // ── xl/styles.xml ───────────────────────────────────────────────────────
10331    zip_add(
10332        &mut entries,
10333        &mut buf,
10334        "xl/styles.xml",
10335        xl_styles().as_bytes().to_vec(),
10336    );
10337
10338    // ── worksheets ───────────────────────────────────────────────────────────
10339    for (i, sheet) in sheets.iter().enumerate() {
10340        let sheet_xml = xl_sheet_xml(sheet);
10341        let name = format!("xl/worksheets/sheet{}.xml", i + 1);
10342        zip_add(&mut entries, &mut buf, &name, sheet_xml);
10343    }
10344
10345    zip_finish(buf, &entries)
10346}
10347
10348/// Write an analysis run as a multi-sheet Excel workbook.
10349///
10350/// # Errors
10351///
10352/// Returns an error if the file cannot be written.
10353#[allow(clippy::too_many_lines)]
10354pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
10355    // Sheet 1 — Summary
10356    let summary_rows: Vec<Vec<String>> = vec![
10357        vec!["Run ID".into(), run.tool.run_id.clone()],
10358        vec![
10359            "Timestamp".into(),
10360            run.tool
10361                .timestamp_utc
10362                .format("%Y-%m-%d %H:%M:%S UTC")
10363                .to_string(),
10364        ],
10365        vec![
10366            "Report Title".into(),
10367            run.effective_configuration.reporting.report_title.clone(),
10368        ],
10369        vec![
10370            "Files Analyzed".into(),
10371            run.summary_totals.files_analyzed.to_string(),
10372        ],
10373        vec![
10374            "Files Skipped".into(),
10375            run.summary_totals.files_skipped.to_string(),
10376        ],
10377        vec![
10378            "Physical Lines".into(),
10379            run.summary_totals.total_physical_lines.to_string(),
10380        ],
10381        vec![
10382            "Code Lines".into(),
10383            run.summary_totals.code_lines.to_string(),
10384        ],
10385        vec![
10386            "Comment Lines".into(),
10387            run.summary_totals.comment_lines.to_string(),
10388        ],
10389        vec![
10390            "Blank Lines".into(),
10391            run.summary_totals.blank_lines.to_string(),
10392        ],
10393        vec![
10394            "Mixed Lines (separate)".into(),
10395            run.summary_totals.mixed_lines_separate.to_string(),
10396        ],
10397    ];
10398
10399    // Sheet 2 — By Language
10400    let lang_rows: Vec<Vec<String>> = run
10401        .totals_by_language
10402        .iter()
10403        .map(|l| {
10404            vec![
10405                l.language.display_name().to_string(),
10406                l.files.to_string(),
10407                l.total_physical_lines.to_string(),
10408                l.code_lines.to_string(),
10409                l.comment_lines.to_string(),
10410                l.blank_lines.to_string(),
10411                l.mixed_lines_separate.to_string(),
10412            ]
10413        })
10414        .collect();
10415
10416    // Sheet 3 — Per File
10417    let file_rows: Vec<Vec<String>> = run
10418        .per_file_records
10419        .iter()
10420        .map(|r| {
10421            vec![
10422                r.relative_path.clone(),
10423                r.language
10424                    .map(|l| l.display_name().to_string())
10425                    .unwrap_or_default(),
10426                r.size_bytes.to_string(),
10427                r.effective_counts.code_lines.to_string(),
10428                r.effective_counts.comment_lines.to_string(),
10429                r.effective_counts.blank_lines.to_string(),
10430                r.raw_line_categories.total_physical_lines.to_string(),
10431                r.generated.to_string(),
10432                r.minified.to_string(),
10433                r.vendor.to_string(),
10434            ]
10435        })
10436        .collect();
10437
10438    // Sheet 4 — Skipped Files
10439    let skipped_rows: Vec<Vec<String>> = run
10440        .skipped_file_records
10441        .iter()
10442        .map(|r| {
10443            vec![
10444                r.relative_path.clone(),
10445                format!("{:?}", r.status),
10446                r.size_bytes.to_string(),
10447            ]
10448        })
10449        .collect();
10450
10451    let summary_hdrs: &[&str] = &["Metric", "Value"];
10452    let lang_hdrs: &[&str] = &[
10453        "Language",
10454        "Files",
10455        "Physical Lines",
10456        "Code Lines",
10457        "Comments",
10458        "Blank",
10459        "Mixed",
10460    ];
10461    let file_hdrs: &[&str] = &[
10462        "Path",
10463        "Language",
10464        "Size (bytes)",
10465        "Code Lines",
10466        "Comments",
10467        "Blank Lines",
10468        "Physical Lines",
10469        "Generated",
10470        "Minified",
10471        "Vendor",
10472    ];
10473    let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
10474
10475    let sheets = vec![
10476        XlSheet {
10477            name: "Summary",
10478            tab_color: "FF283790",
10479            headers: summary_hdrs,
10480            rows: summary_rows,
10481            col_widths: vec![26.0, 44.0],
10482            is_kv: true,
10483        },
10484        XlSheet {
10485            name: "By Language",
10486            tab_color: "FFB85D33",
10487            headers: lang_hdrs,
10488            rows: lang_rows,
10489            col_widths: vec![20.0, 9.0, 15.0, 13.0, 13.0, 11.0, 11.0],
10490            is_kv: false,
10491        },
10492        XlSheet {
10493            name: "Per File",
10494            tab_color: "FF2A6846",
10495            headers: file_hdrs,
10496            rows: file_rows,
10497            col_widths: vec![48.0, 14.0, 13.0, 13.0, 11.0, 11.0, 15.0, 11.0, 11.0, 9.0],
10498            is_kv: false,
10499        },
10500        XlSheet {
10501            name: "Skipped",
10502            tab_color: "FF7B675B",
10503            headers: skipped_hdrs,
10504            rows: skipped_rows,
10505            col_widths: vec![52.0, 24.0, 13.0],
10506            is_kv: false,
10507        },
10508    ];
10509
10510    let bytes = build_xlsx(&sheets);
10511    fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
10512}
10513
10514/// Write a diff comparison as an Excel workbook.
10515///
10516/// # Errors
10517///
10518/// Returns an error if the file cannot be written.
10519pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10520    let s = &cmp.summary;
10521
10522    let summary_rows: Vec<Vec<String>> = vec![
10523        vec!["Baseline Run".into(), s.baseline_run_id.clone()],
10524        vec!["Current Run".into(), s.current_run_id.clone()],
10525        vec!["Files Added".into(), cmp.files_added.to_string()],
10526        vec!["Files Removed".into(), cmp.files_removed.to_string()],
10527        vec!["Files Modified".into(), cmp.files_modified.to_string()],
10528        vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
10529        vec!["Code Δ".into(), s.code_lines_delta.to_string()],
10530        vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
10531        vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
10532        vec!["Total Δ".into(), s.total_lines_delta.to_string()],
10533    ];
10534
10535    let delta_rows: Vec<Vec<String>> = cmp
10536        .file_deltas
10537        .iter()
10538        .map(|f| {
10539            let status = match f.status {
10540                sloc_core::FileChangeStatus::Added => "Added",
10541                sloc_core::FileChangeStatus::Removed => "Removed",
10542                sloc_core::FileChangeStatus::Modified => "Modified",
10543                sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10544            };
10545            vec![
10546                status.to_string(),
10547                f.relative_path.clone(),
10548                f.language.clone().unwrap_or_default(),
10549                f.baseline_code.to_string(),
10550                f.current_code.to_string(),
10551                f.code_delta.to_string(),
10552                f.baseline_comment.to_string(),
10553                f.current_comment.to_string(),
10554                f.comment_delta.to_string(),
10555                f.total_delta.to_string(),
10556            ]
10557        })
10558        .collect();
10559
10560    let summary_hdrs: &[&str] = &["Metric", "Value"];
10561    let delta_hdrs: &[&str] = &[
10562        "Status",
10563        "Path",
10564        "Language",
10565        "Baseline Code",
10566        "Current Code",
10567        "Code Δ",
10568        "Baseline Comment",
10569        "Current Comment",
10570        "Comment Δ",
10571        "Total Δ",
10572    ];
10573
10574    let sheets = vec![
10575        XlSheet {
10576            name: "Diff Summary",
10577            tab_color: "FF283790",
10578            headers: summary_hdrs,
10579            rows: summary_rows,
10580            col_widths: vec![26.0, 44.0],
10581            is_kv: true,
10582        },
10583        XlSheet {
10584            name: "File Deltas",
10585            tab_color: "FFB85D33",
10586            headers: delta_hdrs,
10587            rows: delta_rows,
10588            col_widths: vec![12.0, 48.0, 16.0, 14.0, 14.0, 11.0, 14.0, 14.0, 11.0, 11.0],
10589            is_kv: false,
10590        },
10591    ];
10592
10593    let bytes = build_xlsx(&sheets);
10594    fs::write(path, bytes)
10595        .with_context(|| format!("failed to write diff XLSX to {}", path.display()))
10596}
10597
10598// ── Confluence rendering ────────────────────────────────────────────────────
10599
10600fn html_esc(s: &str) -> String {
10601    s.replace('&', "&amp;")
10602        .replace('<', "&lt;")
10603        .replace('>', "&gt;")
10604        .replace('"', "&quot;")
10605}
10606
10607/// Generates Confluence storage-format XHTML for a scan result page.
10608/// Includes an info panel, summary stats, per-language table, and an optional
10609/// link back to the full oxide-sloc HTML report.
10610#[must_use]
10611pub fn render_confluence_storage(run: &AnalysisRun, report_url: Option<&str>) -> String {
10612    let mut out = String::with_capacity(8192);
10613
10614    let project = run.effective_configuration.reporting.report_title.as_str();
10615    let branch = run.git_branch.as_deref().unwrap_or("—");
10616    let commit = run.git_commit_short.as_deref().unwrap_or("—");
10617    let scanned = run
10618        .tool
10619        .timestamp_utc
10620        .format("%Y-%m-%d %H:%M UTC")
10621        .to_string();
10622
10623    // Info panel macro
10624    out.push_str(
10625        "<ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\">\
10626         <ac:rich-text-body><p>",
10627    );
10628    let _ = write!(
10629        out,
10630        "<strong>Project:</strong> {proj} &nbsp;·&nbsp; \
10631         <strong>Branch:</strong> {branch} &nbsp;·&nbsp; \
10632         <strong>Commit:</strong> {commit} &nbsp;·&nbsp; \
10633         <strong>Scanned:</strong> {scanned}",
10634        proj = html_esc(project),
10635        branch = html_esc(branch),
10636        commit = html_esc(commit),
10637        scanned = html_esc(&scanned),
10638    );
10639    out.push_str("</p></ac:rich-text-body></ac:structured-macro>");
10640
10641    // Summary stats table
10642    out.push_str("<h2>Summary</h2>");
10643    out.push_str(
10644        "<table><thead><tr>\
10645         <th>Files Analyzed</th><th>Code Lines</th><th>Comment Lines</th>\
10646         <th>Blank Lines</th><th>Languages</th>\
10647         </tr></thead><tbody><tr>",
10648    );
10649    let t = &run.summary_totals;
10650    let _ = write!(
10651        out,
10652        "<td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>",
10653        t.files_analyzed,
10654        t.code_lines,
10655        t.comment_lines,
10656        t.blank_lines,
10657        run.totals_by_language.len(),
10658    );
10659    out.push_str("</tr></tbody></table>");
10660
10661    // Per-language breakdown table
10662    if !run.totals_by_language.is_empty() {
10663        out.push_str("<h2>Language Breakdown</h2>");
10664        out.push_str(
10665            "<table><thead><tr>\
10666             <th>Language</th><th>Files</th><th>Code</th><th>Comments</th><th>Blank</th>\
10667             </tr></thead><tbody>",
10668        );
10669        for lang in &run.totals_by_language {
10670            let _ = write!(
10671                out,
10672                "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
10673                html_esc(lang.language.display_name()),
10674                lang.files,
10675                lang.code_lines,
10676                lang.comment_lines,
10677                lang.blank_lines,
10678            );
10679        }
10680        out.push_str("</tbody></table>");
10681    }
10682
10683    // Link back to full report
10684    if let Some(url) = report_url {
10685        let _ = write!(
10686            out,
10687            "<p><strong>Full interactive report:</strong> \
10688             <a href=\"{url}\">{url_disp}</a></p>",
10689            url = html_esc(url),
10690            url_disp = html_esc(url),
10691        );
10692    }
10693
10694    out
10695}
10696
10697/// Generates Confluence wiki markup (legacy syntax) for copy/paste into a
10698/// Confluence page editor.
10699#[must_use]
10700pub fn render_confluence_wiki_markup(run: &AnalysisRun) -> String {
10701    let mut out = String::with_capacity(4096);
10702
10703    let project = run.effective_configuration.reporting.report_title.as_str();
10704    let branch = run.git_branch.as_deref().unwrap_or("—");
10705    let commit = run.git_commit_short.as_deref().unwrap_or("—");
10706    let scanned = run
10707        .tool
10708        .timestamp_utc
10709        .format("%Y-%m-%d %H:%M UTC")
10710        .to_string();
10711
10712    let _ = writeln!(out, "{{info}}");
10713    let _ = writeln!(
10714        out,
10715        "Project: {project}  ·  Branch: {branch}  ·  Commit: {commit}  ·  Scanned: {scanned}"
10716    );
10717    let _ = writeln!(out, "{{info}}");
10718    out.push('\n');
10719
10720    let t = &run.summary_totals;
10721    let _ = writeln!(out, "h2. Summary");
10722    let _ = writeln!(
10723        out,
10724        "||Files Analyzed||Code Lines||Comment Lines||Blank Lines||Languages||"
10725    );
10726    let _ = writeln!(
10727        out,
10728        "|{}|{}|{}|{}|{}|",
10729        t.files_analyzed,
10730        t.code_lines,
10731        t.comment_lines,
10732        t.blank_lines,
10733        run.totals_by_language.len(),
10734    );
10735    out.push('\n');
10736
10737    if !run.totals_by_language.is_empty() {
10738        let _ = writeln!(out, "h2. Language Breakdown");
10739        let _ = writeln!(out, "||Language||Files||Code||Comments||Blank||");
10740        for lang in &run.totals_by_language {
10741            let _ = writeln!(
10742                out,
10743                "|{}|{}|{}|{}|{}|",
10744                lang.language.display_name(),
10745                lang.files,
10746                lang.code_lines,
10747                lang.comment_lines,
10748                lang.blank_lines,
10749            );
10750        }
10751        out.push('\n');
10752    }
10753
10754    let _ = writeln!(
10755        out,
10756        "*Total:* {} code lines · {} files · {} languages",
10757        t.code_lines,
10758        t.files_analyzed,
10759        run.totals_by_language.len(),
10760    );
10761
10762    out
10763}
10764
10765#[cfg(test)]
10766mod tests {
10767    use super::*;
10768    use tempfile::tempdir;
10769
10770    // ── base64_encode ────────────────────────────────────────────────────────────
10771
10772    #[test]
10773    fn base64_encode_empty() {
10774        assert_eq!(base64_encode(b""), "");
10775    }
10776
10777    #[test]
10778    fn base64_encode_one_byte() {
10779        assert_eq!(base64_encode(b"M"), "TQ==");
10780    }
10781
10782    #[test]
10783    fn base64_encode_two_bytes() {
10784        assert_eq!(base64_encode(b"Ma"), "TWE=");
10785    }
10786
10787    #[test]
10788    fn base64_encode_three_bytes_no_padding() {
10789        assert_eq!(base64_encode(b"Man"), "TWFu");
10790    }
10791
10792    #[test]
10793    fn base64_encode_hello() {
10794        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
10795    }
10796
10797    #[test]
10798    fn base64_encode_roundtrip_length_multiple_of_3() {
10799        let data = b"abcdef";
10800        let encoded = base64_encode(data);
10801        assert_eq!(encoded.len(), 8);
10802        assert!(!encoded.contains('='));
10803    }
10804
10805    #[test]
10806    fn base64_encode_all_zeros() {
10807        assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
10808    }
10809
10810    #[test]
10811    fn base64_encode_binary_data() {
10812        let data: Vec<u8> = (0u8..=255).collect();
10813        let encoded = base64_encode(&data);
10814        assert!(!encoded.is_empty());
10815        assert!(encoded
10816            .chars()
10817            .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '='));
10818    }
10819
10820    // ── json_escape ──────────────────────────────────────────────────────────────
10821
10822    #[test]
10823    fn json_escape_no_special_chars() {
10824        assert_eq!(json_escape("hello world"), "hello world");
10825    }
10826
10827    #[test]
10828    fn json_escape_backslash() {
10829        assert_eq!(json_escape(r"path\to\file"), r"path\\to\\file");
10830    }
10831
10832    #[test]
10833    fn json_escape_double_quote() {
10834        assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
10835    }
10836
10837    #[test]
10838    fn json_escape_both_special_chars() {
10839        assert_eq!(json_escape(r#"a\"b"#), r#"a\\\"b"#);
10840    }
10841
10842    #[test]
10843    fn json_escape_empty_string() {
10844        assert_eq!(json_escape(""), "");
10845    }
10846
10847    #[test]
10848    fn json_escape_only_backslashes() {
10849        assert_eq!(json_escape(r"\\"), r"\\\\");
10850    }
10851
10852    // ── coverage_pct_str ─────────────────────────────────────────────────────────
10853
10854    #[test]
10855    fn coverage_pct_str_zero_found_returns_empty() {
10856        assert_eq!(coverage_pct_str(0, 0), "");
10857    }
10858
10859    #[test]
10860    fn coverage_pct_str_full_coverage() {
10861        assert_eq!(coverage_pct_str(100, 100), "100.0");
10862    }
10863
10864    #[test]
10865    fn coverage_pct_str_half_coverage() {
10866        assert_eq!(coverage_pct_str(50, 100), "50.0");
10867    }
10868
10869    #[test]
10870    fn coverage_pct_str_one_decimal_precision() {
10871        let s = coverage_pct_str(7, 10);
10872        assert_eq!(s, "70.0");
10873    }
10874
10875    #[test]
10876    fn coverage_pct_str_zero_hit_but_found() {
10877        assert_eq!(coverage_pct_str(0, 10), "0.0");
10878    }
10879
10880    #[test]
10881    fn coverage_pct_str_non_round_percentage() {
10882        let s = coverage_pct_str(1, 3);
10883        assert!(!s.is_empty());
10884        assert!(s.contains('.'), "result must have decimal point");
10885    }
10886
10887    // ── coverage_class ───────────────────────────────────────────────────────────
10888
10889    #[test]
10890    fn coverage_class_zero_found_is_muted() {
10891        assert_eq!(coverage_class(0, 0), "muted");
10892    }
10893
10894    #[test]
10895    fn coverage_class_100_pct_is_good() {
10896        assert_eq!(coverage_class(100, 100), "good");
10897    }
10898
10899    #[test]
10900    fn coverage_class_80_pct_is_good() {
10901        assert_eq!(coverage_class(80, 100), "good");
10902    }
10903
10904    #[test]
10905    fn coverage_class_79_pct_is_warn() {
10906        assert_eq!(coverage_class(79, 100), "warn");
10907    }
10908
10909    #[test]
10910    fn coverage_class_60_pct_is_warn() {
10911        assert_eq!(coverage_class(60, 100), "warn");
10912    }
10913
10914    #[test]
10915    fn coverage_class_59_pct_is_danger() {
10916        assert_eq!(coverage_class(59, 100), "danger");
10917    }
10918
10919    #[test]
10920    fn coverage_class_zero_hit_is_danger() {
10921        assert_eq!(coverage_class(0, 100), "danger");
10922    }
10923
10924    // ── format_test_density ──────────────────────────────────────────────────────
10925
10926    #[test]
10927    fn format_test_density_zero_code_returns_zero() {
10928        assert_eq!(format_test_density(0, 5), "0.0");
10929    }
10930
10931    #[test]
10932    fn format_test_density_zero_tests_returns_zero() {
10933        assert_eq!(format_test_density(100, 0), "0.0");
10934    }
10935
10936    #[test]
10937    fn format_test_density_both_zero() {
10938        assert_eq!(format_test_density(0, 0), "0.0");
10939    }
10940
10941    #[test]
10942    fn format_test_density_1_test_per_1000_lines() {
10943        assert_eq!(format_test_density(1000, 1), "1.0");
10944    }
10945
10946    #[test]
10947    fn format_test_density_10_tests_per_100_lines() {
10948        assert_eq!(format_test_density(100, 10), "100.0");
10949    }
10950
10951    #[test]
10952    fn format_test_density_fractional() {
10953        let s = format_test_density(1000, 3);
10954        assert!(!s.is_empty());
10955        assert!(s.contains('.'));
10956    }
10957
10958    // ── html_esc ─────────────────────────────────────────────────────────────────
10959
10960    #[test]
10961    fn html_esc_no_special_chars() {
10962        assert_eq!(html_esc("hello"), "hello");
10963    }
10964
10965    #[test]
10966    fn html_esc_ampersand() {
10967        assert_eq!(html_esc("a&b"), "a&amp;b");
10968    }
10969
10970    #[test]
10971    fn html_esc_less_than() {
10972        assert_eq!(html_esc("a<b"), "a&lt;b");
10973    }
10974
10975    #[test]
10976    fn html_esc_greater_than() {
10977        assert_eq!(html_esc("a>b"), "a&gt;b");
10978    }
10979
10980    #[test]
10981    fn html_esc_double_quote() {
10982        assert_eq!(html_esc(r#"a"b"#), "a&quot;b");
10983    }
10984
10985    #[test]
10986    fn html_esc_all_special_chars() {
10987        assert_eq!(
10988            html_esc(r#"<a href="x&y">z</a>"#),
10989            "&lt;a href=&quot;x&amp;y&quot;&gt;z&lt;/a&gt;"
10990        );
10991    }
10992
10993    #[test]
10994    fn html_esc_empty_string() {
10995        assert_eq!(html_esc(""), "");
10996    }
10997
10998    // ── png_data_uri ─────────────────────────────────────────────────────────────
10999
11000    #[test]
11001    fn png_data_uri_has_correct_prefix() {
11002        let uri = png_data_uri(b"\x89PNG\r\n\x1a\n");
11003        assert!(uri.starts_with("data:image/png;base64,"));
11004    }
11005
11006    #[test]
11007    fn png_data_uri_non_empty_for_non_empty_input() {
11008        let uri = png_data_uri(b"fake-png-bytes");
11009        assert!(uri.len() > "data:image/png;base64,".len());
11010    }
11011
11012    // ── load_custom_logo ─────────────────────────────────────────────────────────
11013
11014    #[test]
11015    fn load_custom_logo_nonexistent_file_returns_none() {
11016        let result = load_custom_logo(std::path::Path::new("/nonexistent/__sloc_logo__.png"));
11017        assert!(result.is_none());
11018    }
11019
11020    #[test]
11021    fn load_custom_logo_png_file_returns_data_uri() {
11022        let dir = tempdir().unwrap();
11023        let path = dir.path().join("logo.png");
11024        std::fs::write(&path, b"\x89PNG\r\n\x1a\nfake-png-data").unwrap();
11025        let result = load_custom_logo(&path);
11026        assert!(result.is_some());
11027        let uri = result.unwrap();
11028        assert!(uri.starts_with("data:image/png;base64,"));
11029    }
11030
11031    #[test]
11032    fn load_custom_logo_svg_file_uses_svg_mime() {
11033        let dir = tempdir().unwrap();
11034        let path = dir.path().join("logo.svg");
11035        std::fs::write(&path, b"<svg></svg>").unwrap();
11036        let result = load_custom_logo(&path);
11037        assert!(result.is_some());
11038        let uri = result.unwrap();
11039        assert!(uri.starts_with("data:image/svg+xml;base64,"));
11040    }
11041
11042    #[test]
11043    fn load_custom_logo_unknown_extension_treated_as_png() {
11044        let dir = tempdir().unwrap();
11045        let path = dir.path().join("logo.bin");
11046        std::fs::write(&path, b"some-bytes").unwrap();
11047        let result = load_custom_logo(&path);
11048        assert!(result.is_some());
11049        let uri = result.unwrap();
11050        assert!(uri.starts_with("data:image/png;base64,"));
11051    }
11052}
11053
11054#[cfg(test)]
11055mod coverage_boost_report_tests {
11056    use super::*;
11057    use std::path::Path;
11058
11059    // ── derive_commit_url / derive_branch_url ────────────────────────────────
11060
11061    #[test]
11062    fn derive_commit_url_github_uses_commit_segment() {
11063        let url = derive_commit_url(
11064            "https://github.com/org/repo.git",
11065            "abc1234abc1234abc1234abc1234abc1234abc1234",
11066        );
11067        assert_eq!(
11068            url.as_deref(),
11069            Some("https://github.com/org/repo/commit/abc1234abc1234abc1234abc1234abc1234abc1234")
11070        );
11071    }
11072
11073    #[test]
11074    fn derive_commit_url_bitbucket_uses_commits_plural() {
11075        let url = derive_commit_url("https://bitbucket.org/org/repo.git", "deadbeef");
11076        assert_eq!(
11077            url.as_deref(),
11078            Some("https://bitbucket.org/org/repo/commits/deadbeef")
11079        );
11080    }
11081
11082    #[test]
11083    fn derive_commit_url_gitlab_uses_dash_commit() {
11084        let url = derive_commit_url("https://gitlab.example.com/org/repo.git", "cafe0000");
11085        assert_eq!(
11086            url.as_deref(),
11087            Some("https://gitlab.example.com/org/repo/-/commit/cafe0000")
11088        );
11089    }
11090
11091    #[test]
11092    fn derive_branch_url_github_uses_tree() {
11093        let url = derive_branch_url("https://github.com/org/repo.git", "main");
11094        assert_eq!(
11095            url.as_deref(),
11096            Some("https://github.com/org/repo/tree/main")
11097        );
11098    }
11099
11100    #[test]
11101    fn derive_branch_url_bitbucket_uses_branch_segment() {
11102        let url = derive_branch_url("https://bitbucket.org/org/repo.git", "develop");
11103        assert_eq!(
11104            url.as_deref(),
11105            Some("https://bitbucket.org/org/repo/branch/develop")
11106        );
11107    }
11108
11109    #[test]
11110    fn derive_branch_url_gitlab_uses_dash_tree() {
11111        let url = derive_branch_url("https://gitlab.mycompany.com/org/repo.git", "feature");
11112        assert_eq!(
11113            url.as_deref(),
11114            Some("https://gitlab.mycompany.com/org/repo/-/tree/feature")
11115        );
11116    }
11117
11118    #[test]
11119    fn derive_commit_url_invalid_url_returns_none() {
11120        let url = derive_commit_url("not-a-url", "abc123");
11121        assert!(url.is_none());
11122    }
11123
11124    #[test]
11125    fn normalize_remote_url_variants() {
11126        assert_eq!(
11127            normalize_remote_url("git@github.com:org/repo.git").as_deref(),
11128            Some("https://github.com/org/repo")
11129        );
11130        assert_eq!(
11131            normalize_remote_url("https://gitlab.com/a/b.git").as_deref(),
11132            Some("https://gitlab.com/a/b")
11133        );
11134        assert_eq!(
11135            normalize_remote_url("http://host/x").as_deref(),
11136            Some("http://host/x")
11137        );
11138        assert_eq!(normalize_remote_url("not a url"), None);
11139    }
11140
11141    #[test]
11142    fn classify_and_bucket_helpers() {
11143        assert_eq!(
11144            classify_unsupported_path("README.md"),
11145            "Documentation / text"
11146        );
11147        assert_eq!(
11148            classify_unsupported_path("pkg.json"),
11149            "JSON manifests and config"
11150        );
11151        assert_eq!(
11152            classify_unsupported_path("Cargo.toml"),
11153            "Project metadata and packaging"
11154        );
11155        assert_eq!(classify_unsupported_path("page.html"), "HTML templates");
11156        assert_eq!(classify_unsupported_path("notes.txt"), "Plain text assets");
11157        assert_eq!(
11158            classify_unsupported_path("data.xyz"),
11159            "Other unsupported text formats"
11160        );
11161        assert_eq!(
11162            classify_unsupported_path("Makefile_noext"),
11163            "Extensionless or custom text files"
11164        );
11165        // bucket_description + bucket_recommendation for each known label.
11166        for label in [
11167            "Documentation / text",
11168            "JSON manifests and config",
11169            "Project metadata and packaging",
11170            "HTML templates",
11171            "Plain text assets",
11172            "Extensionless or custom text files",
11173            "Unknown bucket",
11174        ] {
11175            assert!(!bucket_description(label).is_empty());
11176            assert!(!bucket_recommendation(label).is_empty());
11177        }
11178    }
11179
11180    #[test]
11181    fn summarize_warnings_groups_categories() {
11182        let warnings = vec![
11183            "file 'a.md': unsupported or undetected language".to_string(),
11184            "file 'b.bin': binary file skipped by default".to_string(),
11185            "file 'c.min.js': minified file skipped by policy".to_string(),
11186            "file 'big.txt': file exceeded max_file_size_bytes".to_string(),
11187        ];
11188        let rows = summarize_warnings(&warnings);
11189        assert!(!rows.is_empty(), "warnings should summarize into buckets");
11190    }
11191
11192    #[test]
11193    fn pdf_number_and_string_formatters() {
11194        assert_eq!(pdf_fmt_full(0), "0");
11195        assert!(pdf_fmt_full(1_234_567).contains('1'));
11196        // pdf_safe_str must not panic on non-ASCII / control chars.
11197        let s = pdf_safe_str("héllo\tworld\u{1F600}");
11198        assert!(!s.is_empty());
11199    }
11200
11201    #[test]
11202    fn file_url_produces_uri() {
11203        let url = file_url(Path::new("/tmp/report.html"));
11204        assert!(url.starts_with("file://") || url.contains("report.html"));
11205    }
11206
11207    #[test]
11208    fn browser_discovery_is_callable_without_panicking() {
11209        // With no SLOC_BROWSER set, discovery walks the candidate list and
11210        // returns None (no browser in the test sandbox) — exercising the loop.
11211        std::env::remove_var("SLOC_BROWSER");
11212        std::env::remove_var("BROWSER");
11213        let _ = discover_browser();
11214        let _ = discover_browser_from_env();
11215        #[cfg(windows)]
11216        let _ = windows_browser_candidates();
11217        #[cfg(not(windows))]
11218        let _ = linux_browser_candidates();
11219        // With a bogus SLOC_BROWSER, normalize_browser_env_path is exercised.
11220        std::env::set_var("SLOC_BROWSER", "/no/such/browser/path");
11221        let _ = discover_browser_from_env();
11222        let p = normalize_browser_env_path("\"/quoted/path/chrome\"");
11223        assert!(p.to_string_lossy().contains("chrome"));
11224        std::env::remove_var("SLOC_BROWSER");
11225    }
11226
11227    #[test]
11228    fn which_in_path_returns_none_for_missing() {
11229        assert!(which_in_path("definitely-not-a-real-exe-xyz123").is_none());
11230    }
11231
11232    #[test]
11233    fn write_pdf_from_html_without_browser_errors_gracefully() {
11234        std::env::remove_var("SLOC_BROWSER");
11235        std::env::remove_var("BROWSER");
11236        let dir = std::env::temp_dir().join("sloc_report_pdf_test");
11237        let _ = std::fs::create_dir_all(&dir);
11238        let html = dir.join("in.html");
11239        std::fs::write(&html, "<html><body>hi</body></html>").unwrap();
11240        let out = dir.join("out.pdf");
11241        // No browser present → Err, but exercises discovery + early validation.
11242        let res = write_pdf_from_html(&html, &out);
11243        // Either a real browser exists (Ok) or not (Err); both are acceptable.
11244        let _ = res;
11245        let _ = std::fs::remove_dir_all(&dir);
11246    }
11247
11248    // ── helvetica_advance ────────────────────────────────────────────────────────
11249
11250    #[test]
11251    fn helvetica_advance_uppercase_a_differs_by_weight() {
11252        assert_eq!(helvetica_advance('A', true), 722);
11253        assert_eq!(helvetica_advance('A', false), 667);
11254    }
11255
11256    #[test]
11257    fn helvetica_advance_uppercase_w_same_both_weights() {
11258        assert_eq!(helvetica_advance('W', true), 944);
11259        assert_eq!(helvetica_advance('W', false), 944);
11260    }
11261
11262    #[test]
11263    fn helvetica_advance_lowercase_i_differs_by_weight() {
11264        assert_eq!(helvetica_advance('i', true), 278);
11265        assert_eq!(helvetica_advance('i', false), 222);
11266    }
11267
11268    #[test]
11269    fn helvetica_advance_digits_are_556_both_weights() {
11270        for d in '0'..='9' {
11271            assert_eq!(helvetica_advance(d, true), 556, "bold digit {d}");
11272            assert_eq!(helvetica_advance(d, false), 556, "regular digit {d}");
11273        }
11274    }
11275
11276    #[test]
11277    fn helvetica_advance_middle_dot_is_278() {
11278        assert_eq!(helvetica_advance('\u{00B7}', true), 278);
11279        assert_eq!(helvetica_advance('\u{00B7}', false), 278);
11280    }
11281
11282    #[test]
11283    fn helvetica_advance_unknown_char_returns_nonzero_fallback() {
11284        let bold_fb = helvetica_advance('\u{1F600}', true);
11285        let reg_fb = helvetica_advance('\u{1F600}', false);
11286        assert_eq!(bold_fb, 556);
11287        assert_eq!(reg_fb, 500);
11288    }
11289
11290    // ── helvetica_width_mm ───────────────────────────────────────────────────────
11291
11292    #[test]
11293    fn helvetica_width_mm_empty_is_zero() {
11294        assert!(helvetica_width_mm("", 10.0, false).abs() < f32::EPSILON);
11295        assert!(helvetica_width_mm("", 10.0, true).abs() < f32::EPSILON);
11296    }
11297
11298    #[test]
11299    fn helvetica_width_mm_scales_linearly_with_pt_size() {
11300        let w6 = helvetica_width_mm("Hello", 6.0, false);
11301        let w12 = helvetica_width_mm("Hello", 12.0, false);
11302        assert!(
11303            2.0_f32.mul_add(-w6, w12).abs() < 1e-4,
11304            "width must be proportional to pt size"
11305        );
11306    }
11307
11308    #[test]
11309    fn helvetica_width_mm_bold_a_wider_than_regular_a() {
11310        let bold = helvetica_width_mm("A", 10.0, true);
11311        let reg = helvetica_width_mm("A", 10.0, false);
11312        assert!(
11313            bold > reg,
11314            "bold 'A' (722) must be wider than regular 'A' (667)"
11315        );
11316    }
11317
11318    #[test]
11319    fn helvetica_width_mm_single_char_matches_manual_calculation() {
11320        // 'A' regular advance = 667; width_mm = 667 * 10.0 * (25.4/72.0) / 1000.0
11321        let expected = 667.0_f32 * 10.0 * (25.4 / 72.0) / 1000.0;
11322        let got = helvetica_width_mm("A", 10.0, false);
11323        assert!((got - expected).abs() < 1e-4);
11324    }
11325}