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        delta_files_total: delta_ctx.map_or(0, |d| {
613            d.delta_files_added
614                + d.delta_files_removed
615                + d.delta_files_modified
616                + d.delta_files_unchanged
617        }),
618        prev_code_lines: delta_ctx.map_or(0, |d| d.prev_code_lines),
619        prev_scan_count: delta_ctx.map_or(0, |d| d.prev_scan_count),
620        prev_scan_label: delta_ctx
621            .map(|d| d.prev_scan_label.clone())
622            .unwrap_or_default(),
623        prev_run_id: delta_ctx
624            .and_then(|d| d.prev_run_id.clone())
625            .unwrap_or_default(),
626        git_commit_url: run
627            .git_remote_url
628            .as_deref()
629            .zip(run.git_commit_long.as_deref())
630            .and_then(|(remote, sha)| derive_commit_url(remote, sha)),
631        git_branch_url: run
632            .git_remote_url
633            .as_deref()
634            .zip(run.git_branch.as_deref())
635            .and_then(|(remote, branch)| derive_branch_url(remote, branch)),
636        has_cocomo: run.cocomo.is_some(),
637        cocomo_effort_str: run
638            .cocomo
639            .as_ref()
640            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
641        cocomo_duration_str: run
642            .cocomo
643            .as_ref()
644            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
645        cocomo_staff_str: run
646            .cocomo
647            .as_ref()
648            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
649        cocomo_ksloc_str: run
650            .cocomo
651            .as_ref()
652            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
653        cocomo_mode_label: run
654            .cocomo
655            .as_ref()
656            .map_or_else(|| "Organic".to_string(), |c| {
657                match c.mode {
658                    CocomoMode::Organic => "Organic",
659                    CocomoMode::SemiDetached => "Semi-detached",
660                    CocomoMode::Embedded => "Embedded",
661                }
662                .to_string()
663            }),
664        cocomo_mode_tooltip: run
665            .cocomo
666            .as_ref()
667            .map_or(String::new(), |c| match c.mode {
668                CocomoMode::Organic => "Organic: A small team working on a well-understood \
669                    project in a familiar environment with minimal external constraints. \
670                    Suited for internal tools, utilities, and projects with stable requirements. \
671                    Effort = 2.4 \u{00D7} KSLOC^1.05.",
672                CocomoMode::SemiDetached => "Semi-detached: A mixed team with varying levels of \
673                    experience tackling a project with moderate novelty and some rigid constraints. \
674                    Typical for compilers, transaction systems, and batch processors. \
675                    Effort = 3.0 \u{00D7} KSLOC^1.12.",
676                CocomoMode::Embedded => "Embedded: Tight hardware, software, or operational \
677                    constraints requiring significant innovation and deep integration work. \
678                    Typical for real-time control systems and safety-critical software. \
679                    Effort = 3.6 \u{00D7} KSLOC^1.20.",
680            }.to_string()),
681        uloc: run.uloc,
682        dryness_pct_str: run
683            .dryness_pct
684            .map_or(String::new(), |d| format!("{d:.1}")),
685        duplicate_group_count: run.duplicate_groups.len(),
686        has_hotspots: !hotspot_rows.is_empty(),
687        hotspot_rows,
688    };
689
690    template.render().context("failed to render HTML report")
691}
692
693/// One row of the Git Hotspots table: a file ranked by `code_lines × recent commits`.
694struct HotspotRow {
695    path: String,
696    code_lines: u64,
697    commit_count: u32,
698    last_commit_date: String,
699    score: u64,
700}
701
702/// Build the git hotspots from per-file activity (only files that carry a
703/// `commit_count` from an `--activity-window` scan), ranked by `code_lines × commits`
704/// and capped at `limit` rows. The interactive HTML report requests a larger cap (so its
705/// client-side pagination has something to page through); the fixed-height PDF page keeps
706/// the original top-15.
707fn build_hotspot_rows(run: &AnalysisRun, limit: usize) -> Vec<HotspotRow> {
708    let mut rows: Vec<HotspotRow> = run
709        .per_file_records
710        .iter()
711        .filter_map(|r| {
712            let commits = r.commit_count?;
713            let code = r.effective_counts.code_lines;
714            Some(HotspotRow {
715                path: r.relative_path.clone(),
716                code_lines: code,
717                commit_count: commits,
718                // Show the calendar date only (strip the time component of the ISO date).
719                last_commit_date: r.last_commit_date.as_deref().map_or_else(String::new, |d| {
720                    d.split('T').next().unwrap_or(d).to_string()
721                }),
722                score: code.saturating_mul(u64::from(commits)),
723            })
724        })
725        .collect();
726    rows.sort_by(|a, b| {
727        b.score
728            .cmp(&a.score)
729            .then(b.commit_count.cmp(&a.commit_count))
730    });
731    rows.truncate(limit);
732    rows
733}
734
735/// Render an HTML report and write it to `output_path`.
736///
737/// # Errors
738///
739/// Returns an error if rendering fails or the file cannot be written.
740pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
741    let html = render_html_inner(run, false, None, None)?;
742    fs::write(output_path, html)
743        .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
744}
745
746/// Write an HTML report that embeds a relative link to a pre-generated PDF.
747///
748/// When `pdf_path` is in the same directory as `output_path`, the "View PDF"
749/// button in the report opens the PDF directly (e.g. from a Jenkins HTML
750/// Publisher artifact directory) instead of calling the oxide-sloc server route.
751/// Pass `pdf_path = None` to get the same behaviour as [`write_html`].
752///
753/// # Errors
754/// Returns an error if HTML rendering or file I/O fails.
755pub fn write_html_with_pdf_link(
756    run: &AnalysisRun,
757    output_path: &Path,
758    pdf_path: Option<&Path>,
759) -> Result<()> {
760    let pdf_relative = pdf_path.and_then(|pdf| {
761        let html_dir = output_path.parent()?;
762        let pdf_dir = pdf.parent()?;
763        if html_dir == pdf_dir {
764            pdf.file_name().map(|n| n.to_string_lossy().into_owned())
765        } else {
766            None
767        }
768    });
769    let html = render_html_inner(run, false, pdf_relative.as_deref(), None)?;
770    fs::write(output_path, html)
771        .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
772}
773
774/// Launch a headless Chromium browser.
775/// When `no_sandbox` is true (set via `SLOC_BROWSER_NOSANDBOX=1`) the browser
776/// runs without the namespace sandbox — required in containers that drop `SYS_ADMIN`.
777/// Otherwise the sandbox is always enabled with no automatic fallback, so failures
778/// surface as clear errors rather than silently removing a security boundary.
779fn launch_cdp_browser(
780    browser_path: std::path::PathBuf,
781    no_sandbox: bool,
782) -> Result<headless_chrome::Browser> {
783    use headless_chrome::{Browser, LaunchOptions};
784
785    if no_sandbox {
786        return Browser::new(LaunchOptions {
787            headless: true,
788            path: Some(browser_path),
789            window_size: Some((1122, 794)),
790            sandbox: false,
791            ..Default::default()
792        })
793        .context("failed to launch browser via CDP (no-sandbox)");
794    }
795
796    // Sandboxed only — no automatic fallback to --no-sandbox.
797    // If this fails in a container, set SLOC_BROWSER_NOSANDBOX=1 to opt in explicitly.
798    Browser::new(LaunchOptions {
799        headless: true,
800        path: Some(browser_path),
801        window_size: Some((1122, 794)),
802        sandbox: true,
803        ..Default::default()
804    })
805    .map_err(|e| {
806        anyhow::anyhow!(
807            "Browser launch failed with sandbox enabled: {e:#}\n\
808             If running in a container without user namespaces (e.g. Docker with cap_drop:ALL), \
809             set SLOC_BROWSER_NOSANDBOX=1 to opt into --no-sandbox mode."
810        )
811    })
812}
813
814/// If a JS chart error was recorded on the page, print it to stderr.
815fn report_chart_error_if_any(tab: &headless_chrome::Tab) {
816    let Ok(e) = tab.evaluate("window.oxSlocChartError||''", false) else {
817        return;
818    };
819    let Some(serde_json::Value::String(msg)) = e.value else {
820        return;
821    };
822    if !msg.is_empty() {
823        eprintln!("[oxide-sloc][pdf] chart JS error (charts may be missing): {msg}");
824    }
825}
826
827/// Poll `window.oxSlocChartsReady` for up to 15 s so Chart.js canvases finish rendering.
828fn wait_for_charts_ready(tab: &headless_chrome::Tab) {
829    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
830    let mut last_cdp_err: Option<String> = None;
831    loop {
832        match tab.evaluate("!!window.oxSlocChartsReady", false) {
833            Ok(r) => {
834                last_cdp_err = None;
835                if matches!(r.value, Some(serde_json::Value::Bool(true))) {
836                    report_chart_error_if_any(tab);
837                    return;
838                }
839            }
840            Err(e) => {
841                let msg = format!("{e:#}");
842                if last_cdp_err.as_deref() != Some(&msg) {
843                    eprintln!("[oxide-sloc][pdf] CDP evaluate error (will retry): {msg}");
844                    last_cdp_err = Some(msg);
845                }
846            }
847        }
848        if std::time::Instant::now() >= deadline {
849            report_chart_error_if_any(tab);
850            break;
851        }
852        std::thread::sleep(std::time::Duration::from_millis(250));
853    }
854}
855
856/// Read the `.report-id-banner` text from the loaded page, if present and non-empty.
857fn extract_banner_text(tab: &headless_chrome::Tab) -> Option<String> {
858    let result = tab
859        .evaluate(
860            "(function(){\
861               var el=document.querySelector('.report-id-banner');\
862               return el?el.textContent.trim():null;\
863             })()",
864            false,
865        )
866        .ok()?;
867    match result.value? {
868        serde_json::Value::String(s) if !s.is_empty() => Some(s),
869        _ => None,
870    }
871}
872
873/// Read the `innerHTML` of an optional `#<id>` element supplied by the document to act as
874/// a Chrome print header/footer template. Chrome renders these in the page margin on every
875/// printed page (including a short final page), which in-flow or `position:fixed` markup
876/// cannot do reliably. Returns `None` when the element is absent or empty.
877fn extract_pdf_template(tab: &headless_chrome::Tab, id: &str) -> Option<String> {
878    // `id` is always a hard-coded constant below — no script-injection surface.
879    let js = format!(
880        "(function(){{var el=document.getElementById('{id}');return el?el.innerHTML:null;}})()"
881    );
882    let result = tab.evaluate(&js, false).ok()?;
883    match result.value? {
884        serde_json::Value::String(s) if !s.trim().is_empty() => Some(s),
885        _ => None,
886    }
887}
888
889/// Use Chrome `DevTools` Protocol to render `html_path` as a PDF at `output_path`.
890///
891/// Launches a headless Chromium-based browser at A4-landscape viewport (1122 × 794 px),
892/// waits up to 15 s for all Chart.js canvases to signal readiness via
893/// `window.oxSlocChartsReady`, then captures the page using `Page.printToPDF` via CDP.
894fn write_pdf_via_cdp(html_path: &Path, output_path: &Path) -> Result<()> {
895    use headless_chrome::types::PrintToPdfOptions;
896
897    let browser_path = discover_browser().context(
898        "no supported Chromium-based browser found; \
899         set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
900    )?;
901    eprintln!("[oxide-sloc][pdf] browser = {}", browser_path.display());
902
903    let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
904    if no_sandbox {
905        eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
906    }
907
908    let browser = launch_cdp_browser(browser_path, no_sandbox)?;
909    let tab = browser.new_tab().context("failed to open browser tab")?;
910    // Raise the per-call CDP timeout well above the 20 s default. On a loaded host
911    // (e.g. the user's own Chromium already eating several GB) just launching a second
912    // headless instance and navigating a trivial page can take 15-30 s; the old default
913    // made navigation/print time out and fall back to wkhtmltopdf, failing the export.
914    tab.set_default_timeout(std::time::Duration::from_secs(90));
915
916    let html_for_url = PathBuf::from(
917        html_path
918            .to_string_lossy()
919            .trim_start_matches(r"\\?\")
920            .to_string(),
921    );
922    let url = file_url(&html_for_url);
923    eprintln!("[oxide-sloc][pdf] url = {url}");
924
925    tab.navigate_to(&url)
926        .context("failed to navigate browser to HTML file")?;
927    tab.wait_until_navigated()
928        .context("browser navigation did not complete")?;
929
930    wait_for_charts_ready(&tab);
931
932    // Resolve the per-page header/footer chrome (banner or per-document native templates)
933    // and the margins those require. Kept in a helper so this function stays flat.
934    let chrome = build_pdf_chrome(&tab);
935
936    let pdf_bytes = tab
937        .print_to_pdf(Some(PrintToPdfOptions {
938            landscape: Some(true),
939            print_background: Some(true),
940            scale: Some(0.97),
941            paper_width: Some(11.69), // A4 landscape width (inches)
942            paper_height: Some(8.27), // A4 landscape height (inches)
943            margin_top: Some(chrome.margin_top),
944            margin_bottom: Some(chrome.margin_bottom),
945            margin_left: Some(0.0),
946            margin_right: Some(0.0),
947            prefer_css_page_size: Some(false),
948            display_header_footer: if chrome.display_header_footer {
949                Some(true)
950            } else {
951                None
952            },
953            header_template: chrome.header_template,
954            footer_template: chrome.footer_template,
955            ..Default::default()
956        }))
957        .context("browser failed to generate PDF")?;
958
959    fs::write(output_path, &pdf_bytes)
960        .with_context(|| format!("failed to write PDF to {}", output_path.display()))?;
961
962    eprintln!("[oxide-sloc][pdf] wrote {} bytes", pdf_bytes.len());
963    Ok(())
964}
965
966/// Resolved per-page print chrome for the CDP PDF export.
967struct PdfChrome {
968    header_template: Option<String>,
969    footer_template: Option<String>,
970    display_header_footer: bool,
971    margin_top: f64,
972    margin_bottom: f64,
973}
974
975/// HTML template for a centred identification banner rendered in the PDF page margin.
976/// The template renders in the margin area, so `font-size` must be set explicitly.
977fn pdf_banner_template(text: &str) -> String {
978    let escaped = text
979        .replace('&', "&amp;")
980        .replace('<', "&lt;")
981        .replace('>', "&gt;")
982        .replace('"', "&quot;");
983    format!(
984        r#"<div style="font-size:10px;width:100%;text-align:center;\
985color:#fff;background:#b35428;padding:5px 0;\
986font-family:sans-serif;font-weight:700;letter-spacing:0.05em;\
987-webkit-print-color-adjust:exact;print-color-adjust:exact;">{escaped}</div>"#
988    )
989}
990
991/// Reserve top/bottom margins only on the side(s) that actually carry chrome. A banner keeps
992/// its historical top/bottom reserve.
993const fn pdf_margins(
994    has_banner: bool,
995    has_native_header: bool,
996    has_native_footer: bool,
997) -> (f64, f64) {
998    let top = if has_banner {
999        0.35
1000    } else if has_native_header {
1001        0.55
1002    } else {
1003        0.0
1004    };
1005    let bottom = if has_banner {
1006        0.25
1007    } else if has_native_footer {
1008        0.42
1009    } else {
1010        0.0
1011    };
1012    (top, bottom)
1013}
1014
1015/// Choose the Chrome header/footer templates. A banner wins; otherwise per-document native
1016/// chrome is used. Chrome prints both a header and footer template whenever they are supplied,
1017/// so an empty `<span>` suppresses default chrome on the unused side.
1018fn pdf_header_footer_templates(
1019    banner_text: Option<&str>,
1020    native_header: Option<String>,
1021    native_footer: Option<String>,
1022) -> (Option<String>, Option<String>) {
1023    if let Some(t) = banner_text {
1024        let tmpl = pdf_banner_template(t);
1025        return (Some(tmpl.clone()), Some(tmpl));
1026    }
1027    if native_header.is_some() || native_footer.is_some() {
1028        let empty = || "<span></span>".to_string();
1029        return (
1030            Some(native_header.unwrap_or_else(empty)),
1031            Some(native_footer.unwrap_or_else(empty)),
1032        );
1033    }
1034    (None, None)
1035}
1036
1037/// Determine the header/footer templates and reserved margins for the PDF.
1038///
1039/// Priority: a report identification banner (set in step 3 of the scan configuration as
1040/// `report_header_footer`) wins; otherwise per-document hidden `#pdf-native-header` /
1041/// `#pdf-native-footer` elements are used (the Scan Delta report uses these for its per-page
1042/// footer bar).
1043fn build_pdf_chrome(tab: &headless_chrome::Tab) -> PdfChrome {
1044    let banner_text = extract_banner_text(tab);
1045    if let Some(ref t) = banner_text {
1046        eprintln!("[oxide-sloc][pdf] report banner detected: {t}");
1047    }
1048    let has_banner = banner_text.is_some();
1049
1050    let native_header = if has_banner {
1051        None
1052    } else {
1053        extract_pdf_template(tab, "pdf-native-header")
1054    };
1055    let native_footer = if has_banner {
1056        None
1057    } else {
1058        extract_pdf_template(tab, "pdf-native-footer")
1059    };
1060    let has_native_header = native_header.is_some();
1061    let has_native_footer = native_footer.is_some();
1062
1063    let (header_template, footer_template) =
1064        pdf_header_footer_templates(banner_text.as_deref(), native_header, native_footer);
1065    let (margin_top, margin_bottom) = pdf_margins(has_banner, has_native_header, has_native_footer);
1066
1067    PdfChrome {
1068        header_template,
1069        footer_template,
1070        display_header_footer: has_banner || has_native_header || has_native_footer,
1071        margin_top,
1072        margin_bottom,
1073    }
1074}
1075
1076/// Locate the `wkhtmltopdf` binary on Linux and Windows.
1077///
1078/// Search order:
1079/// 1. `wkhtmltopdf` / `wkhtmltopdf.exe` anywhere in `$PATH` (covers Linux packages and
1080///    Windows installs that add the bin dir to the system PATH).
1081/// 2. Windows-only: standard MSI install locations under `Program Files` and
1082///    `Program Files (x86)`.
1083/// 3. Linux-only: absolute paths that package managers commonly use but that may not be
1084///    on the service account's `$PATH`.
1085fn discover_wkhtmltopdf() -> Option<PathBuf> {
1086    if let Some(p) = which_in_path("wkhtmltopdf") {
1087        return Some(p);
1088    }
1089
1090    #[cfg(windows)]
1091    {
1092        for var in ["ProgramFiles", "ProgramFiles(x86)"] {
1093            if let Ok(base) = std::env::var(var) {
1094                let candidate = PathBuf::from(base)
1095                    .join("wkhtmltopdf")
1096                    .join("bin")
1097                    .join("wkhtmltopdf.exe");
1098                if candidate.is_file() {
1099                    return Some(candidate);
1100                }
1101            }
1102        }
1103    }
1104
1105    #[cfg(not(windows))]
1106    for p in [
1107        "/usr/bin/wkhtmltopdf",
1108        "/usr/local/bin/wkhtmltopdf",
1109        "/opt/wkhtmltopdf/bin/wkhtmltopdf",
1110        "/snap/bin/wkhtmltopdf",
1111    ] {
1112        let candidate = PathBuf::from(p);
1113        if candidate.is_file() {
1114            return Some(candidate);
1115        }
1116    }
1117
1118    None
1119}
1120
1121/// Generate a PDF using `wkhtmltopdf` when no Chromium-based browser is available.
1122///
1123/// Works on both Linux and Windows:
1124/// - Linux: install via `dnf install wkhtmltopdf` (RHEL/CentOS) or `apt install wkhtmltopdf`
1125/// - Windows: install the MSI from <https://wkhtmltopdf.org/downloads.html>; the installer
1126///   adds `wkhtmltopdf.exe` to `Program Files\wkhtmltopdf\bin\` which is checked automatically.
1127fn write_pdf_via_wkhtmltopdf(html_path: &Path, pdf_path: &Path) -> Result<()> {
1128    eprintln!("[oxide-sloc][pdf] trying wkhtmltopdf fallback");
1129
1130    let exe = discover_wkhtmltopdf().context(
1131        "wkhtmltopdf not found. \
1132         Linux: install via 'dnf install wkhtmltopdf' or 'apt install wkhtmltopdf'. \
1133         Windows: install the MSI from https://wkhtmltopdf.org/downloads.html. \
1134         Alternatively, set SLOC_BROWSER to a Chromium-based browser executable.",
1135    )?;
1136    eprintln!("[oxide-sloc][pdf] wkhtmltopdf = {}", exe.display());
1137
1138    // Strip the extended-length prefix on Windows (\\?\) so wkhtmltopdf can parse the path.
1139    let html_normalized = PathBuf::from(
1140        html_path
1141            .to_string_lossy()
1142            .trim_start_matches(r"\\?\")
1143            .to_string(),
1144    );
1145    // file_url() handles Windows drive letters (C:\ → /C:/) and encodes special chars.
1146    let html_url = file_url(&html_normalized);
1147    eprintln!("[oxide-sloc][pdf] wkhtmltopdf url = {html_url}");
1148
1149    let pdf_str = pdf_path
1150        .to_str()
1151        .context("PDF output path contains non-UTF-8 characters")?;
1152
1153    let output = std::process::Command::new(&exe)
1154        .args([
1155            "--enable-javascript",
1156            "--javascript-delay",
1157            "2000",
1158            "--quiet",
1159            "--orientation",
1160            "Landscape",
1161            "--page-size",
1162            "A4",
1163            "--margin-top",
1164            "9",
1165            "--margin-bottom",
1166            "9",
1167            "--margin-left",
1168            "13",
1169            "--margin-right",
1170            "13",
1171            "--print-media-type",
1172            &html_url,
1173            pdf_str,
1174        ])
1175        .output()
1176        .with_context(|| format!("failed to launch wkhtmltopdf at {}", exe.display()))?;
1177
1178    if !output.status.success() {
1179        let stderr = String::from_utf8_lossy(&output.stderr);
1180        anyhow::bail!("wkhtmltopdf exited with {}: {stderr}", output.status);
1181    }
1182
1183    if !pdf_path.exists() {
1184        anyhow::bail!(
1185            "wkhtmltopdf exited successfully but {} was not created",
1186            pdf_path.display()
1187        );
1188    }
1189
1190    eprintln!("[oxide-sloc][pdf] wkhtmltopdf wrote {}", pdf_path.display());
1191    Ok(())
1192}
1193
1194struct PdfCtx<'a> {
1195    layer: &'a crate::pdf_compat::PdfLayerReference,
1196    font_reg: crate::pdf_compat::IndirectFontRef,
1197    font_bold: crate::pdf_compat::IndirectFontRef,
1198    w: f32,
1199    margin: f32,
1200    row_h: f32,
1201    tbl_hdr_h: f32,
1202}
1203
1204/// Fixed page geometry (landscape A4 in mm) threaded through the PDF page builders.
1205/// Bundled into one struct so the page helpers stay under the argument-count lint.
1206#[derive(Clone, Copy)]
1207struct PdfPageDims {
1208    w: f32,
1209    h: f32,
1210    margin: f32,
1211    footer_h: f32,
1212    row_h: f32,
1213    tbl_hdr_h: f32,
1214}
1215
1216#[allow(
1217    clippy::cast_precision_loss,
1218    clippy::suboptimal_flops,
1219    clippy::too_many_lines
1220)]
1221fn runtime_mode_display(mode: &str) -> &str {
1222    match mode {
1223        "serve" => "Web UI",
1224        "analyze" => "CLI",
1225        "git-scan" => "Git Scan",
1226        "git-compare" => "Git Compare",
1227        "watch" => "Watch",
1228        other => other,
1229    }
1230}
1231
1232fn pdf_render_page1_header(
1233    ctx: &PdfCtx<'_>,
1234    run: &AnalysisRun,
1235    ts: &str,
1236    title: &str,
1237    h: f32,
1238    hdr_h: f32,
1239    banner: Option<&str>,
1240) -> f32 {
1241    use crate::pdf_compat::{Color, Mm, Rgb};
1242    let hdr_y = h - hdr_h;
1243    pdf_fill_rect(
1244        ctx.layer,
1245        0.0,
1246        hdr_y,
1247        ctx.w,
1248        hdr_h,
1249        Rgb::new(0.098, 0.11, 0.15, None),
1250    );
1251    ctx.layer
1252        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1253    ctx.layer.use_text(
1254        "oxide-sloc",
1255        13.0,
1256        Mm(ctx.margin),
1257        Mm(hdr_y + 4.5),
1258        ctx.font_bold,
1259    );
1260    ctx.layer
1261        .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
1262    ctx.layer.use_text(
1263        "Code Metrics Report",
1264        9.5,
1265        Mm(54.0),
1266        Mm(hdr_y + 5.0),
1267        ctx.font_reg,
1268    );
1269    ctx.layer.use_text(
1270        pdf_safe_str(ts),
1271        8.0,
1272        Mm(ctx.w - 70.0),
1273        Mm(hdr_y + 5.0),
1274        ctx.font_reg,
1275    );
1276    // Report identification banner — white bold, centered between the two header items.
1277    if let Some(text) = banner {
1278        let safe = pdf_trunc(&pdf_safe_str(text), 40);
1279        // Approximate half-width at 9pt bold Helvetica (~0.97 mm per char) for centering.
1280        // `safe` is truncated to 40 chars, so the count is tiny; the f32 cast is exact here
1281        // and only ever feeds a millimetre layout coordinate.
1282        #[allow(
1283            clippy::cast_precision_loss,
1284            reason = "small bounded char count; sub-mm layout offset"
1285        )]
1286        let text_x = (safe.len() as f32).mul_add(-0.97, ctx.w / 2.0).max(95.0);
1287        ctx.layer
1288            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1289        ctx.layer
1290            .use_text(safe, 9.0, Mm(text_x), Mm(hdr_y + 4.5), ctx.font_bold);
1291    }
1292    let title_text_y = hdr_y - 5.5;
1293    ctx.layer
1294        .set_fill_color(Color::Rgb(Rgb::new(0.098, 0.11, 0.15, None)));
1295    ctx.layer.use_text(
1296        pdf_trunc(&pdf_safe_str(title), 55),
1297        9.5,
1298        Mm(ctx.margin),
1299        Mm(title_text_y),
1300        ctx.font_bold,
1301    );
1302    let roots_text_y = title_text_y - 5.0;
1303    // ── Left side: project path ──────────────────────────────────────────────
1304    let roots: String = run
1305        .input_roots
1306        .iter()
1307        .map(|r| pdf_safe_str(r))
1308        .collect::<Vec<_>>()
1309        .join("  ");
1310    ctx.layer
1311        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
1312    ctx.layer.use_text(
1313        pdf_trunc(&roots, 85),
1314        6.5,
1315        Mm(ctx.margin),
1316        Mm(roots_text_y),
1317        ctx.font_reg,
1318    );
1319    // ── Right side: git + environment metadata in a grouped box ─────────────
1320    pdf_render_page1_gitbox(ctx, run, title_text_y, roots_text_y);
1321    roots_text_y
1322}
1323
1324/// Render the right-side git + environment metadata box of the page-1 header.
1325fn pdf_render_page1_gitbox(
1326    ctx: &PdfCtx<'_>,
1327    run: &AnalysisRun,
1328    title_text_y: f32,
1329    roots_text_y: f32,
1330) {
1331    use crate::pdf_compat::{Color, Mm, Rgb};
1332    let mut git_parts: Vec<String> = vec![];
1333    if let Some(ref b) = run.git_branch {
1334        git_parts.push(format!("Branch: {}", pdf_safe_str(b)));
1335    }
1336    if let Some(ref c) = run.git_commit_short {
1337        git_parts.push(format!("Commit: {}", pdf_safe_str(c)));
1338    }
1339    if let Some(ref t) = run.git_nearest_tag {
1340        git_parts.push(format!("Tag: {}", pdf_safe_str(t)));
1341    }
1342    let git_str = pdf_trunc(&git_parts.join("  \u{00B7}  "), 70);
1343
1344    let initiator = run
1345        .environment
1346        .ci_name
1347        .as_deref()
1348        .unwrap_or(run.environment.initiator_username.as_str());
1349    let mode_label = runtime_mode_display(&run.environment.runtime_mode);
1350    let env_str = format!(
1351        "OS: {} / {}  \u{00B7}  User: {}  \u{00B7}  Host: {}  \u{00B7}  Source: {}",
1352        pdf_safe_str(&run.environment.operating_system),
1353        pdf_safe_str(&run.environment.architecture),
1354        pdf_safe_str(initiator),
1355        pdf_safe_str(&run.environment.initiator_hostname),
1356        mode_label,
1357    );
1358    let env_trunc = pdf_trunc(&env_str, 100);
1359
1360    // Shared right anchor — text right-edges land here; box extends pad_h mm beyond.
1361    let right_anchor = ctx.w - ctx.margin - 6.0;
1362    // Accurate widths using exact PDF Helvetica advance tables (PDF spec Appendix D).
1363    // Character-count estimates are unreliable for proportional fonts — actual per-glyph widths vary 4×.
1364    let git_w = helvetica_width_mm(&git_str, 7.5, true);
1365    let env_w = helvetica_width_mm(&env_trunc, 6.5, false);
1366    let max_w = git_w.max(env_w);
1367
1368    // Background pill with 0.6 mm simulated border for visual grouping.
1369    let pad_h: f32 = 3.5;
1370    let pad_v: f32 = 1.8;
1371    let box_left = (right_anchor - max_w - pad_h).max(ctx.w / 2.0 - pad_h);
1372    let box_right = right_anchor + pad_h;
1373    let box_width = box_right - box_left;
1374    let box_bot = roots_text_y - pad_v;
1375    let box_top = title_text_y + pad_v + 1.5;
1376    let box_height = box_top - box_bot;
1377    pdf_fill_rect(
1378        ctx.layer,
1379        box_left - 0.6,
1380        box_bot - 0.6,
1381        box_width + 1.2,
1382        box_height + 1.2,
1383        Rgb::new(0.80, 0.75, 0.68, None),
1384    );
1385    pdf_fill_rect(
1386        ctx.layer,
1387        box_left,
1388        box_bot,
1389        box_width,
1390        box_height,
1391        Rgb::new(0.97, 0.95, 0.92, None),
1392    );
1393
1394    // Git line — right-aligned to shared anchor, dark-green bold
1395    if !git_str.is_empty() {
1396        let git_x = (right_anchor - git_w).max(box_left + 2.0);
1397        ctx.layer
1398            .set_fill_color(Color::Rgb(Rgb::new(0.25, 0.42, 0.25, None)));
1399        ctx.layer.use_text(
1400            git_str.as_str(),
1401            7.5,
1402            Mm(git_x),
1403            Mm(title_text_y),
1404            ctx.font_bold,
1405        );
1406    }
1407    // Env line — same right anchor so "Source: …" right-edge aligns with "Tag: …" above
1408    let env_x = (right_anchor - env_w).max(box_left + 2.0);
1409    ctx.layer
1410        .set_fill_color(Color::Rgb(Rgb::new(0.38, 0.38, 0.38, None)));
1411    ctx.layer.use_text(
1412        env_trunc.as_str(),
1413        6.5,
1414        Mm(env_x),
1415        Mm(roots_text_y),
1416        ctx.font_reg,
1417    );
1418}
1419
1420#[allow(clippy::cast_precision_loss)]
1421fn pdf_render_summary_chips(ctx: &PdfCtx<'_>, run: &AnalysisRun, roots_text_y: f32) -> f32 {
1422    use crate::pdf_compat::{Color, Mm, Rgb};
1423    let tot = &run.summary_totals;
1424    let chip_gap: f32 = 5.0;
1425    let chip_w = 3.0f32.mul_add(-chip_gap, 2.0f32.mul_add(-ctx.margin, ctx.w)) / 4.0;
1426    let chip_h: f32 = 17.0;
1427    let row1_bot = roots_text_y - 4.0 - chip_h;
1428    let row1: [(&str, u64); 4] = [
1429        ("Code Lines", tot.code_lines),
1430        ("Comment Lines", tot.comment_lines),
1431        ("Blank Lines", tot.blank_lines),
1432        ("Physical Lines", tot.total_physical_lines),
1433    ];
1434    for (i, (label, value)) in row1.iter().enumerate() {
1435        let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1436        pdf_fill_rect(
1437            ctx.layer,
1438            cx,
1439            row1_bot,
1440            chip_w,
1441            chip_h,
1442            Rgb::new(0.945, 0.925, 0.90, None),
1443        );
1444        ctx.layer
1445            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1446        // Show the full comma-separated number (no K/M rounding) on the PDF stat cards.
1447        ctx.layer.use_text(
1448            pdf_fmt_full(*value),
1449            13.0,
1450            Mm(cx + 4.0),
1451            Mm(row1_bot + 9.0),
1452            ctx.font_bold,
1453        );
1454        ctx.layer
1455            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
1456        ctx.layer.use_text(
1457            pdf_safe_str(label),
1458            6.5,
1459            Mm(cx + 4.0),
1460            Mm(row1_bot + 3.0),
1461            ctx.font_reg,
1462        );
1463    }
1464    let row2_bot = row1_bot - 3.0 - chip_h;
1465    let row2_4th = if tot.test_count > 0 {
1466        ("Test Methods", tot.test_count)
1467    } else if tot.classes > 0 {
1468        ("Classes", tot.classes)
1469    } else {
1470        ("Mixed Lines", tot.mixed_lines_separate)
1471    };
1472    let row2: [(&str, u64); 4] = [
1473        ("Files Analyzed", tot.files_analyzed),
1474        ("Files Skipped", tot.files_skipped),
1475        ("Functions", tot.functions),
1476        row2_4th,
1477    ];
1478    for (i, (label, value)) in row2.iter().enumerate() {
1479        let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1480        pdf_fill_rect(
1481            ctx.layer,
1482            cx,
1483            row2_bot,
1484            chip_w,
1485            chip_h,
1486            Rgb::new(0.91, 0.92, 0.96, None),
1487        );
1488        ctx.layer
1489            .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.25, 0.55, None)));
1490        ctx.layer.use_text(
1491            pdf_fmt_full(*value),
1492            13.0,
1493            Mm(cx + 4.0),
1494            Mm(row2_bot + 9.0),
1495            ctx.font_bold,
1496        );
1497        ctx.layer
1498            .set_fill_color(Color::Rgb(Rgb::new(0.35, 0.35, 0.45, None)));
1499        ctx.layer.use_text(
1500            pdf_safe_str(label),
1501            6.5,
1502            Mm(cx + 4.0),
1503            Mm(row2_bot + 3.0),
1504            ctx.font_reg,
1505        );
1506    }
1507    row2_bot
1508}
1509
1510#[allow(clippy::cast_precision_loss)]
1511fn pdf_info_parts_stats(tot: &SummaryTotals) -> Vec<String> {
1512    let total = tot.total_physical_lines.max(1) as f64;
1513    let code_pct = tot.code_lines as f64 / total * 100.0;
1514    let cmt_pct = tot.comment_lines as f64 / total * 100.0;
1515    let blank_pct = tot.blank_lines as f64 / total * 100.0;
1516    let mixed_pct = tot.mixed_lines_separate as f64 / total * 100.0;
1517    let mut parts = vec![
1518        format!(
1519            "Code: {code_pct:.1}% ({} lines)",
1520            pdf_fmt_full(tot.code_lines)
1521        ),
1522        format!(
1523            "Comments: {cmt_pct:.1}% ({} lines)",
1524            pdf_fmt_full(tot.comment_lines)
1525        ),
1526        format!(
1527            "Blank: {blank_pct:.1}% ({} lines)",
1528            pdf_fmt_full(tot.blank_lines)
1529        ),
1530    ];
1531    if tot.functions > 0 {
1532        parts.push(format!("Functions: {}", pdf_fmt_full(tot.functions)));
1533    }
1534    if tot.mixed_lines_separate > 0 {
1535        parts.push(format!(
1536            "Mixed: {mixed_pct:.1}% ({} lines)",
1537            pdf_fmt_full(tot.mixed_lines_separate)
1538        ));
1539    }
1540    if tot.imports > 0 {
1541        parts.push(format!("Imports: {}", pdf_fmt_full(tot.imports)));
1542    }
1543    if tot.variables > 0 {
1544        parts.push(format!("Variables: {}", pdf_fmt_full(tot.variables)));
1545    }
1546    if tot.classes > 0 {
1547        parts.push(format!("Classes: {}", pdf_fmt_full(tot.classes)));
1548    }
1549    parts
1550}
1551
1552fn pdf_info_parts_git(run: &AnalysisRun) -> Vec<String> {
1553    let mut parts: Vec<String> = Vec::new();
1554    if let Some(ref b) = run.git_branch {
1555        parts.push(format!("Branch: {}", pdf_safe_str(b)));
1556    }
1557    if let Some(ref c) = run.git_commit_short {
1558        parts.push(format!("Commit: {}", pdf_safe_str(c)));
1559    }
1560    if let Some(ref t) = run.git_nearest_tag {
1561        parts.push(format!("Tag: {}", pdf_safe_str(t)));
1562    }
1563    if let Some(ref a) = run.git_commit_author {
1564        parts.push(format!("Author: {}", pdf_safe_str(a)));
1565    }
1566    if let Some(ref d) = run.git_commit_date {
1567        parts.push(format!("Commit Date: {}", fmt_commit_date_pt(d)));
1568    }
1569    parts
1570}
1571
1572#[allow(clippy::cast_precision_loss)]
1573fn pdf_info_parts_tests(tot: &SummaryTotals) -> Vec<String> {
1574    let mut tc: Vec<String> = Vec::new();
1575    if tot.test_count > 0 {
1576        tc.push(format!("Tests: {}", pdf_fmt_full(tot.test_count)));
1577    }
1578    if tot.test_assertion_count > 0 {
1579        tc.push(format!(
1580            "Assertions: {}",
1581            pdf_fmt_full(tot.test_assertion_count)
1582        ));
1583    }
1584    if tot.test_suite_count > 0 {
1585        tc.push(format!("Suites: {}", pdf_fmt_full(tot.test_suite_count)));
1586    }
1587    if tot.coverage_lines_found > 0 {
1588        tc.push(format!(
1589            "Line Cov: {:.1}% ({}/{})",
1590            tot.coverage_lines_hit as f64 / tot.coverage_lines_found as f64 * 100.0,
1591            pdf_fmt_full(tot.coverage_lines_hit),
1592            pdf_fmt_full(tot.coverage_lines_found)
1593        ));
1594    }
1595    if tot.coverage_functions_found > 0 {
1596        tc.push(format!(
1597            "Func Cov: {:.1}%",
1598            tot.coverage_functions_hit as f64 / tot.coverage_functions_found as f64 * 100.0
1599        ));
1600    }
1601    if tot.coverage_branches_found > 0 {
1602        tc.push(format!(
1603            "Branch Cov: {:.1}%",
1604            tot.coverage_branches_hit as f64 / tot.coverage_branches_found as f64 * 100.0
1605        ));
1606    }
1607    tc
1608}
1609
1610/// Emit one or more info lines, packing `parts` and wrapping onto a fresh line whenever the
1611/// next part would overflow the usable page width. Each part is drawn as a **bold** key
1612/// ("Code:") followed by its regular-weight value, with a muted separator between parts, so the
1613/// dense metric strip reads cleanly. Measured with the exact Helvetica advance table so the
1614/// whole line is always shown — never truncated. Returns the y position below the last line.
1615// x/y are page coordinates and r/g/b are colour channels — the conventional
1616// single-letter names in graphics code; renaming them would hurt, not help.
1617#[allow(clippy::many_single_char_names)]
1618fn pdf_info_emit_line(
1619    ctx: &PdfCtx<'_>,
1620    mut y: f32,
1621    r: f32,
1622    g: f32,
1623    b: f32,
1624    parts: &[String],
1625) -> f32 {
1626    use crate::pdf_compat::{Color, Mm, Rgb};
1627    const SIZE: f32 = 7.0;
1628    const LINE_GAP: f32 = 6.2;
1629    const SEP: &str = "   |   ";
1630    if parts.is_empty() {
1631        return y;
1632    }
1633    let usable = ctx.margin.mul_add(-2.0, ctx.w);
1634    let sep_w = helvetica_width_mm(SEP, SIZE, false);
1635    let group = Color::Rgb(Rgb::new(r, g, b, None));
1636    let sep_color = Color::Rgb(Rgb::new(0.66, 0.63, 0.60, None));
1637    let mut x = ctx.margin;
1638    let mut first_on_line = true;
1639    for part in parts {
1640        // Split "Label: value" into a bold key (kept with its colon) and a regular value.
1641        let (key, val) = match part.split_once(": ") {
1642            Some((k, v)) => (format!("{k}: "), v.to_string()),
1643            None => (part.clone(), String::new()),
1644        };
1645        let key_w = helvetica_width_mm(&key, SIZE, true);
1646        let val_w = helvetica_width_mm(&val, SIZE, false);
1647        let advance = if first_on_line {
1648            key_w + val_w
1649        } else {
1650            sep_w + key_w + val_w
1651        };
1652        if !first_on_line && x + advance > ctx.margin + usable {
1653            y -= LINE_GAP;
1654            x = ctx.margin;
1655            first_on_line = true;
1656        }
1657        if !first_on_line {
1658            ctx.layer.set_fill_color(sep_color.clone());
1659            ctx.layer.use_text(SEP, SIZE, Mm(x), Mm(y), ctx.font_reg);
1660            x += sep_w;
1661        }
1662        ctx.layer.set_fill_color(group.clone());
1663        ctx.layer
1664            .use_text(key.as_str(), SIZE, Mm(x), Mm(y), ctx.font_bold);
1665        x += key_w;
1666        if !val.is_empty() {
1667            ctx.layer
1668                .use_text(val.as_str(), SIZE, Mm(x), Mm(y), ctx.font_reg);
1669            x += val_w;
1670        }
1671        first_on_line = false;
1672    }
1673    y - LINE_GAP
1674}
1675
1676fn pdf_render_info_lines(ctx: &PdfCtx<'_>, run: &AnalysisRun, row2_bot: f32) -> f32 {
1677    let tot = &run.summary_totals;
1678    let mut y = row2_bot - 6.5;
1679    let stats = pdf_info_parts_stats(tot);
1680    y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.15, &stats);
1681    // A little extra breathing room between the stats / git / tests groups.
1682    let git = pdf_info_parts_git(run);
1683    if !git.is_empty() {
1684        y -= 1.6;
1685        y = pdf_info_emit_line(ctx, y, 0.10, 0.35, 0.15, &git);
1686    }
1687    let tests = pdf_info_parts_tests(tot);
1688    if !tests.is_empty() {
1689        y -= 1.6;
1690        y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.50, &tests);
1691    }
1692    y
1693}
1694
1695#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1696fn pdf_table_render_section(
1697    ctx: &PdfCtx<'_>,
1698    x: f32,
1699    top: f32,
1700    w: f32,
1701    lbl_frac: f32,
1702    title: &str,
1703    rows: &[(&str, String)],
1704) {
1705    use crate::pdf_compat::{Color, Mm, Rgb};
1706    pdf_fill_rect(
1707        ctx.layer,
1708        x,
1709        top - ctx.tbl_hdr_h,
1710        w,
1711        ctx.tbl_hdr_h,
1712        Rgb::new(0.098, 0.11, 0.15, None),
1713    );
1714    ctx.layer
1715        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1716    ctx.layer.use_text(
1717        title,
1718        7.0,
1719        Mm(x + 2.0),
1720        Mm(top - ctx.tbl_hdr_h + 1.5),
1721        ctx.font_bold,
1722    );
1723    let y = top - ctx.tbl_hdr_h;
1724    for (ri, (lbl, val)) in rows.iter().enumerate() {
1725        let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, y);
1726        let bg = if ri % 2 == 0 {
1727            Rgb::new(0.975, 0.965, 0.95, None)
1728        } else {
1729            Rgb::new(1.0, 1.0, 1.0, None)
1730        };
1731        pdf_fill_rect(ctx.layer, x, ry, w, ctx.row_h, bg);
1732        ctx.layer
1733            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1734        ctx.layer
1735            .use_text(*lbl, 6.5, Mm(x + 2.0), Mm(ry + 1.5), ctx.font_reg);
1736        let is_dash = val == "--";
1737        let val_rgb = if is_dash {
1738            Rgb::new(0.55, 0.55, 0.55, None)
1739        } else {
1740            Rgb::new(0.12, 0.12, 0.12, None)
1741        };
1742        let val_font = if is_dash { ctx.font_reg } else { ctx.font_bold };
1743        ctx.layer.set_fill_color(Color::Rgb(val_rgb));
1744        ctx.layer.use_text(
1745            val.as_str(),
1746            6.5,
1747            Mm(x + w * lbl_frac + 2.0),
1748            Mm(ry + 1.5),
1749            val_font,
1750        );
1751    }
1752}
1753
1754#[allow(
1755    clippy::cast_precision_loss,
1756    clippy::suboptimal_flops,
1757    clippy::similar_names
1758)]
1759fn pdf_render_metric_tables(ctx: &PdfCtx<'_>, run: &AnalysisRun, tbl_top: f32) {
1760    let tot = &run.summary_totals;
1761    let half_w = (2.0f32.mul_add(-ctx.margin, ctx.w) - 4.0) / 2.0;
1762    let left_x = ctx.margin;
1763    let right_x = ctx.margin + half_w + 4.0;
1764    let lbl_frac: f32 = 0.68;
1765
1766    let files_rows: [(&str, String); 4] = [
1767        ("Files analyzed", pdf_fmt_full(tot.files_analyzed)),
1768        ("Files skipped", pdf_fmt_full(tot.files_skipped)),
1769        ("Files modified", "--".to_string()),
1770        ("Files unchanged", "--".to_string()),
1771    ];
1772    pdf_table_render_section(ctx, left_x, tbl_top, half_w, lbl_frac, "FILES", &files_rows);
1773
1774    let lc_rows: [(&str, String); 5] = [
1775        ("Physical lines", pdf_fmt_full(tot.total_physical_lines)),
1776        ("Code lines", pdf_fmt_full(tot.code_lines)),
1777        ("Comment lines", pdf_fmt_full(tot.comment_lines)),
1778        ("Blank lines", pdf_fmt_full(tot.blank_lines)),
1779        ("Mixed (separate)", pdf_fmt_full(tot.mixed_lines_separate)),
1780    ];
1781    let lc_top = tbl_top - ctx.tbl_hdr_h - (files_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1782    pdf_table_render_section(
1783        ctx,
1784        left_x,
1785        lc_top,
1786        half_w,
1787        lbl_frac,
1788        "LINE COUNTS",
1789        &lc_rows,
1790    );
1791
1792    let cs_rows: [(&str, String); 4] = [
1793        ("Functions", pdf_fmt_full(tot.functions)),
1794        ("Classes / Types", pdf_fmt_full(tot.classes)),
1795        ("Variables", pdf_fmt_full(tot.variables)),
1796        ("Imports", pdf_fmt_full(tot.imports)),
1797    ];
1798    pdf_table_render_section(
1799        ctx,
1800        right_x,
1801        tbl_top,
1802        half_w,
1803        lbl_frac,
1804        "CODE STRUCTURE",
1805        &cs_rows,
1806    );
1807
1808    let lcs_rows: [(&str, String); 4] = [
1809        ("Lines added", "--".to_string()),
1810        ("Lines removed", "--".to_string()),
1811        ("Lines modified (net)", "--".to_string()),
1812        ("Lines unmodified", "--".to_string()),
1813    ];
1814    let lcs_top = tbl_top - ctx.tbl_hdr_h - (cs_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1815    pdf_table_render_section(
1816        ctx,
1817        right_x,
1818        lcs_top,
1819        half_w,
1820        lbl_frac,
1821        "LINE CHANGE SUMMARY",
1822        &lcs_rows,
1823    );
1824}
1825
1826/// Render Tests & Coverage content **inline** on an existing page, starting at `y_start`.
1827/// Draw a full-width dark section title bar at `y` and return the Y just below it.
1828fn pdf_tc_title_bar(ctx: &PdfCtx<'_>, label: &str, y: f32) -> f32 {
1829    use crate::pdf_compat::{Color, Mm, Rgb};
1830    let tbl_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
1831    pdf_fill_rect(
1832        ctx.layer,
1833        ctx.margin,
1834        y - ctx.tbl_hdr_h,
1835        tbl_w,
1836        ctx.tbl_hdr_h,
1837        Rgb::new(0.098, 0.11, 0.15, None),
1838    );
1839    ctx.layer
1840        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1841    ctx.layer.use_text(
1842        label,
1843        7.0,
1844        Mm(ctx.margin + 2.0),
1845        Mm(y - ctx.tbl_hdr_h + 1.5),
1846        ctx.font_bold,
1847    );
1848    y - ctx.tbl_hdr_h
1849}
1850
1851/// Alternating zebra row background for PDF tables.
1852fn pdf_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
1853    use crate::pdf_compat::Rgb;
1854    if ri.is_multiple_of(2) {
1855        Rgb::new(0.975, 0.965, 0.95, None)
1856    } else {
1857        Rgb::new(1.0, 1.0, 1.0, None)
1858    }
1859}
1860
1861/// Sum a per-submodule language metric via the provided accessor.
1862fn pdf_sub_sum(
1863    sub: &sloc_core::SubmoduleSummary,
1864    f: impl Fn(&sloc_core::LanguageSummary) -> u64,
1865) -> u64 {
1866    sub.language_summaries.iter().map(f).sum()
1867}
1868
1869/// Render the four summary stat boxes (test functions/assertions/suites + line coverage).
1870#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1871fn pdf_tc_stat_boxes(ctx: &PdfCtx<'_>, run: &AnalysisRun, has_cov: bool, mut y: f32) -> f32 {
1872    use crate::pdf_compat::{Color, Mm, Rgb};
1873    let gap: f32 = 4.0;
1874    let box_h: f32 = 15.0;
1875    let box_w = (ctx.w - 2.0 * ctx.margin - 3.0 * gap) / 4.0;
1876    let line_cov_str = if has_cov {
1877        let pct = run.summary_totals.coverage_lines_hit as f64
1878            / run.summary_totals.coverage_lines_found as f64
1879            * 100.0;
1880        format!("{pct:.1}%")
1881    } else {
1882        "\u{2014}".to_string()
1883    };
1884    let box_vals: [String; 4] = [
1885        pdf_fmt_full(run.summary_totals.test_count),
1886        pdf_fmt_full(run.summary_totals.test_assertion_count),
1887        pdf_fmt_full(run.summary_totals.test_suite_count),
1888        line_cov_str,
1889    ];
1890    let box_labels: [&str; 4] = [
1891        "Test Functions",
1892        "Test Assertions",
1893        "Test Suites",
1894        "Line Coverage",
1895    ];
1896    for (i, (label, val)) in box_labels.iter().zip(box_vals.iter()).enumerate() {
1897        let bx = ctx.margin + i as f32 * (box_w + gap);
1898        let by = y - box_h;
1899        pdf_fill_rect(
1900            ctx.layer,
1901            bx,
1902            by,
1903            box_w,
1904            box_h,
1905            Rgb::new(0.97, 0.96, 0.94, None),
1906        );
1907        ctx.layer
1908            .set_fill_color(Color::Rgb(Rgb::new(0.60, 0.40, 0.22, None)));
1909        ctx.layer
1910            .use_text(val.as_str(), 9.5, Mm(bx + 3.0), Mm(by + 7.5), ctx.font_bold);
1911        ctx.layer
1912            .set_fill_color(Color::Rgb(Rgb::new(0.50, 0.44, 0.40, None)));
1913        ctx.layer
1914            .use_text(*label, 5.5, Mm(bx + 3.0), Mm(by + 2.0), ctx.font_reg);
1915    }
1916    y -= box_h + 4.0;
1917    y
1918}
1919
1920/// Render the full-width SUBMODULES table when submodule summaries are present.
1921#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1922fn pdf_tc_submodules(ctx: &PdfCtx<'_>, run: &AnalysisRun, footer_h: f32, mut y: f32) -> f32 {
1923    use crate::pdf_compat::{Color, Mm, Rgb};
1924    let subs = &run.submodule_summaries;
1925    if subs.is_empty() {
1926        return y;
1927    }
1928    let margin = ctx.margin;
1929    let row_h = ctx.row_h;
1930    let tbl_w = ctx.w - 2.0 * margin;
1931
1932    let col_name = tbl_w * 0.40;
1933    let rem = tbl_w - col_name;
1934    let col_files = rem * 0.15;
1935    let col_code = rem * 0.20;
1936    let col_tests = rem * 0.20;
1937    let col_assert = rem * 0.20;
1938
1939    let cx_files = margin + col_name;
1940    let cx_code = cx_files + col_files;
1941    let cx_tests = cx_code + col_code;
1942    let cx_assert = cx_tests + col_tests;
1943    let cx_cov = cx_assert + col_assert;
1944
1945    y = pdf_tc_title_bar(ctx, "SUBMODULES", y);
1946
1947    pdf_fill_rect(
1948        ctx.layer,
1949        margin,
1950        y - row_h,
1951        tbl_w,
1952        row_h,
1953        Rgb::new(0.25, 0.27, 0.32, None),
1954    );
1955    ctx.layer
1956        .set_fill_color(Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)));
1957    for (lbl, x) in &[
1958        ("Submodule", margin + 2.0),
1959        ("Files", cx_files + 2.0),
1960        ("Code Lines", cx_code + 2.0),
1961        ("Test Functions", cx_tests + 2.0),
1962        ("Assertions", cx_assert + 2.0),
1963        ("Line Coverage %", cx_cov + 2.0),
1964    ] {
1965        ctx.layer
1966            .use_text(*lbl, 5.5, Mm(*x), Mm(y - row_h + 1.5), ctx.font_bold);
1967    }
1968    y -= row_h;
1969
1970    for (ri, sub) in subs.iter().enumerate() {
1971        if y < footer_h + row_h {
1972            break;
1973        }
1974        let sub_tests = pdf_sub_sum(sub, |l| l.test_count);
1975        let sub_assert = pdf_sub_sum(sub, |l| l.test_assertion_count);
1976        let sub_cov_hit = pdf_sub_sum(sub, |l| l.coverage_lines_hit);
1977        let sub_cov_found = pdf_sub_sum(sub, |l| l.coverage_lines_found);
1978        let sub_cov_str = if sub_cov_found > 0 {
1979            format!("{:.1}%", sub_cov_hit as f64 / sub_cov_found as f64 * 100.0)
1980        } else {
1981            "\u{2014}".to_string()
1982        };
1983
1984        let ry = y - row_h;
1985        pdf_fill_rect(ctx.layer, margin, ry, tbl_w, row_h, pdf_row_bg(ri));
1986        ctx.layer
1987            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1988        ctx.layer.use_text(
1989            pdf_trunc(&pdf_safe_str(&sub.name), 40),
1990            5.5,
1991            Mm(margin + 2.0),
1992            Mm(ry + 1.5),
1993            ctx.font_bold,
1994        );
1995        for (val, x) in &[
1996            (pdf_fmt_full(sub.files_analyzed), cx_files + 2.0),
1997            (pdf_fmt_full(sub.code_lines), cx_code + 2.0),
1998            (pdf_fmt_full(sub_tests), cx_tests + 2.0),
1999            (pdf_fmt_full(sub_assert), cx_assert + 2.0),
2000            (sub_cov_str, cx_cov + 2.0),
2001        ] {
2002            ctx.layer
2003                .use_text(val.as_str(), 5.5, Mm(*x), Mm(ry + 1.5), ctx.font_reg);
2004        }
2005        y -= row_h;
2006    }
2007    y - 3.0
2008}
2009
2010/// Render the line/function/branch coverage gauges across the page width.
2011#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2012fn pdf_tc_gauges(ctx: &PdfCtx<'_>, run: &AnalysisRun, mut y: f32) -> f32 {
2013    use crate::pdf_compat::{Color, Mm, Rgb};
2014    let margin = ctx.margin;
2015    let gap: f32 = 4.0;
2016    let gauges: &[(&str, u64, u64)] = &[
2017        (
2018            "Line Coverage",
2019            run.summary_totals.coverage_lines_hit,
2020            run.summary_totals.coverage_lines_found,
2021        ),
2022        (
2023            "Function Coverage",
2024            run.summary_totals.coverage_functions_hit,
2025            run.summary_totals.coverage_functions_found,
2026        ),
2027        (
2028            "Branch Coverage",
2029            run.summary_totals.coverage_branches_hit,
2030            run.summary_totals.coverage_branches_found,
2031        ),
2032    ];
2033    let visible: Vec<_> = gauges.iter().filter(|(_, _, found)| *found > 0).collect();
2034    if visible.is_empty() {
2035        return y;
2036    }
2037    let count = visible.len() as f32;
2038    let gauge_h: f32 = 16.0;
2039    let pad: f32 = 4.0;
2040    let bar_h: f32 = 3.0;
2041    let gauge_w = (ctx.w - 2.0 * margin - (count - 1.0) * gap) / count;
2042    let bar_w = gauge_w - 2.0 * pad;
2043    for (gi, (label, hit, found)) in visible.iter().enumerate() {
2044        let gx = margin + gi as f32 * (gauge_w + gap);
2045        let pct = *hit as f64 / *found as f64 * 100.0;
2046        let pct_str = format!("{pct:.1}%");
2047        // `pct` is a 0..=100 percentage; narrowing to f32 for a bar-width coordinate is exact
2048        // to well within sub-pixel rendering tolerance.
2049        #[allow(
2050            clippy::cast_possible_truncation,
2051            reason = "0..=100 percentage to f32 bar width"
2052        )]
2053        let bar_fill = bar_w * (pct as f32 / 100.0);
2054        let gy = y - gauge_h;
2055        // Simulated 0.5 mm border (outer rect) behind a lighter card fill, matching the meta box.
2056        pdf_fill_rect(
2057            ctx.layer,
2058            gx - 0.5,
2059            gy - 0.5,
2060            gauge_w + 1.0,
2061            gauge_h + 1.0,
2062            Rgb::new(0.80, 0.75, 0.68, None),
2063        );
2064        pdf_fill_rect(
2065            ctx.layer,
2066            gx,
2067            gy,
2068            gauge_w,
2069            gauge_h,
2070            Rgb::new(0.98, 0.97, 0.95, None),
2071        );
2072        // Label (top), percentage (middle), progress bar (bottom) — evenly padded.
2073        ctx.layer
2074            .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.15, 0.15, None)));
2075        ctx.layer.use_text(
2076            *label,
2077            6.0,
2078            Mm(gx + pad),
2079            Mm(gy + gauge_h - 4.5),
2080            ctx.font_bold,
2081        );
2082        ctx.layer
2083            .set_fill_color(Color::Rgb(Rgb::new(0.20, 0.55, 0.35, None)));
2084        ctx.layer.use_text(
2085            &pct_str,
2086            8.5,
2087            Mm(gx + pad),
2088            Mm(gy + bar_h + 3.0),
2089            ctx.font_bold,
2090        );
2091        pdf_fill_rect(
2092            ctx.layer,
2093            gx + pad,
2094            gy + pad * 0.5,
2095            bar_w,
2096            bar_h,
2097            Rgb::new(0.86, 0.84, 0.80, None),
2098        );
2099        if bar_fill > 0.0 {
2100            pdf_fill_rect(
2101                ctx.layer,
2102                gx + pad,
2103                gy + pad * 0.5,
2104                bar_fill,
2105                bar_h,
2106                Rgb::new(0.20, 0.55, 0.35, None),
2107            );
2108        }
2109    }
2110    y -= gauge_h + 5.0;
2111    y
2112}
2113
2114/// Column layout for the per-file coverage table, shared by the header and row renderers.
2115struct CovCols {
2116    has_fn_cov: bool,
2117    has_br_cov: bool,
2118    col_fn_w: f32,
2119    hdr_x2: f32,
2120}
2121
2122/// Draw the PER-FILE COVERAGE title + column header bar; return `(rows_start_y, cols)`.
2123fn pdf_tc_per_file_header(
2124    ctx: &PdfCtx<'_>,
2125    has_fn_cov: bool,
2126    has_br_cov: bool,
2127    col_fn_w: f32,
2128    y: f32,
2129) -> (f32, CovCols) {
2130    use crate::pdf_compat::{Color, Mm, Rgb};
2131    let margin = ctx.margin;
2132    let col_br_w: f32 = if has_br_cov { 22.0 } else { 0.0 };
2133    let col_file_w = 2.0_f32.mul_add(-margin, ctx.w) - 22.0 - col_fn_w - col_br_w;
2134    let hdr_x2 = margin + col_file_w;
2135
2136    let y = pdf_tc_title_bar(ctx, "PER-FILE COVERAGE", y - 3.0);
2137    ctx.layer
2138        .set_fill_color(Color::Rgb(Rgb::new(0.55, 0.55, 0.55, None)));
2139    ctx.layer
2140        .use_text("Line%", 5.5, Mm(hdr_x2 + 2.0), Mm(y - 3.5), ctx.font_bold);
2141    if has_fn_cov {
2142        ctx.layer
2143            .use_text("Fn%", 5.5, Mm(hdr_x2 + 24.0), Mm(y - 3.5), ctx.font_bold);
2144    }
2145    if has_br_cov {
2146        ctx.layer.use_text(
2147            "Br%",
2148            5.5,
2149            Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2150            Mm(y - 3.5),
2151            ctx.font_bold,
2152        );
2153    }
2154    (
2155        y - ctx.row_h,
2156        CovCols {
2157            has_fn_cov,
2158            has_br_cov,
2159            col_fn_w,
2160            hdr_x2,
2161        },
2162    )
2163}
2164
2165/// Render one per-file coverage row at vertical position `ry`.
2166fn pdf_tc_per_file_row(ctx: &PdfCtx<'_>, file: &FileRecord, ri: usize, cols: &CovCols, ry: f32) {
2167    use crate::pdf_compat::{Color, Mm, Rgb};
2168    let (has_fn_cov, has_br_cov, col_fn_w, hdr_x2) =
2169        (cols.has_fn_cov, cols.has_br_cov, cols.col_fn_w, cols.hdr_x2);
2170    let Some(cov) = file.coverage.as_ref() else {
2171        return;
2172    };
2173    pdf_fill_rect(
2174        ctx.layer,
2175        ctx.margin,
2176        ry,
2177        2.0_f32.mul_add(-ctx.margin, ctx.w),
2178        ctx.row_h,
2179        pdf_row_bg(ri),
2180    );
2181    ctx.layer
2182        .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2183    let fname = pdf_trunc(
2184        &pdf_safe_str(
2185            std::path::Path::new(&file.relative_path)
2186                .file_name()
2187                .and_then(|n| n.to_str())
2188                .unwrap_or(&file.relative_path),
2189        ),
2190        52,
2191    );
2192    ctx.layer.use_text(
2193        &fname,
2194        5.5,
2195        Mm(ctx.margin + 2.0),
2196        Mm(ry + 1.5),
2197        ctx.font_reg,
2198    );
2199    ctx.layer
2200        .set_fill_color(Color::Rgb(Rgb::new(0.10, 0.42, 0.25, None)));
2201    ctx.layer.use_text(
2202        format!("{:.1}%", cov.line_pct()),
2203        5.5,
2204        Mm(hdr_x2 + 2.0),
2205        Mm(ry + 1.5),
2206        ctx.font_bold,
2207    );
2208    if has_fn_cov && cov.functions_found > 0 {
2209        ctx.layer.use_text(
2210            format!("{:.1}%", cov.function_pct()),
2211            5.5,
2212            Mm(hdr_x2 + 24.0),
2213            Mm(ry + 1.5),
2214            ctx.font_bold,
2215        );
2216    }
2217    if has_br_cov && cov.branches_found > 0 {
2218        ctx.layer.use_text(
2219            format!("{:.1}%", cov.branch_pct()),
2220            5.5,
2221            Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2222            Mm(ry + 1.5),
2223            ctx.font_bold,
2224        );
2225    }
2226}
2227
2228/// Render the PER-FILE COVERAGE table (header + rows) when coverage records exist.
2229fn pdf_tc_per_file(
2230    ctx: &PdfCtx<'_>,
2231    run: &AnalysisRun,
2232    footer_h: f32,
2233    has_fn_cov: bool,
2234    has_br_cov: bool,
2235    mut y: f32,
2236) -> f32 {
2237    let cov_files: Vec<_> = run
2238        .per_file_records
2239        .iter()
2240        .filter(|r| r.coverage.is_some())
2241        .collect();
2242    if cov_files.is_empty() {
2243        return y;
2244    }
2245    let col_fn_w: f32 = if has_fn_cov { 22.0 } else { 0.0 };
2246    let (rows_start, cols) = pdf_tc_per_file_header(ctx, has_fn_cov, has_br_cov, col_fn_w, y);
2247    y = rows_start;
2248    for (ri, file) in cov_files.iter().enumerate() {
2249        if y < footer_h + ctx.row_h {
2250            break;
2251        }
2252        let ry = y - ctx.row_h;
2253        pdf_tc_per_file_row(ctx, file, ri, &cols, ry);
2254        y -= ctx.row_h;
2255    }
2256    y
2257}
2258
2259/// Render the "no coverage data" note when no coverage is present.
2260fn pdf_tc_no_coverage_note(ctx: &PdfCtx<'_>, mut y: f32) -> f32 {
2261    use crate::pdf_compat::{Color, Mm, Rgb};
2262    let margin = ctx.margin;
2263    let note_h: f32 = 12.0;
2264    pdf_fill_rect(
2265        ctx.layer,
2266        margin,
2267        y - note_h,
2268        2.0_f32.mul_add(-margin, ctx.w),
2269        note_h,
2270        Rgb::new(0.96, 0.95, 0.93, None),
2271    );
2272    ctx.layer
2273        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.40, 0.37, None)));
2274    ctx.layer.use_text(
2275        "No code coverage data detected.",
2276        7.0,
2277        Mm(margin + 4.0),
2278        Mm(y - note_h + 7.0),
2279        ctx.font_bold,
2280    );
2281    ctx.layer.use_text(
2282        "Re-run with --lcov-path <file.info> to see per-file line, function, and branch coverage.",
2283        6.0,
2284        Mm(margin + 4.0),
2285        Mm(y - note_h + 2.5),
2286        ctx.font_reg,
2287    );
2288    y -= note_h;
2289    y
2290}
2291
2292/// Does NOT create a new page, draw a mini-header, or draw a footer — those are the caller's
2293/// responsibility. Returns the Y position immediately below the last rendered element.
2294fn pdf_render_tc_inline(ctx: &PdfCtx<'_>, run: &AnalysisRun, y_start: f32, footer_h: f32) -> f32 {
2295    let has_cov = run.summary_totals.coverage_lines_found > 0;
2296    let has_fn_cov = run.summary_totals.coverage_functions_found > 0;
2297    let has_br_cov = run.summary_totals.coverage_branches_found > 0;
2298
2299    let mut y = pdf_tc_title_bar(ctx, "TESTS & COVERAGE", y_start) - 4.0;
2300    y = pdf_tc_stat_boxes(ctx, run, has_cov, y);
2301    y = pdf_tc_submodules(ctx, run, footer_h, y);
2302
2303    if has_cov {
2304        y = pdf_tc_gauges(ctx, run, y);
2305        y = pdf_tc_per_file(ctx, run, footer_h, has_fn_cov, has_br_cov, y);
2306    } else {
2307        y = pdf_tc_no_coverage_note(ctx, y);
2308    }
2309    y
2310}
2311
2312/// Build the right-aligned per-page header metadata string shown on every continuation
2313/// page so each printed sheet is self-identifying: Run ID, git commit, and scan time.
2314fn pdf_page_header_meta(run: &AnalysisRun) -> String {
2315    let mut parts = vec![format!(
2316        "Run ID: {}",
2317        pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2318    )];
2319    if let Some(ref c) = run.git_commit_short {
2320        parts.push(format!("Commit: {}", pdf_safe_str(c)));
2321    }
2322    parts.push(to_pt_hhmm(run.tool.timestamp_utc));
2323    parts.join("  \u{00B7}  ")
2324}
2325
2326/// Draw `text` right-aligned (gray, 6.5 pt) inside a navy page-header bar whose text
2327/// baseline sits at `baseline_y`. Uses exact Helvetica advance widths for precise
2328/// right-edge alignment against the page margin.
2329fn pdf_draw_header_meta(
2330    layer: &crate::pdf_compat::PdfLayerReference,
2331    font: crate::pdf_compat::IndirectFontRef,
2332    w: f32,
2333    margin: f32,
2334    baseline_y: f32,
2335    text: &str,
2336) {
2337    use crate::pdf_compat::{Color, Mm, Rgb};
2338    let tw = helvetica_width_mm(text, 6.5, false);
2339    let x = (w - margin - tw).max(margin + 60.0);
2340    layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2341    layer.use_text(text, 6.5, Mm(x), Mm(baseline_y), font);
2342}
2343
2344/// Draw the per-page mini header band (dark bar with "oxide-sloc", the truncated report `title`,
2345/// and right-aligned run metadata) shared by the dedicated T&C and Git Hotspots pages. `h` is the
2346/// page height and `hdr_h` the band height.
2347fn pdf_page_mini_header(ctx: &PdfCtx<'_>, h: f32, hdr_h: f32, title: &str, run: &AnalysisRun) {
2348    use crate::pdf_compat::{Color, Mm, Rgb};
2349    pdf_fill_rect(
2350        ctx.layer,
2351        0.0,
2352        h - hdr_h,
2353        ctx.w,
2354        hdr_h,
2355        Rgb::new(0.098, 0.11, 0.15, None),
2356    );
2357    ctx.layer
2358        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2359    ctx.layer.use_text(
2360        "oxide-sloc",
2361        9.0,
2362        Mm(ctx.margin),
2363        Mm(h - 5.5),
2364        ctx.font_bold,
2365    );
2366    ctx.layer
2367        .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2368    ctx.layer.use_text(
2369        pdf_trunc(&pdf_safe_str(title), 45),
2370        7.5,
2371        Mm(46.0),
2372        Mm(h - 5.5),
2373        ctx.font_reg,
2374    );
2375    pdf_draw_header_meta(
2376        ctx.layer,
2377        ctx.font_reg,
2378        ctx.w,
2379        ctx.margin,
2380        h - 5.5,
2381        &pdf_page_header_meta(run),
2382    );
2383}
2384
2385/// Draw the standard page footer band (light bar with the version/licence line) shared by the
2386/// dedicated T&C and Git Hotspots pages.
2387fn pdf_page_footer_band(ctx: &PdfCtx<'_>, footer_h: f32, version: &str) {
2388    use crate::pdf_compat::{Color, Mm, Rgb};
2389    pdf_fill_rect(
2390        ctx.layer,
2391        0.0,
2392        0.0,
2393        ctx.w,
2394        footer_h,
2395        Rgb::new(0.93, 0.91, 0.87, None),
2396    );
2397    ctx.layer
2398        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2399    ctx.layer.use_text(
2400        format!("oxide-sloc v{version}  \u{00b7}  AGPL-3.0-or-later"),
2401        6.5,
2402        Mm(ctx.margin),
2403        Mm(3.0),
2404        ctx.font_reg,
2405    );
2406}
2407
2408/// Create a dedicated "Tests & Coverage" page, render its content inline, and return the
2409/// `(page, layer, y_bottom)` tuple so `pdf_render_per_file_pages` can continue on this page.
2410#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
2411fn pdf_render_tests_coverage_page(
2412    doc: &crate::pdf_compat::PdfDocumentReference,
2413    font_reg: crate::pdf_compat::IndirectFontRef,
2414    font_bold: crate::pdf_compat::IndirectFontRef,
2415    run: &AnalysisRun,
2416    w: f32,
2417    h: f32,
2418    margin: f32,
2419    footer_h: f32,
2420    title: &str,
2421    version: &str,
2422) -> (
2423    crate::pdf_compat::PdfPageIndex,
2424    crate::pdf_compat::PdfLayerIndex,
2425    f32,
2426) {
2427    use crate::pdf_compat::Mm;
2428    const HDR_H: f32 = 8.0;
2429
2430    let (tc_page, tc_layer_idx) = doc.add_page(Mm(w), Mm(h), "Tests & Coverage");
2431    let layer = doc.get_page(tc_page).get_layer(tc_layer_idx);
2432    let ctx = PdfCtx {
2433        layer: &layer,
2434        font_reg,
2435        font_bold,
2436        w,
2437        margin,
2438        row_h: 5.5,
2439        tbl_hdr_h: 6.0,
2440    };
2441
2442    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
2443
2444    // T&C content inline
2445    let tc_bottom = pdf_render_tc_inline(&ctx, run, h - HDR_H - 4.0, footer_h);
2446
2447    pdf_page_footer_band(&ctx, footer_h, version);
2448
2449    (tc_page, tc_layer_idx, tc_bottom - 3.0)
2450}
2451
2452#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2453fn pdf_render_page1_footer(
2454    ctx: &PdfCtx<'_>,
2455    run: &AnalysisRun,
2456    footer_h: f32,
2457    version: &str,
2458    banner: Option<&str>,
2459) {
2460    use crate::pdf_compat::{Color, Mm, Rgb};
2461    pdf_fill_rect(
2462        ctx.layer,
2463        0.0,
2464        0.0,
2465        ctx.w,
2466        footer_h,
2467        Rgb::new(0.93, 0.91, 0.87, None),
2468    );
2469    ctx.layer
2470        .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2471    // Left section.
2472    ctx.layer.use_text(
2473        format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
2474        6.5,
2475        Mm(ctx.margin),
2476        Mm(3.0),
2477        ctx.font_reg,
2478    );
2479    // Right section — github.com and Run ID, right-aligned (~1.27 mm per char at 6.5 pt).
2480    let right_text = format!(
2481        "github.com/oxide-sloc/oxide-sloc  |  Run ID: {}",
2482        pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2483    );
2484    let right_x = (ctx.w - ctx.margin - right_text.len() as f32 * 1.27).max(ctx.margin + 80.0);
2485    ctx.layer
2486        .use_text(right_text, 6.5, Mm(right_x), Mm(3.0), ctx.font_reg);
2487    // Center section — banner text, no background, oxide brand color, bold.
2488    if let Some(text) = banner {
2489        let safe = pdf_trunc(&pdf_safe_str(text), 40);
2490        // Same per-char width as the header banner (0.97 mm at 9pt bold Helvetica).
2491        let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(ctx.margin + 50.0);
2492        ctx.layer
2493            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2494        ctx.layer
2495            .use_text(safe, 9.0, Mm(text_x), Mm(2.6), ctx.font_bold);
2496    }
2497}
2498
2499fn per_file_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2500    if ri.is_multiple_of(2) {
2501        crate::pdf_compat::Rgb::new(0.975, 0.965, 0.95, None)
2502    } else {
2503        crate::pdf_compat::Rgb::new(1.0, 1.0, 1.0, None)
2504    }
2505}
2506
2507// ── Per-file page layout constants shared by the helpers below ─────────────────
2508const PDF_PERFILE_HDR_H: f32 = 8.0;
2509const PDF_PERFILE_SUB_H: f32 = 5.5;
2510// Gap between the PER-FILE DETAIL sub-bar and the column-header row.
2511// Applied on standalone per-file pages (not when sharing a page with COCOMO/T&C).
2512const PDF_PERFILE_TABLE_GAP: f32 = 3.0;
2513
2514/// Doc/font/dims context for per-file page helpers; carries `doc` instead of `layer`
2515/// because the page layer is created inside `pdf_draw_perfile_header`.
2516struct PdfPerFileCtx<'a> {
2517    doc: &'a crate::pdf_compat::PdfDocumentReference,
2518    font_reg: crate::pdf_compat::IndirectFontRef,
2519    font_bold: crate::pdf_compat::IndirectFontRef,
2520    w: f32,
2521    h: f32,
2522    margin: f32,
2523}
2524
2525/// Compute the `[start, end)` record slice displayed on one per-file page.
2526fn pdf_perfile_page_slice(
2527    page_idx: usize,
2528    use_continuation: bool,
2529    has_first_page: bool,
2530    fp_rows: usize,
2531    rows_per_page: usize,
2532    total_files: usize,
2533) -> (usize, usize) {
2534    if use_continuation {
2535        (0, fp_rows.min(total_files))
2536    } else if has_first_page {
2537        let s = fp_rows + (page_idx - 1) * rows_per_page;
2538        (s, (s + rows_per_page).min(total_files))
2539    } else {
2540        let s = page_idx * rows_per_page;
2541        (s, (s + rows_per_page).min(total_files))
2542    }
2543}
2544
2545/// Obtain (or create) the PDF layer for one per-file page and render its page header.
2546///
2547/// Returns `(layer, sub_top)` where `sub_top` is the y-coordinate at the bottom of the
2548/// header bar. When `use_continuation` is true the layer is taken from `first_page` and
2549/// no new header is drawn — the COCOMO page already has one.
2550#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2551fn pdf_draw_perfile_header(
2552    ctx: &PdfPerFileCtx<'_>,
2553    use_continuation: bool,
2554    first_page: Option<(
2555        crate::pdf_compat::PdfPageIndex,
2556        crate::pdf_compat::PdfLayerIndex,
2557        f32,
2558    )>,
2559    page_idx: usize,
2560    page_count: usize,
2561    banner: Option<&str>,
2562    meta: &str,
2563) -> (crate::pdf_compat::PdfLayerReference, f32) {
2564    use crate::pdf_compat::{Color, Mm, Rgb};
2565    if use_continuation {
2566        let (fp_page, fp_layer_idx, fp_top) = first_page.unwrap();
2567        let layer = ctx.doc.get_page(fp_page).get_layer(fp_layer_idx);
2568        (layer, fp_top - PDF_PERFILE_SUB_H)
2569    } else {
2570        let (pf_page, pf_layer_idx) = ctx.doc.add_page(Mm(ctx.w), Mm(ctx.h), "Content");
2571        let layer = ctx.doc.get_page(pf_page).get_layer(pf_layer_idx);
2572        let hdr_top = ctx.h - PDF_PERFILE_HDR_H;
2573        pdf_fill_rect(
2574            &layer,
2575            0.0,
2576            hdr_top,
2577            ctx.w,
2578            PDF_PERFILE_HDR_H,
2579            Rgb::new(0.098, 0.11, 0.15, None),
2580        );
2581        layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2582        layer.use_text(
2583            "oxide-sloc",
2584            9.0,
2585            Mm(ctx.margin),
2586            Mm(hdr_top + 2.5),
2587            ctx.font_bold,
2588        );
2589        layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2590        layer.use_text(
2591            "Per-File Detail",
2592            8.0,
2593            Mm(46.0),
2594            Mm(hdr_top + 2.5),
2595            ctx.font_reg,
2596        );
2597        // Right-aligned: Run ID / commit / scan time, then the page counter.
2598        let right = format!(
2599            "{meta}  \u{00B7}  Page {} of {}",
2600            page_idx + 2,
2601            page_count + 1
2602        );
2603        let right_w = helvetica_width_mm(&right, 6.5, false);
2604        let right_x = (ctx.w - ctx.margin - right_w).max(ctx.margin + 60.0);
2605        layer.use_text(right, 6.5, Mm(right_x), Mm(hdr_top + 2.5), ctx.font_reg);
2606        if let Some(text) = banner {
2607            let safe = pdf_trunc(&pdf_safe_str(text), 40);
2608            let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(80.0);
2609            layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2610            layer.use_text(safe, 9.0, Mm(text_x), Mm(hdr_top + 2.5), ctx.font_bold);
2611        }
2612        // Leave a gap between the top header bar and the PER-FILE DETAIL sub-bar.
2613        (layer, hdr_top - PDF_PERFILE_TABLE_GAP - PDF_PERFILE_SUB_H)
2614    }
2615}
2616
2617/// Render per-file data rows onto an existing PDF layer.
2618#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2619fn pdf_draw_perfile_rows(
2620    ctx: &PdfCtx<'_>,
2621    records: &[FileRecord],
2622    col_x: &[f32; 13],
2623    pf_tbl_top: f32,
2624) {
2625    use crate::pdf_compat::{Color, Mm, Rgb};
2626    for (ri, rec) in records.iter().enumerate() {
2627        let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, pf_tbl_top - ctx.tbl_hdr_h);
2628        let bg = per_file_row_bg(ri);
2629        pdf_fill_rect(
2630            ctx.layer,
2631            ctx.margin,
2632            ry,
2633            2.0f32.mul_add(-ctx.margin, ctx.w),
2634            ctx.row_h,
2635            bg,
2636        );
2637        ctx.layer
2638            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2639        let file_str = pdf_safe_str(&rec.relative_path);
2640        let lang_str = rec
2641            .language
2642            .as_ref()
2643            .map_or_else(|| "--".to_string(), |l| l.display_name().to_string());
2644        let raw = &rec.raw_line_categories;
2645        let eff = &rec.effective_counts;
2646        let cells = [
2647            pdf_trunc_end(&file_str, 110),
2648            lang_str,
2649            pdf_fmt_full(raw.total_physical_lines),
2650            pdf_fmt_full(eff.code_lines),
2651            pdf_fmt_full(eff.comment_lines),
2652            pdf_fmt_full(eff.blank_lines),
2653            pdf_fmt_full(eff.mixed_lines_separate),
2654            pdf_fmt_full(raw.functions),
2655            pdf_fmt_full(raw.classes),
2656            pdf_fmt_full(raw.variables),
2657            pdf_fmt_full(raw.imports),
2658            pdf_fmt_full(raw.test_count),
2659            pdf_fmt_full(raw.test_assertion_count),
2660        ];
2661        for (ci, cell) in cells.iter().enumerate() {
2662            ctx.layer.use_text(
2663                cell.clone(),
2664                5.5,
2665                Mm(col_x[ci] + 0.5),
2666                Mm(ry + 1.0),
2667                ctx.font_reg,
2668            );
2669        }
2670    }
2671}
2672
2673// PDF per-file page renderer — layout params are distinct; see PdfPerFileCtx for bundling.
2674#[allow(
2675    clippy::cast_precision_loss,
2676    clippy::cast_possible_truncation,
2677    clippy::cast_sign_loss,
2678    clippy::too_many_arguments,
2679    clippy::too_many_lines,
2680    clippy::suboptimal_flops
2681)]
2682fn pdf_render_per_file_pages(
2683    doc: &crate::pdf_compat::PdfDocumentReference,
2684    font_reg: crate::pdf_compat::IndirectFontRef,
2685    font_bold: crate::pdf_compat::IndirectFontRef,
2686    run: &AnalysisRun,
2687    w: f32,
2688    h: f32,
2689    margin: f32,
2690    footer_h: f32,
2691    row_h: f32,
2692    tbl_hdr_h: f32,
2693    title: &str,
2694    ts: &str,
2695    version: &str,
2696    banner: Option<&str>,
2697    // When COCOMO is rendered on its own page, continue the per-file table on that same page
2698    // rather than starting a new one.  Tuple: (page index, layer index, available top y-coord).
2699    first_page: Option<(
2700        crate::pdf_compat::PdfPageIndex,
2701        crate::pdf_compat::PdfLayerIndex,
2702        f32,
2703    )>,
2704) {
2705    use crate::pdf_compat::{Color, Mm, Rgb};
2706    // File column gets ~136 mm; numeric columns compressed to minimum readable width.
2707    // Column widths: File=136, Lang=14, Phys=12, Code=10, Comments=13, Blank=10, Mixed=10,
2708    //   Functions=13, Classes=11, Variables=13, Imports=11, Tests=10, Assertions=14  → total 277 mm
2709    let col_x: [f32; 13] = [
2710        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,
2711    ];
2712    let col_labels: [&str; 13] = [
2713        "File",
2714        "Language",
2715        "Physical",
2716        "Code",
2717        "Comments",
2718        "Blank",
2719        "Mixed",
2720        "Functions",
2721        "Classes",
2722        "Variables",
2723        "Imports",
2724        "Tests",
2725        "Assertions",
2726    ];
2727    let rows_per_page =
2728        ((h - PDF_PERFILE_HDR_H - PDF_PERFILE_SUB_H - PDF_PERFILE_TABLE_GAP - tbl_hdr_h - footer_h)
2729            / row_h)
2730            .floor() as usize;
2731    let total_files = run.per_file_records.len();
2732
2733    // Rows that fit on the continuation page (COCOMO already occupies the top portion).
2734    let fp_rows = match first_page {
2735        Some((_, _, fp_top)) => ((fp_top - PDF_PERFILE_SUB_H - tbl_hdr_h - footer_h) / row_h)
2736            .floor()
2737            .max(0.0) as usize,
2738        None => rows_per_page,
2739    };
2740    let page_count = if first_page.is_some() {
2741        1 + total_files.saturating_sub(fp_rows).div_ceil(rows_per_page)
2742    } else {
2743        total_files.div_ceil(rows_per_page)
2744    };
2745    let pf_ctx = PdfPerFileCtx {
2746        doc,
2747        font_reg,
2748        font_bold,
2749        w,
2750        h,
2751        margin,
2752    };
2753    let header_meta = pdf_page_header_meta(run);
2754
2755    for page_idx in 0..page_count {
2756        let use_continuation = page_idx == 0 && first_page.is_some();
2757        let (pf_layer, sub_top) = pdf_draw_perfile_header(
2758            &pf_ctx,
2759            use_continuation,
2760            first_page,
2761            page_idx,
2762            page_count,
2763            banner,
2764            &header_meta,
2765        );
2766
2767        // Sub-bar — dark navy, matching TESTS & COVERAGE / SUBMODULES section headers.
2768        pdf_fill_rect(
2769            &pf_layer,
2770            margin,
2771            sub_top,
2772            w - 2.0 * margin,
2773            PDF_PERFILE_SUB_H,
2774            Rgb::new(0.098, 0.11, 0.15, None),
2775        );
2776        pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2777        pf_layer.use_text(
2778            "PER-FILE DETAIL",
2779            7.0,
2780            Mm(margin + 2.0),
2781            Mm(sub_top + 1.5),
2782            font_bold,
2783        );
2784        if use_continuation {
2785            // On the continuation page show the project context on the right.
2786            let right = format!(
2787                "{}  |  {} files  |  {ts}",
2788                pdf_trunc(title, 30),
2789                total_files
2790            );
2791            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2792            let right_x = (w - margin - right.len() as f32 * 1.05).max(margin + 80.0);
2793            pf_layer.use_text(right, 5.5, Mm(right_x), Mm(sub_top + 1.5), font_reg);
2794        } else {
2795            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2796            pf_layer.use_text(
2797                pdf_trunc(title, 45),
2798                5.5,
2799                Mm(margin + 60.0),
2800                Mm(sub_top + 1.5),
2801                font_reg,
2802            );
2803            pf_layer.use_text(
2804                format!("{total_files} files  |  {ts}"),
2805                5.5,
2806                Mm(w - margin - 55.0),
2807                Mm(sub_top + 1.5),
2808                font_reg,
2809            );
2810        }
2811
2812        let pf_tbl_top = sub_top;
2813        pdf_fill_rect(
2814            &pf_layer,
2815            margin,
2816            pf_tbl_top - tbl_hdr_h,
2817            2.0f32.mul_add(-margin, w),
2818            tbl_hdr_h,
2819            Rgb::new(0.098, 0.11, 0.15, None),
2820        );
2821        pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2822        for (i, lbl) in col_labels.iter().enumerate() {
2823            pf_layer.use_text(
2824                *lbl,
2825                5.0,
2826                Mm(col_x[i] + 0.5),
2827                Mm(pf_tbl_top - tbl_hdr_h + 1.5),
2828                font_bold,
2829            );
2830        }
2831
2832        let (start, end) = pdf_perfile_page_slice(
2833            page_idx,
2834            use_continuation,
2835            first_page.is_some(),
2836            fp_rows,
2837            rows_per_page,
2838            total_files,
2839        );
2840        let row_ctx = PdfCtx {
2841            layer: &pf_layer,
2842            font_reg,
2843            font_bold,
2844            w,
2845            margin,
2846            row_h,
2847            tbl_hdr_h,
2848        };
2849        pdf_draw_perfile_rows(
2850            &row_ctx,
2851            &run.per_file_records[start..end],
2852            &col_x,
2853            pf_tbl_top,
2854        );
2855
2856        // Footer
2857        pdf_fill_rect(
2858            &pf_layer,
2859            0.0,
2860            0.0,
2861            w,
2862            footer_h,
2863            Rgb::new(0.93, 0.91, 0.87, None),
2864        );
2865        pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2866        pf_layer.use_text(
2867            format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
2868            6.5,
2869            Mm(margin),
2870            Mm(3.0),
2871            font_reg,
2872        );
2873        let right_text = format!(
2874            "github.com/oxide-sloc/oxide-sloc  |  Run ID: {}",
2875            pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2876        );
2877        let right_x = (w - margin - right_text.len() as f32 * 1.27).max(margin + 80.0);
2878        pf_layer.use_text(right_text, 6.5, Mm(right_x), Mm(3.0), font_reg);
2879        // Center section — banner, oxide brand color, bold.
2880        if let Some(text) = banner {
2881            let safe = pdf_trunc(&pdf_safe_str(text), 40);
2882            let text_x = (w / 2.0 - safe.len() as f32 * 0.97).max(margin + 50.0);
2883            pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2884            pf_layer.use_text(safe, 9.0, Mm(text_x), Mm(2.6), font_bold);
2885        }
2886    }
2887}
2888
2889/// Draw the dark section-header bar (full usable width, `hdr_h` tall, top edge at `section_top`)
2890/// with `title` rendered in white bold at the left. Shared by every PDF report section so the
2891/// header styling stays identical across them.
2892fn pdf_section_header_bar(
2893    ctx: &PdfCtx<'_>,
2894    usable_w: f32,
2895    section_top: f32,
2896    hdr_h: f32,
2897    title: &str,
2898) {
2899    use crate::pdf_compat::{Color, Mm, Rgb};
2900    pdf_fill_rect(
2901        ctx.layer,
2902        ctx.margin,
2903        section_top - hdr_h,
2904        usable_w,
2905        hdr_h,
2906        Rgb::new(0.098, 0.11, 0.15, None),
2907    );
2908    ctx.layer
2909        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2910    ctx.layer.use_text(
2911        title,
2912        7.0,
2913        Mm(ctx.margin + 2.0),
2914        Mm(section_top - hdr_h + 1.5),
2915        ctx.font_bold,
2916    );
2917}
2918
2919/// Render the Code Style Analysis section onto page 1 of the printpdf PDF.
2920///
2921/// Draws below the metric tables: a section header, four summary chips, and a
2922/// per-language mini-table showing the top style guide and N-col compliance.
2923/// Returns the y coordinate of the bottom of the rendered section.
2924#[allow(
2925    clippy::cast_precision_loss,
2926    clippy::cast_possible_truncation,
2927    clippy::too_many_lines,
2928    clippy::suboptimal_flops
2929)]
2930fn pdf_render_style_section(ctx: &PdfCtx<'_>, ss: &StyleSummary, section_top: f32) -> f32 {
2931    use crate::pdf_compat::{Color, Mm, Rgb};
2932    const HDR_H: f32 = 5.5;
2933    const CHIP_H: f32 = 11.0;
2934    const CHIP_GAP: f32 = 4.0;
2935    const ROW_H: f32 = 5.0;
2936    const TBL_HDR_H: f32 = 5.0;
2937    const GAP: f32 = 2.5;
2938
2939    let usable_w = ctx.w - 2.0 * ctx.margin;
2940    let chip_w = (usable_w - 3.0 * CHIP_GAP) / 4.0;
2941
2942    // ── section header bar ────────────────────────────────────────────────────
2943    pdf_section_header_bar(ctx, usable_w, section_top, HDR_H, "CODE STYLE ANALYSIS");
2944    let col_label = format!("{}-Col", ss.col_threshold);
2945    ctx.layer
2946        .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
2947    ctx.layer.use_text(
2948        "Lexical heuristics",
2949        5.5,
2950        Mm(ctx.w - ctx.margin - 26.0),
2951        Mm(section_top - HDR_H + 1.5),
2952        ctx.font_reg,
2953    );
2954
2955    // ── summary chips ─────────────────────────────────────────────────────────
2956    let chips_bot = section_top - HDR_H - GAP - CHIP_H;
2957    let chip_data: [(&str, String); 4] = [
2958        ("Files Analyzed", ss.files_analyzed.to_string()),
2959        ("Language Groups", ss.by_language.len().to_string()),
2960        ("Common Indent", ss.common_indent_style.clone()),
2961        (&col_label, format!("{}%", ss.line_col_compliant_pct)),
2962    ];
2963    for (i, (label, value)) in chip_data.iter().enumerate() {
2964        let cx = (i as f32).mul_add(chip_w + CHIP_GAP, ctx.margin);
2965        pdf_fill_rect(
2966            ctx.layer,
2967            cx,
2968            chips_bot,
2969            chip_w,
2970            CHIP_H,
2971            Rgb::new(0.945, 0.925, 0.90, None),
2972        );
2973        ctx.layer
2974            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2975        ctx.layer.use_text(
2976            pdf_trunc(value, 16),
2977            10.0,
2978            Mm(cx + 3.0),
2979            Mm(chips_bot + 5.5),
2980            ctx.font_bold,
2981        );
2982        ctx.layer
2983            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
2984        ctx.layer.use_text(
2985            pdf_safe_str(label),
2986            5.5,
2987            Mm(cx + 3.0),
2988            Mm(chips_bot + 1.5),
2989            ctx.font_reg,
2990        );
2991    }
2992
2993    // ── per-language mini-table ───────────────────────────────────────────────
2994    if ss.by_language.is_empty() {
2995        return chips_bot;
2996    }
2997    let tbl_top = chips_bot - GAP;
2998
2999    // Column widths (fractions of usable_w): Family | Files | Top Guide | Score | N-Col
3000    let col_w = [0.28_f32, 0.08, 0.36, 0.14, 0.14];
3001    let col_x: Vec<f32> = col_w
3002        .iter()
3003        .scan(ctx.margin, |acc, &w| {
3004            let x = *acc;
3005            *acc += w * usable_w;
3006            Some(x)
3007        })
3008        .collect();
3009    let headers = ["Language Family", "Files", "Top Guide", "Score", &col_label];
3010
3011    pdf_fill_rect(
3012        ctx.layer,
3013        ctx.margin,
3014        tbl_top - TBL_HDR_H,
3015        usable_w,
3016        TBL_HDR_H,
3017        Rgb::new(0.098, 0.11, 0.15, None),
3018    );
3019    ctx.layer
3020        .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3021    for (hi, hdr) in headers.iter().enumerate() {
3022        ctx.layer.use_text(
3023            pdf_safe_str(hdr),
3024            5.5,
3025            Mm(col_x[hi] + 2.0),
3026            Mm(tbl_top - TBL_HDR_H + 1.5),
3027            ctx.font_bold,
3028        );
3029    }
3030
3031    let mut row_y = tbl_top - TBL_HDR_H;
3032    for (ri, grp) in ss.by_language.iter().take(5).enumerate() {
3033        let ry = row_y - ROW_H;
3034        let bg = if ri % 2 == 0 {
3035            Rgb::new(0.975, 0.965, 0.95, None)
3036        } else {
3037            Rgb::new(1.0, 1.0, 1.0, None)
3038        };
3039        pdf_fill_rect(ctx.layer, ctx.margin, ry, usable_w, ROW_H, bg);
3040        ctx.layer
3041            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3042        let cells = [
3043            pdf_trunc(&grp.language_family, 26),
3044            grp.files_count.to_string(),
3045            pdf_trunc(&grp.dominant_guide, 28),
3046            format!("{}%", grp.dominant_score_pct),
3047            format!("{}%", grp.line_col_compliant_pct),
3048        ];
3049        for (ci, cell) in cells.iter().enumerate() {
3050            let is_score = ci == 3 || ci == 4;
3051            if is_score && cell != "--" {
3052                ctx.layer
3053                    .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3054            } else {
3055                ctx.layer
3056                    .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3057            }
3058            ctx.layer.use_text(
3059                pdf_safe_str(cell),
3060                6.0,
3061                Mm(col_x[ci] + 2.0),
3062                Mm(ry + 1.5),
3063                ctx.font_reg,
3064            );
3065        }
3066        row_y = ry;
3067    }
3068
3069    row_y
3070}
3071
3072/// Render the COCOMO I estimate section as a compact table on the PDF page.
3073/// Returns the bottom y-coordinate of the rendered section.
3074#[allow(clippy::cast_precision_loss)]
3075fn pdf_render_cocomo_section(ctx: &PdfCtx<'_>, run: &AnalysisRun, section_top: f32) -> f32 {
3076    use crate::pdf_compat::{Color, Mm, Rgb};
3077    const HDR_H: f32 = 5.5;
3078    const ROW_H: f32 = 13.0; // tall enough for label + value with comfortable padding
3079    const NOTE_H: f32 = 2.0; // just enough clearance for 5.5 pt descenders below the baseline
3080    const GAP: f32 = 5.0; // breathing room between data row and footnote
3081
3082    let Some(ref c) = run.cocomo else {
3083        return section_top;
3084    };
3085
3086    let mode_label = match c.mode {
3087        CocomoMode::Organic => "Organic",
3088        CocomoMode::SemiDetached => "Semi-detached",
3089        CocomoMode::Embedded => "Embedded",
3090    };
3091    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3092
3093    // Section header bar
3094    pdf_section_header_bar(
3095        ctx,
3096        usable_w,
3097        section_top,
3098        HDR_H,
3099        "CONSTRUCTIVE COST MODEL (COCOMO I) ESTIMATE",
3100    );
3101    ctx.layer
3102        .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3103    let mode_display = format!("{mode_label} mode");
3104    ctx.layer.use_text(
3105        mode_display.as_str(),
3106        5.5,
3107        Mm(ctx.w - ctx.margin - 28.0),
3108        Mm(section_top - HDR_H + 1.5),
3109        ctx.font_reg,
3110    );
3111
3112    // 4-column data row (full width, single row)
3113    let col_w = usable_w / 4.0;
3114    let row_y = section_top - HDR_H - ROW_H;
3115    let data: [(&str, String); 4] = [
3116        ("Person-months", format!("{:.2}", c.effort_person_months)),
3117        ("Schedule (months)", format!("{:.2}", c.duration_months)),
3118        ("Avg. Team Size", format!("{:.2}", c.avg_staff)),
3119        ("Input KSLOC", format!("{:.2}K", c.ksloc)),
3120    ];
3121    for (i, (label, value)) in data.iter().enumerate() {
3122        let cx = (i as f32).mul_add(col_w, ctx.margin);
3123        let bg = if i % 2 == 0 {
3124            Rgb::new(0.975, 0.965, 0.95, None)
3125        } else {
3126            Rgb::new(1.0, 1.0, 1.0, None)
3127        };
3128        pdf_fill_rect(ctx.layer, cx, row_y, col_w, ROW_H, bg);
3129        ctx.layer
3130            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3131        ctx.layer
3132            .use_text(*label, 5.5, Mm(cx + 2.0), Mm(row_y + 9.0), ctx.font_reg);
3133        ctx.layer
3134            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3135        ctx.layer.use_text(
3136            value.as_str(),
3137            10.0,
3138            Mm(cx + 2.0),
3139            Mm(row_y + 2.5),
3140            ctx.font_bold,
3141        );
3142    }
3143
3144    // Footnote
3145    let note_y = row_y - GAP;
3146    ctx.layer
3147        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3148    ctx.layer.use_text(
3149        "COCOMO I (Boehm, 1981): algorithmic model converting SLOC into effort, schedule, and team-size estimates. \
3150         Ballpark figures only - actual outcomes vary with team experience and domain complexity.",
3151        5.5,
3152        Mm(ctx.margin),
3153        Mm(note_y),
3154        ctx.font_reg,
3155    );
3156
3157    note_y - NOTE_H
3158}
3159
3160/// Draw `text` so its right edge sits at `x_right` mm, at vertical `y` mm. The caller sets the
3161/// fill colour beforehand. Uses the Helvetica advance-width table for alignment.
3162fn pdf_text_right(ctx: &PdfCtx<'_>, text: &str, pt: f32, x_right: f32, y: f32, bold: bool) {
3163    use crate::pdf_compat::Mm;
3164    let font = if bold { ctx.font_bold } else { ctx.font_reg };
3165    let w = helvetica_width_mm(text, pt, bold);
3166    ctx.layer.use_text(text, pt, Mm(x_right - w), Mm(y), font);
3167}
3168
3169/// Front-truncate `path` with a leading "..." so it fits within `budget_mm` at `pt`, keeping the
3170/// most informative tail (the filename). Returns the path unchanged when it already fits.
3171fn pdf_fit_path(path: &str, budget_mm: f32, pt: f32) -> String {
3172    if helvetica_width_mm(path, pt, false) <= budget_mm {
3173        return path.to_string();
3174    }
3175    let mut chars: Vec<char> = path.chars().collect();
3176    while !chars.is_empty() {
3177        chars.remove(0);
3178        let candidate: String = format!("...{}", chars.iter().collect::<String>());
3179        if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3180            return candidate;
3181        }
3182    }
3183    "...".to_string()
3184}
3185
3186/// Render the Git Hotspots table (files ranked by code lines x recent commits) starting at
3187/// `section_top`. Returns the Y coordinate below the rendered content. Mirrors the COCOMO
3188/// section's dark header bar and the per-file table's right-aligned numeric columns.
3189fn pdf_render_hotspots_section(ctx: &PdfCtx<'_>, rows: &[HotspotRow], section_top: f32) -> f32 {
3190    use crate::pdf_compat::{Color, Mm, Rgb};
3191    const HDR_H: f32 = 5.5;
3192    const COLHDR_H: f32 = 5.0;
3193    const ROW_H: f32 = 5.2;
3194    const NOTE_GAP: f32 = 4.0;
3195
3196    let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3197
3198    // Section header bar.
3199    pdf_section_header_bar(
3200        ctx,
3201        usable_w,
3202        section_top,
3203        HDR_H,
3204        "GIT HOTSPOTS (CODE LINES x RECENT COMMITS)",
3205    );
3206
3207    // Column right edges (numeric columns are right-aligned); File fills the remaining left space.
3208    let col_last_r = ctx.w - ctx.margin;
3209    let col_score_r = col_last_r - 32.0;
3210    let col_commits_r = col_score_r - 33.0;
3211    let col_code_r = col_commits_r - 32.0;
3212    let file_x = ctx.margin + 2.0;
3213    let file_budget = (col_code_r - 26.0) - file_x;
3214
3215    // Column-header row.
3216    let chdr_y = section_top - HDR_H - COLHDR_H;
3217    pdf_fill_rect(
3218        ctx.layer,
3219        ctx.margin,
3220        chdr_y,
3221        usable_w,
3222        COLHDR_H,
3223        Rgb::new(0.90, 0.88, 0.84, None),
3224    );
3225    ctx.layer
3226        .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3227    ctx.layer
3228        .use_text("File", 6.0, Mm(file_x), Mm(chdr_y + 1.4), ctx.font_bold);
3229    pdf_text_right(ctx, "Code lines", 6.0, col_code_r, chdr_y + 1.4, true);
3230    pdf_text_right(ctx, "Commits", 6.0, col_commits_r, chdr_y + 1.4, true);
3231    pdf_text_right(ctx, "Hotspot score", 6.0, col_score_r, chdr_y + 1.4, true);
3232    pdf_text_right(ctx, "Last changed", 6.0, col_last_r, chdr_y + 1.4, true);
3233
3234    // Data rows (zebra background).
3235    let mut y = chdr_y;
3236    for (ri, hrow) in rows.iter().enumerate() {
3237        y -= ROW_H;
3238        let bg = if ri.is_multiple_of(2) {
3239            Rgb::new(0.975, 0.965, 0.95, None)
3240        } else {
3241            Rgb::new(1.0, 1.0, 1.0, None)
3242        };
3243        pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3244        // File path (front-truncated to its width budget).
3245        ctx.layer
3246            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3247        let path = pdf_fit_path(&pdf_safe_str(&hrow.path), file_budget, 6.0);
3248        ctx.layer
3249            .use_text(path, 6.0, Mm(file_x), Mm(y + 1.4), ctx.font_reg);
3250        // Numeric columns.
3251        ctx.layer
3252            .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3253        pdf_text_right(
3254            ctx,
3255            &group_thousands(&hrow.code_lines.to_string()),
3256            6.0,
3257            col_code_r,
3258            y + 1.4,
3259            false,
3260        );
3261        pdf_text_right(
3262            ctx,
3263            &hrow.commit_count.to_string(),
3264            6.0,
3265            col_commits_r,
3266            y + 1.4,
3267            false,
3268        );
3269        // Hotspot score — emphasised in the oxide accent colour.
3270        ctx.layer
3271            .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3272        pdf_text_right(
3273            ctx,
3274            &group_thousands(&hrow.score.to_string()),
3275            6.0,
3276            col_score_r,
3277            y + 1.4,
3278            true,
3279        );
3280        ctx.layer
3281            .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3282        pdf_text_right(ctx, &hrow.last_commit_date, 6.0, col_last_r, y + 1.4, false);
3283    }
3284
3285    // Footnote.
3286    let note_y = y - NOTE_GAP;
3287    ctx.layer
3288        .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3289    ctx.layer.use_text(
3290        "Files ranked by code lines x commits over the configured git activity window. \
3291         Distinct from the Compare page's scan-to-scan churn rate.",
3292        5.5,
3293        Mm(ctx.margin),
3294        Mm(note_y + 1.0),
3295        ctx.font_reg,
3296    );
3297
3298    note_y
3299}
3300
3301/// Render a dedicated "Git Hotspots" page and return its `(page, layer, y_below)` so the per-file
3302/// table can continue on the same page (mirrors `pdf_render_tests_coverage_page`).
3303#[allow(clippy::too_many_arguments)]
3304fn pdf_render_hotspots_page(
3305    doc: &crate::pdf_compat::PdfDocumentReference,
3306    font_reg: crate::pdf_compat::IndirectFontRef,
3307    font_bold: crate::pdf_compat::IndirectFontRef,
3308    run: &AnalysisRun,
3309    rows: &[HotspotRow],
3310    w: f32,
3311    h: f32,
3312    margin: f32,
3313    footer_h: f32,
3314    title: &str,
3315    version: &str,
3316) -> (
3317    crate::pdf_compat::PdfPageIndex,
3318    crate::pdf_compat::PdfLayerIndex,
3319    f32,
3320) {
3321    use crate::pdf_compat::Mm;
3322    const HDR_H: f32 = 8.0;
3323
3324    let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Git Hotspots");
3325    let layer = doc.get_page(page).get_layer(layer_idx);
3326    let ctx = PdfCtx {
3327        layer: &layer,
3328        font_reg,
3329        font_bold,
3330        w,
3331        margin,
3332        row_h: 5.5,
3333        tbl_hdr_h: 6.0,
3334    };
3335
3336    pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3337
3338    let bottom = pdf_render_hotspots_section(&ctx, rows, h - HDR_H - 4.0);
3339
3340    pdf_page_footer_band(&ctx, footer_h, version);
3341
3342    (page, layer_idx, bottom - 3.0)
3343}
3344
3345/// Measure how tall the COCOMO + Tests & Coverage page needs to be, so a terminal
3346/// (last) page can be trimmed to its content instead of left at full landscape height
3347/// with a large empty gap below the last section.
3348///
3349/// Renders the same sections onto a throwaway, never-saved document of height `h_full`
3350/// and reads where the content ends. Layout is vertically translation-invariant, so the
3351/// trimmed height is `h_full - content_bottom + footer_h + pad`. Falls back to `h_full`
3352/// on any error so the report is always produced.
3353#[allow(clippy::too_many_arguments)]
3354fn measure_terminal_tc_page_height(
3355    run: &AnalysisRun,
3356    w: f32,
3357    h_full: f32,
3358    margin: f32,
3359    footer_h: f32,
3360    row_h: f32,
3361    tbl_hdr_h: f32,
3362    with_cocomo: bool,
3363) -> f32 {
3364    use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3365    let measure = || -> Option<f32> {
3366        let (doc, page, layer_idx) = PdfDocument::new("measure", Mm(w), Mm(h_full), "m");
3367        let font_reg = doc.add_builtin_font(BuiltinFont::Helvetica).ok()?;
3368        let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).ok()?;
3369        let layer = doc.get_page(page).get_layer(layer_idx);
3370        let ctx = PdfCtx {
3371            layer: &layer,
3372            font_reg,
3373            font_bold,
3374            w,
3375            margin,
3376            row_h,
3377            tbl_hdr_h,
3378        };
3379        // Mirror the real render's starting offsets exactly (see the cocomo/T&C branches
3380        // in `write_pdf_from_run`): an 8 mm header band, then the first section below it.
3381        let content_bottom = if with_cocomo {
3382            let cocomo_bottom = pdf_render_cocomo_section(&ctx, run, h_full - 8.0 - 6.0);
3383            pdf_render_tc_inline(&ctx, run, cocomo_bottom - 2.0, footer_h)
3384        } else {
3385            pdf_render_tc_inline(&ctx, run, h_full - 8.0 - 4.0, footer_h)
3386        };
3387        // 4 mm bottom padding below the last element, mirroring the top-of-content gap.
3388        let pad = 4.0;
3389        Some((h_full - content_bottom + footer_h + pad).clamp(60.0, h_full))
3390    };
3391    measure().unwrap_or(h_full)
3392}
3393
3394/// Render the dedicated COCOMO + Tests & Coverage page (page 2) when COCOMO did not fit
3395/// on page 1, or a standalone Tests & Coverage page otherwise. Returns the page/layer and
3396/// the Y below the last section so the per-file table can continue on the same page with
3397/// no blank-page gap. Extracted from `write_pdf_from_run` to keep that function's cognitive
3398/// complexity low; layout and output are unchanged.
3399#[allow(
3400    clippy::cast_precision_loss,
3401    clippy::cast_possible_truncation,
3402    clippy::cast_sign_loss,
3403    clippy::too_many_arguments
3404)]
3405fn pdf_render_cocomo_or_tc_page(
3406    doc: &crate::pdf_compat::PdfDocumentReference,
3407    font_reg: crate::pdf_compat::IndirectFontRef,
3408    font_bold: crate::pdf_compat::IndirectFontRef,
3409    run: &AnalysisRun,
3410    dims: PdfPageDims,
3411    title: &str,
3412    version: &str,
3413    cocomo_fits_page1: bool,
3414    trim_page: bool,
3415) -> (
3416    crate::pdf_compat::PdfPageIndex,
3417    crate::pdf_compat::PdfLayerIndex,
3418    f32,
3419) {
3420    use crate::pdf_compat::{Color, Mm, Mm as PdfMm, Rgb};
3421    let PdfPageDims {
3422        w,
3423        h,
3424        margin,
3425        footer_h,
3426        row_h,
3427        tbl_hdr_h,
3428    } = dims;
3429
3430    // No COCOMO on its own page — create a dedicated T&C page and start per-file from it.
3431    if run.cocomo.is_none() || cocomo_fits_page1 {
3432        let page_h = if trim_page {
3433            measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, false)
3434        } else {
3435            h
3436        };
3437        return pdf_render_tests_coverage_page(
3438            doc, font_reg, font_bold, run, w, page_h, margin, footer_h, title, version,
3439        );
3440    }
3441
3442    let page_h = if trim_page {
3443        measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, true)
3444    } else {
3445        h
3446    };
3447    let (c2_page, c2_layer_idx) = doc.add_page(Mm(w), Mm(page_h), "Content");
3448    let c2_layer = doc.get_page(c2_page).get_layer(c2_layer_idx);
3449    let c2_ctx = PdfCtx {
3450        layer: &c2_layer,
3451        font_reg,
3452        font_bold,
3453        w,
3454        margin,
3455        row_h,
3456        tbl_hdr_h,
3457    };
3458    // Small page header so the reader knows which report this is.
3459    pdf_fill_rect(
3460        &c2_layer,
3461        0.0,
3462        page_h - 8.0,
3463        w,
3464        8.0,
3465        Rgb::new(0.098, 0.11, 0.15, None),
3466    );
3467    c2_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3468    c2_layer.use_text(
3469        "oxide-sloc",
3470        9.0,
3471        PdfMm(margin),
3472        PdfMm(page_h - 5.5),
3473        font_bold,
3474    );
3475    c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3476    c2_layer.use_text(
3477        pdf_trunc(&pdf_safe_str(title), 45),
3478        7.5,
3479        PdfMm(46.0),
3480        PdfMm(page_h - 5.5),
3481        font_reg,
3482    );
3483    pdf_draw_header_meta(
3484        &c2_layer,
3485        font_reg,
3486        w,
3487        margin,
3488        page_h - 5.5,
3489        &pdf_page_header_meta(run),
3490    );
3491    let cocomo_bottom = pdf_render_cocomo_section(&c2_ctx, run, page_h - 8.0 - 6.0);
3492    // Render T&C inline on the same page immediately after COCOMO — no blank gap.
3493    let tc_bottom = pdf_render_tc_inline(&c2_ctx, run, cocomo_bottom - 2.0, footer_h);
3494    // Footer (per-file renderer will overdraw with its richer version if it starts here).
3495    pdf_fill_rect(
3496        &c2_layer,
3497        0.0,
3498        0.0,
3499        w,
3500        footer_h,
3501        Rgb::new(0.93, 0.91, 0.87, None),
3502    );
3503    c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3504    c2_layer.use_text(
3505        format!("oxide-sloc v{version}  |  AGPL-3.0-or-later"),
3506        6.5,
3507        PdfMm(margin),
3508        PdfMm(3.0),
3509        font_reg,
3510    );
3511    // Pass the Y below T&C content so per-file can continue on this page without a gap.
3512    (c2_page, c2_layer_idx, tc_bottom - 3.0)
3513}
3514
3515/// Generate a PDF summary report from `AnalysisRun` data using the pure-Rust `printpdf` crate.
3516///
3517/// No external tools (Chrome, wkhtmltopdf) are required — this path is always available on
3518/// both Windows and Linux server deployments.
3519///
3520/// # Errors
3521///
3522/// Returns an error if the output directory cannot be created or the PDF file cannot be written.
3523// Casts throughout are for PDF layout coordinates and percentage ratios; precision loss is fine.
3524#[allow(
3525    clippy::cast_precision_loss,
3526    clippy::cast_possible_truncation,
3527    clippy::cast_sign_loss,
3528    clippy::too_many_lines
3529)]
3530pub fn write_pdf_from_run(run: &AnalysisRun, pdf_path: &Path) -> Result<()> {
3531    use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3532    use std::fs::File;
3533    use std::io::BufWriter;
3534
3535    const W: f32 = 297.0;
3536    const H: f32 = 210.0;
3537    const MARGIN: f32 = 10.0;
3538    const FOOTER_H: f32 = 10.0;
3539    const HDR_H: f32 = 13.5;
3540    const ROW_H: f32 = 5.5;
3541    const TBL_HDR_H: f32 = 6.0;
3542
3543    if let Some(parent) = pdf_path.parent() {
3544        fs::create_dir_all(parent)
3545            .with_context(|| format!("failed to create PDF directory {}", parent.display()))?;
3546    }
3547
3548    let title = pdf_safe_str(&run.effective_configuration.reporting.report_title);
3549    let ts = to_pt_hhmm(run.tool.timestamp_utc);
3550    let version = env!("CARGO_PKG_VERSION");
3551    let banner = run
3552        .effective_configuration
3553        .reporting
3554        .report_header_footer
3555        .as_deref();
3556
3557    let (doc, page1, layer1) =
3558        PdfDocument::new(format!("oxide-sloc: {title}"), Mm(W), Mm(H), "Content");
3559    let font_reg = doc
3560        .add_builtin_font(BuiltinFont::Helvetica)
3561        .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3562    let font_bold = doc
3563        .add_builtin_font(BuiltinFont::HelveticaBold)
3564        .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3565    let layer = doc.get_page(page1).get_layer(layer1);
3566
3567    let ctx = PdfCtx {
3568        layer: &layer,
3569        font_reg,
3570        font_bold,
3571        w: W,
3572        margin: MARGIN,
3573        row_h: ROW_H,
3574        tbl_hdr_h: TBL_HDR_H,
3575    };
3576    let roots_text_y = pdf_render_page1_header(&ctx, run, &ts, &title, H, HDR_H, banner);
3577    let row2_bot = pdf_render_summary_chips(&ctx, run, roots_text_y);
3578    let info_y = pdf_render_info_lines(&ctx, run, row2_bot);
3579    let tbl_top = info_y - 4.0;
3580    pdf_render_metric_tables(&ctx, run, tbl_top);
3581    // Style analysis section — rendered below the metric tables when data is available.
3582    // The metric tables occupy ~64.5 mm below tbl_top; leave 4 mm clearance before drawing.
3583    let after_tables_y = tbl_top - 64.5 - 4.0;
3584    let after_style_y = run.style_summary.as_ref().map_or(after_tables_y, |ss| {
3585        if after_tables_y > FOOTER_H + 12.0 {
3586            pdf_render_style_section(&ctx, ss, after_tables_y)
3587        } else {
3588            after_tables_y
3589        }
3590    });
3591    // COCOMO estimate — on page 1 if room remains, otherwise on its own page 2.
3592    // Need ~32 mm: header (5.5) + data row (13) + gap (5) + note (5) + margins (~3.5).
3593    let cocomo_fits_page1 = run.cocomo.is_some() && (after_style_y - 3.0) > FOOTER_H + 32.0;
3594    if cocomo_fits_page1 {
3595        pdf_render_cocomo_section(&ctx, run, after_style_y - 3.0);
3596    }
3597    pdf_render_page1_footer(&ctx, run, FOOTER_H, version, banner);
3598
3599    // Page-flow bookkeeping for empty-gap trimming. The per-file table continues on the
3600    // COCOMO/T&C page only when there is no Git Hotspots page in between (the Hotspots page,
3601    // when present, becomes the per-file continuation instead). A page that nothing flows
3602    // onto is trimmed to its content height to avoid a large empty gap below the last section.
3603    let hotspot_rows = build_hotspot_rows(run, 15);
3604    let has_per_file = !run.per_file_records.is_empty();
3605    let tc_page_gets_per_file = hotspot_rows.is_empty() && has_per_file;
3606    let trim_tc_page = !tc_page_gets_per_file;
3607
3608    // If COCOMO didn't fit on page 1, render it on a dedicated page 2 (with T&C inline);
3609    // otherwise render a standalone T&C page. Either way the returned page/layer/Y lets the
3610    // per-file table continue on the same page with no blank-page gap.
3611    let page_dims = PdfPageDims {
3612        w: W,
3613        h: H,
3614        margin: MARGIN,
3615        footer_h: FOOTER_H,
3616        row_h: ROW_H,
3617        tbl_hdr_h: TBL_HDR_H,
3618    };
3619    let cocomo_page_ctx = pdf_render_cocomo_or_tc_page(
3620        &doc,
3621        font_reg,
3622        font_bold,
3623        run,
3624        page_dims,
3625        &title,
3626        version,
3627        cocomo_fits_page1,
3628        trim_tc_page,
3629    );
3630
3631    // Git Hotspots — its own page after COCOMO/T&C, only when an --activity-window scan
3632    // collected per-file git activity. Threaded as the per-file continuation (like COCOMO)
3633    // so the per-file table flows on below it with no blank-page gap.
3634    // A Git Hotspots page is only emitted when per-file git activity exists, which means
3635    // `per_file_records` is non-empty and the per-file table always flows onto it — so it is
3636    // never a terminal page and needs no trimming (it stays full height for the per-file rows).
3637    let per_file_start = if hotspot_rows.is_empty() {
3638        Some(cocomo_page_ctx)
3639    } else {
3640        Some(pdf_render_hotspots_page(
3641            &doc,
3642            font_reg,
3643            font_bold,
3644            run,
3645            &hotspot_rows,
3646            W,
3647            H,
3648            MARGIN,
3649            FOOTER_H,
3650            &title,
3651            version,
3652        ))
3653    };
3654
3655    if !run.per_file_records.is_empty() {
3656        // Per-file continues on the same page as T&C / COCOMO / Hotspots — no blank page between.
3657        pdf_render_per_file_pages(
3658            &doc,
3659            font_reg,
3660            font_bold,
3661            run,
3662            W,
3663            H,
3664            MARGIN,
3665            FOOTER_H,
3666            ROW_H,
3667            TBL_HDR_H,
3668            &title,
3669            &ts,
3670            version,
3671            banner,
3672            per_file_start,
3673        );
3674    }
3675
3676    doc.save(&mut BufWriter::new(File::create(pdf_path).with_context(
3677        || format!("cannot create PDF at {}", pdf_path.display()),
3678    )?))
3679    .map_err(|e| anyhow::anyhow!("printpdf save error: {e}"))?;
3680
3681    Ok(())
3682}
3683
3684/// Per-character advance widths for the PDF built-in Helvetica and Helvetica-Bold fonts
3685/// (1/1000 em units, PDF spec Appendix D). Used to right-align text without a layout engine.
3686///
3687/// Each row is `(glyph, bold_advance, regular_advance)`, a verbatim transcription of the PDF
3688/// spec width tables. Keeping both weights on one row per glyph preserves the spec mapping for
3689/// audit while expressing it as data rather than two parallel `match` arms. Digits (`'0'..='9'`,
3690/// 556 in both weights) are handled in `helvetica_advance` and intentionally omitted here.
3691const HELVETICA_WIDTHS: &[(char, u32, u32)] = &[
3692    (' ', 278, 278),
3693    ('!', 333, 278),
3694    ('"', 474, 355),
3695    ('#', 556, 556),
3696    ('$', 556, 556),
3697    ('%', 889, 889),
3698    ('&', 722, 667),
3699    ('\'', 278, 222),
3700    ('(', 333, 333),
3701    (')', 333, 333),
3702    ('*', 389, 389),
3703    ('+', 584, 584),
3704    (',', 278, 278),
3705    ('-', 333, 333),
3706    ('.', 278, 278),
3707    ('/', 278, 278),
3708    (':', 333, 278),
3709    (';', 333, 278),
3710    ('<', 584, 584),
3711    ('=', 584, 584),
3712    ('>', 584, 584),
3713    ('?', 556, 472),
3714    ('@', 975, 1015),
3715    ('A', 722, 667),
3716    ('B', 722, 667),
3717    ('C', 722, 722),
3718    ('D', 722, 722),
3719    ('E', 667, 667),
3720    ('F', 611, 611),
3721    ('G', 778, 778),
3722    ('H', 722, 722),
3723    ('I', 278, 278),
3724    ('J', 556, 500),
3725    ('K', 722, 667),
3726    ('L', 611, 556),
3727    ('M', 833, 833),
3728    ('N', 722, 722),
3729    ('O', 778, 778),
3730    ('P', 667, 667),
3731    ('Q', 778, 778),
3732    ('R', 722, 722),
3733    ('S', 667, 667),
3734    ('T', 611, 611),
3735    ('U', 722, 722),
3736    ('V', 667, 667),
3737    ('W', 944, 944),
3738    ('X', 667, 667),
3739    ('Y', 611, 611),
3740    ('Z', 611, 611),
3741    ('[', 333, 278),
3742    ('\\', 278, 278),
3743    (']', 333, 278),
3744    ('^', 584, 469),
3745    ('_', 556, 556),
3746    ('`', 278, 222),
3747    ('a', 556, 556),
3748    ('b', 611, 556),
3749    ('c', 556, 500),
3750    ('d', 611, 556),
3751    ('e', 556, 556),
3752    ('f', 333, 278),
3753    ('g', 611, 556),
3754    ('h', 611, 556),
3755    ('i', 278, 222),
3756    ('j', 278, 222),
3757    ('k', 556, 500),
3758    ('l', 278, 222),
3759    ('m', 889, 833),
3760    ('n', 611, 556),
3761    ('o', 611, 556),
3762    ('p', 611, 556),
3763    ('q', 611, 556),
3764    ('r', 389, 333),
3765    ('s', 556, 500),
3766    ('t', 333, 278),
3767    ('u', 611, 556),
3768    ('v', 556, 500),
3769    ('w', 778, 722),
3770    ('x', 556, 500),
3771    ('y', 556, 500),
3772    ('z', 500, 500),
3773    ('\u{00B7}', 278, 278), // middle dot (Latin-1 0xB7) — used as section separator
3774];
3775
3776/// Advance width (1/1000 em) for `ch` in Helvetica (`bold` selects the bold weight). Looks up
3777/// `HELVETICA_WIDTHS`; digits are a uniform 556, and unknown glyphs fall back to the average
3778/// advance for the weight (556 bold, 500 regular).
3779fn helvetica_advance(ch: char, bold: bool) -> u32 {
3780    if ch.is_ascii_digit() {
3781        return 556;
3782    }
3783    for &(glyph, bold_w, regular_w) in HELVETICA_WIDTHS {
3784        if glyph == ch {
3785            return if bold { bold_w } else { regular_w };
3786        }
3787    }
3788    if bold { 556 } else { 500 }
3789}
3790
3791/// Convert a string to mm given a font size (pt) and bold flag, using exact PDF Helvetica metrics.
3792//
3793// Rendered strings are length-bounded, so the glyph-unit sum is far below f32's 2^23
3794// exact-integer ceiling; the cast feeds a millimetre layout width where any rounding is
3795// sub-pixel.
3796#[allow(
3797    clippy::cast_precision_loss,
3798    reason = "bounded glyph-unit sum to mm width"
3799)]
3800fn helvetica_width_mm(text: &str, pt: f32, bold: bool) -> f32 {
3801    let units: u32 = text.chars().map(|ch| helvetica_advance(ch, bold)).sum();
3802    // 1 unit = (pt × 25.4 mm/in ÷ 72 pt/in) / 1000.
3803    units as f32 * pt * (25.4 / 72.0) / 1000.0
3804}
3805
3806fn pdf_fill_rect(
3807    layer: &crate::pdf_compat::PdfLayerReference,
3808    x: f32,
3809    y: f32,
3810    w: f32,
3811    h: f32,
3812    color: crate::pdf_compat::Rgb,
3813) {
3814    layer.fill_rect(x, y, w, h, color);
3815}
3816
3817fn pdf_safe_str(s: &str) -> String {
3818    let mut out = String::with_capacity(s.len());
3819    for c in s.chars() {
3820        match c {
3821            // Common Unicode punctuation → readable ASCII equivalents
3822            '\u{2014}' | '\u{2013}' => out.push_str(" - "), // em dash / en dash
3823            '\u{2026}' => out.push_str("..."),              // ellipsis
3824            '\u{2018}' | '\u{2019}' => out.push('\''),      // curly single quotes
3825            '\u{201C}' | '\u{201D}' => out.push('"'),       // curly double quotes
3826            '\u{00B7}' | '\u{2022}' => out.push('-'),       // middle dot / bullet
3827            '\u{00A0}' => out.push(' '),                    // non-breaking space
3828            c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
3829            _ => {} // drop truly unprintable non-ASCII rather than emitting '?'
3830        }
3831    }
3832    out
3833}
3834
3835fn pdf_trunc(s: &str, max: usize) -> String {
3836    if s.len() <= max {
3837        s.to_string()
3838    } else {
3839        format!("{}...", &s[..max.saturating_sub(3)])
3840    }
3841}
3842
3843// Show the tail of a string — prepend "..." when truncated so the meaningful end is visible.
3844// Used for file paths where the filename/leaf matters more than the leading directories.
3845fn pdf_trunc_end(s: &str, max: usize) -> String {
3846    if s.len() <= max {
3847        s.to_string()
3848    } else {
3849        format!("...{}", &s[s.len() - max.saturating_sub(3)..])
3850    }
3851}
3852
3853fn pdf_fmt_full(n: u64) -> String {
3854    // Comma-separated full number: 15319 → "15,319", 1374 → "1,374"
3855    let s = n.to_string();
3856    let mut out = String::with_capacity(s.len() + s.len() / 3);
3857    for (i, ch) in s.chars().rev().enumerate() {
3858        if i > 0 && i % 3 == 0 {
3859            out.push(',');
3860        }
3861        out.push(ch);
3862    }
3863    out.chars().rev().collect()
3864}
3865
3866/// Launch a headless Chromium-based browser to print `html_path` as a PDF to `pdf_path`.
3867///
3868/// Tries CDP (headless Chrome) first; falls back to `wkhtmltopdf` when no Chromium-based
3869/// browser is found on the server.
3870///
3871/// # Errors
3872///
3873/// Returns an error if no PDF tool (Chromium or wkhtmltopdf) is available, the tool fails
3874/// to start, or the PDF file is not produced within the timeout.
3875pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
3876    eprintln!("[oxide-sloc][pdf] starting");
3877
3878    let absolute_html = html_path
3879        .canonicalize()
3880        .with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
3881    // canonicalize() on Windows prepends \\?\ (extended-length path prefix) — strip it for display.
3882    eprintln!(
3883        "[oxide-sloc][pdf] html = {}",
3884        absolute_html.to_string_lossy().trim_start_matches(r"\\?\")
3885    );
3886
3887    let absolute_pdf = if pdf_path.is_absolute() {
3888        pdf_path.to_path_buf()
3889    } else {
3890        std::env::current_dir()
3891            .context("failed to resolve current working directory")?
3892            .join(pdf_path)
3893    };
3894    eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
3895
3896    if let Some(parent) = absolute_pdf.parent() {
3897        fs::create_dir_all(parent).with_context(|| {
3898            format!("failed to create PDF output directory {}", parent.display())
3899        })?;
3900    }
3901
3902    match write_pdf_via_cdp(&absolute_html, &absolute_pdf) {
3903        Ok(()) => {}
3904        Err(cdp_err) => {
3905            eprintln!("[oxide-sloc][pdf] CDP failed ({cdp_err:#}), trying wkhtmltopdf fallback");
3906            write_pdf_via_wkhtmltopdf(&absolute_html, &absolute_pdf).with_context(|| {
3907                format!(
3908                    "PDF generation failed via both CDP ({cdp_err:#}) and wkhtmltopdf. \
3909                     Install a Chromium-based browser (Chrome, Edge, Brave) or wkhtmltopdf \
3910                     on the server, or set SLOC_BROWSER to the browser executable path."
3911                )
3912            })?;
3913        }
3914    }
3915
3916    eprintln!("[oxide-sloc][pdf] done");
3917    Ok(())
3918}
3919
3920fn normalize_browser_env_path(raw: &str) -> PathBuf {
3921    let trimmed = raw.trim();
3922    #[cfg(windows)]
3923    {
3924        let bytes = trimmed.as_bytes();
3925        if bytes.len() >= 3
3926            && bytes[0] == b'/'
3927            && bytes[2] == b'/'
3928            && bytes[1].is_ascii_alphabetic()
3929        {
3930            let drive = (bytes[1] as char).to_ascii_uppercase();
3931            let rest = &trimmed[3..];
3932            return PathBuf::from(format!("{drive}:/{rest}"));
3933        }
3934    }
3935    PathBuf::from(trimmed)
3936}
3937
3938fn discover_browser_from_env() -> Option<PathBuf> {
3939    for var_name in ["SLOC_BROWSER", "BROWSER"] {
3940        if let Ok(path) = std::env::var(var_name) {
3941            let candidate = normalize_browser_env_path(&path);
3942            if candidate.is_file() {
3943                return Some(candidate);
3944            }
3945        }
3946    }
3947    None
3948}
3949
3950fn discover_browser() -> Option<PathBuf> {
3951    if let Some(p) = discover_browser_from_env() {
3952        return Some(p);
3953    }
3954
3955    let names = [
3956        "chromium",
3957        "chromium-browser",
3958        "google-chrome",
3959        "google-chrome-stable",
3960        "microsoft-edge",
3961        "msedge",
3962        "brave",
3963        "brave-browser",
3964        "vivaldi",
3965        "opera",
3966        "opera-stable",
3967    ];
3968
3969    for name in names {
3970        if let Some(path) = which_in_path(name) {
3971            return Some(path);
3972        }
3973    }
3974
3975    #[cfg(windows)]
3976    {
3977        for candidate in windows_browser_candidates() {
3978            if candidate.is_file() {
3979                return Some(candidate);
3980            }
3981        }
3982    }
3983
3984    // Absolute path fallbacks for Linux servers where the browser may not be
3985    // in $PATH (e.g. installed via snap, flatpak, or a minimal systemd service env).
3986    #[cfg(not(windows))]
3987    {
3988        for candidate in linux_browser_candidates() {
3989            if candidate.is_file() {
3990                return Some(candidate);
3991            }
3992        }
3993
3994        // Final fallback: ask the shell's `which` so we catch browsers installed
3995        // in non-standard locations that weren't found via PATH or static paths.
3996        if let Some(path) = which_subprocess(&[
3997            "chromium-browser",
3998            "chromium",
3999            "google-chrome",
4000            "google-chrome-stable",
4001            "microsoft-edge",
4002            "brave-browser",
4003        ]) {
4004            return Some(path);
4005        }
4006    }
4007
4008    None
4009}
4010
4011/// Push the Chrome/Edge/Brave/Vivaldi `Application`-layout executable paths under `base` onto
4012/// `paths`. These four share the same per-base directory layout; Opera differs and is added by
4013/// the caller.
4014#[cfg(windows)]
4015fn push_chromium_app_browsers(paths: &mut Vec<PathBuf>, base: &Path) {
4016    paths.push(
4017        base.join("Google")
4018            .join("Chrome")
4019            .join("Application")
4020            .join("chrome.exe"),
4021    );
4022    paths.push(
4023        base.join("Microsoft")
4024            .join("Edge")
4025            .join("Application")
4026            .join("msedge.exe"),
4027    );
4028    paths.push(
4029        base.join("BraveSoftware")
4030            .join("Brave-Browser")
4031            .join("Application")
4032            .join("brave.exe"),
4033    );
4034    paths.push(base.join("Vivaldi").join("Application").join("vivaldi.exe"));
4035}
4036
4037#[cfg(windows)]
4038fn windows_browser_candidates() -> Vec<PathBuf> {
4039    let mut paths = Vec::new();
4040
4041    let program_files = std::env::var_os("ProgramFiles");
4042    let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
4043    let local_app_data = std::env::var_os("LocalAppData");
4044
4045    for base in [program_files, program_files_x86].into_iter().flatten() {
4046        let base = PathBuf::from(base);
4047        push_chromium_app_browsers(&mut paths, &base);
4048        paths.push(base.join("Opera").join("launcher.exe"));
4049        paths.push(base.join("Opera GX").join("launcher.exe"));
4050    }
4051
4052    if let Some(base) = local_app_data {
4053        let base = PathBuf::from(base);
4054        push_chromium_app_browsers(&mut paths, &base);
4055        paths.push(base.join("Programs").join("Opera").join("launcher.exe"));
4056        paths.push(base.join("Programs").join("Opera GX").join("launcher.exe"));
4057    }
4058
4059    paths
4060}
4061
4062#[cfg(not(windows))]
4063fn linux_browser_candidates() -> Vec<PathBuf> {
4064    vec![
4065        // snap (Ubuntu, common on servers)
4066        PathBuf::from("/snap/bin/chromium"),
4067        PathBuf::from("/snap/bin/chromium-browser"),
4068        // standard apt/dnf paths
4069        PathBuf::from("/usr/bin/chromium"),
4070        PathBuf::from("/usr/bin/chromium-browser"),
4071        PathBuf::from("/usr/bin/google-chrome"),
4072        PathBuf::from("/usr/bin/google-chrome-stable"),
4073        PathBuf::from("/usr/bin/microsoft-edge"),
4074        PathBuf::from("/usr/bin/microsoft-edge-stable"),
4075        PathBuf::from("/usr/bin/brave-browser"),
4076        PathBuf::from("/usr/bin/brave-browser-stable"),
4077        // package-managed library locations (Ubuntu 20.04, Debian)
4078        PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
4079        PathBuf::from("/usr/lib/chromium/chromium"),
4080        PathBuf::from("/usr/lib/chromium/chrome"),
4081        // manual / opt installs
4082        PathBuf::from("/opt/google/chrome/google-chrome"),
4083        PathBuf::from("/opt/google/chrome-beta/google-chrome"),
4084        PathBuf::from("/opt/google/chrome-unstable/google-chrome"),
4085        // local installs
4086        PathBuf::from("/usr/local/bin/chromium"),
4087        PathBuf::from("/usr/local/bin/chromium-browser"),
4088        PathBuf::from("/usr/local/bin/google-chrome"),
4089        // flatpak wrapper scripts
4090        PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
4091        PathBuf::from("/usr/share/flatpak/exports/bin/org.chromium.Chromium"),
4092    ]
4093}
4094
4095/// Ask the shell for a browser that may be in PATH but not in the hardcoded list above.
4096/// Used as a last resort when all static-path checks have failed.
4097#[cfg(not(windows))]
4098fn which_subprocess(names: &[&str]) -> Option<PathBuf> {
4099    for name in names {
4100        if let Ok(out) = std::process::Command::new("which").arg(name).output()
4101            && out.status.success()
4102        {
4103            let s = String::from_utf8_lossy(&out.stdout);
4104            let path = PathBuf::from(s.trim());
4105            if path.is_file() {
4106                return Some(path);
4107            }
4108        }
4109    }
4110    None
4111}
4112
4113fn which_in_path(exe: &str) -> Option<PathBuf> {
4114    let path_var = std::env::var_os("PATH")?;
4115    for dir in std::env::split_paths(&path_var) {
4116        let candidate = dir.join(exe);
4117        if candidate.is_file() {
4118            return Some(candidate);
4119        }
4120        #[cfg(windows)]
4121        {
4122            let candidate = dir.join(format!("{exe}.exe"));
4123            if candidate.is_file() {
4124                return Some(candidate);
4125            }
4126        }
4127    }
4128    None
4129}
4130
4131fn file_url(path: &Path) -> String {
4132    let raw = path.to_string_lossy().replace('\\', "/");
4133    let normalized = if raw.starts_with('/') {
4134        raw
4135    } else {
4136        format!("/{raw}")
4137    };
4138
4139    let mut encoded = String::with_capacity(normalized.len() + 8);
4140    for byte in normalized.bytes() {
4141        match byte {
4142            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
4143                encoded.push(byte as char);
4144            }
4145            _ => {
4146                let _ = write!(encoded, "%{byte:02X}");
4147            }
4148        }
4149    }
4150
4151    format!("file://{encoded}")
4152}
4153
4154fn file_row_view(file: &FileRecord) -> FileRow {
4155    FileRow {
4156        relative_path: file.relative_path.clone(),
4157        language: file.language.map_or_else(
4158            || "-".into(),
4159            |language| language.display_name().to_string(),
4160        ),
4161        total_physical_lines: file.raw_line_categories.total_physical_lines,
4162        code_lines: file.effective_counts.code_lines,
4163        comment_lines: file.effective_counts.comment_lines,
4164        blank_lines: file.effective_counts.blank_lines,
4165        mixed_lines_separate: file.effective_counts.mixed_lines_separate,
4166        functions: file.raw_line_categories.functions,
4167        classes: file.raw_line_categories.classes,
4168        variables: file.raw_line_categories.variables,
4169        imports: file.raw_line_categories.imports,
4170        test_count: file.raw_line_categories.test_count,
4171        test_assertion_count: file.raw_line_categories.test_assertion_count,
4172        test_suite_count: file.raw_line_categories.test_suite_count,
4173        line_cov_pct: file
4174            .coverage
4175            .as_ref()
4176            .map(|c| format!("{:.1}", c.line_pct()))
4177            .unwrap_or_default(),
4178        fn_cov_pct: file
4179            .coverage
4180            .as_ref()
4181            .filter(|c| c.functions_found > 0)
4182            .map(|c| format!("{:.1}", c.function_pct()))
4183            .unwrap_or_default(),
4184        branch_cov_pct: file
4185            .coverage
4186            .as_ref()
4187            .filter(|c| c.branches_found > 0)
4188            .map(|c| format!("{:.1}", c.branch_pct()))
4189            .unwrap_or_default(),
4190        cov_lines_detail: file.coverage.as_ref().map_or_else(String::new, |c| {
4191            format!("{}/{}", c.lines_hit, c.lines_found)
4192        }),
4193        status: format!("{:?}", file.status),
4194        status_class: format!("{:?}", file.status).to_ascii_lowercase(),
4195        warnings: if file.warnings.is_empty() {
4196            String::new()
4197        } else {
4198            file.warnings.join("; ")
4199        },
4200    }
4201}
4202
4203fn is_pacific_dst_report(dt: DateTime<Utc>) -> bool {
4204    use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Weekday};
4205    let year = dt.year();
4206    let nth_sun = |month: u32, n: u32, hour: u32| {
4207        let mut count = 0u32;
4208        let mut day = 1u32;
4209        loop {
4210            let d = NaiveDate::from_ymd_opt(year, month, day).expect("valid");
4211            if d.weekday() == Weekday::Sun {
4212                count += 1;
4213                if count == n {
4214                    return Utc.from_utc_datetime(
4215                        &d.and_time(NaiveTime::from_hms_opt(hour, 0, 0).expect("valid")),
4216                    );
4217                }
4218            }
4219            day += 1;
4220        }
4221    };
4222    let dst_start = nth_sun(3, 2, 10);
4223    let dst_end = nth_sun(11, 1, 9);
4224    dt >= dst_start && dt < dst_end
4225}
4226
4227fn to_pst_display(dt: DateTime<Utc>) -> String {
4228    let (offset, label) = if is_pacific_dst_report(dt) {
4229        (
4230            FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4231            "PDT",
4232        )
4233    } else {
4234        (
4235            FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4236            "PST",
4237        )
4238    };
4239    format!(
4240        "{} {label}",
4241        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
4242    )
4243}
4244
4245/// Format a UTC `DateTime` as "YYYY-MM-DD HH:MM PDT/PST" (no seconds).
4246fn to_pt_hhmm(dt: DateTime<Utc>) -> String {
4247    let (offset, label) = if is_pacific_dst_report(dt) {
4248        (
4249            FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4250            "PDT",
4251        )
4252    } else {
4253        (
4254            FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4255            "PST",
4256        )
4257    };
4258    format!(
4259        "{} {label}",
4260        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M")
4261    )
4262}
4263
4264/// Parse an RFC 3339 / ISO 8601 git commit date string and reformat it as
4265/// "YYYY-MM-DD HH:MM PDT/PST", converting from the embedded offset to Pacific time.
4266fn fmt_commit_date_pt(s: &str) -> String {
4267    use chrono::DateTime as ChronoDateTime;
4268    ChronoDateTime::parse_from_rfc3339(s).map_or_else(
4269        |_| s.replace('T', " "),
4270        |dt| to_pt_hhmm(dt.with_timezone(&Utc)),
4271    )
4272}
4273
4274fn build_warning_console(warnings: &[String]) -> String {
4275    if warnings.is_empty() {
4276        return "No top-level warnings.".to_string();
4277    }
4278
4279    warnings
4280        .iter()
4281        .enumerate()
4282        .map(|(index, warning)| {
4283            format!(
4284                "[{index:03}] {warning}",
4285                index = index + 1,
4286                warning = warning
4287            )
4288        })
4289        .collect::<Vec<_>>()
4290        .join("\n")
4291}
4292
4293fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
4294    let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
4295    for warning in warnings {
4296        let key = if warning.contains("unsupported or undetected language") {
4297            "Unsupported or undetected text formats"
4298        } else if warning.contains("file exceeded max_file_size_bytes") {
4299            "Large files skipped by size limit"
4300        } else if warning.contains("binary file skipped by default") {
4301            "Binary assets skipped"
4302        } else if warning.contains("minified file skipped by policy") {
4303            "Minified files skipped by policy"
4304        } else if warning.contains("vendor file skipped by policy") {
4305            "Vendor files skipped by policy"
4306        } else if warning.contains("best effort") || warning.contains("unclosed string literal") {
4307            "Best-effort parse results"
4308        } else {
4309            "Other warnings"
4310        };
4311        *counts.entry(key).or_default() += 1;
4312    }
4313
4314    counts
4315        .into_iter()
4316        .map(|(label, count)| {
4317            let (tone_class, detail) = match label {
4318                "Unsupported or undetected text formats" => (
4319                    "tone-neutral",
4320                    "These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
4321                ),
4322                "Large files skipped by size limit" => (
4323                    "tone-warn",
4324                    "Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
4325                ),
4326                "Binary assets skipped" => (
4327                    "tone-neutral",
4328                    "Binary bundles are excluded from source counting unless you explicitly opt into them.",
4329                ),
4330                "Minified files skipped by policy" => (
4331                    "tone-warn",
4332                    "Generated and minified assets are being filtered out to avoid inflating code totals.",
4333                ),
4334                "Vendor files skipped by policy" => (
4335                    "tone-neutral",
4336                    "Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
4337                ),
4338                "Best-effort parse results" => (
4339                    "tone-danger",
4340                    "These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
4341                ),
4342                _ => (
4343                    "tone-danger",
4344                    "Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
4345                ),
4346            };
4347
4348            WarningSummaryRow {
4349                label: label.to_string(),
4350                count,
4351                tone_class: tone_class.to_string(),
4352                detail: detail.to_string(),
4353            }
4354        })
4355        .collect()
4356}
4357
4358/// Classify an unsupported-language warning path into a named bucket.
4359fn classify_unsupported_path(path: &str) -> &'static str {
4360    let ext_lc = Path::new(path)
4361        .extension()
4362        .and_then(|e| e.to_str())
4363        .map(str::to_ascii_lowercase)
4364        .unwrap_or_default();
4365
4366    if ext_lc == "md"
4367        || path.ends_with("README")
4368        || path.ends_with("README.md")
4369        || path.ends_with("LICENSE")
4370    {
4371        "Documentation / text"
4372    } else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
4373        "JSON manifests and config"
4374    } else if ext_lc == "toml"
4375        || path.ends_with("MANIFEST.in")
4376        || path.ends_with("requirements.txt")
4377    {
4378        "Project metadata and packaging"
4379    } else if ext_lc == "html" {
4380        "HTML templates"
4381    } else if ext_lc == "txt" {
4382        "Plain text assets"
4383    } else if ext_lc.is_empty() {
4384        "Extensionless or custom text files"
4385    } else {
4386        "Other unsupported text formats"
4387    }
4388}
4389
4390/// Map a bucket label to its recommendation string.
4391fn bucket_recommendation(label: &str) -> String {
4392    match label {
4393        "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(),
4394        "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(),
4395        "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(),
4396        "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(),
4397        "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(),
4398        "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(),
4399        _ => "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(),
4400    }
4401}
4402
4403/// Short human-readable description of what each bucket means.
4404fn bucket_description(label: &str) -> String {
4405    match label {
4406        "Documentation / text" => {
4407            "README, LICENSE, and markdown files — not source code.".to_string()
4408        }
4409        "JSON manifests and config" => {
4410            "JSON configuration or manifest files (package.json, lockfiles, etc.).".to_string()
4411        }
4412        "Project metadata and packaging" => {
4413            "TOML, requirements.txt, and MANIFEST.in files that describe package metadata."
4414                .to_string()
4415        }
4416        "HTML templates" => {
4417            "HTML files not covered by the built-in HTML analyzer (unexpected extension or path)."
4418                .to_string()
4419        }
4420        "Plain text assets" => "Plain .txt files that have no analyzable structure.".to_string(),
4421        "Extensionless or custom text files" => {
4422            "Files with no extension that could not be language-detected.".to_string()
4423        }
4424        _ => "Files with an unrecognized extension or format.".to_string(),
4425    }
4426}
4427
4428fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
4429    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4430    let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
4431
4432    for warning in warnings {
4433        if !warning.contains("unsupported or undetected language") {
4434            continue;
4435        }
4436
4437        let path = warning
4438            .split_once(':')
4439            .map(|(path, _)| path.trim())
4440            .unwrap_or_default();
4441        if path.is_empty() {
4442            continue;
4443        }
4444
4445        let bucket = classify_unsupported_path(path);
4446        *counts.entry(bucket.to_string()).or_default() += 1;
4447
4448        let ex = examples.entry(bucket.to_string()).or_default();
4449        if ex.len() < 3 {
4450            let basename = Path::new(path)
4451                .file_name()
4452                .and_then(|n| n.to_str())
4453                .unwrap_or(path)
4454                .to_string();
4455            if !ex.contains(&basename) {
4456                ex.push(basename);
4457            }
4458        }
4459    }
4460
4461    let mut rows = counts.into_iter().collect::<Vec<_>>();
4462    rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4463
4464    rows.into_iter()
4465        .map(|(label, count)| {
4466            let recommendation = bucket_recommendation(&label);
4467            let bucket_description = bucket_description(&label);
4468            let example_files = examples.remove(&label).unwrap_or_default();
4469            WarningOpportunityRow {
4470                label,
4471                count,
4472                recommendation,
4473                example_files,
4474                bucket_description,
4475            }
4476        })
4477        .collect()
4478}
4479
4480#[derive(Debug, Clone)]
4481struct LanguageRow {
4482    language: String,
4483    files: u64,
4484    total_physical_lines: u64,
4485    code_lines: u64,
4486    comment_lines: u64,
4487    blank_lines: u64,
4488    mixed_lines_separate: u64,
4489    functions: u64,
4490    classes: u64,
4491    variables: u64,
4492    imports: u64,
4493    test_count: u64,
4494    test_assertion_count: u64,
4495    test_suite_count: u64,
4496    test_density_str: String,
4497}
4498
4499#[derive(Debug, Clone)]
4500struct FileRow {
4501    relative_path: String,
4502    language: String,
4503    total_physical_lines: u64,
4504    code_lines: u64,
4505    comment_lines: u64,
4506    blank_lines: u64,
4507    mixed_lines_separate: u64,
4508    functions: u64,
4509    classes: u64,
4510    variables: u64,
4511    imports: u64,
4512    test_count: u64,
4513    test_assertion_count: u64,
4514    test_suite_count: u64,
4515    /// Line coverage percentage, e.g. "96.7" — empty string when no coverage data.
4516    line_cov_pct: String,
4517    /// Function coverage percentage — empty string when no coverage data.
4518    fn_cov_pct: String,
4519    /// Branch coverage percentage — empty string when no branch coverage data.
4520    branch_cov_pct: String,
4521    /// Lines hit out of lines found, e.g. "142/156" — empty string when no coverage data.
4522    cov_lines_detail: String,
4523    status: String,
4524    status_class: String,
4525    warnings: String,
4526}
4527
4528#[derive(Debug, Clone)]
4529struct WarningSummaryRow {
4530    label: String,
4531    count: usize,
4532    tone_class: String,
4533    detail: String,
4534}
4535
4536#[derive(Debug, Clone)]
4537struct WarningOpportunityRow {
4538    label: String,
4539    count: usize,
4540    recommendation: String,
4541    /// Up to 3 example file names (basename only) that triggered this bucket.
4542    example_files: Vec<String>,
4543    /// Short description of what this bucket means for the user.
4544    bucket_description: String,
4545}
4546
4547#[derive(Template)]
4548#[template(
4549    source = r##"<!doctype html>
4550<html lang="en">
4551<head>
4552  <meta charset="utf-8" />
4553  <meta name="viewport" content="width=device-width, initial-scale=1" />
4554  <title>{{ browser_title }}</title>
4555  <link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
4556  <style nonce="{{ nonce }}">
4557    :root {
4558      --radius: 18px;
4559      --bg: #f5efe8;
4560      --surface: rgba(255,255,255,0.82);
4561      --surface-2: #fbf7f2;
4562      --surface-3: #efe6dc;
4563      --line: #e6d0bf;
4564      --line-strong: #dcb89f;
4565      --text: #43342d;
4566      --muted: #7b675b;
4567      --muted-2: #a08777;
4568      --nav: #b85d33;
4569      --nav-2: #7a371b;
4570      --accent: #6f9bff;
4571      --accent-2: #4a78ee;
4572      --oxide: #d37a4c;
4573      --oxide-2: #b35428;
4574      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
4575      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
4576      --good-bg: #e8f5ed;
4577      --good-text: #1a8f47;
4578      --warn-bg: #fff4dc;
4579      --warn-text: #9a6d00;
4580      --danger-bg: #fdebec;
4581      --danger-text: #cc4b4b;
4582      --info-bg: #f2f6ff;
4583      --info-text: #4467d8;
4584    }
4585    {% if let Some(hex) = accent_hex %}
4586    :root, body.dark-theme { --accent: {{ hex }}; --accent-2: {{ hex }}; }
4587    {% endif %}
4588    body.dark-theme {
4589      --bg: #1b1511;
4590      --surface: #261c17;
4591      --surface-2: #2d221d;
4592      --surface-3: #372922;
4593      --line: #524238;
4594      --line-strong: #6c5649;
4595      --text: #f5ece6;
4596      --muted: #c7b7aa;
4597      --muted-2: #aa9485;
4598      --nav: #b85d33;
4599      --nav-2: #7a371b;
4600      --accent: #6f9bff;
4601      --accent-2: #4a78ee;
4602      --oxide: #d37a4c;
4603      --oxide-2: #b35428;
4604      --shadow: 0 18px 42px rgba(0,0,0,0.28);
4605      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
4606      --good-bg: #163927;
4607      --good-text: #8fe2a8;
4608      --warn-bg: #3c2d11;
4609      --warn-text: #f3cb75;
4610      --danger-bg: #3d1f1f;
4611      --danger-text: #ff9f9f;
4612      --info-bg: #202e55;
4613      --info-text: #a9c1ff;
4614    }
4615    * { box-sizing: border-box; }
4616    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); }
4617    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
4618    .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); }
4619    .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; }
4620    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
4621    .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)); }
4622    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4623    .background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
4624    .code-particles { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4625    .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; }
4626    @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)); } }
4627    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
4628    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
4629    .brand-subtitle { color: rgba(255,255,255,0.72); font-size: 11px; line-height: 1.2; margin-top: 2px; letter-spacing: 0.01em; }
4630    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
4631    .nav-project-pill, .nav-pill, .theme-toggle, .header-button {
4632      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;
4633    }
4634    .nav-project-pill { pointer-events: auto; width: 100%; max-width: 300px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4635    .nav-project-label { color: rgba(255,255,255,0.72); text-transform: uppercase; letter-spacing: 0.09em; font-size: 10px; font-weight: 800; }
4636    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 13px; }
4637    .nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:nowrap; min-width:0; }
4638    @media (max-width: 1400px) { .nav-status { gap: 6px; } .header-button, .theme-toggle { padding: 0 10px; } }
4639    @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; } }
4640    .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; }
4641    .theme-toggle:hover, .header-button:hover { background: rgba(255,255,255,0.18); transform: translateY(-1px); }
4642    .theme-toggle { width: 38px; justify-content:center; padding:0; }
4643    .nav-dropdown-wrap { position: relative; }
4644    .nav-dropdown-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 6px; }
4645    .nav-dropdown-trigger { }
4646    .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); }
4647    .nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
4648    .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; }
4649    .nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
4650    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
4651    .theme-toggle .icon-sun { display:none; }
4652    body.dark-theme .theme-toggle .icon-sun { display:block; }
4653    body.dark-theme .theme-toggle .icon-moon { display:none; }
4654    .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;}
4655    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
4656    .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);}
4657    .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;}
4658    .settings-close:hover{color:var(--text);background:var(--surface-2);}
4659    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
4660    .settings-modal-body{padding:14px 16px 16px;}
4661    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
4662    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
4663    .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;}
4664    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
4665    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
4666    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
4667    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
4668    .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;}
4669    .tz-select:focus{border-color:var(--oxide);}
4670    .page { max-width: 1720px; margin: 0 auto; padding: 32px 24px 40px; }
4671    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
4672    /* Uniform two-row card strip. JS pads the card count to even (revealing a
4673       reserve card when odd) and sets the column count to n/2, so the cards form
4674       exactly two full rows with every column aligned and every card the same
4675       width — no oversized card, no empty trailing cell. */
4676    .summary-grid { display:grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap:10px; align-items:stretch; }
4677    .panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
4678    .panel { padding: 20px; }
4679    .metric { padding: 11px 12px 20px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 70px; }
4680    .metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
4681    .metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
4682    .section-kicker { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
4683    .metric-value { margin-top: 6px; }
4684    .metric-big { display:block; font-size: 20px; font-weight: 900; color: var(--oxide); line-height: 1.15; letter-spacing: -0.02em; }
4685    .metric-exact { position: absolute; bottom: 6px; right: 10px; font-size: 12px; font-weight: 600; color: var(--muted); font-family: ui-monospace, monospace; }
4686    .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); }
4687    .metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
4688    .metric:hover .metric-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); }
4689    .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); }
4690    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
4691    .hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
4692    .run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
4693    @media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
4694    @media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
4695    .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; }
4696    .run-id-chip[data-copy] { cursor:pointer; }
4697    a.run-id-chip-link { text-decoration:none; cursor:pointer; }
4698    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); }
4699    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
4700    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
4701    .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; }
4702    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
4703    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
4704    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
4705    .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; }
4706    body.dark-theme .submodule-state-badge { color:var(--accent); background:rgba(111,155,255,0.13); border-color:rgba(111,155,255,0.28); }
4707    .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; }
4708    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4709    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4710    a.run-id-chip-link:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4711    .chip-copy-icon { display:inline-block; margin-left:5px; font-size:10px; opacity:0.55; vertical-align:middle; }
4712    .chip-label-icon { display:inline-block; vertical-align:middle; margin-right:3px; margin-top:-1px; opacity:0.8; }
4713    .chip-popout-icon { display:inline-block; vertical-align:middle; margin-left:4px; opacity:0.6; flex-shrink:0; }
4714    .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; }
4715    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
4716    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
4717    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
4718    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
4719    .subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
4720    .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%; }
4721    .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; }
4722    .meta-chip:last-child { border-right:none; }
4723    .meta-chip b { color:var(--text); font-weight:700; }
4724    .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); }
4725    body.dark-theme .prev-scan-banner { box-shadow:0 4px 16px rgba(0,0,0,0.3); }
4726    .prev-scan-banner-empty { flex-direction:row; align-items:center; gap:8px; font-size:13px; color:var(--muted); font-style:italic; }
4727    .prev-scan-banner-top { display:flex; flex-direction:column; gap:4px; }
4728    .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; }
4729    .prev-scan-ts { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4730    .prev-scan-count { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4731    .prev-scan-summary { font-size:13px; font-weight:600; color:var(--text); }
4732    .prev-scan-summary b { font-weight:900; }
4733    .delta-neutral-text { color:var(--muted); }
4734    .delta-up { color:#2a6846; }
4735    .delta-down { color:#b23030; }
4736    body.dark-theme .delta-up { color:#5aba8a; }
4737    body.dark-theme .delta-down { color:#e07070; }
4738    .delta-card-row { display:grid; grid-template-columns:repeat(7,1fr); gap:12px; width:100%; }
4739    @media(max-width:1000px){ .delta-card-row { grid-template-columns:repeat(4,1fr); } }
4740    @media(max-width:540px){ .delta-card-row { grid-template-columns:repeat(2,1fr); } }
4741    .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); }
4742    .delta-card-inline:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.22); z-index:10; }
4743    body.dark-theme .delta-card-inline { box-shadow:0 2px 8px rgba(0,0,0,0.2); }
4744    body.dark-theme .delta-card-inline:hover { box-shadow:0 12px 32px rgba(0,0,0,0.55); }
4745    .delta-card-val { font-size:20px; font-weight:900; color:var(--oxide); line-height:1.2; }
4746    .delta-card-val.pos { color:#2a6846; }
4747    .delta-card-val.neg { color:#b23030; }
4748    .delta-card-val.mod { color:#7a5a10; }
4749    body.dark-theme .delta-card-val.pos { color:#5aba8a; }
4750    body.dark-theme .delta-card-val.neg { color:#e07070; }
4751    body.dark-theme .delta-card-val.mod { color:#d4a843; }
4752    .delta-card-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4753    .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); }
4754    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4755    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4756    .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); }
4757    .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); }
4758    body.dark-theme .delta-panel-link { box-shadow:0 2px 6px rgba(0,0,0,0.2); }
4759    body.dark-theme .delta-panel-link:hover { box-shadow:0 6px 18px rgba(0,0,0,0.45); }
4760    .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; }
4761    .toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
4762    .toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
4763    .search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
4764    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
4765    .pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
4766    .pill.good { background: var(--good-bg); color: var(--good-text); }
4767    .pill.info { background: var(--info-bg); color: var(--info-text); }
4768    .export-group { display:flex; gap:6px; align-items:center; }
4769    .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; }
4770    .export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
4771    .page-size-row { display:flex; align-items:center; gap:6px; }
4772    .page-size-label { font-size:13px; color:var(--muted); font-weight:600; }
4773    .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; }
4774    .page-count-label { font-size:12px; color:var(--muted); white-space:nowrap; }
4775    .pagination-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:10px 0 2px; }
4776    .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; }
4777    .pager-btn:hover:not(:disabled) { background:var(--accent); color:#fff; border-color:var(--accent); }
4778    .pager-btn:disabled { opacity:.4; cursor:default; }
4779    .pager-info { font-size:13px; color:var(--muted); font-weight:600; min-width:120px; text-align:center; }
4780    .pager-edge { font-size:12px; padding:5px 10px; }
4781    .pager-jump-wrap { font-size:13px; color:var(--muted); font-weight:600; display:flex; align-items:center; gap:5px; white-space:nowrap; }
4782    .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; }
4783    .pager-jump::-webkit-inner-spin-button,.pager-jump::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
4784    .table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
4785    /* Clip wrapper: hides the scrollbar track that hangs 8px past the right edge */
4786    .table-shell-clip { overflow: hidden !important; max-height: none !important; }
4787    /* Skipped-files scroll pane: auto so no phantom space when content is short */
4788    #skipped-shell { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--surface-2); }
4789    #per-file-table tbody tr:last-child td, #skipped-table tbody tr:last-child td { border-bottom: none; }
4790    #skipped-shell::-webkit-scrollbar { width: 8px; }
4791    #skipped-shell::-webkit-scrollbar-track { background: var(--surface-2); }
4792    #skipped-shell::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
4793    table { width: 100%; border-collapse: collapse; font-size: 14px; }
4794    th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
4795    th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
4796    /* Per-file detail table — auto layout so File column sizes to content */
4797    .table-resizable { table-layout: auto; }
4798    .table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
4799    .table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4800    .table-resizable td.mono { overflow: visible; text-overflow: unset; white-space: nowrap; }
4801    #skipped-table { table-layout: fixed; width: 100%; }
4802    #skipped-table th, #skipped-table td { padding: 7px 8px; }
4803    #skipped-table td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4804    /* Hotspots table — overflow visible so header tooltips can escape the cell */
4805    #hotspots-table th { overflow: visible; }
4806    .hs-hint { color: var(--muted); font-style: italic; }
4807    /* Column-header explainer tooltip (shared visual language with .stat-chip-tip) */
4808    .col-tip { position: absolute; top: calc(100% + 9px); left: 0; z-index: 60; width: max-content; max-width: 270px;
4809      background: var(--text); color: var(--bg); padding: 9px 12px; border-radius: 9px;
4810      font-size: 11.5px; font-weight: 500; line-height: 1.5; letter-spacing: normal; text-transform: none;
4811      white-space: normal; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.22);
4812      opacity: 0; pointer-events: none; transition: opacity .18s ease; }
4813    .col-tip.col-tip-r { left: auto; right: 0; }
4814    .col-tip strong { color: var(--bg); }
4815    .col-tip::after { content: ''; position: absolute; bottom: 100%; left: 16px;
4816      border: 6px solid transparent; border-bottom-color: var(--text); }
4817    .col-tip.col-tip-r::after { left: auto; right: 16px; }
4818    #hotspots-table th:hover .col-tip { opacity: 1; }
4819    /* Column resize handle */
4820    .col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
4821    .col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
4822    #per-file-table { table-layout: fixed; width: 100%; min-width: 0; }
4823    #per-file-table th, #per-file-table td { padding: 8px 6px; }
4824    /* File column: pinned, truncates long paths */
4825    #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; }
4826    #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; }
4827    #per-file-table th:nth-child(2) { width: 6%; }
4828    /* 12 numeric columns share the remaining 68%: 26+6+12×5.67≈98% total */
4829    #per-file-table th:nth-child(n+3) { width: 5.67%; }
4830    /* Override mono class overflow so file paths truncate */
4831    #per-file-table td.mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4832    #per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
4833    body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
4834    /* Language breakdown: auto layout with resizable columns — headers size to content */
4835    #lang-breakdown-table { width: 100%; min-width: 760px; }
4836    #lang-breakdown-table th, #lang-breakdown-table td { padding: 8px 6px; font-size: 13px; }
4837    /* Skipped table: extend truncation to all cells, not just the first column */
4838    #skipped-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
4839    /* Support opportunities table: fixed layout so Category/Count columns don't grow */
4840    .support-table { table-layout: fixed; width: 100%; }
4841    .support-table th:first-child { width: 20%; }
4842    .support-table th:nth-child(2) { width: 6%; }
4843    .support-table th:nth-child(3) { width: 24%; }
4844    .support-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
4845    /* Description and example columns must wrap — content can be long */
4846    .support-table td:nth-child(3) { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; line-height: 1.45; }
4847    .support-table td:last-child { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; }
4848    .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; }
4849    .support-recommendation { color: var(--muted); font-size: 11px; margin: 6px 0 0; line-height: 1.5; }
4850    .num-col { text-align: right !important; }
4851    /* Per-file coverage table: fixed layout keeps numeric columns compact and off the shell border */
4852    .cov-file-table { table-layout: fixed; }
4853    .cov-file-table th:not(:first-child), .cov-file-table td:not(:first-child) { width: 150px; }
4854    .cov-file-table th.num-col, .cov-file-table td.num-col { padding-right: 16px; }
4855    tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
4856    body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
4857    tr:last-child td { border-bottom: none; }
4858    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4859    .small { color: var(--muted); font-size: 13px; }
4860    .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; }
4861    .status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
4862    .status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
4863    .status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
4864    .stack { display:grid; gap:22px; }
4865    .summary-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
4866    @media(max-width:800px) { .summary-strip { grid-template-columns:repeat(2,1fr) !important; } }
4867    .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); }
4868    .test-density-num { font-size:22px; font-weight:900; color:var(--oxide); line-height:1; }
4869    .test-density-meta { display:flex; flex-direction:column; gap:2px; }
4870    .test-density-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4871    .test-density-sub { font-size:11px; color:var(--muted-2); }
4872    .test-density-badge { margin-left:auto; padding:4px 12px; border-radius:999px; font-size:12px; font-weight:700; }
4873    .test-density-badge.good { background:var(--good-bg); color:var(--good-text); }
4874    .test-density-badge.warn { background:var(--warn-bg); color:var(--warn-text); }
4875    .test-density-badge.danger { background:var(--danger-bg); color:var(--danger-text); }
4876    .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; }
4877    .info-callout-icon { flex:0 0 auto; font-size:15px; margin-top:1px; }
4878    .info-callout code { background:rgba(68,103,216,0.12); border-radius:4px; padding:1px 5px; font-size:12px; }
4879    body.dark-theme .info-callout { background:rgba(100,130,255,0.09); border-color:rgba(100,130,255,0.22); }
4880    .empty-state-row td { text-align:center; padding:20px; color:var(--muted-2); font-size:13px; font-style:italic; }
4881    /* auto-fit so a lone gauge card (line-only coverage) spans the full width instead of 1/3 */
4882    .cov-gauge-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:16px; margin-bottom:18px; }
4883    @media(max-width:700px) { .cov-gauge-row { grid-template-columns:1fr; } }
4884    .cov-gauge-card { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; display:flex; flex-direction:column; gap:8px; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); min-width:0; }
4885    .cov-gauge-card:hover { transform:translateY(-3px); box-shadow:0 10px 28px rgba(77,44,20,0.15); z-index:10; }
4886    .cov-gauge-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; font-weight:500; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
4887    .cov-gauge-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4888    .cov-gauge-card:hover .cov-gauge-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4889    .cov-gauge-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4890    .cov-gauge-val { font-size:32px; font-weight:900; line-height:1; }
4891    .cov-gauge-track { height:8px; border-radius:4px; background:var(--line); overflow:hidden; }
4892    .cov-gauge-fill { height:100%; border-radius:4px; transition:width .5s ease; }
4893    .cov-gauge-sub { font-size:11px; color:var(--muted); }
4894    .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); }
4895    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
4896    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
4897    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4898    .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); }
4899    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4900    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4901    .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; }
4902    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
4903    .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); }
4904    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
4905    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
4906    .report-stack { display:grid; gap: 18px; align-items:start; }
4907    pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
4908    .warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
4909    .sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
4910    .warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
4911    .warning-card { padding: 10px 12px; }
4912    .warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
4913    .warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
4914    .tone-neutral .count { color: var(--text); }
4915    .tone-warn .count { color: var(--warn-text); }
4916    .tone-danger .count { color: var(--danger-text); }
4917    .tone-neutral .warning-count { color: var(--oxide); }
4918    .tone-warn .warning-count { color: var(--warn-text); }
4919    .tone-danger .warning-count { color: var(--danger-text); }
4920    .support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
4921    .support-table th { cursor: default; }
4922    details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
4923    summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
4924    details > div { padding: 0 16px 16px; }
4925    .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; }
4926    .warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
4927    .warning-console.hidden { display:none; }
4928    @media (max-width: 1200px) {
4929      .warning-grid { grid-template-columns: 1fr 1fr; }
4930    }
4931    @media (max-width: 960px) {
4932      .top-nav-inner { grid-template-columns: 1fr; }
4933      .nav-project-slot, .nav-status { justify-content:flex-start; }
4934      .warning-grid, .report-stack { grid-template-columns: 1fr; }
4935      .hero-top { flex-direction: column; }
4936      .search { min-width: 100%; width: 100%; }
4937    }
4938    @media (max-width: 640px) {
4939      .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
4940    }
4941    /* ── Report header / footer identification banner ─────────────────── */
4942    .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; }
4943    .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; }
4944    body.has-report-banner .top-nav { top: 27px; }
4945    body.has-report-banner { padding-bottom: 27px; }
4946    /* ── Print & PDF export ──────────────────────────────────────────── */
4947    @page { size: A4 landscape; margin: 0.35in 0.5in; }
4948
4949    @media print {
4950      *, *::before, *::after {
4951        -webkit-print-color-adjust: exact !important;
4952        print-color-adjust: exact !important;
4953        box-sizing: border-box !important;
4954      }
4955
4956      html, body {
4957        background: #f5efe8 !important;
4958        min-height: auto !important;
4959        width: 100% !important;
4960      }
4961
4962      /* Report id banner — fixed position repeats the banner on every printed page */
4963      .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; }
4964      .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; }
4965      body.has-report-banner .top-nav { top: 0 !important; }
4966      body.has-report-banner { padding-bottom: 0 !important; }
4967      /* Hide interactive UI-chrome; keep section heading text visible */
4968      .top-nav, .hero-actions,
4969      .background-watermarks, #code-particles,
4970      .header-button, .theme-toggle,
4971      .nav-dropdown-wrap, .config-actions,
4972      .warnings-show-link, .warning-console-actions,
4973      .toolbar .pill-row, .toolbar .export-group,
4974      input[type="search"], button { display: none !important; }
4975      /* Show toolbar as a plain block so h2 headings are visible */
4976      .toolbar { display: block !important; margin-bottom: 8px !important; }
4977      .toolbar-left { display: block !important; }
4978
4979      /* Remove page-level layout constraints */
4980      .page {
4981        max-width: none !important;
4982        width: 100% !important;
4983        padding: 0 !important;
4984        margin: 0 !important;
4985      }
4986
4987      .panel, .hero, .section,
4988      .saved-report-shell, .saved-panel, .report-shell, .stack {
4989        max-width: none !important;
4990        width: 100% !important;
4991        box-shadow: none !important;
4992        border: 1px solid #ddd !important;
4993        border-radius: 10px !important;
4994        margin-bottom: 10px !important;
4995        overflow: visible !important;
4996      }
4997
4998      /* Force grids to their full-width column counts regardless of viewport */
4999      .summary-grid {
5000        display: grid !important;
5001        grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
5002        gap: 10px !important;
5003      }
5004
5005      .warning-grid {
5006        display: grid !important;
5007        grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
5008        gap: 8px !important;
5009      }
5010
5011      .report-stack {
5012        display: grid !important;
5013        gap: 12px !important;
5014        align-items: start !important;
5015      }
5016
5017      /* Metric cards */
5018      .metric {
5019        box-shadow: none !important;
5020        border: 1px solid #e0d0c0 !important;
5021        border-radius: 8px !important;
5022        break-inside: avoid !important;
5023        padding: 10px 12px 22px !important;
5024        min-height: 0 !important;
5025      }
5026
5027      .metric-big { font-size: 20px !important; }
5028      .metric-exact { font-size: 10px !important; bottom: 5px !important; right: 8px !important; }
5029      .metric-label { font-size: 10px !important; }
5030
5031      /* Page break control — small atomic cards stay together; large panels
5032         and tables flow freely so they never force blank pages. */
5033      .metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
5034      .hero, .panel, .stack { break-inside: auto !important; }
5035      section { break-inside: auto !important; }
5036      /* Keep each chart panel whole — browser moves it to the next page rather than
5037         slicing through the middle of a canvas. */
5038      .chart-section { break-inside: avoid !important; }
5039      /* Keep the summary grid on the same page as the hero header when possible */
5040      .summary-grid { break-before: avoid !important; }
5041      /* Section headings never orphan at the bottom of a page */
5042      h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
5043      /* Keep the first few rows of a table with the header */
5044      thead { break-after: avoid !important; }
5045
5046      /* Language charts — table layout is inherently side-by-side */
5047      #lang-overview-charts table { display: inline-table !important; }
5048      #lang-overview-charts td { vertical-align: top !important; }
5049
5050      /* Tables */
5051      .table-shell {
5052        max-height: none !important;
5053        overflow: visible !important;
5054        width: 100% !important;
5055        break-inside: auto !important;
5056      }
5057
5058      table {
5059        width: 100% !important;
5060        table-layout: auto !important;
5061        font-size: 10px !important;
5062        border-collapse: collapse !important;
5063        orphans: 4 !important;
5064        widows: 4 !important;
5065      }
5066
5067      /* Remove the screen-layout min-width so tables scale to page width */
5068      #per-file-table, #skipped-table { min-width: 0 !important; }
5069      /* Release sticky column positioning (not meaningful on paper) */
5070      #per-file-table th:first-child,
5071      #per-file-table td:first-child { position: static !important; }
5072      /* Show ALL rows — JS pagination hides rows via inline style; !important overrides it */
5073      #per-file-table tbody tr, #skipped-table tbody tr, #hotspots-table tbody tr { display: table-row !important; }
5074      /* Hide pagination controls — not interactive in PDF */
5075      .page-size-row, .pagination-bar { display: none !important; }
5076      /* Header tooltips and the interaction hint are screen-only */
5077      .col-tip { display: none !important; }
5078      .hs-hint { display: none !important; }
5079
5080      thead { display: table-header-group; }
5081      tr { break-inside: avoid !important; }
5082
5083      th {
5084        position: relative !important;
5085        font-size: 9px !important;
5086        font-weight: 700 !important;
5087        color: #333 !important;
5088        padding: 5px 8px !important;
5089        background: rgba(211,122,76,0.18) !important;
5090        white-space: normal !important;
5091      }
5092      /* Resize handles are screen-only — hide them in print */
5093      .col-resize-handle { display: none !important; }
5094      /* Sort indicators are redundant on paper */
5095      .sort-indicator { display: none !important; }
5096
5097      td {
5098        white-space: normal !important;
5099        overflow-wrap: anywhere !important;
5100        word-break: break-word !important;
5101        padding: 5px 8px !important;
5102        font-size: 10px !important;
5103        border-bottom: 1px solid #e8d8c8 !important;
5104      }
5105
5106      pre, code {
5107        white-space: pre-wrap !important;
5108        overflow-wrap: anywhere !important;
5109        word-break: break-word !important;
5110        font-size: 9px !important;
5111        max-height: none !important;
5112      }
5113
5114      .warning-card {
5115        box-shadow: none !important;
5116        border: 1px solid #ddd !important;
5117        break-inside: avoid !important;
5118        padding: 10px !important;
5119      }
5120
5121      .hero-top { flex-direction: row !important; }
5122
5123      .run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
5124      .run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
5125      .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; }
5126      .meta-chip { flex: 1 !important; justify-content: center !important; font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
5127      .meta-chip:last-child { border-right: none !important; }
5128
5129      .report-footer {
5130        border-top: 1px solid #ccc !important;
5131        margin-top: 12px !important;
5132        font-size: 10px !important;
5133      }
5134
5135      /* Collapse all <details> in print except the warnings block */
5136      details { border: 1px solid #ddd !important; border-radius: 8px !important; }
5137      details > summary { display: block !important; font-size: 10px !important; }
5138      details > div { display: none !important; }
5139      .warning-console { display: none !important; }
5140      .warning-console-actions { display: none !important; }
5141      /* Always expand the run-warnings details in PDF */
5142      details.warnings-details > div { display: block !important; }
5143      details.warnings-details .warning-console {
5144        display: block !important;
5145        max-height: none !important;
5146        overflow: visible !important;
5147        font-size: 8px !important;
5148        white-space: pre-wrap !important;
5149        word-break: break-all !important;
5150      }
5151      details.warnings-details .code-block-toolbar { display: none !important; }
5152
5153      /* Pill badges */
5154      .pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
5155
5156      /* Support opportunities table */
5157      .support-table td:first-child { font-weight: 600; font-size: 10px !important; }
5158
5159      /* Hide canvas-based interactive chart sections; replaced by pre-rendered variants */
5160      .chart-section { display: none !important; }
5161      .charts-grid   { display: none !important; }
5162      /* Pre-rendered chart variants — no forced page break; flow naturally after hero section */
5163      .pdf-variants-root { display: block !important; padding: 0 !important; }
5164      .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; }
5165      .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; }
5166      .pdf-variant-grid { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
5167      /* Single-column chart (scatter, etc.) — centre and constrain width in print */
5168      .pdf-variant-grid.single-col { grid-template-columns: 1fr !important; }
5169      .pdf-variant-grid.single-col .pdf-variant-panel { max-width: 62% !important; margin: 0 auto !important; }
5170      .pdf-variant-panel { break-inside: avoid !important; }
5171      .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; }
5172      .pdf-variant-img { width: 100% !important; height: auto !important; display: block !important; border-radius: 5px !important; border: 1px solid #ddd !important; }
5173    }
5174
5175
5176    .warnings-show-link {
5177      display: inline-flex;
5178      align-items: center;
5179      gap: 8px;
5180      padding: 8px 12px;
5181      border-radius: 10px;
5182      border: 1px solid rgba(111, 144, 255, 0.35);
5183      background: #eef3ff;
5184      color: #2f5fe3 !important;
5185      font-weight: 800;
5186      text-decoration: none;
5187      box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
5188    }
5189
5190    body.dark-theme .warnings-show-link {
5191      background: #1c2847;
5192      color: #a9c1ff !important;
5193      border-color: rgba(169, 193, 255, 0.32);
5194    }
5195
5196    .effective-config-note {
5197      margin: 8px 0 0;
5198      color: var(--muted);
5199      font-size: 14px;
5200      line-height: 1.6;
5201    }
5202    .config-actions { display: flex; gap: 8px; flex-shrink: 0; }
5203    .config-pre-wrap { position: relative; }
5204    .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; }
5205    body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
5206    .code-block-toolbar { display:flex; justify-content:flex-end; margin-bottom:6px; }
5207    .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; }
5208    .code-copy-btn:hover { background: rgba(184,93,51,0.08); color: var(--oxide-2); border-color: rgba(184,93,51,0.30); }
5209    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); }
5210    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); }
5211
5212
5213    .page {
5214      position: relative;
5215      z-index: 1;
5216    }
5217    .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; }
5218
5219    /* ── Chart controls & containers ───────────────────────────────────── */
5220    .chart-section { }
5221    .chart-controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
5222    .chart-controls label { font-size:13px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:6px; }
5223    .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; }
5224    .chart-select:focus { border-color:var(--accent); }
5225    .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; }
5226    .chart-expand-btn:hover { background:var(--surface-2); color:var(--text); }
5227    .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; }
5228    .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); }
5229    .chart-modal-title { font-size:15px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--text); margin:0 0 2px; display:block; }
5230    .chart-modal-subtitle { font-size:13px; font-weight:600; color:var(--muted); margin:0 0 16px; display:block; letter-spacing:.02em; }
5231    .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; }
5232    .chart-modal-close:hover { opacity:.7; }
5233    .chart-modal-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin:0 0 16px; padding-right:44px; }
5234    .chart-modal-header .chart-modal-title { flex:1 1 auto; margin:0; min-width:0; }
5235    body.dark-theme .chart-modal { background:var(--surface); }
5236    .chart-container { width:100%; overflow:visible; }
5237    .charts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
5238    .charts-grid > .panel { margin:0; min-width:0; display:flex; flex-direction:column; }
5239    .charts-grid .chart-section > div { display:flex; flex-direction:column; flex:1; }
5240    .charts-grid .chart-container { flex:1; min-height:180px; }
5241    .chart-pre { min-height:72px; }
5242    @media (max-width:820px) { .charts-grid { grid-template-columns:1fr; } }
5243    .r-lang-overview { display:flex; gap:40px; align-items:center; justify-content:center; flex-wrap:wrap; padding:8px 0 16px; }
5244    .r-lang-overview-cell { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 1 280px; max-width:480px; }
5245    .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; }
5246    .r-lang-overview svg { display:block; max-width:100%; height:auto; }
5247    .rchit { cursor:pointer; transition:opacity .17s,filter .17s,transform .17s; transform-box:fill-box; transform-origin:center center; }
5248    .rchit:hover { filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18)); transform:scale(1.05); }
5249    .lang-bar-row { cursor:pointer; transition:transform .2s cubic-bezier(.34,1.56,.64,1); }
5250    .lang-bar-row:hover { transform:translateY(-2px); }
5251    .lang-bar-row .rchit:hover { filter:none; transform:none; }
5252    .lang-bar-row:hover .rchit { filter:brightness(1.12); transform:scaleY(1.22); }
5253    #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; }
5254    .chart-tab-bar { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
5255    .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; }
5256    .chart-tab:hover { background:var(--surface-3); color:var(--text); }
5257    .chart-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
5258    .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; }
5259    .chart-locked-card a { color:var(--accent-2); font-weight:700; }
5260    .chart-locked-card h3 { margin:0 0 6px; font-size:15px; }
5261
5262    /* Print: hide interactive controls; keep SVGs; show locked card for history mode */
5263    @media print {
5264      .chart-controls, .chart-tab-bar { display:none !important; }
5265      /* Single-column grid: each chart gets full page width, renders shorter, fits on one page */
5266      .charts-grid { grid-template-columns: 1fr !important; gap: 10px !important; }
5267      /* Cap canvas height so a single chart never overflows a landscape page */
5268      canvas { max-width: 100% !important; max-height: 280px !important; }
5269      .chart-container { width: 100% !important; overflow: visible !important; }
5270      .chart-container svg { max-height:300px !important; }
5271      /* chart-locked-card: do NOT force display:block — let JS-set visibility carry
5272         into print (hidden in normal mode; visible only when history mode is active).
5273         When it does show, use readable dark text instead of accent blue. */
5274      .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; }
5275      .chart-locked-card h3 { color:#222 !important; font-size:13px !important; }
5276      .chart-locked-card a, .chart-locked-card strong { color:#1a4fa0 !important; }
5277      .chart-locked-card code { background:rgba(0,0,0,0.08) !important; padding:1px 4px !important; border-radius:3px !important; }
5278    }
5279
5280    /* PDF-only chart variants container — hidden on screen, rendered in print */
5281    .pdf-variants-root{display:none;}
5282    .pdf-variant-group{margin-bottom:12px;background:#faf6f0;border:1px solid #e0d0c0;border-radius:10px;padding:12px 14px;}
5283    .pdf-variant-group-title{font-size:15px;font-weight:800;color:#3d2d26;margin:0 0 8px;padding-bottom:5px;border-bottom:2px solid #d37a4c;}
5284    .pdf-variant-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
5285    /* Solo charts (one per row) — constrained width, centred */
5286    .pdf-variant-grid.single-col{grid-template-columns:1fr;}
5287    .pdf-variant-grid.single-col .pdf-variant-panel{max-width:62%;margin:0 auto;}
5288    .pdf-variant-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b675b;margin:0 0 3px;}
5289    .pdf-variant-img{width:100%;height:auto;display:block;border-radius:6px;border:1px solid #ddd;}
5290
5291    #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%);}
5292    #rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
5293    /* Drifting color blobs — transform/opacity only (GPU composited, no per-frame repaint) */
5294    .rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
5295    .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;}
5296    .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;}
5297    .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;}
5298    @keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
5299    @keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
5300    @keyframes rpt-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(-7vw,8vw,0) scale(1.22);}}
5301    body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
5302    body.dark-theme .rpt-bg-blob{opacity:.36;}
5303    .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;}
5304    @keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
5305    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);}
5306    /* Logo is static — no bounce (kept GPU-cheap) */
5307    .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;}
5308    .rpt-spinner-wrap{position:relative;width:90px;height:90px;}
5309    .rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
5310    .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));}
5311    @keyframes rpt-spin{to{transform:rotate(360deg);}}
5312    .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;}
5313    body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
5314    body.dark-theme .rpt-spinner-pct{color:#e8932f;}
5315    .rpt-load-divider{width:54px;height:1px;background:linear-gradient(90deg,transparent,rgba(196,92,16,.22),transparent);}
5316    .rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
5317    .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;}
5318    @keyframes rpt-text-shimmer{to{background-position:-220% center;}}
5319    .rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
5320    .rpt-dot:nth-child(2){animation-delay:.28s;}
5321    .rpt-dot:nth-child(3){animation-delay:.56s;}
5322    @keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
5323    .rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
5324    .rpt-status-in{animation:rpt-status-pop .38s ease both;}
5325    @keyframes rpt-status-pop{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
5326    .rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
5327    .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;}
5328    body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
5329    .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;}
5330    .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;}
5331    .rpt-feed-line::before{content:'>';color:#c45c10;font-weight:700;}
5332    @keyframes rpt-feed-in{from{opacity:0;transform:translateX(-6px);}to{opacity:.82;transform:none;}}
5333    body.dark-theme .rpt-feed{color:rgba(204,172,150,.62);}
5334    body.dark-theme .rpt-load-divider{background:linear-gradient(90deg,transparent,rgba(196,92,16,.28),transparent);}
5335    @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;}}
5336    /* ── Code Style Analysis section ── */
5337    .style-guide-grid{display:grid;gap:10px;}
5338    .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;}
5339    .style-guide-row:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(77,44,20,0.18);background:var(--surface-2);}
5340    .style-guide-label{font-size:12px;font-weight:800;color:var(--text);text-align:right;white-space:nowrap;}
5341    .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);}
5342    .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;}
5343    .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;}
5344    .style-guide-row:hover .style-guide-fill{filter:brightness(1.12);}
5345    .style-guide-score{font-size:12px;font-weight:800;color:var(--oxide);text-align:right;white-space:nowrap;}
5346    .style-guide-desc{font-size:10px;color:var(--muted);margin-top:2px;grid-column:2/3;}
5347    .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);}
5348    .style-bar-tip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:var(--text);}
5349    .style-guide-row:hover .style-bar-tip{opacity:1;}
5350    .style-metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:18px 0 0;}
5351    @media(max-width:800px){.style-metrics-strip{grid-template-columns:repeat(2,1fr);}}
5352    .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);}
5353    .style-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
5354    .style-chip-val{font-size:18px;font-weight:900;color:var(--oxide);}
5355    .style-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:3px;}
5356    .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;}
5357    .style-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
5358    .style-chip:hover .style-chip-tip{opacity:1;}
5359    .style-file-table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed;}
5360    .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;}
5361    .style-file-table th:hover{background:var(--surface-2);color:var(--text);}
5362    .style-sort-ind{display:inline-block;margin-left:4px;font-size:9px;opacity:.4;vertical-align:middle;}
5363    .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);}
5364    .style-file-table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
5365    .style-file-table tr:hover td{background:var(--surface-2);}
5366    .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;}
5367    .style-score-fill{position:absolute;left:0;top:0;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));}
5368    .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;}
5369    a.style-badge:hover{background:var(--oxide);color:#fff !important;transform:translateY(-1px);box-shadow:0 3px 10px rgba(77,44,20,.24);}
5370    .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);}
5371    .style-lang-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
5372    .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;}
5373    .style-lang-tab:hover{background:var(--surface-2);}
5374    .style-lang-tab.active{background:var(--oxide);color:#fff;border-color:var(--oxide);}
5375    .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;}
5376    .style-row-warn td{background:rgba(178,48,48,0.06)!important;}
5377    .style-row-warn td:first-child{border-left:3px solid #b23030;}
5378    .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;}
5379    .style-sig-more:hover{background:var(--oxide);color:#fff;}
5380    .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;}
5381    .style-sig-info-btn:hover{color:var(--oxide);}
5382    .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;}
5383    .style-sig-pop-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
5384    .style-sig-pop-row{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);}
5385    .style-sig-pop-row:last-child{border-bottom:none;}
5386    .style-sig-pop-key{color:var(--muted);font-weight:700;white-space:nowrap;flex-shrink:0;}
5387    .style-sig-pop-val{color:var(--text);}
5388    .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;}
5389    .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;}
5390    .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;}
5391    .style-sig-info-close:hover{color:var(--text);}
5392    .style-sig-info-grid{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin-top:14px;font-size:13px;}
5393    .style-sig-info-name{color:var(--oxide);font-weight:700;padding:2px 0;}
5394    .style-sig-info-desc{color:var(--text);padding:2px 0;}
5395    body.dark-theme .style-guide-track{background:var(--surface-3);}
5396    body.dark-theme .style-chip{background:var(--surface-2);}
5397    body.dark-theme .style-file-table th{background:var(--surface-3);}
5398    body.dark-theme .style-heuristic-note{border-color:rgba(100,130,255,0.22);}
5399    body.dark-theme .style-lang-tab{background:var(--surface-2);color:var(--text);}
5400    body.dark-theme .style-lang-tab.active{background:var(--oxide);color:#fff;}
5401    body.dark-theme .style-sig-chip{background:var(--surface-3);color:var(--muted);}
5402    body.dark-theme .style-sig-pop{background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.4);}
5403    body.dark-theme .style-sig-info-modal{background:var(--surface);}
5404    .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;}
5405    .sig-tip.visible{opacity:1;}
5406    .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);}
5407    .sig-tip-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:rgba(240,235,228,.5);margin-bottom:5px;}
5408    .sig-tip-row{display:flex;gap:8px;align-items:baseline;}
5409    .sig-tip-k{color:#e07b3a;font-weight:700;white-space:nowrap;flex-shrink:0;}
5410    .sig-tip-v{color:#f0ebe4;}
5411</style>
5412<script nonce="{{ nonce }}">{{ chart_js|safe }}</script>
5413</head>
5414<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
5415  <div id="rpt-loading-overlay" aria-live="polite" aria-label="Loading report">
5416    <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
5417    <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
5418    <div class="rpt-bg-blob rpt-blob-c" aria-hidden="true"></div>
5419    <div class="rpt-load-card">
5420      <img src="{{ small_logo_uri }}" alt="oxide-sloc" class="rpt-load-logo" />
5421      <div class="rpt-spinner-wrap">
5422        <div class="rpt-spinner-track"></div>
5423        <div class="rpt-spinner"></div>
5424        <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
5425      </div>
5426      <div class="rpt-load-divider"></div>
5427      <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>
5428      <div class="rpt-status" id="rpt-status">Initializing analysis engine</div>
5429      <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
5430      <div class="rpt-feed" id="rpt-feed" aria-hidden="true"></div>
5431    </div>
5432  </div>
5433  <script nonce="{{ nonce }}">
5434  (function(){
5435    var ov=document.getElementById('rpt-loading-overlay');if(!ov)return;
5436    var statusEl=document.getElementById('rpt-status'),bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),feed=document.getElementById('rpt-feed');
5437    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'];
5438    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'];
5439    var mi=0,li=0,prog=0,ready=false,start=Date.now(),MIN=1700;
5440    function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
5441    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++;}
5442    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);}
5443    nextMsg();addLog();setProg(6);
5444    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);
5445    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);}
5446    window.__rptFinish=function(){ready=true;setTimeout(done,Math.max(0,MIN-(Date.now()-start)));};
5447  })();
5448  </script>
5449  <div class="background-watermarks" aria-hidden="true">
5450    <img src="{{ logo_text_uri }}" alt="" />
5451    <img src="{{ logo_text_uri }}" alt="" />
5452    <img src="{{ logo_text_uri }}" alt="" />
5453    <img src="{{ logo_text_uri }}" alt="" />
5454    <img src="{{ logo_text_uri }}" alt="" />
5455    <img src="{{ logo_text_uri }}" alt="" />
5456    <img src="{{ logo_text_uri }}" alt="" />
5457    <img src="{{ logo_text_uri }}" alt="" />
5458    <img src="{{ logo_text_uri }}" alt="" />
5459    <img src="{{ logo_text_uri }}" alt="" />
5460    <img src="{{ logo_text_uri }}" alt="" />
5461    <img src="{{ logo_text_uri }}" alt="" />
5462  </div>
5463  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5464  {% if let Some(banner) = report_header_footer %}
5465  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
5466  {% endif %}
5467  <div class="top-nav">
5468    <div class="top-nav-inner">
5469      <a class="brand" href="/" data-local-brand="1">
5470        {% if let Some(uri) = custom_logo_uri %}
5471        <img class="brand-logo" src="{{ uri }}" alt="logo" />
5472        {% else %}
5473        <img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
5474        {% endif %}
5475        <div class="brand-copy">
5476          {% if let Some(name) = company_name %}
5477          <div class="brand-title">{{ name }}</div>
5478          {% else %}
5479          <div class="brand-title">OxideSLOC</div>
5480          {% endif %}
5481          <div class="brand-subtitle">Saved HTML report</div>
5482        </div>
5483      </a>
5484      <div class="nav-project-slot">
5485        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ title }}</span></div>
5486      </div>
5487      <div class="nav-status">
5488        <button type="button" class="header-button" data-copy-link>Copy link</button>
5489        <button type="button" class="header-button" data-share-report>Share</button>
5490        <div class="nav-dropdown-wrap">
5491          <button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
5492          <div class="nav-dropdown-menu">
5493            <button type="button" class="nav-dropdown-item" data-export-csv>Export CSV</button>
5494            <button type="button" class="nav-dropdown-item" data-export-xls>Export Excel</button>
5495          </div>
5496        </div>
5497        <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>
5498        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5499          <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>
5500        </button>
5501        <button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
5502          <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>
5503          <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>
5504        </button>
5505      </div>
5506    </div>
5507  </div>
5508
5509  <div class="page">
5510    <section class="hero panel">
5511      <div class="hero-top">
5512        <div>
5513          <div class="section-kicker">Saved report artifact</div>
5514          <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
5515            <h1>{{ title }}</h1>
5516            <span class="run-id-short-badge" title="Short run ID \u2014 matches the ID shown in View Reports">{{ run_id_short }}</span>
5517          </div>
5518        </div>
5519      </div>
5520      <div class="run-id-row">
5521            <span class="run-id-chip" data-copy="{{ run.tool.run_id }}">
5522              <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>
5523              <span class="run-id-chip-value">{{ run.tool.run_id }}</span>
5524              <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
5525            </span>
5526            {% if let Some(long_commit) = run.git_commit_long %}
5527            {% if let Some(commit_url) = git_commit_url %}
5528            <a class="run-id-chip run-id-chip-link" href="{{ commit_url }}" target="_blank" rel="noopener noreferrer" title="Open commit in source control">
5529              <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>
5530              <span class="run-id-chip-value">{{ long_commit }}</span>
5531              <span class="chip-tooltip">Opens commit in source control — new tab</span>
5532            </a>
5533            {% else %}
5534            <span class="run-id-chip" data-copy="{{ long_commit }}">
5535              <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>
5536              <span class="run-id-chip-value">{{ long_commit }}</span>
5537              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
5538            </span>
5539            {% endif %}
5540            {% else %}
5541            <span class="run-id-chip muted-chip">
5542              <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>
5543              <span class="run-id-chip-value">Not detected</span>
5544              <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
5545            </span>
5546            {% endif %}
5547            {% if let Some(branch) = run.git_branch %}
5548            {% if let Some(branch_url) = git_branch_url %}
5549            <a class="run-id-chip run-id-chip-link" href="{{ branch_url }}" target="_blank" rel="noopener noreferrer" title="Open branch in source control">
5550              <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>
5551              <span class="run-id-chip-value">{{ branch }}</span>
5552              <span class="chip-tooltip">Opens branch in source control — new tab</span>
5553            </a>
5554            {% else %}
5555            <span class="run-id-chip">
5556              <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>
5557              <span class="run-id-chip-value">{{ branch }}</span>
5558              <span class="chip-tooltip">Git branch scanned for this report</span>
5559            </span>
5560            {% endif %}
5561            {% else %}
5562            {% if is_sub_report %}
5563            <span class="run-id-chip">
5564              <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>
5565              <span class="run-id-chip-value"><span class="submodule-state-badge">detached HEAD</span></span>
5566              <span class="chip-tooltip">Submodules are pinned to a specific commit — no branch ref</span>
5567            </span>
5568            {% else %}
5569            <span class="run-id-chip muted-chip">
5570              <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>
5571              <span class="run-id-chip-value">Not detected</span>
5572              <span class="chip-tooltip">No Git branch was found for this scan</span>
5573            </span>
5574            {% endif %}
5575            {% endif %}
5576            {% if let Some(author) = run.git_commit_author %}
5577            <span class="run-id-chip" data-author="{{ author }}">
5578              <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>
5579              <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
5580              <span class="chip-tooltip">Author of the most recent commit in this repository</span>
5581            </span>
5582            {% else %}
5583            <span class="run-id-chip muted-chip">
5584              <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>
5585              <span class="run-id-chip-value">Not detected</span>
5586              <span class="chip-tooltip">No commit author was found for this scan</span>
5587            </span>
5588            {% endif %}
5589      </div>
5590
5591      <div class="meta">
5592        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
5593        <span class="meta-chip">Scanned <b>{{ scan_time_pst }}</b></span>
5594        <span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
5595        <span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
5596        <span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
5597      </div>
5598
5599      {% if has_delta %}
5600      <div class="prev-scan-banner" aria-label="Changes vs. previous scan">
5601        <div class="prev-scan-banner-top">
5602          <div class="prev-scan-meta">
5603            <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>
5604            {% 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 %}
5605            <span class="prev-scan-ts">{{ prev_scan_label }}</span>
5606            {% if prev_scan_count > 0 %}
5607            <span class="prev-scan-count">&#xb7; {{ prev_scan_count }} scan{% if prev_scan_count != 1 %}s{% endif %} total</span>
5608            {% endif %}
5609          </div>
5610          <div class="prev-scan-summary">
5611            Code before: <b data-raw="{{ prev_code_lines }}">{{ prev_code_lines }}</b>
5612            &nbsp;&rarr;&nbsp;
5613            Code now: <b data-raw="{{ run.summary_totals.code_lines }}">{{ run.summary_totals.code_lines }}</b>
5614            &nbsp;&#xb7;&nbsp;
5615            <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>
5616            &nbsp;
5617            <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>
5618          </div>
5619        </div>
5620        <div class="delta-card-row">
5621          <div class="delta-card-inline {% if delta_code_added > 0 %}pos{% endif %}">
5622            <div class="delta-card-val pos">+{{ delta_code_added|commas }}</div>
5623            <div class="delta-card-lbl">Lines added</div>
5624            <div class="delta-card-tip">Code lines added since {{ prev_scan_label }}</div>
5625          </div>
5626          <div class="delta-card-inline {% if delta_code_removed > 0 %}neg{% endif %}">
5627            <div class="delta-card-val neg">&minus;{{ delta_code_removed|commas }}</div>
5628            <div class="delta-card-lbl">Lines removed</div>
5629            <div class="delta-card-tip">Code lines removed since {{ prev_scan_label }}</div>
5630          </div>
5631          <div class="delta-card-inline">
5632            <div class="delta-card-val">{{ delta_unmodified_lines|commas }}</div>
5633            <div class="delta-card-lbl">Unmodified lines</div>
5634            <div class="delta-card-tip">Code lines unchanged since {{ prev_scan_label }}</div>
5635          </div>
5636          <div class="delta-card-inline {% if delta_files_modified > 0 %}mod{% endif %}">
5637            <div class="delta-card-val mod">{{ delta_files_modified|commas }}</div>
5638            <div class="delta-card-lbl">Files modified</div>
5639            <div class="delta-card-tip">Files with at least one line changed</div>
5640          </div>
5641          <div class="delta-card-inline {% if delta_files_added > 0 %}pos{% endif %}">
5642            <div class="delta-card-val pos">{{ delta_files_added|commas }}</div>
5643            <div class="delta-card-lbl">Files added</div>
5644            <div class="delta-card-tip">New files added since {{ prev_scan_label }}</div>
5645          </div>
5646          <div class="delta-card-inline {% if delta_files_removed > 0 %}neg{% endif %}">
5647            <div class="delta-card-val neg">{{ delta_files_removed|commas }}</div>
5648            <div class="delta-card-lbl">Files removed</div>
5649            <div class="delta-card-tip">Files deleted since {{ prev_scan_label }}</div>
5650          </div>
5651          <div class="delta-card-inline">
5652            <div class="delta-card-val">{{ delta_files_unchanged|commas }}</div>
5653            <div class="delta-card-lbl">Files unchanged</div>
5654            <div class="delta-card-tip">Files with no changes since {{ prev_scan_label }}</div>
5655          </div>
5656          <div class="delta-card-inline">
5657            <div class="delta-card-val">{{ delta_files_total|commas }}</div>
5658            <div class="delta-card-lbl">Files total</div>
5659            <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
5660          </div>
5661        </div>
5662      </div>
5663      {% else %}
5664      <div class="prev-scan-banner prev-scan-banner-empty" aria-label="No previous scan">
5665        <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>
5666        No previous scan found for this project &#x2014; this report is the baseline.
5667      </div>
5668      {% endif %}
5669
5670      <div class="summary-grid">
5671        <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>
5672        <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>
5673        <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>
5674        <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>
5675        <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>
5676        <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>
5677        <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>
5678        <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>
5679        <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>
5680        <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>
5681        <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>
5682        <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>
5683        {% 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 %}
5684        {% 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 %}
5685        {% 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 %}
5686        {% 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 %}
5687        {% 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 %}
5688        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
5689             odd, so the strip always has an even number of cards that fill exactly
5690             two aligned rows (no oversized card, no empty trailing cell). -->
5691        <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>
5692      </div>
5693    </section>
5694
5695    <!-- ── PDF-only pre-rendered chart variants (hidden on screen) ─────── -->
5696    <div id="pdf-variants" class="pdf-variants-root"></div>
5697
5698    <div class="report-stack">
5699      <!-- ── Chart row 1: Overview + Composition ───────────────────────── -->
5700      <div class="charts-grid">
5701        <section class="panel stack chart-section">
5702          <div>
5703            <div class="toolbar">
5704              <div class="toolbar-left"><h2>Project Overview</h2></div>
5705              <button class="chart-expand-btn" id="overview-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5706            </div>
5707            <div class="chart-pre">
5708            <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>
5709            <div class="chart-controls">
5710              <label>Y Axis:
5711                <select class="chart-select" id="overview-y-axis">
5712                  <option value="code">Code Lines</option>
5713                  <option value="comments">Comment Lines</option>
5714                  <option value="blanks">Blank Lines</option>
5715                  <option value="physical">Total Physical Lines</option>
5716                  <option value="files">File Count</option>
5717                </select>
5718              </label>
5719              <label>X Axis / Mode:
5720                <select class="chart-select" id="overview-x-mode">
5721                  <option value="languages">Languages</option>
5722                  {% if has_submodule_data %}<option value="submodules">Submodules</option>{% endif %}
5723                  <option value="history-commits">Per Commit (Web UI)</option>
5724                  <option value="history-tags">Per Tag (Web UI)</option>
5725                  <option value="history-releases">Per Release (Web UI)</option>
5726                  <option value="history-repos">Other Repos (Web UI)</option>
5727                </select>
5728              </label>
5729            </div>
5730            </div>
5731            <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>
5732            <div class="chart-locked-card" id="overview-chart-locked">
5733              <h3>Historical trend requires the web UI</h3>
5734              <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>
5735            </div>
5736          </div>
5737        </section>
5738
5739        <section class="panel stack chart-section">
5740          <div>
5741            <div class="toolbar">
5742              <div class="toolbar-left"><h2>Language Composition</h2></div>
5743              <button class="chart-expand-btn" id="comp-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5744            </div>
5745            <div class="chart-pre">
5746            <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>
5747            <div class="chart-tab-bar">
5748              <button type="button" class="chart-tab active" data-comp-tab="absolute">Absolute Lines</button>
5749              <button type="button" class="chart-tab" data-comp-tab="pct">Composition %</button>
5750            </div>
5751            </div>
5752            <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>
5753          </div>
5754        </section>
5755      </div>
5756
5757      <!-- ── Chart row 2: Scatter + Semantic ───────────────────────────── -->
5758      <div class="charts-grid">
5759        <section class="panel stack chart-section">
5760          <div>
5761            <div class="toolbar">
5762              <div class="toolbar-left"><h2>Files vs Code Lines</h2></div>
5763              <button class="chart-expand-btn" id="scatter-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5764            </div>
5765            <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>
5766            <div id="scatter-chart" class="chart-container" style="position:relative;height:224px;"><canvas id="canvas-scatter"></canvas></div>
5767          </div>
5768        </section>
5769
5770        <section class="panel stack chart-section">
5771          <div>
5772            <div class="toolbar">
5773              <div class="toolbar-left"><h2>Semantic Metrics</h2></div>
5774              {% if has_semantic_data %}
5775              <button class="chart-expand-btn" id="semantic-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5776              {% endif %}
5777            </div>
5778            <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Detected structural elements per language. Select a metric to explore.</p>
5779            {% if has_semantic_data %}
5780            <div class="chart-controls">
5781              <label>Metric:
5782                <select class="chart-select" id="semantic-metric">
5783                  <option value="functions">Functions</option>
5784                  <option value="classes">Classes / Types</option>
5785                  <option value="variables">Variables</option>
5786                  <option value="imports">Imports</option>
5787                  <option value="tests">Tests</option>
5788                </select>
5789              </label>
5790            </div>
5791            <div id="semantic-chart" class="chart-container" style="position:relative;height:234px;"><canvas id="canvas-semantic"></canvas></div>
5792            {% else %}
5793            <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;">
5794              <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>
5795            </div>
5796            {% endif %}
5797          </div>
5798        </section>
5799        <section class="panel stack chart-section">
5800          <div>
5801            <div class="toolbar">
5802              <div class="toolbar-left"><h2>Comment Density</h2></div>
5803              <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5804            </div>
5805            <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>
5806            <div id="density-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-density"></canvas></div>
5807          </div>
5808        </section>
5809
5810        <section class="panel stack chart-section">
5811          <div>
5812            <div class="toolbar">
5813              <div class="toolbar-left"><h2>File Size Distribution</h2></div>
5814              <button class="chart-expand-btn" id="filesize-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
5815            </div>
5816            <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>
5817            <div id="filesize-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-filesize"></canvas></div>
5818          </div>
5819        </section>
5820      </div>
5821
5822      <!-- ── Tests & Coverage ──────────────────────────────────────────── -->
5823      <section class="panel stack">
5824        <div>
5825          <div class="toolbar">
5826            <div class="toolbar-left"><h2>Tests &amp; Coverage</h2></div>
5827            {% if has_coverage_data %}<div class="pill-row"><span class="pill good">LCOV coverage data present</span></div>{% endif %}
5828          </div>
5829          <div class="summary-strip">
5830            <div class="stat-chip">
5831              <div class="stat-chip-val" data-fmt="{{ run.summary_totals.test_count }}">{{ run.summary_totals.test_count|commas }}</div>
5832              <div class="stat-chip-label">Test Functions</div>
5833              <div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div>
5834              <span class="stat-chip-exact">{{ run.summary_totals.test_count|commas }}</span>
5835            </div>
5836            <div class="stat-chip">
5837              <div class="stat-chip-val" data-fmt="{{ test_assertion_count }}">{{ test_assertion_count|commas }}</div>
5838              <div class="stat-chip-label">Assertions</div>
5839              <div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div>
5840              <span class="stat-chip-exact">{{ test_assertion_count|commas }}</span>
5841            </div>
5842            <div class="stat-chip">
5843              <div class="stat-chip-val" data-fmt="{{ test_suite_count }}">{{ test_suite_count|commas }}</div>
5844              <div class="stat-chip-label">Test Suites</div>
5845              <div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div>
5846              <span class="stat-chip-exact">{{ test_suite_count|commas }}</span>
5847            </div>
5848            <div class="stat-chip">
5849              <div class="stat-chip-val">{{ test_files_count|commas }} / {{ run.summary_totals.files_analyzed|commas }}</div>
5850              <div class="stat-chip-label">Test Files</div>
5851              <div class="stat-chip-tip">Files containing at least one detected test definition out of total analyzed files</div>
5852            </div>
5853          </div>
5854          <div class="summary-strip" style="margin-top:0;">
5855            <div class="stat-chip">
5856              <div class="stat-chip-val">{{ test_density }}</div>
5857              <div class="stat-chip-label">Tests per 1K SLOC</div>
5858              <div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div>
5859            </div>
5860            <div class="stat-chip">
5861              <div class="stat-chip-val" style="font-size:15px;word-break:break-word;line-height:1.2;">{{ most_tested_lang }}</div>
5862              <div class="stat-chip-label">Most Tested Language</div>
5863              <div class="stat-chip-tip">Language with the highest absolute test function count</div>
5864            </div>
5865            <div class="stat-chip">
5866              <div class="stat-chip-val">{{ langs_with_tests }}</div>
5867              <div class="stat-chip-label">Languages with Tests</div>
5868              <div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div>
5869            </div>
5870            <div class="stat-chip">
5871              {% 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 %}
5872              <div class="stat-chip-label">Line Coverage</div>
5873              <div class="stat-chip-tip">Overall line coverage from LCOV data — run with --lcov-path to populate</div>
5874            </div>
5875          </div>
5876          {% if has_coverage_data %}
5877          <div class="cov-gauge-row">
5878            <div class="cov-gauge-card">
5879              <div class="cov-gauge-label">Line Coverage</div>
5880              <div class="cov-gauge-val" style="color:var(--{{ cov_line_class }}-text);">{{ cov_line_pct }}%</div>
5881              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_line_pct }}%;background:var(--{{ cov_line_class }}-text);"></div></div>
5882              <div class="cov-gauge-sub">Lines hit / instrumented</div>
5883              <div class="cov-gauge-tip">Share of instrumented source lines executed at least once during the test run (from LCOV <code>DA</code> records).</div>
5884            </div>
5885            {% if has_fn_coverage %}
5886            <div class="cov-gauge-card">
5887              <div class="cov-gauge-label">Function Coverage</div>
5888              <div class="cov-gauge-val" style="color:var(--{{ cov_fn_class }}-text);">{{ cov_fn_pct }}%</div>
5889              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_fn_pct }}%;background:var(--{{ cov_fn_class }}-text);"></div></div>
5890              <div class="cov-gauge-sub">Functions hit / found</div>
5891            </div>
5892            {% endif %}
5893            {% if has_branch_coverage %}
5894            <div class="cov-gauge-card">
5895              <div class="cov-gauge-label">Branch Coverage</div>
5896              <div class="cov-gauge-val" style="color:var(--{{ cov_branch_class }}-text);">{{ cov_branch_pct }}%</div>
5897              <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_branch_pct }}%;background:var(--{{ cov_branch_class }}-text);"></div></div>
5898              <div class="cov-gauge-sub">Branches hit / found</div>
5899            </div>
5900            {% endif %}
5901          </div>
5902          {% endif %}
5903          <div class="table-shell" style="margin-top:16px;">
5904            <table data-sort-table style="min-width:560px;">
5905              <thead>
5906                <tr>
5907                  <th data-sort-type="text">Language</th>
5908                  <th data-sort-type="number">Test Fns</th>
5909                  <th data-sort-type="number">Assertions</th>
5910                  <th data-sort-type="number">Suites</th>
5911                  <th data-sort-type="text">Density (per 1K SLOC)</th>
5912                </tr>
5913              </thead>
5914              <tbody>
5915                {% for row in language_rows %}
5916                {% if row.test_count > 0 || row.test_assertion_count > 0 %}
5917                <tr>
5918                  <td>{{ row.language }}</td>
5919                  <td>{{ row.test_count|commas }}</td>
5920                  <td>{{ row.test_assertion_count|commas }}</td>
5921                  <td>{{ row.test_suite_count|commas }}</td>
5922                  <td>{{ row.test_density_str }}</td>
5923                </tr>
5924                {% endif %}
5925                {% endfor %}
5926                {% if run.summary_totals.test_count == 0 && test_assertion_count == 0 %}
5927                <tr class="empty-state-row"><td colspan="5">No test functions or assertions detected in this scan</td></tr>
5928                {% endif %}
5929              </tbody>
5930            </table>
5931          </div>
5932          {% if has_coverage_data %}
5933          <div style="display:flex;align-items:center;gap:10px;margin:16px 0 8px;">
5934            <h3 style="margin:0;font-size:14px;font-weight:800;color:var(--text);">Per-File Coverage</h3>
5935            <span class="pill good" style="font-size:10px;">{{ file_rows.len() }} files with data</span>
5936          </div>
5937          <div class="table-shell">
5938            <table data-sort-table class="cov-file-table" style="min-width:560px;">
5939              <thead>
5940                <tr>
5941                  <th data-sort-type="text">File</th>
5942                  <th class="num-col" data-sort-type="number">Line Cov %</th>
5943                  <th class="num-col" data-sort-type="text">Lines Hit / Found</th>
5944                  {% if has_fn_coverage %}<th class="num-col" data-sort-type="number">Fn Cov %</th>{% endif %}
5945                  {% if has_branch_coverage %}<th class="num-col" data-sort-type="number">Branch Cov %</th>{% endif %}
5946                </tr>
5947              </thead>
5948              <tbody>
5949                {% for row in file_rows %}
5950                {% if !row.line_cov_pct.is_empty() %}
5951                <tr>
5952                  <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>
5953                  <td class="num-col">{{ row.line_cov_pct }}%</td>
5954                  <td class="num-col" style="font-size:11px;color:var(--muted);">{{ row.cov_lines_detail }}</td>
5955                  {% if has_fn_coverage %}<td class="num-col">{% if !row.fn_cov_pct.is_empty() %}{{ row.fn_cov_pct }}%{% else %}&mdash;{% endif %}</td>{% endif %}
5956                  {% if has_branch_coverage %}<td class="num-col">{% if !row.branch_cov_pct.is_empty() %}{{ row.branch_cov_pct }}%{% else %}&mdash;{% endif %}</td>{% endif %}
5957                </tr>
5958                {% endif %}
5959                {% endfor %}
5960                {% if file_rows.is_empty() %}
5961                <tr class="empty-state-row"><td colspan="5">No per-file coverage data available</td></tr>
5962                {% endif %}
5963              </tbody>
5964            </table>
5965          </div>
5966          {% else %}
5967          <div class="info-callout">
5968            <span class="info-callout-icon">&#x2139;&#xFE0F;</span>
5969            <span>No code coverage detected. Re-run with <code>--lcov-path coverage.info</code> to see line, function, and branch coverage here.</span>
5970          </div>
5971          {% endif %}
5972        </div>
5973      </section>
5974
5975      <!-- ── Multi-Language Code Style Analysis ───────────────────────── -->
5976      {% if has_style_data %}
5977      {% if let Some(ss) = style_summary %}
5978      <section class="panel stack">
5979        <div>
5980          <div class="toolbar">
5981            <div class="toolbar-left"><h2>Code Style Analysis</h2></div>
5982            <div class="pill-row"><span class="pill info">{{ style_lang_count }} language group(s) &#xB7; Lexical heuristics</span></div>
5983          </div>
5984          <div class="style-heuristic-note">
5985            <span class="info-callout-icon">&#x2139;&#xFE0F;</span>
5986            <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>
5987          </div>
5988          <!-- Summary chips -->
5989          <div class="style-metrics-strip">
5990            <div class="style-chip">
5991              <div class="style-chip-val">{{ ss.files_analyzed }}</div>
5992              <div class="style-chip-label">Files Analyzed</div>
5993              <div class="style-chip-tip">Total files with style data</div>
5994            </div>
5995            <div class="style-chip">
5996              <div class="style-chip-val">{{ style_lang_count }}</div>
5997              <div class="style-chip-label">Language Groups</div>
5998              <div class="style-chip-tip">Distinct language families detected</div>
5999            </div>
6000            <div class="style-chip">
6001              <div class="style-chip-val">{{ ss.common_indent_style }}</div>
6002              <div class="style-chip-label">Common Indent</div>
6003              <div class="style-chip-tip">Most prevalent indentation across all files</div>
6004            </div>
6005            <div class="style-chip">
6006              <div class="style-chip-val">{{ ss.line_col_compliant_pct }}%</div>
6007              <div class="style-chip-label">{{ ss.col_threshold }}-Col Compliant</div>
6008              <div class="style-chip-tip">Files where &le;5% of lines exceed {{ ss.col_threshold }} chars</div>
6009            </div>
6010          </div>
6011          <!-- Language selector tab strip -->
6012          <div style="margin-top:20px;">
6013            <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>
6014            <div id="style-lang-tabs" class="style-lang-tabs"></div>
6015            <div class="style-guide-grid" id="style-guide-bars"></div>
6016          </div>
6017          <!-- Per-file style table -->
6018          <div style="margin-top:22px;">
6019            <div class="toolbar" style="margin-bottom:8px;">
6020              <div class="toolbar-left">
6021                <span style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);">Per-File Style Details</span>
6022                <input id="sft-search" class="search" type="search" placeholder="Filter files, languages, guides..." style="margin-left:12px;" />
6023                <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>
6024              </div>
6025            </div>
6026            <div class="table-scroll-wrap">
6027              <table class="style-file-table" id="style-file-table">
6028                <thead>
6029                  <tr>
6030                    <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>
6031                    <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>
6032                    <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>
6033                    <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>
6034                    <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>
6035                    <th style="width:13%;" title="Hover a row to see all signals \u2014 signal name and detected value.">Signals</th>
6036                  </tr>
6037                </thead>
6038                <tbody id="style-file-tbody">
6039                  <tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">Loading...</td></tr>
6040                </tbody>
6041              </table>
6042            </div>
6043            <div id="sft-pagination" class="pagination-bar">
6044              <button id="sft-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6045              <button id="sft-prev" class="pager-btn" disabled>&#8592; Prev</button>
6046              <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>
6047              <span id="sft-page-info" class="pager-info"></span>
6048              <button id="sft-next" class="pager-btn">Next &#8594;</button>
6049              <button id="sft-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6050            </div>
6051          </div>
6052        </div>
6053      </section>
6054      {% endif %}
6055      {% endif %}
6056
6057      <!-- ── Submodule Breakdown (2-column, conditional) ─────────────── -->
6058      {% if has_submodule_data %}
6059      <div class="charts-grid">
6060        <section class="panel stack chart-section">
6061          <div>
6062            <div class="toolbar">
6063              <div class="toolbar-left"><h2>Submodule Breakdown</h2></div>
6064              <button class="chart-expand-btn" id="sub-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6065            </div>
6066            <div class="chart-controls">
6067              <label>Y Axis:
6068                <select class="chart-select" id="sub-y-axis">
6069                  <option value="code">Code Lines</option>
6070                  <option value="comment">Comment Lines</option>
6071                  <option value="blank">Blank Lines</option>
6072                  <option value="physical">Total Physical Lines</option>
6073                  <option value="files">File Count</option>
6074                </select>
6075              </label>
6076              <label>Sort:
6077                <select class="chart-select" id="sub-sort">
6078                  <option value="desc">Value ↓</option>
6079                  <option value="asc">Value ↑</option>
6080                  <option value="name">Name A→Z</option>
6081                </select>
6082              </label>
6083            </div>
6084            <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>
6085          </div>
6086        </section>
6087        <section class="panel stack chart-section">
6088          <div>
6089            <div class="toolbar">
6090              <div class="toolbar-left"><h2>Submodule Composition</h2></div>
6091              <button class="chart-expand-btn" id="sub-comp-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
6092            </div>
6093            <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>
6094            <div id="submodule-donut" style="width:100%;padding:4px 0;overflow:hidden;"></div>
6095          </div>
6096        </section>
6097      </div>
6098      {% endif %}
6099
6100      {% if has_cocomo %}
6101      <section class="panel" id="cocomo-section">
6102        <div class="toolbar">
6103          <div class="toolbar-left">
6104            <h2>Constructive Cost Model &mdash; COCOMO I</h2>
6105            <span class="cocomo-mode-pill-wrap" style="margin-left:12px;">
6106              <span class="pill" style="background:var(--surface-3);color:var(--muted);border:1px solid var(--line);font-size:11px;">{{ cocomo_mode_label }} mode</span>
6107              <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
6108            </span>
6109          </div>
6110        </div>
6111        <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
6112          <div class="stat-chip">
6113            <div class="stat-chip-label">Person-months</div>
6114            <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
6115            <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>
6116          </div>
6117          <div class="stat-chip">
6118            <div class="stat-chip-label">Schedule (months)</div>
6119            <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
6120            <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>
6121          </div>
6122          <div class="stat-chip">
6123            <div class="stat-chip-label">Avg. Team Size</div>
6124            <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
6125            <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>
6126          </div>
6127          <div class="stat-chip">
6128            <div class="stat-chip-label">Input KSLOC</div>
6129            <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
6130            <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>
6131          </div>
6132        </div>
6133        <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>
6134      </section>
6135      {% endif %}
6136
6137      {% if has_hotspots %}
6138      <section class="panel stack" id="hotspots-section">
6139        <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>
6140        <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>
6141        <div class="table-shell">
6142          <table id="hotspots-table" data-sort-table class="table-resizable hotspots-table">
6143            <colgroup><col><col><col><col><col></colgroup>
6144            <thead><tr>
6145              <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>
6146              <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>
6147              <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>
6148              <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>
6149              <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>
6150            </tr></thead>
6151            <tbody>
6152            {% for h in hotspot_rows %}
6153              <tr>
6154                <td class="mono" title="{{ h.path }}">{{ h.path }}</td>
6155                <td class="num-col">{{ h.code_lines|commas }}</td>
6156                <td class="num-col">{{ h.commit_count }}</td>
6157                <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ h.score|commas }}</td>
6158                <td class="num-col" style="color:var(--muted);">{{ h.last_commit_date }}</td>
6159              </tr>
6160            {% endfor %}
6161            </tbody>
6162          </table>
6163        </div>
6164        <div id="hotspots-pagination" class="pagination-bar">
6165          <button id="hs-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6166          <button id="hs-prev" class="pager-btn" disabled>&#8592; Prev</button>
6167          <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>
6168          <span id="hs-page-info" class="pager-info"></span>
6169          <button id="hs-next" class="pager-btn">Next &#8594;</button>
6170          <button id="hs-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6171        </div>
6172      </section>
6173      {% endif %}
6174
6175      <section class="panel stack">
6176        <div>
6177          <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>
6178          <div id="report-lang-overview" style="margin:0 0 16px;"></div>
6179          <div class="table-shell">
6180            <table id="lang-breakdown-table" data-sort-table class="table-resizable">
6181              <colgroup>
6182                <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6183              </colgroup>
6184              <thead>
6185                <tr>
6186                  <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6187                  <th data-sort-type="number" class="num-col">Files<div class="col-resize-handle"></div></th>
6188                  <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6189                  <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6190                  <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6191                  <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6192                  <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6193                  <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6194                  <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6195                  <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6196                  <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6197                  <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6198                  <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6199                  <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6200                </tr>
6201              </thead>
6202              <tbody>
6203                {% for row in language_rows %}
6204                <tr>
6205                  <td title="{{ row.language }}">{{ row.language }}</td>
6206                  <td class="num-col">{{ row.files|commas }}</td>
6207                  <td class="num-col">{{ row.total_physical_lines|commas }}</td>
6208                  <td class="num-col">{{ row.code_lines|commas }}</td>
6209                  <td class="num-col">{{ row.comment_lines|commas }}</td>
6210                  <td class="num-col">{{ row.blank_lines|commas }}</td>
6211                  <td class="num-col">{{ row.mixed_lines_separate|commas }}</td>
6212                  <td class="num-col">{{ row.functions|commas }}</td>
6213                  <td class="num-col">{{ row.classes|commas }}</td>
6214                  <td class="num-col">{{ row.variables|commas }}</td>
6215                  <td class="num-col">{{ row.imports|commas }}</td>
6216                  <td class="num-col">{{ row.test_count|commas }}</td>
6217                  <td class="num-col">{{ row.test_assertion_count|commas }}</td>
6218                  <td class="num-col">{{ row.test_suite_count|commas }}</td>
6219                </tr>
6220                {% endfor %}
6221              </tbody>
6222            </table>
6223          </div>
6224        </div>
6225      </section>
6226
6227      <section class="panel stack">
6228        <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>
6229        <div class="table-shell table-shell-clip">
6230        <div id="per-file-shell">
6231          <table id="per-file-table" data-sort-table class="table-resizable">
6232            <colgroup>
6233              <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6234            </colgroup>
6235            <thead>
6236              <tr>
6237                <th data-sort-type="text">File<div class="col-resize-handle"></div></th>
6238                <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6239                <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6240                <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6241                <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6242                <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6243                <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6244                <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6245                <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6246                <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6247                <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6248                <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6249                <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6250                <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6251                {% 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 %}
6252              </tr>
6253            </thead>
6254            <tbody>
6255              {% for row in file_rows %}
6256              <tr>
6257                <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6258                <td title="{{ row.language }}">{{ row.language }}</td>
6259                <td class="num-col">{{ row.total_physical_lines }}</td>
6260                <td class="num-col">{{ row.code_lines }}</td>
6261                <td class="num-col">{{ row.comment_lines }}</td>
6262                <td class="num-col">{{ row.blank_lines }}</td>
6263                <td class="num-col">{{ row.mixed_lines_separate }}</td>
6264                <td class="num-col">{{ row.functions }}</td>
6265                <td class="num-col">{{ row.classes }}</td>
6266                <td class="num-col">{{ row.variables }}</td>
6267                <td class="num-col">{{ row.imports }}</td>
6268                <td class="num-col">{{ row.test_count }}</td>
6269                <td class="num-col">{{ row.test_assertion_count }}</td>
6270                <td class="num-col">{{ row.test_suite_count }}</td>
6271                {% if has_coverage_data %}<td class="num-col">{{ row.line_cov_pct }}</td><td class="num-col">{{ row.fn_cov_pct }}</td>{% endif %}
6272              </tr>
6273              {% endfor %}
6274            </tbody>
6275          </table>
6276        </div>
6277        </div>
6278        <div id="per-file-pagination" class="pagination-bar">
6279          <button id="pf-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6280          <button id="pf-prev" class="pager-btn" disabled>&#8592; Prev</button>
6281          <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>
6282          <span id="pf-page-info" class="pager-info"></span>
6283          <button id="pf-next" class="pager-btn">Next &#8594;</button>
6284          <button id="pf-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6285        </div>
6286      </section>
6287
6288      <section class="panel stack">
6289        <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>
6290        <div class="table-shell table-shell-clip" style="margin-top:6px;">
6291        <div id="skipped-shell">
6292          <table id="skipped-table" data-sort-table class="table-resizable">
6293            <thead>
6294              <tr>
6295                <th data-sort-type="text" style="width:42%">File</th>
6296                <th data-sort-type="text" style="width:20%">Status</th>
6297                <th data-sort-type="text" style="width:38%">Warnings</th>
6298              </tr>
6299            </thead>
6300            <tbody>
6301              {% for row in skipped_rows %}
6302              <tr>
6303                <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6304                <td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
6305                <td class="small" title="{{ row.warnings }}">{{ row.warnings }}</td>
6306              </tr>
6307              {% endfor %}
6308            </tbody>
6309          </table>
6310        </div>
6311        </div>
6312        <div id="skipped-pagination" class="pagination-bar">
6313          <button id="sk-first" class="pager-btn pager-edge" disabled title="First page">&#8676; First</button>
6314          <button id="sk-prev" class="pager-btn" disabled>&#8592; Prev</button>
6315          <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>
6316          <span id="sk-page-info" class="pager-info"></span>
6317          <button id="sk-next" class="pager-btn">Next &#8594;</button>
6318          <button id="sk-last" class="pager-btn pager-edge" title="Last page">Last &#8677;</button>
6319        </div>
6320      </section>
6321
6322      <section class="panel stack">
6323        <div>
6324          <div class="toolbar">
6325            <div class="toolbar-left"><h2>Diagnostics &amp; Configuration</h2></div>
6326            {% 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 %}
6327          </div>
6328          <p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
6329        </div>
6330
6331        {% if !is_sub_report %}
6332        <div style="margin-top:-14px;">
6333          <h3 style="margin:0 0 4px;">Warnings overview</h3>
6334          <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>
6335          {% if !has_run_warnings %}
6336            <div class="pill good">No top-level warnings.</div>
6337          {% else %}
6338            <div class="table-shell">
6339              <table class="support-table">
6340                <thead>
6341                  <tr><th style="width:30%;">Category</th><th style="width:8%;">Count</th><th>What this means</th></tr>
6342                </thead>
6343                <tbody>
6344                  {% for row in warning_summary_rows %}
6345                  <tr class="{{ row.tone_class }}">
6346                    <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6347                    <td class="warning-count" style="font-weight:800;">{{ row.count }}</td>
6348                    <td class="small" style="color:var(--muted);">{{ row.detail }}</td>
6349                  </tr>
6350                  {% endfor %}
6351                </tbody>
6352              </table>
6353            </div>
6354          {% endif %}
6355        </div>
6356
6357        <div>
6358          <h3 style="margin:0 0 4px;">Skipped file categories</h3>
6359          <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>
6360          {% if warning_opportunity_rows.is_empty() %}
6361            <div class="pill good">No unsupported text-format buckets detected.</div>
6362          {% else %}
6363          <div class="table-shell">
6364            <table class="support-table">
6365              <thead>
6366                <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>
6367              </thead>
6368              <tbody>
6369                {% for row in warning_opportunity_rows %}
6370                <tr>
6371                  <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6372                  <td style="font-weight:800;color:var(--oxide);">{{ row.count }}</td>
6373                  <td class="small" style="color:var(--muted);">{{ row.bucket_description }}</td>
6374                  <td>
6375                    {% if !row.example_files.is_empty() %}
6376                    <div style="margin-bottom:6px;">
6377                      {% for f in row.example_files %}<span class="support-example-file">{{ f }}</span> {% endfor %}
6378                      {% 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 %}
6379                    </div>
6380                    {% endif %}
6381                    <p class="support-recommendation">{{ row.recommendation }}</p>
6382                  </td>
6383                </tr>
6384                {% endfor %}
6385              </tbody>
6386            </table>
6387          </div>
6388          {% endif %}
6389        </div>
6390
6391        <div>
6392          <details open class="warnings-details">
6393            <summary>Detailed run warnings ({{ warning_count }})</summary>
6394            <div>
6395              <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>
6396              {% if !has_run_warnings %}
6397                <div class="pill good">No top-level warnings.</div>
6398              {% else %}
6399                <div class="code-block-toolbar">
6400                  <button type="button" class="code-copy-btn" id="warning-console-copy-btn" aria-label="Copy warnings">Copy</button>
6401                </div>
6402                <pre class="warning-console" id="warning-console-full" style="max-height:210px;">{{ warning_console_full }}</pre>
6403              {% endif %}
6404            </div>
6405          </details>
6406        </div>
6407        {% endif %}
6408
6409        <div>
6410          <details open>
6411            <summary>Effective configuration</summary>
6412            <div>
6413              <div style="display:flex;gap:8px;margin-bottom:10px;">
6414                <button type="button" class="export-btn" data-copy-config>Copy</button>
6415                <button type="button" class="export-btn" data-download-config>Download</button>
6416              </div>
6417              <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>
6418              <div class="config-pre-wrap">
6419                <div class="code-block-toolbar">
6420                  <button type="button" class="code-copy-btn" id="config-inline-copy-btn" aria-label="Copy configuration">Copy</button>
6421                </div>
6422                <pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
6423              </div>
6424            </div>
6425          </details>
6426        </div>
6427      </section>
6428    </div>
6429  </div>
6430
6431  <div id="r-tt" aria-hidden="true"></div>
6432  <script nonce="{{ nonce }}">
6433    // Hide "View PDF" button and block brand-link navigation when opened as a local file
6434    (function () {
6435      var pdfBtn = document.getElementById('nav-view-pdf-btn');
6436      if (pdfBtn && window.location.protocol === 'file:') {
6437        pdfBtn.style.display = 'none';
6438      }
6439      var brand = document.querySelector('a[data-local-brand]');
6440      if (brand && window.location.protocol === 'file:') {
6441        brand.addEventListener('click', function (e) { e.preventDefault(); });
6442      }
6443    })();
6444
6445    (function () {
6446      var body = document.body;
6447      var storageKey = 'oxide-sloc-theme';
6448      var themeToggle = document.querySelector('[data-theme-toggle]');
6449      var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
6450      var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
6451      var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
6452
6453      function applyTheme(theme) {
6454        body.classList.toggle('dark-theme', theme === 'dark');
6455      }
6456
6457      function currentTheme() {
6458        return body.classList.contains('dark-theme') ? 'dark' : 'light';
6459      }
6460
6461      try {
6462        var saved = localStorage.getItem(storageKey);
6463        if (saved === 'dark' || saved === 'light') {
6464          applyTheme(saved);
6465        }
6466      } catch (e) {}
6467
6468      if (themeToggle) {
6469        themeToggle.addEventListener('click', function () {
6470          var next = currentTheme() === 'dark' ? 'light' : 'dark';
6471          applyTheme(next);
6472          try { localStorage.setItem(storageKey, next); } catch (e) {}
6473        });
6474      }
6475
6476      function copyText(value) {
6477        if (!value) return;
6478        if (navigator.clipboard && navigator.clipboard.writeText) {
6479          navigator.clipboard.writeText(value).catch(function () {});
6480        }
6481      }
6482
6483      copyLinkButtons.forEach(function (button) {
6484        button.addEventListener('click', function () {
6485          copyText(window.location.href);
6486        });
6487      });
6488
6489      shareButtons.forEach(function (button) {
6490        button.addEventListener('click', function () {
6491          if (navigator.share) {
6492            navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
6493          } else {
6494            copyText(window.location.href);
6495          }
6496        });
6497      });
6498
6499      printButtons.forEach(function (button) {
6500        button.addEventListener('click', function () {
6501          window.print();
6502        });
6503      });
6504
6505      // "View PDF" nav button.
6506      // Priority order:
6507      //  1. data-standalone-pdf attr — pre-generated PDF in the same directory
6508      //     (set when oxide-sloc CLI was invoked with both --html-out and
6509      //     --pdf-out). Opens the file directly; works in Jenkins HTML Publisher.
6510      //  2. Server route (/runs/pdf/<id>) — oxide-sloc web server generates
6511      //     the PDF on demand via headless Chrome. Checked via HEAD request.
6512      //  3. Neither available — inform the user how to generate a PDF via CLI.
6513      var pdfNavBtn = document.getElementById('nav-view-pdf-btn');
6514      if (pdfNavBtn) {
6515        pdfNavBtn.addEventListener('click', function (e) {
6516          e.preventDefault();
6517          var standaloneUrl = pdfNavBtn.getAttribute('data-standalone-pdf');
6518          if (standaloneUrl) {
6519            window.open(standaloneUrl, '_blank', 'noopener');
6520            return;
6521          }
6522          var serverUrl = pdfNavBtn.getAttribute('href');
6523          var xhr = new XMLHttpRequest();
6524          xhr.open('HEAD', serverUrl, true);
6525          xhr.onreadystatechange = function () {
6526            if (xhr.readyState === 4) {
6527              if (xhr.status >= 200 && xhr.status < 300) {
6528                window.open(serverUrl, '_blank', 'noopener');
6529              } else {
6530                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.');
6531              }
6532            }
6533          };
6534          xhr.onerror = function () {
6535            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.');
6536          };
6537          xhr.send();
6538        });
6539      }
6540
6541      var copyConfigBtn = document.querySelector('[data-copy-config]');
6542      var downloadConfigBtn = document.querySelector('[data-download-config]');
6543      var configBlock = document.getElementById('config-json-block');
6544      var inlineCopyBtn = document.getElementById('config-inline-copy-btn');
6545      function handleConfigCopy(btn) {
6546        if (!btn || !configBlock) return;
6547        btn.addEventListener('click', function (e) {
6548          e.stopPropagation();
6549          copyText(configBlock.textContent);
6550          var orig = btn.textContent;
6551          btn.textContent = 'Copied!';
6552          setTimeout(function () { btn.textContent = orig; }, 1600);
6553        });
6554      }
6555      handleConfigCopy(copyConfigBtn);
6556      handleConfigCopy(inlineCopyBtn);
6557
6558      var warnCopyBtn = document.getElementById('warning-console-copy-btn');
6559      var warnBlock = document.getElementById('warning-console-full');
6560      if (warnCopyBtn && warnBlock) {
6561        warnCopyBtn.addEventListener('click', function () {
6562          copyText(warnBlock.textContent);
6563          var orig = warnCopyBtn.textContent;
6564          warnCopyBtn.textContent = 'Copied!';
6565          setTimeout(function () { warnCopyBtn.textContent = orig; }, 1600);
6566        });
6567      }
6568
6569      if (downloadConfigBtn && configBlock) {
6570        downloadConfigBtn.addEventListener('click', function (e) {
6571          e.stopPropagation();
6572          var blob = new Blob([configBlock.textContent], { type: 'application/json' });
6573          var url = URL.createObjectURL(blob);
6574          var a = document.createElement('a');
6575          a.href = url; a.download = 'effective-config.json';
6576          document.body.appendChild(a); a.click();
6577          document.body.removeChild(a);
6578          setTimeout(function () { URL.revokeObjectURL(url); }, 200);
6579        });
6580      }
6581
6582      function detectType(value) {
6583        // Strip thousands separators so comma-formatted numbers (e.g. "121,542")
6584        // still sort numerically rather than lexicographically.
6585        var v = value.trim().replace(/,/g, '');
6586        return /^-?\d+(?:\.\d+)?$/.test(v) ? parseFloat(v) : value.trim().toLowerCase();
6587      }
6588
6589      document.querySelectorAll('[data-sort-table]').forEach(function (table) {
6590        var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
6591        var allMarkers = [];
6592        headers.forEach(function (th, idx) {
6593          var direction = 1;
6594          var marker = document.createElement('span');
6595          marker.className = 'sort-indicator';
6596          marker.textContent = ' \u2195';
6597          th.style.cursor = 'pointer';
6598          th.appendChild(marker);
6599          allMarkers.push(marker);
6600          th.addEventListener('click', function (e) {
6601            if (e.target.closest && e.target.closest('.col-resize-handle')) return;
6602            var tbody = table.tBodies[0];
6603            var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
6604            rows.sort(function (a, b) {
6605              var av = detectType((a.children[idx].textContent || '').trim());
6606              var bv = detectType((b.children[idx].textContent || '').trim());
6607              if (av < bv) return -1 * direction;
6608              if (av > bv) return 1 * direction;
6609              return 0;
6610            });
6611            rows.forEach(function (row) { tbody.appendChild(row); });
6612            allMarkers.forEach(function(m) { m.textContent = ' \u2195'; });
6613            direction = direction * -1;
6614            marker.textContent = direction === -1 ? ' \u2191' : ' \u2193';
6615            table.dispatchEvent(new CustomEvent('sloc-sorted'));
6616          });
6617        });
6618      });
6619
6620      // ── Column resize for all table-resizable tables ──────────────────────────
6621      (function() {
6622        document.querySelectorAll('.table-resizable').forEach(function(table) {
6623          var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
6624          var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
6625          ths.forEach(function(th, i) {
6626            var handle = th.querySelector('.col-resize-handle');
6627            if (!handle || !cols[i]) return;
6628            var startX, startW;
6629            handle.addEventListener('mousedown', function(e) {
6630              e.stopPropagation(); e.preventDefault();
6631              startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
6632              handle.classList.add('dragging');
6633              function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
6634              function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
6635              document.addEventListener('mousemove', onMove);
6636              document.addEventListener('mouseup', onUp);
6637            });
6638          });
6639        });
6640      })();
6641
6642      document.querySelectorAll('[data-table-filter]').forEach(function (input) {
6643        var table = document.getElementById(input.getAttribute('data-table-filter'));
6644        if (!table) return;
6645        var filterTimer = null;
6646        var rowCache = null;
6647        input.addEventListener('input', function () {
6648          clearTimeout(filterTimer);
6649          var q = input.value.toLowerCase();
6650          filterTimer = setTimeout(function () {
6651            if (!rowCache) {
6652              rowCache = Array.prototype.map.call(table.tBodies[0].rows, function (row) {
6653                return { row: row, text: row.textContent.toLowerCase() };
6654              });
6655            }
6656            rowCache.forEach(function (item) {
6657              item.row.style.display = q === '' || item.text.indexOf(q) >= 0 ? '' : 'none';
6658            });
6659          }, 200);
6660        });
6661      });
6662
6663      // ── Per-file table pagination ────────────────────────────────────────────
6664      (function () {
6665        var table = document.getElementById('per-file-table');
6666        if (!table) return;
6667        var tbody = table.tBodies[0];
6668        var searchInput = document.getElementById('per-file-search');
6669        var pageSizeSelect = document.getElementById('per-file-page-size');
6670        var firstBtn = document.getElementById('pf-first');
6671        var prevBtn = document.getElementById('pf-prev');
6672        var nextBtn = document.getElementById('pf-next');
6673        var lastBtn = document.getElementById('pf-last');
6674        var pageInfo = document.getElementById('pf-page-info');
6675        var jumpInput = document.getElementById('pf-page-jump');
6676        var pageTotal = document.getElementById('pf-page-total');
6677        var countLabel = document.getElementById('per-file-count-label');
6678        var filteredRows = [];
6679        var currentPage = 1;
6680        var totalAll = tbody.rows.length;
6681
6682        function getPageSize() {
6683          var v = pageSizeSelect ? pageSizeSelect.value : '20';
6684          return v === 'all' ? Infinity : parseInt(v, 10);
6685        }
6686
6687        function applyFilter() {
6688          var q = searchInput ? searchInput.value.toLowerCase() : '';
6689          var rows = Array.prototype.slice.call(tbody.rows);
6690          filteredRows = q === '' ? rows : rows.filter(function (row) {
6691            return row.textContent.toLowerCase().indexOf(q) >= 0;
6692          });
6693          currentPage = 1;
6694          render();
6695        }
6696
6697        function render() {
6698          var ps = getPageSize();
6699          var total = filteredRows.length;
6700          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6701          if (currentPage > totalPages) currentPage = totalPages;
6702          if (currentPage < 1) currentPage = 1;
6703          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6704          var end = ps === Infinity ? total : Math.min(start + ps, total);
6705          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6706          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6707          if (pageInfo) {
6708            if (total === 0) {
6709              pageInfo.textContent = 'No results';
6710            } else if (ps === Infinity) {
6711              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6712            } else {
6713              pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6714            }
6715          }
6716          if (countLabel) {
6717            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6718          }
6719          var edgeDisabled = ps === Infinity;
6720          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6721          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6722          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6723          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6724          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6725          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6726        }
6727
6728        if (searchInput) {
6729          var filterTimer = null;
6730          searchInput.addEventListener('input', function () {
6731            clearTimeout(filterTimer);
6732            filterTimer = setTimeout(applyFilter, 200);
6733          });
6734        }
6735        if (pageSizeSelect) {
6736          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6737        }
6738        if (firstBtn) {
6739          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6740        }
6741        if (prevBtn) {
6742          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6743        }
6744        if (nextBtn) {
6745          nextBtn.addEventListener('click', function () {
6746            var ps = getPageSize();
6747            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6748            if (currentPage < totalPages) { currentPage++; render(); }
6749          });
6750        }
6751        if (lastBtn) {
6752          lastBtn.addEventListener('click', function () {
6753            var ps = getPageSize();
6754            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6755            render();
6756          });
6757        }
6758        if (jumpInput) {
6759          function pfJump() {
6760            var ps = getPageSize();
6761            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6762            var v = parseInt(jumpInput.value, 10);
6763            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6764          }
6765          jumpInput.addEventListener('change', pfJump);
6766          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') pfJump(); });
6767        }
6768        table.addEventListener('sloc-sorted', function () { applyFilter(); });
6769        window._pfPaginationReset = function () { currentPage = 1; applyFilter(); };
6770        applyFilter();
6771      })();
6772
6773      // ── Skipped-files table pagination ───────────────────────────────────────
6774      (function () {
6775        var table = document.getElementById('skipped-table');
6776        if (!table) return;
6777        var tbody = table.tBodies[0];
6778        var searchInput = document.getElementById('skipped-search');
6779        var pageSizeSelect = document.getElementById('skipped-page-size');
6780        var firstBtn = document.getElementById('sk-first');
6781        var prevBtn = document.getElementById('sk-prev');
6782        var nextBtn = document.getElementById('sk-next');
6783        var lastBtn = document.getElementById('sk-last');
6784        var pageInfo = document.getElementById('sk-page-info');
6785        var jumpInput = document.getElementById('sk-page-jump');
6786        var pageTotal = document.getElementById('sk-page-total');
6787        var countLabel = document.getElementById('skipped-count-label');
6788        var filteredRows = [];
6789        var currentPage = 1;
6790        var totalAll = tbody.rows.length;
6791
6792        function getPageSize() {
6793          var v = pageSizeSelect ? pageSizeSelect.value : '10';
6794          return v === 'all' ? Infinity : parseInt(v, 10);
6795        }
6796
6797        function applyFilter() {
6798          var q = searchInput ? searchInput.value.toLowerCase() : '';
6799          var rows = Array.prototype.slice.call(tbody.rows);
6800          filteredRows = q === '' ? rows : rows.filter(function (row) {
6801            return row.textContent.toLowerCase().indexOf(q) >= 0;
6802          });
6803          currentPage = 1;
6804          render();
6805        }
6806
6807        function render() {
6808          var ps = getPageSize();
6809          var total = filteredRows.length;
6810          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6811          if (currentPage > totalPages) currentPage = totalPages;
6812          if (currentPage < 1) currentPage = 1;
6813          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6814          var end = ps === Infinity ? total : Math.min(start + ps, total);
6815          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6816          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6817          if (pageInfo) {
6818            if (total === 0) {
6819              pageInfo.textContent = 'No results';
6820            } else if (ps === Infinity) {
6821              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6822            } else {
6823              pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6824            }
6825          }
6826          if (countLabel) {
6827            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6828          }
6829          var edgeDisabled = ps === Infinity;
6830          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6831          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6832          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6833          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6834          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6835          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6836        }
6837
6838        if (searchInput) {
6839          var filterTimer = null;
6840          searchInput.addEventListener('input', function () {
6841            clearTimeout(filterTimer);
6842            filterTimer = setTimeout(applyFilter, 200);
6843          });
6844        }
6845        if (pageSizeSelect) {
6846          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6847        }
6848        if (firstBtn) {
6849          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6850        }
6851        if (prevBtn) {
6852          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6853        }
6854        if (nextBtn) {
6855          nextBtn.addEventListener('click', function () {
6856            var ps = getPageSize();
6857            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6858            if (currentPage < totalPages) { currentPage++; render(); }
6859          });
6860        }
6861        if (lastBtn) {
6862          lastBtn.addEventListener('click', function () {
6863            var ps = getPageSize();
6864            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6865            render();
6866          });
6867        }
6868        if (jumpInput) {
6869          function skJump() {
6870            var ps = getPageSize();
6871            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6872            var v = parseInt(jumpInput.value, 10);
6873            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6874          }
6875          jumpInput.addEventListener('change', skJump);
6876          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') skJump(); });
6877        }
6878        table.addEventListener('sloc-sorted', function () { applyFilter(); });
6879        applyFilter();
6880      })();
6881
6882      // ── Hotspots table pagination ────────────────────────────────────────────
6883      (function () {
6884        var table = document.getElementById('hotspots-table');
6885        if (!table) return;
6886        var tbody = table.tBodies[0];
6887        var searchInput = document.getElementById('hotspots-search');
6888        var pageSizeSelect = document.getElementById('hotspots-page-size');
6889        var firstBtn = document.getElementById('hs-first');
6890        var prevBtn = document.getElementById('hs-prev');
6891        var nextBtn = document.getElementById('hs-next');
6892        var lastBtn = document.getElementById('hs-last');
6893        var pageInfo = document.getElementById('hs-page-info');
6894        var jumpInput = document.getElementById('hs-page-jump');
6895        var pageTotal = document.getElementById('hs-page-total');
6896        var countLabel = document.getElementById('hotspots-count-label');
6897        var filteredRows = [];
6898        var currentPage = 1;
6899        var totalAll = tbody.rows.length;
6900
6901        function getPageSize() {
6902          var v = pageSizeSelect ? pageSizeSelect.value : '15';
6903          return v === 'all' ? Infinity : parseInt(v, 10);
6904        }
6905
6906        function applyFilter() {
6907          var q = searchInput ? searchInput.value.toLowerCase() : '';
6908          var rows = Array.prototype.slice.call(tbody.rows);
6909          filteredRows = q === '' ? rows : rows.filter(function (row) {
6910            return row.textContent.toLowerCase().indexOf(q) >= 0;
6911          });
6912          currentPage = 1;
6913          render();
6914        }
6915
6916        function render() {
6917          var ps = getPageSize();
6918          var total = filteredRows.length;
6919          var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6920          if (currentPage > totalPages) currentPage = totalPages;
6921          if (currentPage < 1) currentPage = 1;
6922          var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6923          var end = ps === Infinity ? total : Math.min(start + ps, total);
6924          Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6925          for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6926          if (pageInfo) {
6927            if (total === 0) {
6928              pageInfo.textContent = 'No results';
6929            } else if (ps === Infinity) {
6930              pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6931            } else {
6932              pageInfo.textContent = (start + 1) + '-' + end + ' of ' + total.toLocaleString() + ' files';
6933            }
6934          }
6935          if (countLabel) {
6936            countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6937          }
6938          var edgeDisabled = ps === Infinity;
6939          if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6940          if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6941          if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6942          if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6943          if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6944          if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6945        }
6946
6947        if (searchInput) {
6948          var filterTimer = null;
6949          searchInput.addEventListener('input', function () {
6950            clearTimeout(filterTimer);
6951            filterTimer = setTimeout(applyFilter, 200);
6952          });
6953        }
6954        if (pageSizeSelect) {
6955          pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6956        }
6957        if (firstBtn) {
6958          firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6959        }
6960        if (prevBtn) {
6961          prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6962        }
6963        if (nextBtn) {
6964          nextBtn.addEventListener('click', function () {
6965            var ps = getPageSize();
6966            var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6967            if (currentPage < totalPages) { currentPage++; render(); }
6968          });
6969        }
6970        if (lastBtn) {
6971          lastBtn.addEventListener('click', function () {
6972            var ps = getPageSize();
6973            currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6974            render();
6975          });
6976        }
6977        if (jumpInput) {
6978          function hsJump() {
6979            var ps = getPageSize();
6980            var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6981            var v = parseInt(jumpInput.value, 10);
6982            if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6983          }
6984          jumpInput.addEventListener('change', hsJump);
6985          jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') hsJump(); });
6986        }
6987        table.addEventListener('sloc-sorted', function () { applyFilter(); });
6988        applyFilter();
6989      })();
6990    })();
6991
6992    (function randomizeWatermarks() {
6993      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6994      if (!wms.length) return;
6995      var placed = [];
6996      function tooClose(t, l) {
6997        for (var i = 0; i < placed.length; i++) {
6998          var dt = Math.abs(placed[i][0] - t);
6999          var dl = Math.abs(placed[i][1] - l);
7000          if (dt < 18 && dl < 18) return true;
7001        }
7002        return false;
7003      }
7004      function pick(leftBias) {
7005        for (var attempt = 0; attempt < 40; attempt++) {
7006          var t = Math.random() * 90;
7007          var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
7008          if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
7009        }
7010        var fb = [Math.random() * 90, Math.random() * 95];
7011        placed.push(fb);
7012        return fb;
7013      }
7014      var half = Math.floor(wms.length / 2);
7015      wms.forEach(function (img, i) {
7016        var pos = pick(i < half);
7017        var sz = Math.floor(Math.random() * 80 + 110);
7018        var rot = (Math.random() * 360).toFixed(1);
7019        var op = (Math.random() * 0.07 + 0.10).toFixed(2);
7020        img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
7021      });
7022    })();
7023
7024    (function spawnCodeParticles() {
7025      var container = document.getElementById('code-particles');
7026      if (!container) return;
7027      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'];
7028      for (var i = 0; i < 38; i++) {
7029        (function (idx) {
7030          var el = document.createElement('span');
7031          el.className = 'code-particle';
7032          el.textContent = snippets[idx % snippets.length];
7033          var left = Math.random() * 94 + 2;
7034          var top = Math.random() * 88 + 6;
7035          var dur = (Math.random() * 10 + 9).toFixed(1);
7036          var delay = (Math.random() * 18).toFixed(1);
7037          var rot = (Math.random() * 26 - 13).toFixed(1);
7038          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
7039          el.style.left = left.toFixed(1) + '%';
7040          el.style.top = top.toFixed(1) + '%';
7041          el.style.setProperty('--rot', rot + 'deg');
7042          el.style.setProperty('--op', op);
7043          el.style.animationDuration = dur + 's';
7044          el.style.animationDelay = '-' + delay + 's';
7045          container.appendChild(el);
7046        })(i);
7047      }
7048    })();
7049    // ── Metric number formatting ─────────────────────────────────────────────
7050    (function () {
7051      function fmtBig(n) {
7052        if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
7053        if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
7054        return n.toLocaleString();
7055      }
7056      function fmtExact(n) { return n.toLocaleString(); }
7057      document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7058        var n = parseInt(el.getAttribute('data-metric-value'), 10);
7059        if (isNaN(n)) return;
7060        var big = el.querySelector('.metric-big');
7061        var exact = el.querySelector('.metric-exact');
7062        if (big) big.textContent = fmtBig(n);
7063        if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
7064      });
7065      var densityCard = document.querySelector('[data-metric-density]');
7066      if (densityCard) {
7067        var phys = 0, code = 0;
7068        document.querySelectorAll('[data-metric-value]').forEach(function (el) {
7069          var lbl = el.querySelector('.metric-label');
7070          if (!lbl) return;
7071          var t = lbl.textContent.trim().toLowerCase();
7072          var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
7073          if (t === 'physical lines') phys = v;
7074          if (t === 'code') code = v;
7075        });
7076        var pct = phys > 0 ? (code / phys * 100) : 0;
7077        var big = densityCard.querySelector('.metric-big');
7078        var exact = densityCard.querySelector('.metric-exact');
7079        if (big) big.textContent = pct.toFixed(1) + '%';
7080        if (exact) exact.textContent = '';
7081      }
7082      (function(){
7083        var g=document.querySelector('.summary-grid');if(!g)return;
7084        var pad=g.querySelector('.metric-pad');
7085        var real=Array.prototype.slice.call(g.querySelectorAll('.metric')).filter(function(el){return el!==pad;});
7086        if(!real.length)return;
7087        function upd(){
7088          // Pad the strip to an EVEN card count so a true CSS grid lays it out as
7089          // exactly two full rows with every column aligned and every card the
7090          // same size. When the real-card count is odd, reveal the reserve
7091          // "Assertions" pad card; otherwise keep it hidden.
7092          var n=real.length;
7093          if(pad){ if(n%2===1){pad.style.display='';n++;} else {pad.style.display='none';} }
7094          var perRow=window.innerWidth<=640?2:Math.ceil(n/2);
7095          g.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';
7096        }
7097        upd();window.addEventListener('resize',upd);
7098      })();
7099      (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);}})();
7100    })();
7101    // ── Info chip interactivity ───────────────────────────────────────────────
7102    (function() {
7103      document.querySelectorAll('.run-id-chip[data-copy]').forEach(function(chip) {
7104        chip.addEventListener('click', function() {
7105          var val = chip.getAttribute('data-copy');
7106          var tt = chip.querySelector('.chip-tooltip');
7107          var orig = tt ? tt.textContent : '';
7108          if (!navigator.clipboard) return;
7109          navigator.clipboard.writeText(val).then(function() {
7110            chip.classList.add('chip-copied-flash');
7111            if (tt) tt.textContent = 'Copied!';
7112            setTimeout(function() {
7113              chip.classList.remove('chip-copied-flash');
7114              if (tt) tt.textContent = orig;
7115            }, 1100);
7116          });
7117        });
7118      });
7119      document.querySelectorAll('.run-id-chip[data-author]').forEach(function(chip) {
7120        var author = chip.getAttribute('data-author');
7121        var el = chip.querySelector('.author-handle');
7122        if (el) el.textContent = '/' + author.replace(/\s+/g, '');
7123      });
7124    })();
7125    // ── Export helpers ────────────────────────────────────────────────────────
7126    function _slocUnh(s){var e=document.createElement('div');e.innerHTML=s;return e.textContent;}
7127    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 }}"};
7128    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
7129    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7130    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);}
7131    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;');}
7132    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');}
7133    function slocXlsMulti(fname,sheets){
7134      var enc=new TextEncoder();
7135      var CT=[];
7136      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;}
7137      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;}
7138      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
7139      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
7140      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7141      var ss=[],si={};
7142      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
7143      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;}
7144      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
7145      // 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(@)
7146      var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
7147        +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
7148        +'<fonts count="3">'
7149          +'<font><sz val="11"/><name val="Calibri"/></font>'
7150          +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
7151          +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
7152        +'</fonts>'
7153        +'<fills count="4">'
7154          +'<fill><patternFill patternType="none"/></fill>'
7155          +'<fill><patternFill patternType="gray125"/></fill>'
7156          +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
7157          +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
7158        +'</fills>'
7159        +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
7160        +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
7161        +'<cellXfs count="7">'
7162          +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
7163          +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7164          +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
7165          +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7166          +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
7167          +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
7168          +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
7169        +'</cellXfs>'
7170        +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
7171        +'</styleSheet>';
7172      var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
7173      function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
7174      sheets.forEach(function(sh,sheetIdx){
7175        var rx='<row r="1">';
7176        sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
7177        rx+='</row>';
7178        var rn=2;
7179        sh.rows.forEach(function(row){
7180          if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
7181          if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
7182            rx+='<row r="'+rn+'">';
7183            rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
7184            for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
7185            rx+='</row>';rn++;return;
7186          }
7187          rx+='<row r="'+rn+'">';
7188          row.forEach(function(cell,c){
7189            var ref=colRef(c,rn);
7190            if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
7191            if(typeof cell==='object'&&cell!==null){
7192              var cv=cell.v,cs=cell.s!=null?cell.s:0;
7193              if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
7194              else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
7195              return;
7196            }
7197            if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
7198            rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
7199          });
7200          rx+='</row>';rn++;
7201        });
7202        var cw='';
7203        if(sh.colWidths&&sh.colWidths.length>0){
7204          cw='<cols>';
7205          sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
7206          cw+='</cols>';
7207        }
7208        var tblParts='';
7209        if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
7210          tableCounter++;
7211          var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
7212          var tRef='A1:'+colNm(colCount)+rowCount;
7213          tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7214            +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
7215            +'<autoFilter ref="'+tRef+'"/>'
7216            +'<tableColumns count="'+colCount+'">'
7217            +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
7218            +'</tableColumns>'
7219            +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
7220            +'</table>';
7221          wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7222            +'<Relationships xmlns="'+pns+'relationships">'
7223            +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
7224            +'</Relationships>';
7225          tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
7226        }
7227        wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
7228          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
7229          +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
7230      });
7231      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>';
7232      var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
7233      var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
7234      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>';
7235      var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
7236      var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
7237      var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
7238      wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
7239        +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
7240      var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
7241      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};
7242      var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
7243      sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
7244      Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
7245      Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
7246      var zparts=[],zcds=[],zoff=0,znf=0;
7247      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++;});
7248      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
7249      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]);
7250      var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
7251      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
7252      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
7253      zout.set(new Uint8Array(ea),zpos);
7254      slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
7255    }
7256    window.resetPerFileTable = function() {
7257      var tbl = document.getElementById('per-file-table');
7258      if (!tbl) return;
7259      var shell = tbl.closest('.table-shell');
7260      if (shell) shell.scrollLeft = 0;
7261      Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
7262      Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7263      if (window._pfPaginationReset) window._pfPaginationReset();
7264      var si = document.getElementById('per-file-search');
7265      if (si) si.value = '';
7266    };
7267    var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
7268    var _titleSlug="{{ title }}".replace(/[^a-zA-Z0-9\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'');
7269    var _commitSlug="{% if let Some(c) = run.git_commit_short %}{{ c }}{% endif %}";
7270    var _exportSlug='per-file_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7271    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;}
7272    window.exportReportCsv=function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());};
7273    window.exportReportXls=function(){
7274      var fname='report_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'')+'.xlsx';
7275      function sec(v){return[{_sec:true,v:v}];}
7276      function B(v){return{v:v,s:4};}
7277      function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
7278      // Table cells render with thousands separators (1,656,153) via the |commas
7279      // filter; Number() on that string is NaN, which would store the value as text
7280      // (green-triangle warning, left-aligned). Strip separators so numeric cells
7281      // become real numbers and align correctly. Non-numeric text is left untouched.
7282      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;}
7283      function pnum(v){var t=String(v==null?'':v).replace(/,/g,'').trim();return /^-?\d+(\.\d+)?$/.test(t)?Number(t):0;}
7284      var dens=_SLOC_META.physicalLines>0?(_SLOC_META.codeLines/_SLOC_META.physicalLines*100).toFixed(1)+'%':'0%';
7285      var sumRows=[
7286        sec('RUN INFORMATION'),
7287        [B('Run ID'),_SLOC_META.runId,''],
7288        [B('Git Commit'),_SLOC_META.gitCommit,''],
7289        [B('Branch'),_SLOC_META.branch,''],
7290        [B('Last Commit By'),_SLOC_META.lastCommitBy,''],
7291        [B('Scan By'),_SLOC_META.scanBy,''],
7292        [B('Scanned'),_SLOC_META.scanned,''],
7293        [B('OS'),_SLOC_META.os,''],
7294        [B('Files Analyzed'),N(_SLOC_META.filesAnalyzed),'Total source files included in this analysis'],
7295        [B('Files Skipped'),N(_SLOC_META.filesSkipped),'Files excluded (binary, unsupported, or policy-filtered)'],
7296        [],
7297        sec('CODE METRICS'),
7298        [B('Physical Lines'),N(_SLOC_META.physicalLines),'Total lines including code, comments, and blanks'],
7299        [B('Code Lines'),N(_SLOC_META.codeLines),'Lines containing executable source code'],
7300        [B('Comments'),N(_SLOC_META.commentLines),'Lines consisting entirely of comments or documentation'],
7301        [B('Blank Lines'),N(_SLOC_META.blankLines),'Empty or whitespace-only lines'],
7302        [B('Mixed Separate'),N(_SLOC_META.mixedSeparate),'Lines with both code and trailing comment, counted separately'],
7303        [B('Functions'),N(_SLOC_META.functions),'Best-effort count of function/method definitions'],
7304        [B('Classes / Types'),N(_SLOC_META.classes),'Best-effort count of class, struct, interface definitions'],
7305        [B('Variables'),N(_SLOC_META.variables),'Best-effort count of variable and constant declarations'],
7306        [B('Imports'),N(_SLOC_META.imports),'Best-effort count of import, include, module-use statements'],
7307        [B('Tests'),N(_SLOC_META.tests),'Best-effort count of test cases (GTest, PyTest, JUnit, etc.)'],
7308        [B('Code Density'),{v:dens,s:6},'Percentage of physical lines that contain executable source code'],
7309        [B('Tool Version'),'oxide-sloc '+_SLOC_META.toolVersion,''],
7310      ];
7311      var langHdrs=['Language','Files','Physical Lines','Code Lines','Comments','Blank Lines','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7312      var langRows=[];
7313      document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7314        var tds=tr.querySelectorAll('td');
7315        var row=[];
7316        Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>0?numify(v):v);});
7317        langRows.push(row);
7318      });
7319      var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7320      var pfRows=[];
7321      document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){
7322        var tds=tr.querySelectorAll('td');
7323        if(tds.length<11)return;
7324        var row=[];
7325        Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>=2?numify(v):v);});
7326        pfRows.push(row);
7327      });
7328      var skHdrs=['File','Status','Warnings'];
7329      var skRows=[];
7330      document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){
7331        var tds=tr.querySelectorAll('td');
7332        if(tds.length<3)return;
7333        skRows.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);
7334      });
7335      var covHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
7336      var covRows=[];
7337      document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7338        var tds=tr.querySelectorAll('td');
7339        if(tds.length<4)return;
7340        var phys=pnum(tds[2].textContent);
7341        var code=pnum(tds[3].textContent);
7342        var densStr=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
7343        var row=[tds[0].textContent.trim(),pnum(tds[1].textContent),phys,code,{v:densStr,s:6}];
7344        for(var i=7;i<Math.min(tds.length,14);i++){row.push(numify(tds[i].textContent.trim()));}
7345        covRows.push(row);
7346      });
7347      slocXlsMulti(fname,[
7348        {name:'Summary',hdrs:['Field / Metric','Value','Description'],rows:sumRows,colWidths:[22,45,55],isKv:true},
7349        {name:'Language Breakdown',hdrs:langHdrs,rows:langRows,colWidths:[16,8,14,12,12,12,8,10,10,10,10,8,10,8]},
7350        {name:'Per-File Detail',hdrs:pfHdrs,rows:pfRows,colWidths:[50,12,12,12,12,10,8,10,10,10,10,8,10,8]},
7351        {name:'Code Coverage',hdrs:covHdrs,rows:covRows,colWidths:[18,7,14,12,13,11,10,10,10,8,11,12]},
7352        {name:'Skipped Files',hdrs:skHdrs,rows:skRows,colWidths:[60,25,50]}
7353      ]);
7354    };
7355    Array.prototype.slice.call(document.querySelectorAll('[data-export-csv]')).forEach(function(btn){btn.addEventListener('click',function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());});});
7356    Array.prototype.slice.call(document.querySelectorAll('[data-export-xls]')).forEach(function(btn){btn.addEventListener('click',window.exportReportXls);});
7357    Array.prototype.slice.call(document.querySelectorAll('[data-reset-table]')).forEach(function(btn){btn.addEventListener('click',window.resetPerFileTable);});
7358    var _skippedRh=['File','Status','Warnings'];
7359    var _skippedSlug='skipped_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7360    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;}
7361    (function(){var b=document.getElementById('skipped-export-csv');if(b)b.addEventListener('click',function(){slocCsv(_skippedSlug+'.csv',_skippedRh,getSkippedExportRows());});})();
7362    (function(){var b=document.getElementById('skipped-export-xls');if(b)b.addEventListener('click',function(){slocXls(_skippedSlug+'.xlsx','Skipped Files',_skippedRh,getSkippedExportRows());});})();
7363    // ── Chart.js initialization ───────────────────────────────────────────────
7364    // Deferred so the browser can repaint (dismiss the loading overlay) before
7365    // the canvas/SVG chart work blocks the main thread.
7366    requestAnimationFrame(function() {
7367    try {
7368    (function() {
7369      var D = {{ lang_chart_json|safe }};
7370      var SUB_D = {{ submodule_chart_json|safe }};
7371      var SCAT_D = {{ scatter_chart_json|safe }};
7372      var SEM_D = {{ semantic_chart_json|safe }};
7373      var HIST_D = {{ file_size_histogram_json|safe }};
7374      if (!D || !D.length) return;
7375
7376      var PALETTE = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030',
7377                     '#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082',
7378                     '#D0743C','#5BA8A0','#8B3A8B','#3D7A3D','#AA5500','#005599'];
7379      var OX = '#C45C10', GN = '#2A6846', GY = '#BBBBBB';
7380      var ALL_CHARTS = [];
7381      function hexAlpha(hex, a) {
7382        var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
7383        return 'rgba('+r+','+g+','+b+','+a+')';
7384      }
7385
7386      function fmt(n) {
7387        var v = Number(n), a = Math.abs(v);
7388        if (a >= 1e6) return (v/1e6).toFixed(1).replace(/\.0$/,'') + 'M';
7389        if (a >= 1e4) return Math.round(v/1e3) + 'K';
7390        return v.toLocaleString();
7391      }
7392      function isDark() { return document.body.classList.contains('dark-theme'); }
7393      function clr() {
7394        return isDark()
7395          ? { text: '#d4c5b8', grid: 'rgba(255,255,255,0.10)' }
7396          : { text: '#43342d', grid: '#e6d0bf' };
7397      }
7398      // Legend-highlight alpha for a dataset's drawn label / marker. `chart.$hiDs` is
7399      // set by legend hover (see attachScatterLegend); when it is null every dataset
7400      // draws at full strength. Once a language is hovered, the others fade so the
7401      // hovered one's marker, name and number stay readable through overlapping
7402      // neighbours. Applied globally to every value-labelled Chart.js plot.
7403      function hiAlpha(chart, di) {
7404        var h = chart.$hiDs;
7405        if (h == null) return 1;
7406        return di === h ? 1 : 0.1;
7407      }
7408      // Inline Chart.js plugin: draws a permanent value label on each bar / bubble.
7409      // fmtFn(rawValue, datasetIndex, pointIndex) → string | null
7410      // anchor: 'top' = above vertical bar, 'end' = right of horizontal bar, 'bubble' = above bubble
7411      function makeDlPlugin(fmtFn, anchor) {
7412        return {
7413          afterDatasetsDraw: function(chart) {
7414            var ctx = chart.ctx;
7415            var tc = clr().text;
7416            chart.data.datasets.forEach(function(ds, di) {
7417              var meta = chart.getDatasetMeta(di);
7418              meta.data.forEach(function(el, idx) {
7419                var label = fmtFn(ds.data[idx], di, idx);
7420                if (label == null || label === '') return;
7421                ctx.save();
7422                ctx.globalAlpha = hiAlpha(chart, di);
7423                ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7424                ctx.fillStyle = tc;
7425                if (anchor === 'top') {
7426                  ctx.textAlign = 'center';
7427                  ctx.textBaseline = 'bottom';
7428                  ctx.fillText(String(label), el.x, el.y - 3);
7429                } else if (anchor === 'end') {
7430                  ctx.textAlign = 'left';
7431                  ctx.textBaseline = 'middle';
7432                  ctx.fillText(String(label), el.x + 5, el.y);
7433                } else {
7434                  ctx.textAlign = 'center';
7435                  ctx.textBaseline = 'bottom';
7436                  var r = (el.options && el.options.radius) ? el.options.radius : 10;
7437                  ctx.fillText(String(label), el.x, el.y - r - 3);
7438                }
7439                ctx.restore();
7440              });
7441            });
7442          }
7443        };
7444      }
7445      // Bubble-chart value labels (language name + code lines above each bubble).
7446      // Shared by the dashboard card and the Full View modal. Honours the legend
7447      // highlight: non-hovered languages' labels fade and the hovered one is drawn
7448      // last so its name + number sit on top of any overlapping neighbours — the
7449      // clustered bubbles at the origin are otherwise an unreadable pile of text.
7450      function scatterLabelPlugin() {
7451        return { afterDatasetsDraw: function(chart) {
7452          var ctx = chart.ctx, tc = clr().text, hi = chart.$hiDs;
7453          function drawOne(di) {
7454            var d = SCAT_D[di]; if (!d) return;
7455            var meta = chart.getDatasetMeta(di), a = hiAlpha(chart, di);
7456            meta.data.forEach(function(el) {
7457              var r = (el.options && el.options.radius) ? el.options.radius : 10;
7458              var ty2 = Math.max(14, el.y - r - 3), ty1 = Math.max(1, ty2 - 14);
7459              ctx.save();
7460              ctx.globalAlpha = a; ctx.fillStyle = tc;
7461              ctx.textBaseline = 'bottom'; ctx.textAlign = 'center';
7462              ctx.font = '800 11px Inter,ui-sans-serif,sans-serif';
7463              ctx.fillText(d.lang, el.x, ty1);
7464              ctx.font = '700 10px Inter,ui-sans-serif,sans-serif';
7465              ctx.fillText(fmt(d.code), el.x, ty2);
7466              ctx.restore();
7467            });
7468          }
7469          chart.data.datasets.forEach(function(_, di) { if (hi == null || di !== hi) drawOne(di); });
7470          if (hi != null && hi >= 0) drawOne(hi);
7471        } };
7472      }
7473      function makeStackedEndPlugin(fmtFn) {
7474        return {
7475          afterDatasetsDraw: function(chart) {
7476            var ctx = chart.ctx;
7477            var tc = clr().text;
7478            var nDs = chart.data.datasets.length;
7479            if (nDs === 0) return;
7480            var lastMeta = chart.getDatasetMeta(nDs - 1);
7481            lastMeta.data.forEach(function(el, idx) {
7482              var total = 0;
7483              chart.data.datasets.forEach(function(ds) { total += ds.data[idx] || 0; });
7484              var label = fmtFn(total, idx);
7485              if (label == null || label === '') return;
7486              ctx.save();
7487              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7488              ctx.fillStyle = tc;
7489              ctx.textAlign = 'left';
7490              ctx.textBaseline = 'middle';
7491              ctx.fillText(String(label), el.x + 5, el.y);
7492              ctx.restore();
7493            });
7494          }
7495        };
7496      }
7497
7498      function wireDonutLegend(svg) {
7499        if(!svg) return;
7500        // Every donut element carries data-lang: slices (path/circle), leader lines,
7501        // outside labels + % labels (text) and legend rows (g). Hovering any one of
7502        // them emphasises that language across all of them and fades the rest, so the
7503        // slice, its leader line, its label and its legend row move as one picture.
7504        var items=svg.querySelectorAll('[data-lang]');
7505        function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
7506          var tag=el.tagName.toLowerCase();
7507          if(tag==='path'||tag==='circle'){
7508            if(st===1){el.style.opacity='1';el.style.filter='brightness(1.15) drop-shadow(0 3px 9px rgba(0,0,0,.28))';el.style.transform='scale(1.06)';}
7509            else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
7510            else{el.style.opacity='';el.style.filter='';el.style.transform='';}
7511          }else if(tag==='line'){
7512            if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
7513            else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
7514            else{el.style.opacity='';el.style.strokeWidth='';}
7515          }else if(tag==='text'){
7516            if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
7517            else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
7518            else{el.style.opacity='';el.style.fontWeight='';}
7519          }else{ // legend group
7520            if(st===1){el.style.opacity='1';}
7521            else if(st===-1){el.style.opacity='0.4';}
7522            else{el.style.opacity='';}
7523          }
7524        }
7525        function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
7526        function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
7527        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();});
7528        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();});
7529        svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
7530      }
7531      function wireMixLegend(svg) {
7532        if(!svg) return;
7533        var legGs=svg.querySelectorAll('g[data-kind]');
7534        var allRects=svg.querySelectorAll('rect[data-kind]');
7535        if(!legGs.length) return;
7536        function hlKind(kind) {
7537          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';}}
7538          for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}
7539        }
7540        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='';}}
7541        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]);}
7542      }
7543
7544      // ── Language overview: SVG donut + horizontal stacked bars ───────────────
7545      (function() {
7546        var el = document.getElementById('report-lang-overview');
7547        if (!el || !D || !D.length) return;
7548        var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7549        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7550        function px(n){return Math.round(n);}
7551        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;')+'"';}
7552        var tot = D.reduce(function(a,d){return a+d.code;},0)||1;
7553        // Donut — height matches the stacked-bar chart so both panels align
7554        var rHb_d=28;
7555        var DH=Math.max(220,D.length*rHb_d+32);
7556        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48,legX=208,DW=395;
7557        var legCount=D.length;
7558        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
7559        var legYStart=Math.round((DH-legCount*legSpacing)/2);
7560        var ds='<svg id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7561        // One shared transition on every donut element so slices, leader lines,
7562        // outside labels, % labels and the legend all animate together as a single
7563        // picture when a language is hovered. Slices scale from the donut centre.
7564        ds+='<style>#dnt-svg path,#dnt-svg circle,#dnt-svg line,#dnt-svg text,#dnt-svg g{transition:opacity .22s ease,filter .22s ease,transform .22s ease,stroke-width .22s ease;}#dnt-svg path,#dnt-svg circle{transform-origin:'+cx+'px '+cy+'px;}</style>';
7565        if(D.length===1){
7566          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
7567          ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' data-lang="'+esc(D[0].lang)+'" cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+PALETTE[0]+'" stroke-width="'+rsw+'"/>';
7568        } else {
7569          var smalls=[];
7570          var ang=-Math.PI/2;
7571          D.forEach(function(d,i){
7572            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
7573            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
7574            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
7575            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
7576            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
7577            var pct=Math.round(d.code/tot*100);
7578            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"/>';
7579            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:PALETTE[i%PALETTE.length]});}
7580            ang+=sw;
7581          });
7582          // Small slices (<5%) get outside labels positioned near each slice's own
7583          // angular position (a slice on the left gets its label/leader on the left),
7584          // then nudged apart horizontally so text never overlaps. Leader lines point
7585          // from each slice to its label. Horizontal text keeps long names legible;
7586          // the whole SVG scales up in Full View so these stay readable there too.
7587          if(smalls.length){
7588            smalls.sort(function(a,b){return a.mAng-b.mAng;});
7589            var sPad=6,sRowY=11;
7590            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)));});
7591            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;}
7592            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
7593            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
7594            smalls.forEach(function(sm){
7595              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
7596              ds+='<line data-lang="'+esc(sm.lang)+'" x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
7597              ds+='<text data-lang="'+esc(sm.lang)+'" x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="cursor:pointer;">'+esc(sm.txt)+'</text>';
7598            });
7599          }
7600        }
7601        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
7602        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
7603        D.forEach(function(d,i){
7604          var ly=legYStart+i*legSpacing;
7605          var pctL=Math.round(d.code/tot*100);
7606          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
7607          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
7608          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
7609          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
7610          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(PALETTE[i%PALETTE.length])+'"/>';
7611          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
7612          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>';
7613          ds+='</g>';
7614        });
7615        ds+='</svg>';
7616        // Horizontal stacked-bar chart
7617        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
7618        var LW=108,BW=260,svgW=LW+BW+68;
7619        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
7620        var barBH=Math.min(32,Math.round(barRhb*0.7));
7621        var SH=DH;
7622        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
7623        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">';
7624        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
7625        // it cannot fit legibly even at the 6.5 floor (labels shrink to fit instead
7626        // of disappearing; the SVG scales up in Full View so small fonts stay legible).
7627        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;}
7628        D.forEach(function(d,i){
7629          var y=barTopPad+i*barRhb,x=LW;
7630          var phys=d.physical||d.code+d.comments+d.blanks;
7631          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
7632          var lmid=y+barBH/2+4;
7633          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
7634          bs+='<g class="lang-bar-row">';
7635          // Hit area ends just past the total label so empty space to the right of the
7636          // bar does not trigger the tooltip — only the name, bar and total are hot.
7637          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
7638          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
7639          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>';
7640          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;}
7641          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;}
7642          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>';}
7643          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>';
7644          bs+='</g>';
7645        });
7646        var ly=SH-14;
7647        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
7648        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
7649        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
7650        var totAll=totC+totCm+totBl||1;
7651        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
7652        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7653        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7654        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7655        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7656        bs+='<g data-kind="code" style="cursor:pointer;">'
7657          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
7658          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
7659          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
7660          +'</g>';
7661        bs+='<g data-kind="comment" style="cursor:pointer;">'
7662          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
7663          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
7664          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
7665          +'</g>';
7666        bs+='<g data-kind="blank" style="cursor:pointer;">'
7667          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
7668          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
7669          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
7670          +'</g>';
7671        bs+='</svg>';
7672        el.innerHTML='<div class="r-lang-overview">'+
7673          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
7674          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
7675        '</div>';
7676        wireDonutLegend(el.querySelector('svg'));
7677        wireMixLegend(el.querySelectorAll('svg')[1]);
7678      })();
7679
7680      // Shared cursor helper: pointer over data elements, default elsewhere.
7681      // Added to options.onHover on every Chart.js instance.
7682      // Legend items handled separately via legend.onHover / legend.onLeave.
7683      function chartCursor(e, els) {
7684        var t = e.native && e.native.target;
7685        if (t) t.style.cursor = els.length ? 'pointer' : 'default';
7686      }
7687      function legendCursorOn(e) { var t=e.native&&e.native.target; if(t)t.style.cursor='pointer'; }
7688      function legendCursorOff(e){ var t=e.native&&e.native.target; if(t)t.style.cursor='default'; }
7689      // Pushes a right-positioned legend away from the plot by `gap` px. Chart.js
7690      // (v4) places a right legend flush against the plot area: fit() reserves the
7691      // legend box width and _draw() lays items out from `this.left + padding`, so
7692      // the column hugs the bubbles. We reserve `gap` extra width in fit() (which
7693      // shrinks the plot by `gap`), then translate the canvas right by `gap` while
7694      // the legend draws so the column lands in that reserved space — clear of the
7695      // plot. The legendHitBoxes (used only for hover hit-testing, not drawing) are
7696      // shifted by the same `gap` so hover targets stay aligned with what's drawn.
7697      function legendGapPlugin(gap) {
7698        return {
7699          id: 'legendGap',
7700          beforeInit: function(chart) {
7701            var lg = chart.legend; if (!lg) return;
7702            var origFit = lg.fit, origDraw = lg.draw;
7703            lg.fit = function() { origFit.call(this); this.width += gap; this._needGap = true; };
7704            lg.draw = function() {
7705              if (this._needGap && this.legendHitBoxes) {
7706                this.legendHitBoxes.forEach(function(h){ h.left += gap; });
7707                this._needGap = false;
7708              }
7709              var ctx = this.ctx;
7710              ctx.save();
7711              ctx.translate(gap, 0);
7712              origDraw.call(this);
7713              ctx.restore();
7714            };
7715          }
7716        };
7717      }
7718
7719      // ── Project Overview bar ─────────────────────────────────────────────────
7720      var projChart = null;
7721      (function() {
7722        var ySel = document.getElementById('overview-y-axis');
7723        var xSel = document.getElementById('overview-x-mode');
7724        var el = document.getElementById('overview-chart');
7725        var lockedEl = document.getElementById('overview-chart-locked');
7726        var wrap = document.getElementById('canvas-proj-wrap');
7727        var canvas = document.getElementById('canvas-proj');
7728        if (!canvas || !ySel || !xSel) return;
7729        var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines',
7730                         physical:'Physical Lines', files:'Files', comment:'Comment Lines', blank:'Blank Lines' };
7731        function getData() {
7732          var yKey = ySel.value, mode = xSel.value;
7733          var src = mode === 'submodules' ? SUB_D : D;
7734          var lKey = mode === 'submodules' ? 'name' : 'lang';
7735          var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7736          return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7737        }
7738        function renderOverview() {
7739          var mode = xSel.value, isHist = mode.indexOf('history') === 0;
7740          if (el) el.style.display = isHist ? 'none' : 'block';
7741          if (lockedEl) lockedEl.style.display = isHist ? 'block' : 'none';
7742          if (isHist) return;
7743          var r = getData();
7744          var c = clr();
7745          if (wrap) wrap.style.height = Math.max(200, Math.min(432, r.sorted.length * 29 + 60)) + 'px';
7746          if (projChart) {
7747            projChart.data.labels = r.sorted.map(function(d){return d[r.lKey];});
7748            projChart.data.datasets[0].data = r.sorted.map(function(d){return d[r.yKey]||0;});
7749            projChart.data.datasets[0].backgroundColor = r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7750            projChart.data.datasets[0].label = r.yLabel;
7751            projChart.options.scales.x.title.text = r.yLabel;
7752            projChart.update('none'); return;
7753          }
7754          projChart = new Chart(canvas, {
7755            type: 'bar',
7756            data: {
7757              labels: r.sorted.map(function(d){return d[r.lKey];}),
7758              datasets: [{ label: r.yLabel,
7759                data: r.sorted.map(function(d){return d[r.yKey]||0;}),
7760                backgroundColor: r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7761                borderRadius: 3 }]
7762            },
7763            options: {
7764              indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7765              onHover: chartCursor,
7766              animation: { duration: 500, easing: 'easeOutQuart' },
7767              layout: { padding: { right: 64 } },
7768              scales: {
7769                x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
7770                     title: { display: true, text: r.yLabel, color: c.text } },
7771                y: { grid: { display: false }, ticks: { color: c.text } }
7772              },
7773              plugins: {
7774                legend: { display: false },
7775                tooltip: {
7776                  callbacks: {
7777                    title: function(items) { return items.length ? items[0].label : ''; },
7778                    label: function(ctx) {
7779                      return '  ' + ctx.dataset.label + ': ' + Number(ctx.parsed.x).toLocaleString();
7780                    }
7781                  }
7782                }
7783              }
7784            },
7785            plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7786          });
7787          ALL_CHARTS.push(projChart);
7788        }
7789        ySel.addEventListener('change', renderOverview);
7790        xSel.addEventListener('change', renderOverview);
7791        renderOverview();
7792
7793        var overviewExpandBtn = document.getElementById('overview-expand-btn');
7794        if (overviewExpandBtn) {
7795          overviewExpandBtn.addEventListener('click', function() {
7796            var r = getData();
7797            var n = r.sorted.length || 1;
7798            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
7799            var modalH = Math.min(Math.max(480, n * 29 + 96), maxH);
7800            var overlay = document.createElement('div');
7801            overlay.className = 'chart-modal-overlay';
7802            overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;">'
7803              + '<button class="chart-modal-close" aria-label="Close">&times;</button>'
7804              + '<div class="chart-modal-header">'
7805              + '<span class="chart-modal-title">Project Overview \u2014 Full View</span>'
7806              + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
7807              + '<select id="ov-modal-y" class="chart-select">'
7808              + '<option value="code">Code Lines</option>'
7809              + '<option value="comments">Comment Lines</option>'
7810              + '<option value="blanks">Blank Lines</option>'
7811              + '<option value="physical">Total Physical Lines</option>'
7812              + '<option value="files">File Count</option>'
7813              + '</select></label>'
7814              + (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:'
7815              + '<select id="ov-modal-x" class="chart-select">'
7816              + '<option value="languages">Languages</option>'
7817              + '<option value="submodules">Submodules</option>'
7818              + '</select></label>' : '')
7819              + '</div>'
7820              + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-proj-modal"></canvas></div></div>';
7821            document.body.appendChild(overlay);
7822            overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
7823            overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
7824            var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines', physical:'Physical Lines', files:'Files' };
7825            var modalYSel = document.getElementById('ov-modal-y');
7826            var modalXSel = document.getElementById('ov-modal-x');
7827            if (modalYSel) modalYSel.value = ySel ? ySel.value : 'code';
7828            if (modalXSel && xSel) modalXSel.value = (xSel.value === 'languages' || xSel.value === 'submodules') ? xSel.value : 'languages';
7829            var modalCanvas = document.getElementById('canvas-proj-modal');
7830            if (!modalCanvas) return;
7831            var c = clr();
7832            function getModalData() {
7833              var yKey = modalYSel ? modalYSel.value : 'code';
7834              var mode = modalXSel ? modalXSel.value : 'languages';
7835              var src = mode === 'submodules' ? SUB_D : D;
7836              var lKey = mode === 'submodules' ? 'name' : 'lang';
7837              var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7838              return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7839            }
7840            var ovModalChart = null;
7841            function renderOverviewModal() {
7842              var r2 = getModalData();
7843              if (ovModalChart) {
7844                ovModalChart.data.labels = r2.sorted.map(function(d){return d[r2.lKey];});
7845                ovModalChart.data.datasets[0].data = r2.sorted.map(function(d){return d[r2.yKey]||0;});
7846                ovModalChart.data.datasets[0].backgroundColor = r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7847                ovModalChart.data.datasets[0].label = r2.yLabel;
7848                ovModalChart.options.scales.x.title.text = r2.yLabel;
7849                ovModalChart.update('none'); return;
7850              }
7851              ovModalChart = new Chart(modalCanvas, {
7852                type: 'bar',
7853                data: {
7854                  labels: r2.sorted.map(function(d){return d[r2.lKey];}),
7855                  datasets: [{ label: r2.yLabel,
7856                    data: r2.sorted.map(function(d){return d[r2.yKey]||0;}),
7857                    backgroundColor: r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7858                    borderRadius: 3 }]
7859                },
7860                options: {
7861                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7862                  onHover: chartCursor,
7863                  animation: { duration: 500, easing: 'easeOutQuart' },
7864                  layout: { padding: { right: 64 } },
7865                  scales: {
7866                    x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
7867                         title:{display:true, text:r2.yLabel, color:c.text} },
7868                    y: { grid:{display:false}, ticks:{color:c.text} }
7869                  },
7870                  plugins: {
7871                    legend:{display:false},
7872                    tooltip:{callbacks:{
7873                      title:function(items){return items.length?items[0].label:'';},
7874                      label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x).toLocaleString();}
7875                    }}
7876                  }
7877                },
7878                plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7879              });
7880            }
7881            renderOverviewModal();
7882            if (modalYSel) modalYSel.addEventListener('change', renderOverviewModal);
7883            if (modalXSel) modalXSel.addEventListener('change', renderOverviewModal);
7884          });
7885        }
7886      })();
7887
7888      // ── Language Composition (SVG — matches /runs/result behaviour) ──────────
7889      (function() {
7890        var el = document.getElementById('comp-svg-container');
7891        if (!el || !D || !D.length) return;
7892        var cData = D.slice(0, 15);
7893        var cMode = 'absolute';
7894        var CX = OX, CG = GN, CB = '#BBBBBB';
7895        var CFONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7896        function cEsc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
7897        function cPx(n){return Math.round(n);}
7898        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;')+'"';}
7899        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if it
7900        // cannot fit legibly even at the 6.5 floor (labels shrink to fit rather than
7901        // disappear; the SVG scales up in Full View so small fonts stay legible).
7902        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;}
7903        function cLT(l,v){return ' data-ttl="'+l+'" data-ttv="'+v.replace(/"/g,'&quot;')+'"';}
7904        function renderCompSVG() {
7905          var isPct = cMode === 'pct';
7906          var totC=cData.reduce(function(a,d){return a+(d.code||0);},0);
7907          var totCm=cData.reduce(function(a,d){return a+(d.comments||0);},0);
7908          var totBl=cData.reduce(function(a,d){return a+(d.blanks||0);},0);
7909          var totAll=totC+totCm+totBl||1;
7910          var svgW=Math.max(320,el.offsetWidth||540);
7911          var LW=108,legendH=24,topPad=4;
7912          var MIN_SVG_H=220;
7913          var rHb=Math.min(80,Math.max(26,Math.floor((MIN_SVG_H-legendH-topPad-10)/cData.length)));
7914          var bH=Math.min(38,Math.round(rHb*0.68));
7915          var BW=Math.max(120,svgW-LW-84);
7916          var SH=Math.max(MIN_SVG_H,cData.length*rHb+legendH+topPad+10);
7917          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">';
7918          if(isPct){
7919            cData.forEach(function(d,i){
7920              var t2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
7921              var cW=(d.code||0)/t2*BW,cmW=(d.comments||0)/t2*BW,blW=(d.blanks||0)/t2*BW;
7922              var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7923              var lmid=y+Math.floor(bH/2)+4;
7924              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||t2);
7925              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>';
7926              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;}
7927              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;}
7928              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>';}
7929              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>';
7930            });
7931          } else {
7932            var maxT=Math.max.apply(null,cData.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);})) || 1;
7933            cData.forEach(function(d,i){
7934              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
7935              var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7936              var lmid=y+Math.floor(bH/2)+4;
7937              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));
7938              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>';
7939              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;}
7940              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;}
7941              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>';}
7942              var phys=d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0);
7943              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>';
7944            });
7945          }
7946          var ly=SH-legendH+4;
7947          var ttC=cLT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7948          var ttCm=cLT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7949          var ttBl=cLT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7950          var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7951          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>';
7952          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>';
7953          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>';
7954          s+='</svg>';
7955          el.innerHTML=s;
7956          wireMixLegend(el.querySelector('svg'));
7957        }
7958        document.querySelectorAll('[data-comp-tab]').forEach(function(btn){
7959          btn.addEventListener('click', function(){
7960            document.querySelectorAll('[data-comp-tab]').forEach(function(b){b.classList.remove('active');});
7961            btn.classList.add('active');
7962            cMode=btn.getAttribute('data-comp-tab');
7963            renderCompSVG();
7964          });
7965        });
7966        renderCompSVG();
7967        window.addEventListener('resize', renderCompSVG);
7968      })();
7969
7970      // Custom HTML legend for the bubble chart: balanced columns (~15 rows max
7971      // per column, evenly distributed) placed to the right of the canvas. Chart.js'
7972      // native right-legend fills one column to full height then dumps the rest into
7973      // a tiny second column (and clips when space is tight) — this gives even columns
7974      // and never hides languages. Hover mirrors the old dim/highlight behaviour.
7975      //
7976      // Must run BEFORE `new Chart(canvas, …)` so Chart.js' resize observer binds to
7977      // the inner wrapper (not the full-width host). Returns a holder whose `.chart`
7978      // field the caller assigns once the chart exists, so hover can drive it.
7979      // expandBtnId: when set (compact card), the legend is capped to what fits in
7980      // 2 columns at the available height and a trailing "+N more" row links to Full
7981      // View. When null (Full View itself) every language is shown across (up to) 2
7982      // tall columns. Languages are ordered by code lines so the compact view keeps
7983      // the biggest ones; colours/hover still key off each language's original index.
7984      function attachScatterLegend(canvas, expandBtnId) {
7985        var holder = { chart: null };
7986        var host = canvas && canvas.parentNode;
7987        if (!host) return holder;
7988        host.style.display = 'flex';
7989        host.style.alignItems = 'center';
7990        host.style.gap = '12px';
7991        var cwrap = document.createElement('div');
7992        cwrap.style.cssText = 'position:relative;flex:1 1 auto;min-width:0;height:100%;';
7993        host.insertBefore(cwrap, canvas);
7994        cwrap.appendChild(canvas);
7995
7996        var n = SCAT_D.length;
7997        var availH = Math.max(120, host.clientHeight || 224);
7998        var rowsFit = Math.max(2, Math.floor(availH / 18));   // readable pitch
7999        // Never more than 2 columns; compact view truncates to fit, Full View shows all.
8000        var truncated = expandBtnId ? (n > 2 * rowsFit) : false;
8001        var realShown = truncated ? (2 * rowsFit - 1) : n;
8002        var totalItems = truncated ? (2 * rowsFit) : n;
8003        // Split into 2 equal columns once a single column would exceed ~18 rows, even
8004        // when the (tall) Full-View modal could fit them all in one column.
8005        var cols = totalItems > Math.min(rowsFit, 18) ? 2 : 1;
8006        var perCol = Math.ceil(totalItems / cols);
8007        var rowH = Math.max(14, Math.min(30, Math.floor(availH / perCol)));
8008
8009        // Order by code lines desc so the compact view keeps the biggest languages.
8010        var order = SCAT_D.map(function(_, i){ return i; })
8011          .sort(function(a, b){ return (SCAT_D[b].code || 0) - (SCAT_D[a].code || 0); });
8012
8013        var leg = document.createElement('div');
8014        leg.style.cssText = 'flex:0 0 auto;display:grid;grid-auto-flow:column;'
8015          + 'grid-template-rows:repeat(' + perCol + ',' + rowH + 'px);column-gap:18px;'
8016          + 'align-content:center;font-size:12px;line-height:1;';
8017        function setHi(idx) {
8018          var chart = holder.chart; if (!chart) return;
8019          chart.$hiDs = idx;   // read by scatterLabelPlugin so labels fade in step
8020          chart.data.datasets.forEach(function(ds, i) {
8021            var b = PALETTE[i % PALETTE.length];
8022            ds.backgroundColor = i === idx ? b + 'b8' : b + '20';
8023            ds.borderColor = i === idx ? b : b + '30';
8024          });
8025          chart.setActiveElements([{ datasetIndex: idx, index: 0 }]);
8026          chart.update();
8027        }
8028        function clearHi() {
8029          var chart = holder.chart; if (!chart) return;
8030          chart.$hiDs = null;
8031          chart.data.datasets.forEach(function(ds, i) {
8032            var b = PALETTE[i % PALETTE.length];
8033            ds.backgroundColor = b + 'b8';
8034            ds.borderColor = b;
8035          });
8036          chart.setActiveElements([]);
8037          chart.update('none');
8038        }
8039        function addItem(swColor, label, idx, isMore) {
8040          var it = document.createElement('div');
8041          it.style.cssText = 'display:flex;align-items:center;gap:7px;white-space:nowrap;'
8042            + ((idx != null || isMore) ? 'cursor:pointer;' : '');
8043          var sw = document.createElement('span');
8044          sw.style.cssText = 'width:22px;height:12px;border-radius:2px;flex:0 0 auto;background:'
8045            + swColor + ';' + (isMore ? 'opacity:0.45;' : '');
8046          var tx = document.createElement('span');
8047          tx.textContent = label;
8048          if (isMore) { tx.style.fontStyle = 'italic'; tx.style.opacity = '0.8'; }
8049          it.appendChild(sw); it.appendChild(tx);
8050          if (idx != null) {
8051            it.addEventListener('mouseenter', function(){ setHi(idx); });
8052            it.addEventListener('mouseleave', clearHi);
8053          }
8054          if (isMore) {
8055            it.addEventListener('click', function(){
8056              var b = document.getElementById(expandBtnId); if (b) b.click();
8057            });
8058          }
8059          leg.appendChild(it);
8060        }
8061        for (var k = 0; k < realShown; k++) {
8062          var oi = order[k];
8063          addItem(PALETTE[oi % PALETTE.length], SCAT_D[oi].lang, oi, false);
8064        }
8065        if (truncated) addItem('#9a8c82', '+' + (n - realShown) + ' more — Full View', null, true);
8066        host.appendChild(leg);
8067        return holder;
8068      }
8069
8070      // ── Scatter / Bubble chart ────────────────────────────────────────────────
8071      (function() {
8072        var canvas = document.getElementById('canvas-scatter');
8073        if (!canvas || !SCAT_D || !SCAT_D.length) return;
8074        var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8075        var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8076        var c = clr();
8077        var legHolder = attachScatterLegend(canvas, 'scatter-expand-btn');
8078        var chart = new Chart(canvas, {
8079          type: 'bubble',
8080          data: {
8081            datasets: SCAT_D.map(function(d, i) {
8082              return {
8083                label: d.lang,
8084                data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8085                backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8086                borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8087                hoverBorderWidth: 2
8088              };
8089            })
8090          },
8091          options: {
8092            responsive: true, maintainAspectRatio: false,
8093            onHover: chartCursor,
8094            animation: { duration: 500, easing: 'easeOutQuart' },
8095            layout: { padding: { top: 44, right: 12 } },
8096            scales: {
8097              x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6,
8098                   grid: { color: c.grid },
8099                   ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } },
8100                   title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
8101              y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} },
8102                   title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
8103            },
8104            plugins: {
8105              legend: { display: false },
8106              tooltip: {
8107                callbacks: {
8108                  title: function(items) { return items.length ? items[0].dataset.label : ''; },
8109                  label: function(ctx){
8110                    var d = SCAT_D[ctx.datasetIndex];
8111                    return [
8112                      '  Files analyzed: ' + fmt(d.files),
8113                      '  Code lines: ' + Number(d.code).toLocaleString(),
8114                      '  Physical lines: ' + Number(d.physical).toLocaleString()
8115                    ];
8116                  }
8117                }
8118              }
8119            }
8120          },
8121          plugins: [scatterLabelPlugin()]
8122        });
8123        ALL_CHARTS.push(chart);
8124        legHolder.chart = chart;
8125      })();
8126
8127      // ── Submodule breakdown ──────────────────────────────────────────────────
8128      // No-op plugins: hover row-dimming was removed because the flashing row
8129      // background looked out of place vs. every other chart. Kept as empty stubs
8130      // so the (inline + Full View) chart configs that reference them stay valid.
8131      var rowDimPlugin = {};
8132      var barJumpPlugin = {};
8133      var subChart = null;
8134      (function() {
8135        if (!SUB_D || !SUB_D.length) return;
8136        var subYSel = document.getElementById('sub-y-axis');
8137        var subSortSel = document.getElementById('sub-sort');
8138        var wrap = document.getElementById('canvas-sub-wrap');
8139        var canvas = document.getElementById('canvas-sub');
8140        if (!canvas) return;
8141        var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines',
8142                         physical:'Physical Lines', files:'Files' };
8143        var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8144        function renderSubmodule() {
8145          var yKey = subYSel ? subYSel.value : 'code';
8146          var sortMode = subSortSel ? subSortSel.value : 'desc';
8147          var data = SUB_D.slice();
8148          if (sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8149          else if (sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8150          else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8151          data = data.slice(0, 30);
8152          var c = clr();
8153          var col = SUB_COLS[yKey] || OX;
8154          if (wrap) wrap.style.height = Math.max(200, Math.min(540, data.length * 28 + 60)) + 'px';
8155          if (subChart) {
8156            subChart.data.labels = data.map(function(d){return d.name;});
8157            subChart.data.datasets[0].data = data.map(function(d){return d[yKey]||0;});
8158            subChart.data.datasets[0].backgroundColor = col;
8159            subChart.data.datasets[0].label = Y_LABELS[yKey]||yKey;
8160            subChart.options.scales.x.title.text = Y_LABELS[yKey]||yKey;
8161            subChart.update('none'); return;
8162          }
8163          subChart = new Chart(canvas, {
8164            type: 'bar',
8165            data: {
8166              labels: data.map(function(d){return d.name;}),
8167              datasets: [{ label: Y_LABELS[yKey]||yKey,
8168                data: data.map(function(d){return d[yKey]||0;}),
8169                backgroundColor: col, hoverBackgroundColor: col === OX ? '#d97020' : col,
8170                borderRadius: 3 }]
8171            },
8172            options: {
8173              indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8174              onHover: chartCursor,
8175              animation: { duration: 500, easing: 'easeOutQuart' },
8176              transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8177              layout: { padding: { right: 64 } },
8178              scales: {
8179                x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8180                     title: { display: true, text: Y_LABELS[yKey]||yKey, color: c.text } },
8181                y: { grid: { display: false }, ticks: { color: c.text } }
8182              },
8183              plugins: {
8184                legend: { display: false },
8185                tooltip: {
8186                  callbacks: {
8187                    title: function(items) { return items.length ? items[0].label : ''; },
8188                    label: function(ctx){
8189                      var d = data[ctx.dataIndex] || {};
8190                      return [
8191                        '  Code: ' + Number(d.code||0).toLocaleString(),
8192                        '  Comments: ' + Number(d.comment||0).toLocaleString(),
8193                        '  Blanks: ' + Number(d.blank||0).toLocaleString(),
8194                        '  Physical: ' + Number(d.physical||0).toLocaleString(),
8195                        '  Files: ' + fmt(d.files||0)
8196                      ];
8197                    }
8198                  }
8199                }
8200              }
8201            },
8202            plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end'), barJumpPlugin]
8203          });
8204          ALL_CHARTS.push(subChart);
8205        }
8206        if (subYSel) subYSel.addEventListener('change', renderSubmodule);
8207        if (subSortSel) subSortSel.addEventListener('change', renderSubmodule);
8208        renderSubmodule();
8209      })();
8210
8211      // ── Submodule composition: stacked horizontal bar (Chart.js) ─────────────
8212      var subCompChart = null;
8213      // Plugin: draw value label inside each visible segment of a stacked horizontal bar.
8214      var segLabelPlugin = {
8215        afterDatasetsDraw: function(chart) {
8216          var ctx = chart.ctx, nDs = chart.data.datasets.length;
8217          var tc = clr().text;
8218          for (var di = 0; di < nDs; di++) {
8219            var meta = chart.getDatasetMeta(di);
8220            if (meta.hidden) continue;
8221            meta.data.forEach(function(el, idx) {
8222              var v = chart.data.datasets[di].data[idx] || 0;
8223              if (!v) return;
8224              var w = Math.abs(el.x - el.base);
8225              if (w < 28) return; // too narrow to show label
8226              ctx.save();
8227              ctx.font = '600 10px Inter,ui-sans-serif,sans-serif';
8228              ctx.fillStyle = di === 0 ? '#fff' : (di === 1 ? '#fff' : '#555');
8229              ctx.textAlign = 'center';
8230              ctx.textBaseline = 'middle';
8231              ctx.fillText(fmt(v), el.base + w / 2, el.y);
8232              ctx.restore();
8233            });
8234          }
8235        }
8236      };
8237      (function() {
8238        var el = document.getElementById('submodule-donut');
8239        if (!el || !SUB_D || !SUB_D.length) return;
8240        var data = SUB_D.slice().sort(function(a,b){
8241          return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));
8242        }).slice(0, 15);
8243        var h = Math.max(150, Math.min(540, data.length * 40 + 90));
8244        el.style.height = h + 'px';
8245        el.style.position = 'relative';
8246        var cv = document.createElement('canvas');
8247        cv.id = 'canvas-sub-comp';
8248        el.innerHTML = '';
8249        el.appendChild(cv);
8250        var c = clr();
8251        subCompChart = new Chart(cv, {
8252          type: 'bar',
8253          data: {
8254            labels: data.map(function(d){ return d.name; }),
8255            datasets: [
8256              { label: 'Code',     data: data.map(function(d){ return d.code||0; }),    backgroundColor: OX, hoverBackgroundColor: '#d97020', borderRadius: 0, borderSkipped: false },
8257              { label: 'Comments', data: data.map(function(d){ return d.comment||0; }), backgroundColor: GN, hoverBackgroundColor: '#3a8a5e', borderRadius: 0, borderSkipped: false },
8258              { label: 'Blank',    data: data.map(function(d){ return d.blank||0; }),   backgroundColor: GY, hoverBackgroundColor: '#999',    borderRadius: 0, borderSkipped: false }
8259            ]
8260          },
8261          options: {
8262            indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8263            onHover: chartCursor,
8264            animation: { duration: 500, easing: 'easeOutQuart' },
8265            transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8266            layout: { padding: { right: 56 } },
8267            scales: {
8268              x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){ return fmt(v); } } },
8269              y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8270            },
8271            plugins: {
8272              legend: {
8273                position: 'bottom',
8274                labels: { color: c.text, usePointStyle: true, pointStyle: 'rect', font: { size: 11, weight: '700' }, padding: 16 },
8275                onHover: function(e, item, leg) {
8276                  legendCursorOn(e);
8277                  var ch = leg.chart, di = item.datasetIndex;
8278                  var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8279                  ch.data.datasets.forEach(function(ds, i) {
8280                    ds.backgroundColor = i===di ? orig[i] : hexAlpha(orig[i], 0.15);
8281                    ds.hoverBackgroundColor = i===di ? hov[i] : hexAlpha(orig[i], 0.15);
8282                  });
8283                  // show tooltip on first bar row with all datasets (index mode)
8284                  var n = ch.data.datasets.length, ae = [];
8285                  for (var ii = 0; ii < n; ii++) { ae.push({ datasetIndex: ii, index: 0 }); }
8286                  var fp = ch.getDatasetMeta(di).data[0];
8287                  ch.setActiveElements([{ datasetIndex: di, index: 0 }]);
8288                  ch.tooltip.setActiveElements(ae, fp ? { x: fp.x, y: fp.y } : { x: 0, y: 0 });
8289                  ch.update();
8290                },
8291                onLeave: function(e, item, leg) {
8292                  legendCursorOff(e);
8293                  var ch = leg.chart;
8294                  var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8295                  ch.data.datasets.forEach(function(ds, i) { ds.backgroundColor = orig[i]; ds.hoverBackgroundColor = hov[i]; });
8296                  ch.setActiveElements([]);
8297                  ch.tooltip.setActiveElements([], {});
8298                  ch.update('none');
8299                }
8300              },
8301              tooltip: {
8302                mode: 'index',
8303                callbacks: {
8304                  title: function(items){ return items.length ? items[0].label : ''; },
8305                  label: function(ctx){
8306                    var v = ctx.parsed.x || 0;
8307                    return '  ' + ctx.dataset.label + ': ' + Number(v).toLocaleString();
8308                  },
8309                  footer: function(items){
8310                    var tot = items.reduce(function(s,i){ return s + (i.parsed.x||0); }, 0);
8311                    return 'Total: ' + Number(tot).toLocaleString();
8312                  }
8313                }
8314              }
8315            }
8316          },
8317          plugins: [makeStackedEndPlugin(function(v){ return fmt(v); }), segLabelPlugin, rowDimPlugin]
8318        });
8319        ALL_CHARTS.push(subCompChart);
8320      })();
8321
8322      // ── Semantic Metrics ─────────────────────────────────────────────────────
8323      (function() {
8324        if (!SEM_D || !SEM_D.length) return;
8325        var semSel = document.getElementById('semantic-metric');
8326        var canvas = document.getElementById('canvas-semantic');
8327        if (!canvas) return;
8328        var SEM_LABELS = { functions:'Functions', classes:'Classes / Types', variables:'Variables',
8329                           imports:'Imports', tests:'Tests' };
8330        var SEM_COLS = { functions:OX, classes:'#4472C4', variables:GN, imports:'#805099', tests:'#B23030' };
8331        var SEM_HCOLS = { functions:'#d97020', classes:'#5a8ad8', variables:'#3a8a5e', imports:'#9a68b3', tests:'#cc4545' };
8332        var semChart = null;
8333        function renderSemantic() {
8334          var mKey = semSel ? semSel.value : 'functions';
8335          var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
8336          var c = clr();
8337          var col = SEM_COLS[mKey] || OX;
8338          var hCol = SEM_HCOLS[mKey] || '#d97020';
8339          if (semChart) {
8340            semChart.data.labels = data.map(function(d){return d.lang;});
8341            semChart.data.datasets[0].data = data.map(function(d){return d[mKey]||0;});
8342            semChart.data.datasets[0].backgroundColor = col;
8343            semChart.data.datasets[0].hoverBackgroundColor = hCol;
8344            semChart.data.datasets[0].label = SEM_LABELS[mKey]||mKey;
8345            semChart.update('none'); return;
8346          }
8347          semChart = new Chart(canvas, {
8348            type: 'bar',
8349            data: {
8350              labels: data.map(function(d){return d.lang;}),
8351              datasets: [{ label: SEM_LABELS[mKey]||mKey,
8352                data: data.map(function(d){return d[mKey]||0;}),
8353                backgroundColor: col, hoverBackgroundColor: hCol,
8354                borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8355            },
8356            options: {
8357              responsive: true, maintainAspectRatio: false,
8358              onHover: chartCursor,
8359              animation: { duration: 500, easing: 'easeOutQuart' },
8360              transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8361              layout: { padding: { top: 18 } },
8362              scales: {
8363                x: { grid: { display: false }, ticks: { color: c.text } },
8364                y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8365              },
8366              plugins: {
8367                legend: { display: false },
8368                tooltip: {
8369                  callbacks: {
8370                    title: function(items) { return items.length ? items[0].label : ''; },
8371                    label: function(ctx) {
8372                      var d = data[ctx.dataIndex] || {};
8373                      var lines = ['  ' + (SEM_LABELS[mKey]||mKey) + ': ' + Number(ctx.parsed.y).toLocaleString()];
8374                      var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== mKey && (d[k]||0) > 0; });
8375                      others.forEach(function(k) {
8376                        lines.push('  ' + SEM_LABELS[k] + ': ' + Number(d[k]||0).toLocaleString());
8377                      });
8378                      return lines;
8379                    }
8380                  }
8381                }
8382              }
8383            },
8384            plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8385          });
8386          ALL_CHARTS.push(semChart);
8387        }
8388        if (semSel) semSel.addEventListener('change', renderSemantic);
8389        renderSemantic();
8390
8391        var semExpandBtn = document.getElementById('semantic-expand-btn');
8392        if (semExpandBtn) {
8393          semExpandBtn.addEventListener('click', function() {
8394            var mKey = semSel ? semSel.value : 'functions';
8395            var n = SEM_D.length || 1;
8396            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8397            var modalH = Math.min(Math.max(400, n * 46 + 96), maxH);
8398            var overlay = document.createElement('div');
8399            overlay.className = 'chart-modal-overlay';
8400            var semOptHtml = '<option value="functions">Functions</option>'
8401              + '<option value="classes">Classes / Types</option>'
8402              + '<option value="variables">Variables</option>'
8403              + '<option value="imports">Imports</option>'
8404              + '<option value="tests">Tests</option>';
8405            var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">Semantic Metrics \u2014 Full View</span>'
8406              + '<select class="chart-select" id="sem-modal-metric">' + semOptHtml + '</select></div>';
8407            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>';
8408            document.body.appendChild(overlay);
8409            overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
8410            overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
8411            var modalSel = document.getElementById('sem-modal-metric');
8412            if (modalSel) modalSel.value = mKey;
8413            var modalCanvas = document.getElementById('canvas-semantic-modal');
8414            var semModalChart = null;
8415            function renderSemModal(key) {
8416              if (semModalChart) { semModalChart.destroy(); semModalChart = null; }
8417              if (!modalCanvas) return;
8418              var data = SEM_D.slice().sort(function(a,b){return (b[key]||0)-(a[key]||0);});
8419              var c = clr();
8420              var col = SEM_COLS[key] || OX;
8421              var hcol = SEM_HCOLS[key] || '#d97020';
8422              semModalChart = new Chart(modalCanvas, {
8423                type: 'bar',
8424                data: {
8425                  labels: data.map(function(d){return d.lang;}),
8426                  datasets: [{ label: SEM_LABELS[key]||key, data: data.map(function(d){return d[key]||0;}),
8427                    backgroundColor: col, hoverBackgroundColor: hcol,
8428                    borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8429                },
8430                options: {
8431                  responsive: true, maintainAspectRatio: false,
8432                  onHover: chartCursor,
8433                  animation: { duration: 500, easing: 'easeOutQuart' },
8434                  transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8435                  layout: { padding: { top: 18 } },
8436                  scales: {
8437                    x: { grid: { display: false }, ticks: { color: c.text } },
8438                    y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8439                  },
8440                  plugins: { legend: { display: false }, tooltip: { callbacks: {
8441                    title: function(items){return items.length?items[0].label:'';},
8442                    label: function(ctx){
8443                      var d = data[ctx.dataIndex] || {};
8444                      var lines = ['  '+(SEM_LABELS[key]||key)+': '+Number(ctx.parsed.y).toLocaleString()];
8445                      var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== key && (d[k]||0) > 0; });
8446                      others.forEach(function(k){ lines.push('  '+SEM_LABELS[k]+': '+Number(d[k]||0).toLocaleString()); });
8447                      return lines;
8448                    }
8449                  }}}
8450                },
8451                plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8452              });
8453            }
8454            renderSemModal(mKey);
8455            if (modalSel) modalSel.addEventListener('change', function() { renderSemModal(this.value); });
8456          });
8457        }
8458      })();
8459
8460      // ── Comment Density: comments / (code + comments) per language ──────────
8461      (function() {
8462        var canvas = document.getElementById('canvas-density');
8463        if (!canvas || !D || !D.length) return;
8464        var data = D.slice().sort(function(a,b){
8465          var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8466          var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8467          return db-da;
8468        });
8469        var labels = data.map(function(d){return d.lang;});
8470        var densities = data.map(function(d){
8471          var sig=(d.code||0)+(d.comments||0);
8472          return sig>0?Math.round((d.comments||0)/sig*1000)/10:0;
8473        });
8474        var wrap = canvas.parentElement;
8475        if (wrap) wrap.style.height = Math.max(150, Math.min(500, data.length*29+36))+'px';
8476        var c = clr();
8477        var densChart = new Chart(canvas, {
8478          type: 'bar',
8479          data: {
8480            labels: labels,
8481            datasets: [{ label: 'Comment %',
8482              data: densities,
8483              backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8484              borderRadius: 4
8485            }]
8486          },
8487          options: {
8488            indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8489            onHover: chartCursor,
8490            animation: { duration: 500, easing: 'easeOutQuart' },
8491            layout: { padding: { right: 42 } },
8492            scales: {
8493              x: { min: 0, max: 100,
8494                   grid: { color: c.grid },
8495                   ticks: { color: c.text, callback: function(v){return v+'%';} },
8496                   title: { display: true, text: 'Comment %', color: c.text } },
8497              y: { grid: { display: false }, ticks: { color: c.text } }
8498            },
8499            plugins: {
8500              legend: { display: false },
8501              tooltip: { callbacks: {
8502                title: function(items){return items.length?items[0].label:'';},
8503                label: function(ctx){
8504                  var d=data[ctx.dataIndex]||{};
8505                  var sig=(d.code||0)+(d.comments||0);
8506                  return ['  Comment ratio: '+ctx.parsed.x+'%',
8507                          '  Comments: '+Number(d.comments||0).toLocaleString(),
8508                          '  Significant lines: '+Number(sig).toLocaleString()];
8509                }
8510              }}
8511            }
8512          },
8513          plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8514        });
8515        ALL_CHARTS.push(densChart);
8516      })();
8517
8518      // ── File Size Distribution histogram ──────────────────────────────────────
8519      (function() {
8520        var canvas = document.getElementById('canvas-filesize');
8521        if (!canvas || !HIST_D || !HIST_D.length) return;
8522        var labels = HIST_D.map(function(d){return d.label;});
8523        var counts = HIST_D.map(function(d){return d.count||0;});
8524        var total = counts.reduce(function(a,b){return a+b;},0);
8525        var c = clr();
8526        var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8527        var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8528        var fsChart = new Chart(canvas, {
8529          type: 'bar',
8530          data: {
8531            labels: labels,
8532            datasets: [{ label: 'Files',
8533              data: counts,
8534              backgroundColor: fsBg,
8535              hoverBackgroundColor: fsHv,
8536              borderRadius: 6,
8537              borderWidth: 0,
8538              hoverBorderWidth: 0
8539            }]
8540          },
8541          options: {
8542            responsive: true, maintainAspectRatio: false,
8543            onHover: chartCursor,
8544            animation: { duration: 500, easing: 'easeOutQuart' },
8545            transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8546            layout: { padding: { top: 18 } },
8547            scales: {
8548              x: { grid: { display: false }, ticks: { color: c.text, font: { size: 11 } } },
8549              y: { beginAtZero: true,
8550                   grid: { color: c.grid },
8551                   ticks: { color: c.text, precision: 0 },
8552                   title: { display: true, text: 'File Count', color: c.text } }
8553            },
8554            plugins: {
8555              legend: { display: false },
8556              tooltip: { callbacks: {
8557                label: function(ctx) {
8558                  var n = ctx.parsed.y;
8559                  var pct = total > 0 ? Math.round(n/total*1000)/10 : 0;
8560                  return ['  Files: '+n, '  Share: '+pct+'%'];
8561                }
8562              }}
8563            }
8564          },
8565          plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8566        });
8567        ALL_CHARTS.push(fsChart);
8568      })();
8569
8570      // ── Expand button handlers ────────────────────────────────────────────────
8571      (function() {
8572        function makeOverlay(title, h, subtitle, ctrlHtml) {
8573          var overlay = document.createElement('div');
8574          overlay.className = 'chart-modal-overlay';
8575          var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8576          var hAttr = 'height:' + Math.min(h || 696, maxH) + 'px;';
8577          var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
8578          var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">' + title + '</span>' + (ctrlHtml || '') + '</div>';
8579          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>';
8580          document.body.appendChild(overlay);
8581          overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8582          overlay.addEventListener('click', function(e){ if(e.target === overlay) document.body.removeChild(overlay); });
8583          return document.getElementById('modal-expand-canvas');
8584        }
8585
8586        // Language Composition
8587        (function(){
8588          var btn = document.getElementById('comp-expand-btn');
8589          if(!btn) return;
8590          btn.addEventListener('click', function(){
8591            var activeTab = document.querySelector('[data-comp-tab].active');
8592            var compMode = activeTab ? activeTab.getAttribute('data-comp-tab') : 'absolute';
8593            var ctrlHtml = '<select class="chart-select" id="comp-modal-mode">'
8594              + '<option value="absolute">Absolute Lines</option>'
8595              + '<option value="pct">100% Normalized</option>'
8596              + '</select>';
8597            var canvas = makeOverlay('Language Composition \u2014 Full View', undefined, null, ctrlHtml);
8598            if(!canvas) return;
8599            var modalMode = document.getElementById('comp-modal-mode');
8600            if(modalMode) modalMode.value = compMode;
8601            var compModalChart = null;
8602            function renderCompModal(mode) {
8603              if(compModalChart) { compModalChart.destroy(); compModalChart = null; }
8604              var data = D.slice(0, 15);
8605              var c = clr(), isPct = mode === 'pct';
8606              var tot = function(d){ return (d.code||0)+(d.comments||0)+(d.blanks||0)||1; };
8607              var codeD = data.map(function(d){ return isPct ? (d.code||0)/tot(d)*100 : d.code||0; });
8608              var cmD   = data.map(function(d){ return isPct ? (d.comments||0)/tot(d)*100 : d.comments||0; });
8609              var blD   = data.map(function(d){ return isPct ? (d.blanks||0)/tot(d)*100 : d.blanks||0; });
8610              var tickCb = isPct ? function(v){return v.toFixed(0)+'%';} : function(v){return fmt(v);};
8611              compModalChart = new Chart(canvas, {
8612                type: 'bar',
8613                data: {
8614                  labels: data.map(function(d){ return d.lang; }),
8615                  datasets: [
8616                    { label:'Code',     data: codeD, backgroundColor: OX, borderRadius: 3 },
8617                    { label:'Comments', data: cmD,   backgroundColor: GN, borderRadius: 3 },
8618                    { label:'Blanks',   data: blD,   backgroundColor: GY, borderRadius: 3 }
8619                  ]
8620                },
8621                options: {
8622                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8623                  layout: { padding: { right: 64 } },
8624                  scales: {
8625                    x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: tickCb } },
8626                    y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8627                  },
8628                  plugins: { legend: { position: 'bottom', labels: { color: c.text } } }
8629                },
8630                plugins: [makeStackedEndPlugin(function(total, idx) {
8631                  if (isPct) return '';
8632                  var d = data[idx]; return fmt(Math.round(d && d.physical ? d.physical : total));
8633                })]
8634              });
8635            }
8636            renderCompModal(compMode);
8637            if(modalMode) modalMode.addEventListener('change', function(){ renderCompModal(this.value); });
8638          });
8639        })();
8640
8641        // Files vs Code Lines (Scatter)
8642        (function(){
8643          var btn = document.getElementById('scatter-expand-btn');
8644          if(!btn || !SCAT_D || !SCAT_D.length) return;
8645          btn.addEventListener('click', function(){
8646            var canvas = makeOverlay('Files vs Code Lines \u2014 Full View', undefined, 'File count vs SLOC per language');
8647            if(!canvas) return;
8648            var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8649            var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8650            var c = clr();
8651            var scLegHolder = attachScatterLegend(canvas);
8652            var scExpand = new Chart(canvas, {
8653              type: 'bubble',
8654              data: {
8655                datasets: SCAT_D.map(function(d, i) {
8656                  return {
8657                    label: d.lang,
8658                    data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8659                    backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8660                    borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8661                    hoverBorderWidth: 2
8662                  };
8663                })
8664              },
8665              options: {
8666                responsive: true, maintainAspectRatio: false,
8667                onHover: chartCursor,
8668                animation: { duration: 500, easing: 'easeOutQuart' },
8669                layout: { padding: { top: 44, right: 12 } },
8670                scales: {
8671                  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 } } },
8672                  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 } } }
8673                },
8674                plugins: {
8675                  legend: { display: false },
8676                  tooltip: { callbacks: {
8677                    title: function(items){ return items.length ? items[0].dataset.label : ''; },
8678                    label: function(ctx){
8679                      var d = SCAT_D[ctx.datasetIndex];
8680                      return ['  Files analyzed: '+fmt(d.files), '  Code lines: '+Number(d.code).toLocaleString(), '  Physical lines: '+Number(d.physical).toLocaleString()];
8681                    }
8682                  }}
8683                }
8684              },
8685              plugins: [scatterLabelPlugin()]
8686            });
8687            scLegHolder.chart = scExpand;
8688          });
8689        })();
8690
8691        // Comment Density
8692        (function(){
8693          var btn = document.getElementById('density-expand-btn');
8694          if(!btn) return;
8695          btn.addEventListener('click', function(){
8696            var data = D.slice().sort(function(a,b){
8697              var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8698              var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8699              return db-da;
8700            });
8701            var h = Math.min(Math.max(672, data.length * 46 + 96), Math.max(400, Math.floor(window.innerHeight * 0.82) - 130));
8702            var canvas = makeOverlay('Comment Density \u2014 Full View', h, 'Comment ratio per language');
8703            if(!canvas) return;
8704            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; });
8705            var c = clr();
8706            new Chart(canvas, {
8707              type: 'bar',
8708              data: {
8709                labels: data.map(function(d){return d.lang;}),
8710                datasets: [{ label: 'Comment %', data: densities,
8711                  backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius: 4 }]
8712              },
8713              options: {
8714                indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8715                animation: { duration: 500, easing: 'easeOutQuart' },
8716                layout: { padding: { right: 42 } },
8717                scales: {
8718                  x: { min:0, max:100, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return v+'%';}} },
8719                  y: { grid:{display:false}, ticks:{color:c.text} }
8720                },
8721                plugins: { legend:{display:false}, tooltip:{callbacks:{
8722                  title:function(items){return items.length?items[0].label:'';},
8723                  label:function(ctx){
8724                    var d=data[ctx.dataIndex]||{};
8725                    var sig=(d.code||0)+(d.comments||0);
8726                    return ['  Comment ratio: '+ctx.parsed.x.toFixed(1)+'%',
8727                            '  Comments: '+Number(d.comments||0).toLocaleString(),
8728                            '  Significant lines: '+Number(sig).toLocaleString()];
8729                  }
8730                }}}
8731              },
8732              plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8733            });
8734          });
8735        })();
8736
8737        // File Size Distribution
8738        (function(){
8739          var btn = document.getElementById('filesize-expand-btn');
8740          if(!btn || !HIST_D || !HIST_D.length) return;
8741          btn.addEventListener('click', function(){
8742            var canvas = makeOverlay('File Size Distribution \u2014 Full View', undefined, 'File count per SLOC bucket');
8743            if(!canvas) return;
8744            var labels = HIST_D.map(function(d){return d.label;});
8745            var counts = HIST_D.map(function(d){return d.count||0;});
8746            var total = counts.reduce(function(a,b){return a+b;},0);
8747            var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8748            var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8749            var c = clr();
8750            new Chart(canvas, {
8751              type: 'bar',
8752              data: {
8753                labels: labels,
8754                datasets: [{ label: 'Files', data: counts,
8755                  backgroundColor: fsBg, hoverBackgroundColor: fsHv,
8756                  borderRadius: 6, borderWidth: 0, hoverBorderWidth: 0 }]
8757              },
8758              options: {
8759                responsive: true, maintainAspectRatio: false,
8760                animation: { duration: 500, easing: 'easeOutQuart' },
8761                transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8762                layout: { padding: { top: 18 } },
8763                scales: {
8764                  x: { grid:{display:false}, ticks:{color:c.text} },
8765                  y: { beginAtZero:true, grid:{color:c.grid}, ticks:{color:c.text, precision:0}, title:{display:true, text:'File Count', color:c.text} }
8766                },
8767                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+'%']; }}} }
8768              },
8769              plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8770            });
8771          });
8772        })();
8773
8774        // Submodule Breakdown — Full View with live Y Axis + Sort controls
8775        (function(){
8776          var btn = document.getElementById('sub-expand-btn');
8777          if(!btn || !SUB_D || !SUB_D.length) return;
8778          btn.addEventListener('click', function(){
8779            var subYSel = document.getElementById('sub-y-axis');
8780            var subSortSel = document.getElementById('sub-sort');
8781            var initY = subYSel ? subYSel.value : 'code';
8782            var initSort = subSortSel ? subSortSel.value : 'desc';
8783            var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines', physical:'Physical Lines', files:'Files' };
8784            var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8785            var SUB_HCOLS = { code:'#d97020', comment:'#3a8a5e', blank:'#999', physical:'#5a8ad8', files:'#9a68b3' };
8786            var n = Math.min(SUB_D.length, 30);
8787            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8788            var modalH = Math.min(Math.max(480, n * 36 + 96), maxH);
8789            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:'
8790              + '<select class="chart-select" id="sub-modal-y">'
8791              + '<option value="code">Code Lines</option>'
8792              + '<option value="comment">Comment Lines</option>'
8793              + '<option value="blank">Blank Lines</option>'
8794              + '<option value="physical">Physical Lines</option>'
8795              + '<option value="files">File Count</option>'
8796              + '</select></label>'
8797              + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8798              + '<select class="chart-select" id="sub-modal-sort">'
8799              + '<option value="desc">Value \u2193</option>'
8800              + '<option value="asc">Value \u2191</option>'
8801              + '<option value="name">Name A\u2192Z</option>'
8802              + '</select></label>';
8803            var canvas = makeOverlay('Submodule Breakdown \u2014 Full View', modalH, null, ctrlHtml);
8804            if(!canvas) return;
8805            var modalY = document.getElementById('sub-modal-y');
8806            var modalSort = document.getElementById('sub-modal-sort');
8807            if(modalY) modalY.value = initY;
8808            if(modalSort) modalSort.value = initSort;
8809            var subModalChart = null;
8810            function renderSubModal(yKey, sortMode) {
8811              if(subModalChart) { subModalChart.destroy(); subModalChart = null; }
8812              var data = SUB_D.slice();
8813              if(sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8814              else if(sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8815              else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8816              data = data.slice(0, 30);
8817              var c = clr(), col = SUB_COLS[yKey]||OX, hcol = SUB_HCOLS[yKey]||'#d97020';
8818              subModalChart = new Chart(canvas, {
8819                type: 'bar',
8820                data: {
8821                  labels: data.map(function(d){return d.name;}),
8822                  datasets: [{ label: Y_LABELS[yKey]||yKey,
8823                    data: data.map(function(d){return d[yKey]||0;}),
8824                    backgroundColor: col, hoverBackgroundColor: hcol, borderRadius: 3 }]
8825                },
8826                options: {
8827                  indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8828                  onHover: chartCursor,
8829                  animation: { duration: 500, easing: 'easeOutQuart' },
8830                  transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8831                  layout: { padding: { right: 72 } },
8832                  scales: {
8833                    x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
8834                         title:{display:true, text:Y_LABELS[yKey]||yKey, color:c.text} },
8835                    y: { grid:{display:false}, ticks:{color:c.text} }
8836                  },
8837                  plugins: {
8838                    legend:{display:false},
8839                    tooltip: { callbacks: {
8840                      title: function(items){return items.length?items[0].label:'';},
8841                      label: function(ctx){
8842                        var d = data[ctx.dataIndex]||{};
8843                        return ['  Code: '+Number(d.code||0).toLocaleString(),
8844                                '  Comments: '+Number(d.comment||0).toLocaleString(),
8845                                '  Blanks: '+Number(d.blank||0).toLocaleString(),
8846                                '  Physical: '+Number(d.physical||0).toLocaleString(),
8847                                '  Files: '+fmt(d.files||0)];
8848                      }
8849                    }}
8850                  }
8851                },
8852                plugins: [makeDlPlugin(function(v){return fmt(v||0);}, 'end'), barJumpPlugin]
8853              });
8854            }
8855            renderSubModal(initY, initSort);
8856            if(modalY) modalY.addEventListener('change', function(){ renderSubModal(this.value, modalSort ? modalSort.value : 'desc'); });
8857            if(modalSort) modalSort.addEventListener('change', function(){ renderSubModal(modalY ? modalY.value : 'code', this.value); });
8858          });
8859        })();
8860
8861        // Submodule Composition — Full View (Chart.js with sort control)
8862        (function(){
8863          var btn = document.getElementById('sub-comp-expand-btn');
8864          if(!btn || !SUB_D || !SUB_D.length) return;
8865          btn.addEventListener('click', function(){
8866            var n = Math.min(SUB_D.length, 20);
8867            var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8868            var modalH = Math.min(Math.max(400, n * 40 + 90), maxH);
8869            var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8870              + '<select class="chart-select" id="sub-comp-modal-sort">'
8871              + '<option value="desc">Total Lines \u2193</option>'
8872              + '<option value="asc">Total Lines \u2191</option>'
8873              + '<option value="name">Name A\u2192Z</option>'
8874              + '</select></label>';
8875            var canvas = makeOverlay('Submodule Composition \u2014 Full View', modalH, null, ctrlHtml);
8876            if(!canvas) return;
8877            var modalSort = document.getElementById('sub-comp-modal-sort');
8878            var scModalChart = null;
8879            function renderSCModal(sortMode) {
8880              if(scModalChart) { scModalChart.destroy(); scModalChart = null; }
8881              var data = SUB_D.slice();
8882              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));});
8883              else if(sortMode==='name') data.sort(function(a,b){return a.name.localeCompare(b.name);});
8884              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));});
8885              data = data.slice(0, 20);
8886              var c = clr();
8887              scModalChart = new Chart(canvas, {
8888                type: 'bar',
8889                data: {
8890                  labels: data.map(function(d){ return d.name; }),
8891                  datasets: [
8892                    { label:'Code',     data:data.map(function(d){return d.code||0;}),    backgroundColor:OX, hoverBackgroundColor:'#d97020', borderRadius:0, borderSkipped:false },
8893                    { label:'Comments', data:data.map(function(d){return d.comment||0;}), backgroundColor:GN, hoverBackgroundColor:'#3a8a5e', borderRadius:0, borderSkipped:false },
8894                    { label:'Blank',    data:data.map(function(d){return d.blank||0;}),   backgroundColor:GY, hoverBackgroundColor:'#999',    borderRadius:0, borderSkipped:false }
8895                  ]
8896                },
8897                options: {
8898                  indexAxis:'y', responsive:true, maintainAspectRatio:false,
8899                  onHover: chartCursor,
8900                  animation: { duration: 500, easing: 'easeOutQuart' },
8901                  transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8902                  layout:{ padding:{ right:72 } },
8903                  scales: {
8904                    x:{ stacked:true, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}} },
8905                    y:{ stacked:true, grid:{display:false}, ticks:{color:c.text} }
8906                  },
8907                  plugins: {
8908                    legend:{ position:'bottom', labels:{color:c.text, usePointStyle:true, pointStyle:'rect', font:{size:11,weight:'700'}, padding:16},
8909                      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(); },
8910                      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'); }
8911                    },
8912                    tooltip:{ mode:'index', callbacks:{
8913                      title:function(items){return items.length?items[0].label:'';},
8914                      label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
8915                      footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}
8916                    }}
8917                  }
8918                },
8919                plugins: [makeStackedEndPlugin(function(v){return fmt(v);}), segLabelPlugin, rowDimPlugin]
8920              });
8921            }
8922            renderSCModal('desc');
8923            if(modalSort) modalSort.addEventListener('change', function(){ renderSCModal(this.value); });
8924          });
8925        })();
8926
8927        // Language overview (donut + line-mix) — clone both SVGs side-by-side
8928        (function(){
8929          var btn = document.getElementById('lang-overview-expand-btn');
8930          if(!btn) return;
8931          btn.addEventListener('click', function(){
8932            var src = document.getElementById('report-lang-overview');
8933            if(!src) return;
8934            var overlay = document.createElement('div');
8935            overlay.className = 'chart-modal-overlay';
8936            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>';
8937            document.body.appendChild(overlay);
8938            overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8939            overlay.addEventListener('click', function(e){ if(e.target===overlay) document.body.removeChild(overlay); });
8940            var wrap = document.getElementById('lang-overview-modal-wrap');
8941            if(wrap) {
8942              wrap.innerHTML = src.innerHTML;
8943              var svgs = wrap.querySelectorAll('svg');
8944              for(var i=0;i<svgs.length;i++){
8945                svgs[i].removeAttribute('width');
8946                svgs[i].removeAttribute('height');
8947                svgs[i].style.cssText='display:block;width:100%;height:auto;';
8948              }
8949              var ov = wrap.querySelector('.r-lang-overview');
8950              if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
8951              var cells = wrap.querySelectorAll('.r-lang-overview-cell');
8952              if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
8953              if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
8954              wireDonutLegend(wrap.querySelector('svg'));
8955              wireMixLegend(wrap.querySelectorAll('svg')[1]);
8956              requestAnimationFrame(function(){
8957                var ss=wrap.querySelectorAll('svg');
8958                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%;';}}
8959              });
8960            }
8961          });
8962        })();
8963      })();
8964
8965      // ── Dark mode sync ────────────────────────────────────────────────────────
8966      document.querySelectorAll('[data-theme-toggle]').forEach(function(btn) {
8967        btn.addEventListener('click', function() {
8968          setTimeout(function() {
8969            var c = clr();
8970            ALL_CHARTS.forEach(function(chart) {
8971              if (chart.options.scales) {
8972                Object.keys(chart.options.scales).forEach(function(k) {
8973                  var ax = chart.options.scales[k];
8974                  if (ax.grid) ax.grid.color = c.grid;
8975                  if (ax.ticks) ax.ticks.color = c.text;
8976                  if (ax.title) ax.title.color = c.text;
8977                });
8978              }
8979              if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels)
8980                chart.options.plugins.legend.labels.color = c.text;
8981              chart.update('none');
8982            });
8983          }, 60);
8984        });
8985      });
8986
8987      // ── Pre-render all chart variants for PDF export ──────────────────────────
8988      (function() {
8989        var root = document.getElementById('pdf-variants');
8990        if (!root) return;
8991
8992        // Plugin: fill a light background behind every off-screen chart so the PNG
8993        // is opaque — without this Chart.js canvases are transparent and render as
8994        // blank white boxes in print.
8995        var PDF_BG = {
8996          id: 'pdfBg',
8997          beforeDraw: function(ch) {
8998            var ctx = ch.canvas.getContext('2d');
8999            ctx.save();
9000            ctx.globalCompositeOperation = 'destination-over';
9001            ctx.fillStyle = '#faf6f0';
9002            ctx.fillRect(0, 0, ch.canvas.width, ch.canvas.height);
9003            ctx.restore();
9004          }
9005        };
9006
9007        // Off-screen Chart.js render → PNG data-URL → destroy chart
9008        function snap(type, data, opts, w, h) {
9009          var c = document.createElement('canvas');
9010          c.width = w || 900; c.height = h || 280;
9011          var ch = new Chart(c, {
9012            type: type, data: data,
9013            options: Object.assign({}, opts, {
9014              animation: false, responsive: false, devicePixelRatio: 1,
9015              // Breathing room so labels never clip at the canvas edge
9016              layout: { padding: { top: 10, right: 18, bottom: 10, left: 10 } }
9017            }),
9018            plugins: [PDF_BG]
9019          });
9020          var png = c.toDataURL('image/png');
9021          ch.destroy();
9022          return png;
9023        }
9024
9025        function mkPanel(label, imgSrc) {
9026          var d = document.createElement('div'); d.className = 'pdf-variant-panel';
9027          if (label) {
9028            var lbl = document.createElement('div');
9029            lbl.className = 'pdf-variant-label'; lbl.textContent = label;
9030            d.appendChild(lbl);
9031          }
9032          if (imgSrc) {
9033            var img = document.createElement('img');
9034            img.className = 'pdf-variant-img'; img.src = imgSrc;
9035            d.appendChild(img);
9036          }
9037          return d;
9038        }
9039
9040        function mkGroup(title) {
9041          var g = document.createElement('div'); g.className = 'pdf-variant-group';
9042          var h = document.createElement('h2'); h.className = 'pdf-variant-group-title'; h.textContent = title;
9043          g.appendChild(h);
9044          var grid = document.createElement('div'); grid.className = 'pdf-variant-grid';
9045          g.appendChild(grid);
9046          return { group: g, grid: grid };
9047        }
9048
9049        var tc = '#43342d', gc = 'rgba(0,0,0,0.07)';
9050
9051        // ── Project Overview — 4 Y-axis variants ─────────────────────────────────
9052        var pgProj = mkGroup('Project Overview');
9053        var projVariants = [
9054          { label:'Code Lines',     fn:function(d){return d.code||0;} },
9055          { label:'Comment Lines',  fn:function(d){return d.comments||0;} },
9056          { label:'Physical Lines', fn:function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0);} },
9057          { label:'File Count',     fn:function(d){return d.files||0;} }
9058        ];
9059        projVariants.forEach(function(y) {
9060          var sorted = D.slice().sort(function(a,b){return y.fn(b)-y.fn(a);});
9061          var h = Math.max(110, Math.min(360, sorted.length*18+40));
9062          var png = snap('bar', {
9063            labels: sorted.map(function(d){return d.lang;}),
9064            datasets:[{ label:y.label, data:sorted.map(y.fn),
9065                        backgroundColor:sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius:3 }]
9066          }, {
9067            indexAxis:'y',
9068            scales:{
9069              x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9070              y:{grid:{display:false},ticks:{color:tc}}
9071            },
9072            plugins:{legend:{display:false}}
9073          }, 900, h);
9074          pgProj.grid.appendChild(mkPanel(y.label, png));
9075        });
9076        root.appendChild(pgProj.group);
9077
9078        // ── Language Composition — Absolute Lines + Composition % ────────────────
9079        var pgComp = mkGroup('Language Composition');
9080        var cData = D.slice(0,15);
9081        var totFn = function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0)||1;};
9082        var compH = Math.max(110, Math.min(340, cData.length*18+50));
9083        [{id:'absolute',label:'Absolute Lines',isPct:false},{id:'pct',label:'Composition %',isPct:true}]
9084          .forEach(function(m) {
9085            var pct = m.isPct;
9086            var png = snap('bar', {
9087              labels: cData.map(function(d){return d.lang;}),
9088              datasets:[
9089                {label:'Code',     data:cData.map(function(d){return pct?(d.code||0)/totFn(d)*100:d.code||0;}),    backgroundColor:OX,borderRadius:3},
9090                {label:'Comments', data:cData.map(function(d){return pct?(d.comments||0)/totFn(d)*100:d.comments||0;}),backgroundColor:GN,borderRadius:3},
9091                {label:'Blanks',   data:cData.map(function(d){return pct?(d.blanks||0)/totFn(d)*100:d.blanks||0;}),  backgroundColor:GY,borderRadius:3}
9092              ]
9093            }, {
9094              indexAxis:'y',
9095              scales:{
9096                x:{stacked:true,grid:{color:gc},ticks:{color:tc,callback:pct?function(v){return v.toFixed(0)+'%';}:function(v){return fmt(v);}}},
9097                y:{stacked:true,grid:{display:false},ticks:{color:tc}}
9098              },
9099              plugins:{legend:{position:'bottom',labels:{color:tc}}}
9100            }, 900, compH);
9101            pgComp.grid.appendChild(mkPanel(m.label, png));
9102          });
9103        root.appendChild(pgComp.group);
9104
9105        // ── Files vs Code Lines — render off-screen (bubble chart, single-col centred) ─
9106        if (SCAT_D && SCAT_D.length) {
9107          var pgScat = mkGroup('Files vs Code Lines');
9108          pgScat.grid.classList.add('single-col'); // CSS class drives centering in print
9109          var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical||0;})) || 1;
9110          var scatPng = snap('bubble', {
9111            datasets: SCAT_D.map(function(d, i) {
9112              return {
9113                label: d.lang,
9114                data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt((d.physical||0)/maxP)*20)) }],
9115                backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
9116                borderColor: PALETTE[i % PALETTE.length], borderWidth: 1
9117              };
9118            })
9119          }, {
9120            scales: {
9121              x: { grid:{color:gc}, ticks:{color:tc}, title:{display:true, text:'Files Analyzed', color:tc} },
9122              y: { grid:{color:gc}, ticks:{color:tc, callback:function(v){return fmt(v);}}, title:{display:true, text:'Code Lines', color:tc} }
9123            },
9124            plugins: { legend:{position:'right', labels:{color:tc, boxWidth:12}} }
9125          }, 900, 260);
9126          pgScat.grid.appendChild(mkPanel('Files \u00d7 Code Lines (bubble size \u221d physical lines)', scatPng));
9127          root.appendChild(pgScat.group);
9128        }
9129
9130        // ── Semantic Metrics — up to 5 metrics, skip empty ones ─────────────────
9131        if (SEM_D && SEM_D.length) {
9132          var pgSem = mkGroup('Semantic Metrics');
9133          var SL={functions:'Functions',classes:'Classes / Types',variables:'Variables',imports:'Imports',tests:'Tests'};
9134          var SC={functions:OX,classes:'#4472C4',variables:GN,imports:'#805099',tests:'#B23030'};
9135          Object.keys(SL).forEach(function(mKey) {
9136            var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
9137            if (!data.some(function(d){return (d[mKey]||0)>0;})) return;
9138            var semH = 210; // vertical bar — fixed height; width drives layout, not row count
9139            var png = snap('bar', {
9140              labels: data.map(function(d){return d.lang;}),
9141              datasets:[{label:SL[mKey],data:data.map(function(d){return d[mKey]||0;}),backgroundColor:SC[mKey],borderRadius:4}]
9142            }, {
9143              scales:{
9144                x:{grid:{display:false},ticks:{color:tc}},
9145                y:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}}}
9146              },
9147              plugins:{legend:{display:false}}
9148            }, 900, semH);
9149            pgSem.grid.appendChild(mkPanel(SL[mKey], png));
9150          });
9151          root.appendChild(pgSem.group);
9152        }
9153
9154        // ── Submodule Breakdown — 3 Y-axis variants + donut SVG clone ────────────
9155        if (SUB_D && SUB_D.length) {
9156          var pgSub = mkGroup('Submodule Breakdown');
9157          [{key:'code',label:'Code Lines',col:OX},{key:'comment',label:'Comment Lines',col:GN},{key:'files',label:'File Count',col:'#805099'}]
9158            .forEach(function(y) {
9159              var data = SUB_D.slice().sort(function(a,b){return (b[y.key]||0)-(a[y.key]||0);}).slice(0,30);
9160              if (!data.length) return;
9161              var subH = Math.max(100, Math.min(420, data.length*16+30));
9162              var png = snap('bar', {
9163                labels: data.map(function(d){return d.name;}),
9164                datasets:[{label:y.label,data:data.map(function(d){return d[y.key]||0;}),backgroundColor:y.col,borderRadius:3}]
9165              }, {
9166                indexAxis:'y',
9167                scales:{
9168                  x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9169                  y:{grid:{display:false},ticks:{color:tc}}
9170                },
9171                plugins:{legend:{display:false}}
9172              }, 900, subH);
9173              pgSub.grid.appendChild(mkPanel(y.label, png));
9174            });
9175          var donutEl = document.getElementById('submodule-donut');
9176          if (donutEl && donutEl.innerHTML.trim()) {
9177            var dp = document.createElement('div'); dp.className = 'pdf-variant-panel';
9178            var dl = document.createElement('div'); dl.className = 'pdf-variant-label'; dl.textContent = 'Distribution';
9179            dp.appendChild(dl);
9180            var dw = document.createElement('div'); dw.style.cssText = 'display:flex;justify-content:center;';
9181            dw.innerHTML = donutEl.innerHTML; dp.appendChild(dw);
9182            pgSub.grid.appendChild(dp);
9183          }
9184          root.appendChild(pgSub.group);
9185        }
9186      })();
9187    })();
9188    window.oxSlocChartsReady = true;
9189    } catch(e) { window.oxSlocChartError = String(e); window.oxSlocChartsReady = true; }
9190    }); // end requestAnimationFrame
9191    // Safety net: if rAF never fires (headless browsers throttle it), mark ready
9192    // unconditionally so the PDF capture does not wait the full 15 s.
9193    setTimeout(function() { if (!window.oxSlocChartsReady) window.oxSlocChartsReady = true; }, 3000);
9194    // ── SVG tooltip delegation ───────────────────────────────────────────────
9195    (function(){
9196      var tt = document.getElementById('r-tt');
9197      if (!tt) return;
9198      function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
9199      function show(e, html) { tt.innerHTML=html; tt.style.display='block'; move(e); }
9200      function hide() { tt.style.display='none'; }
9201      function move(e) {
9202        var x=e.clientX+16, y=e.clientY-12;
9203        var r=tt.getBoundingClientRect();
9204        if (x+r.width>window.innerWidth-8) x=e.clientX-r.width-8;
9205        if (y+r.height>window.innerHeight-8) y=e.clientY-r.height-8;
9206        tt.style.left=x+'px'; tt.style.top=y+'px';
9207      }
9208      document.addEventListener('mouseover', function(e) {
9209        var t=e.target;
9210        while(t&&t.getAttribute){
9211          var l=t.getAttribute('data-ttl');
9212          if(l!==null){ show(e,'<strong>'+escH(l)+'</strong><br>'+escH(t.getAttribute('data-ttv')||'').replace(/\n/g,'<br>')); return; }
9213          t=t.parentNode;
9214        }
9215      });
9216      document.addEventListener('mouseout', function(e) {
9217        var t=e.target;
9218        while(t&&t.getAttribute){
9219          if(t.getAttribute('data-ttl')!==null){ hide(); return; }
9220          t=t.parentNode;
9221        }
9222      });
9223      document.addEventListener('mousemove', function(e) {
9224        if(tt.style.display!=='none') move(e);
9225      });
9226      window.addEventListener('blur', function() { hide(); });
9227      document.addEventListener('visibilitychange', function() { if(document.hidden) hide(); });
9228    })();
9229    // Auto-populate title on any td that is visually truncated but has no explicit title
9230    requestAnimationFrame(function() {
9231      document.querySelectorAll('td').forEach(function(td) {
9232        if (!td.title && td.scrollWidth > td.clientWidth) {
9233          td.title = td.textContent.trim();
9234        }
9235      });
9236    });
9237
9238  </script>
9239  <script nonce="{{ nonce }}">
9240  (function(){
9241    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'}];
9242    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);});}
9243    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
9244    function init(){
9245      var btn=document.getElementById('settings-btn');if(!btn)return;
9246      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9247      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>';
9248      document.body.appendChild(m);
9249      var g=document.getElementById('scheme-grid');
9250      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);});
9251      var cl=document.getElementById('settings-close');
9252      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);
9253      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');});
9254      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
9255      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
9256    }
9257    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9258  }());
9259  </script>
9260  <script nonce="{{ nonce }}">
9261  (function(){
9262    // Format delta card unmodified-lines value with comma separators
9263    Array.prototype.slice.call(document.querySelectorAll('.delta-card-inline[data-raw] .delta-card-val')).forEach(function(el){
9264      var raw=parseInt(el.parentNode.getAttribute('data-raw'),10);
9265      if(!isNaN(raw))el.textContent=raw.toLocaleString();
9266    });
9267    // Format code-before / code-now numbers in the prev-scan summary line
9268    Array.prototype.slice.call(document.querySelectorAll('.prev-scan-summary [data-raw]')).forEach(function(el){
9269      var raw=parseInt(el.getAttribute('data-raw'),10);
9270      if(!isNaN(raw))el.textContent=raw.toLocaleString();
9271    });
9272  }());
9273  </script>
9274  {% if has_style_data %}
9275  <script nonce="{{ nonce }}">
9276  (function(){
9277    var CHART_DATA = {{ style_chart_json|safe }};
9278    var FILE_DATA  = {{ style_file_json|safe }};
9279    var SCORE_THRESHOLD = {{ style_score_threshold }};
9280    var activeLang = CHART_DATA.length ? CHART_DATA[0].family : '';
9281    var sftSortKey = '';
9282    var sftSortDir = 1;
9283    function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
9284    // Official style guide URLs — covers every guide produced by the language analysers
9285    var GUIDE_URLS = {
9286      'PEP 8':'https://peps.python.org/pep-0008/',
9287      'PEP 8 (99-col)':'https://peps.python.org/pep-0008/',
9288      'Black':'https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html',
9289      'Google Python':'https://google.github.io/styleguide/pyguide.html',
9290      'Effective Go':'https://go.dev/doc/effective_go',
9291      'Uber Go':'https://github.com/uber-go/guide/blob/master/style.md',
9292      'Google Go':'https://google.github.io/styleguide/go/',
9293      'LLVM':'https://llvm.org/docs/CodingStandards.html',
9294      'Google':'https://google.github.io/styleguide/cppguide.html',
9295      'Mozilla':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/',
9296      'Microsoft':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9297      'WebKit':'https://webkit.org/code-style-guidelines/',
9298      'rustfmt defaults':'https://doc.rust-lang.org/rustfmt/',
9299      'Mozilla Rust':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/coding-style-rust.html',
9300      'Rust API Guidelines':'https://rust-lang.github.io/api-guidelines/',
9301      'Relaxed (120-col)':'https://doc.rust-lang.org/rustfmt/',
9302      'Airbnb':'https://airbnb.io/javascript/',
9303      'Google JS':'https://google.github.io/styleguide/jsguide.html',
9304      'Standard.js':'https://standardjs.com/',
9305      'Prettier':'https://prettier.io/docs/en/options.html',
9306      'Airbnb TS':'https://airbnb.io/javascript/',
9307      'Google TS':'https://google.github.io/styleguide/tsguide.html',
9308      'Angular':'https://angular.dev/style-guide',
9309      'Microsoft TS':'https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines',
9310      'Google Java':'https://google.github.io/styleguide/javaguide.html',
9311      'Oracle/Sun':'https://www.oracle.com/java/technologies/javase/codeconventions-contents.html',
9312      'Spring':'https://github.com/spring-projects/spring-framework/wiki/Code-Style',
9313      'JetBrains':'https://www.jetbrains.com/help/idea/code-style.html',
9314      'Android':'https://source.android.com/docs/setup/contribute/code-style',
9315      'Google Kotlin':'https://developer.android.com/kotlin/style-guide',
9316      'Apache Groovy':'https://groovy-lang.org/style-guide.html',
9317      'Gradle DSL':'https://docs.gradle.org/current/userguide/groovy_build_script_primer.html',
9318      'Scala Style Guide':'https://docs.scala-lang.org/style/',
9319      'Lightbend':'https://docs.scala-lang.org/style/',
9320      'Spark':'https://spark.apache.org/contributing.html',
9321      'RuboCop':'https://docs.rubocop.org/rubocop/',
9322      'Airbnb Ruby':'https://github.com/airbnb/ruby',
9323      'Standard Ruby':'https://github.com/standardrb/standard',
9324      'Microsoft .NET':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9325      'Google C#':'https://google.github.io/styleguide/csharp-style.html',
9326      'StyleCop':'https://github.com/DotNetAnalyzers/StyleCopAnalyzers',
9327      'Microsoft F#':'https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting',
9328      'FSharp.Formatting':'https://fsprojects.github.io/FSharp.Formatting/'
9329    };
9330    // Human-readable descriptions for each guide shown in bar tooltips
9331    var GUIDE_DESC = {
9332      'PEP 8':'4-space | 79-col | Python style standard',
9333      'PEP 8 (99-col)':'4-space | 99-col | relaxed line limit',
9334      'Black':'4-space | 88-col | double quotes enforced',
9335      'Google Python':'4-space | 80-col | double quotes preferred',
9336      'Effective Go':'tabs | ~80-col | gofmt standard',
9337      'Uber Go':'tabs | 120-col max',
9338      'Google Go':'tabs | 80-col',
9339      'LLVM':'2-space | 80-col | C/C++ LLVM project style',
9340      'Google':'2-space | 80-col | Google C++ style',
9341      'Mozilla':'4-space | 80-col | Firefox codebase style',
9342      'Microsoft':'4-space | Allman braces | C++ Win32 style',
9343      'WebKit':'4-space | 80-col | WebKit engine style',
9344      'rustfmt defaults':'4-space | 100-col | official Rust formatter',
9345      'Mozilla Rust':'4-space | 100-col | Firefox Rust style',
9346      'Rust API Guidelines':'4-space | naming + docs conventions',
9347      'Relaxed (120-col)':'4-space | 120-col | relaxed line limit',
9348      'Airbnb':'2-space | single quotes | no semicolons opt',
9349      'Google JS':'2-space | 80-col | single quotes',
9350      'Standard.js':'2-space | no semicolons | single quotes',
9351      'Prettier':'2-space | 80-col | double quotes | semicolons',
9352      'Airbnb TS':'2-space | single quotes | TypeScript variant',
9353      'Google TS':'2-space | 80-col | single quotes | TypeScript',
9354      'Angular':'2-space | Angular team TypeScript conventions',
9355      'Microsoft TS':'4-space | TypeScript compiler team style',
9356      'Google Java':'2-space | 100-col | Google Java guide',
9357      'Oracle/Sun':'4-space | 80-col | original Java conventions',
9358      'Spring':'4-space | Spring Framework code style',
9359      'JetBrains':'4-space | IntelliJ default Java style',
9360      'Android':'4-space | 100-col | AOSP Java style',
9361      'Google Kotlin':'4-space | 100-col | Android Kotlin style',
9362      'Apache Groovy':'4-space | Apache Groovy style',
9363      'Gradle DSL':'4-space | Gradle build script conventions',
9364      'Scala Style Guide':'2-space | 100-col | official Scala style',
9365      'Lightbend':'2-space | Lightbend/Akka Scala style',
9366      'Spark':'2-space | Apache Spark Scala style',
9367      'RuboCop':'2-space | 120-col | community Ruby style',
9368      'Airbnb Ruby':'2-space | 80-col | Airbnb Ruby guide',
9369      'Standard Ruby':'2-space | 80-col | StandardRB formatter',
9370      'Microsoft .NET':'4-space | Allman braces | .NET C# style',
9371      'Google C#':'2-space | Google C# style guide',
9372      'StyleCop':'4-space | StyleCop analyzer rules',
9373      'Microsoft F#':'4-space | official F# formatting guide',
9374      'FSharp.Formatting':'4-space | FSharp.Formatting conventions'
9375    };
9376    function renderBars(family){
9377      var wrap=document.getElementById('style-guide-bars');
9378      if(!wrap)return;
9379      wrap.innerHTML='';
9380      var grp=null;
9381      for(var i=0;i<CHART_DATA.length;i++){if(CHART_DATA[i].family===family){grp=CHART_DATA[i];break;}}
9382      if(!grp||!grp.guides.length)return;
9383      grp.guides.forEach(function(d){
9384        var isTop=(d.guide===grp.dominant);
9385        var row=document.createElement('div');row.className='style-guide-row';
9386        // Hover tooltip showing guide name + score + description
9387        var tip=document.createElement('div');tip.className='style-bar-tip';
9388        var desc=GUIDE_DESC[d.guide]||'';
9389        tip.textContent=d.guide+': '+d.score+'%'+(desc?' \u00b7 '+desc:'');
9390        var lbl=document.createElement('div');lbl.className='style-guide-label';
9391        lbl.textContent=d.guide;
9392        if(isTop)lbl.style.color='var(--oxide)';
9393        var track=document.createElement('div');track.className='style-guide-track';
9394        var fill=document.createElement('div');fill.className='style-guide-fill';
9395        fill.style.width='0%';
9396        var pct=document.createElement('div');pct.className='style-guide-score';
9397        pct.textContent=d.score+'%';
9398        if(isTop)pct.style.color='var(--oxide)';
9399        track.appendChild(fill);
9400        row.appendChild(tip);
9401        row.appendChild(lbl);row.appendChild(track);row.appendChild(pct);
9402        wrap.appendChild(row);
9403        setTimeout(function(f,s){return function(){f.style.width=s+'%';};}(fill,d.score),60);
9404      });
9405    }
9406    function initTabs(){
9407      var tabsWrap=document.getElementById('style-lang-tabs');
9408      if(!tabsWrap||!CHART_DATA.length)return;
9409      CHART_DATA.forEach(function(grp){
9410        var btn=document.createElement('button');
9411        btn.className='style-lang-tab'+(grp.family===activeLang?' active':'');
9412        btn.textContent=grp.family+' ('+grp.files+')';
9413        btn.onclick=function(){
9414          activeLang=grp.family;
9415          var tabs=tabsWrap.querySelectorAll('.style-lang-tab');
9416          for(var i=0;i<tabs.length;i++)tabs[i].className='style-lang-tab';
9417          btn.className='style-lang-tab active';
9418          renderBars(activeLang);
9419        };
9420        tabsWrap.appendChild(btn);
9421      });
9422      renderBars(activeLang);
9423    }
9424    function buildGuideHtml(guide){
9425      if(!guide||guide==='\u2014'||guide==='Unknown')return'<span style="color:var(--muted);">\u2014</span>';
9426      var url=GUIDE_URLS[guide];
9427      var desc=GUIDE_DESC[guide]||'';
9428      var tipText='Open official '+guide+' documentation'+(desc?' \u00b7 '+desc:'');
9429      if(url){return'<a href="'+escH(url)+'" target="_blank" rel="noopener" class="style-badge">'+escH(guide)+'</a>';}
9430      return'<span class="style-badge">'+escH(guide)+'</span>';
9431    }
9432    function buildSigsHtml(sigs){
9433      if(!sigs||!sigs.length)return'<span style="color:var(--muted);">\u2014</span>';
9434      var html='';
9435      var visible=sigs.slice(0,2);
9436      var rest=sigs.slice(2);
9437      visible.forEach(function(s){html+='<span class="style-sig-chip">'+escH(s.v)+'</span>';});
9438      if(rest.length){html+='<span style="color:var(--muted);font-size:11px;margin-left:2px;">\u22EF</span>';}
9439      return html;
9440    }
9441    var _sigPop=null;
9442    window.showSigPop=function(btn,ev){
9443      ev.stopPropagation();
9444      if(_sigPop){var prev=_sigPop;_sigPop=null;prev.remove();if(btn._ownPop===prev)return;}
9445      var sigs;try{sigs=JSON.parse(btn.getAttribute('data-sigs'));}catch(e){return;}
9446      var pop=document.createElement('div');
9447      pop.className='style-sig-pop';
9448      pop.setAttribute('role','tooltip');
9449      var inner='<div class="style-sig-pop-title">All Signals</div>';
9450      sigs.forEach(function(s){
9451        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>';
9452      });
9453      pop.innerHTML=inner;
9454      document.body.appendChild(pop);
9455      _sigPop=pop;
9456      btn._ownPop=pop;
9457      var r=btn.getBoundingClientRect();
9458      var pw=pop.offsetWidth||220;
9459      var left=r.left;
9460      if(left+pw>window.innerWidth-8)left=window.innerWidth-pw-8;
9461      if(left<8)left=8;
9462      var top=r.bottom+6;
9463      if(top+(pop.offsetHeight||120)>window.innerHeight-8)top=r.top-(pop.offsetHeight||120)-6;
9464      pop.style.left=left+'px';
9465      pop.style.top=top+'px';
9466      function dismiss(e){if(!pop.contains(e.target)){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9467      function dismissKey(e){if(e.key==='Escape'){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9468      setTimeout(function(){document.addEventListener('click',dismiss);document.addEventListener('keydown',dismissKey);},0);
9469    }
9470    var sftRows=[];
9471    var sftFilteredRows=[];
9472    var sftCurrentPage=1;
9473    function sftGetPageSize(){
9474      var sel=document.getElementById('sft-page-size');
9475      var v=sel?sel.value:'20';
9476      return v==='all'?Infinity:parseInt(v,10);
9477    }
9478    function sftApplyFilter(){
9479      var inp=document.getElementById('sft-search');
9480      var q=inp?inp.value.toLowerCase():'';
9481      var sorted=sftRows.slice();
9482      if(sftSortKey){
9483        sorted.sort(function(a,b){
9484          if(sftSortKey==='score'){var av=a.score||0,bv=b.score||0;return sftSortDir*(av-bv);}
9485          var av=String(a[sftSortKey]||'').toLowerCase(),bv=String(b[sftSortKey]||'').toLowerCase();
9486          return av<bv?-1*sftSortDir:av>bv?1*sftSortDir:0;
9487        });
9488      }
9489      sftFilteredRows=q===''?sorted:sorted.filter(function(f){
9490        return (f.path||'').toLowerCase().indexOf(q)>=0
9491          ||(f.lang||'').toLowerCase().indexOf(q)>=0
9492          ||(f.guide||'').toLowerCase().indexOf(q)>=0
9493          ||(f.indent||'').toLowerCase().indexOf(q)>=0;
9494      });
9495      sftCurrentPage=1;
9496      renderSftTable();
9497    }
9498    function renderSftTable(){
9499      var tbody=document.getElementById('style-file-tbody');
9500      if(!tbody)return;
9501      var ps=sftGetPageSize();
9502      var total=sftFilteredRows.length;
9503      var totalAll=sftRows.length;
9504      var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(total/ps));
9505      if(sftCurrentPage>totalPages)sftCurrentPage=totalPages;
9506      if(sftCurrentPage<1)sftCurrentPage=1;
9507      var start=ps===Infinity?0:(sftCurrentPage-1)*ps;
9508      var end=ps===Infinity?total:Math.min(start+ps,total);
9509      var page=sftFilteredRows.slice(start,end);
9510      var html='';
9511      page.forEach(function(f){
9512        var barW=Math.round(f.score);
9513        var guide=f.guide&&f.guide!=='Unknown'?f.guide:'';
9514        var badge=guide?buildGuideHtml(guide):'<span style="color:var(--muted);">\u2014</span>';
9515                var sigHtml=buildSigsHtml(f.signals);
9516        var rowClass=SCORE_THRESHOLD>0&&f.score<SCORE_THRESHOLD?' class="style-row-warn"':'';
9517        html+='<tr'+rowClass+'>'
9518          +'<td title="'+escH(f.path)+'">'+escH(f.path.replace(/^.*[\/\\]/,''))+'</td>'
9519          +'<td>'+escH(f.lang)+'</td>'
9520          +'<td>'+escH(f.indent)+'</td>'
9521          +'<td class="guide-cell" data-gtip="'+(guide?(escH(guide)+(GUIDE_DESC[guide]?' \u00b7 '+escH(GUIDE_DESC[guide]):'')):'')+'">' +badge+'</td>'
9522          +'<td><span class="style-score-bar"><span class="style-score-fill" style="width:'+barW+'%"></span></span>'+f.score+'%</td>'
9523          +'<td class="sig-cell" data-sigs="'+escH(JSON.stringify(f.signals||[]))+'">'+sigHtml+'</td>'
9524          +'</tr>';
9525      });
9526      tbody.innerHTML=html||'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9527      var pageInfo=document.getElementById('sft-page-info');
9528      var firstBtn=document.getElementById('sft-first');
9529      var prevBtn=document.getElementById('sft-prev');
9530      var nextBtn=document.getElementById('sft-next');
9531      var lastBtn=document.getElementById('sft-last');
9532      var jumpInput=document.getElementById('sft-page-jump');
9533      var pageTotal=document.getElementById('sft-page-total');
9534      var countLabel=document.getElementById('sft-count-label');
9535      if(pageInfo){
9536        if(total===0){pageInfo.textContent='No results';}
9537        else if(ps===Infinity){pageInfo.textContent='All '+total.toLocaleString()+' files';}
9538        else{pageInfo.textContent=(start+1)+'\u2013'+end+' of '+total.toLocaleString()+' files';}
9539      }
9540      if(countLabel){countLabel.textContent=(total<totalAll&&total>0)?'('+total.toLocaleString()+' matching)':'';}
9541      var edgeOff=ps===Infinity;
9542      if(firstBtn)firstBtn.disabled=sftCurrentPage<=1||edgeOff;
9543      if(prevBtn)prevBtn.disabled=sftCurrentPage<=1||edgeOff;
9544      if(nextBtn)nextBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9545      if(lastBtn)lastBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9546      if(jumpInput){jumpInput.value=sftCurrentPage;jumpInput.max=totalPages;jumpInput.disabled=edgeOff;}
9547      if(pageTotal)pageTotal.textContent=totalPages.toLocaleString();
9548    }
9549    function initStyleTable(){
9550      if(!FILE_DATA.length){
9551        var tb=document.getElementById('style-file-tbody');
9552        if(tb)tb.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9553        return;
9554      }
9555      sftRows=FILE_DATA.slice();
9556      sftFilteredRows=sftRows.slice();
9557      sftApplyFilter();
9558      // Signal & guide cell tooltip (appears above hovered cell, arrow points down)
9559      var chipTipEl=document.createElement('div');
9560      chipTipEl.className='sig-tip';
9561      document.body.appendChild(chipTipEl);
9562      function _showSigTip(html,cell){
9563        chipTipEl.innerHTML=html;
9564        chipTipEl.style.display='block';
9565        var r=cell.getBoundingClientRect();
9566        var tw=chipTipEl.offsetWidth||220;
9567        var th=chipTipEl.offsetHeight||80;
9568        var cx=r.left+r.width/2;
9569        var left=cx-tw/2;
9570        if(left<8)left=8;
9571        if(left+tw>window.innerWidth-8)left=window.innerWidth-tw-8;
9572        var arrowPct=Math.round((cx-left)/tw*100);
9573        if(arrowPct<10)arrowPct=10;
9574        if(arrowPct>90)arrowPct=90;
9575        chipTipEl.style.setProperty('--sig-tip-ax',arrowPct+'%');
9576        var top=r.top-th-12;
9577        if(top<8)top=r.bottom+8;
9578        chipTipEl.style.left=left+'px';
9579        chipTipEl.style.top=top+'px';
9580        chipTipEl.classList.add('visible');
9581      }
9582      function _hideSigTip(){
9583        chipTipEl.classList.remove('visible');
9584        chipTipEl.style.display='none';
9585      }
9586      function _buildSigHtml(cell){
9587        var sigs;try{sigs=JSON.parse(cell.getAttribute('data-sigs'));}catch(ex){return null;}
9588        if(!sigs||!sigs.length)return null;
9589        var html='<div class="sig-tip-hd">Signals</div>';
9590        sigs.forEach(function(s){
9591          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>';
9592        });
9593        return html;
9594      }
9595      function _buildGuideHtml(cell){
9596        var tip=cell.getAttribute('data-gtip')||'';
9597        if(!tip)return null;
9598        var parts=tip.split(' \u00b7 ',2);
9599        var html='<div class="sig-tip-hd">'+escH(parts[0])+'</div>';
9600        if(parts[1])html+='<div class="sig-tip-v" style="font-size:11px;">'+escH(parts[1])+'</div>';
9601        return html;
9602      }
9603      var sigTbl=document.getElementById('style-file-table');
9604      if(sigTbl){
9605        sigTbl.addEventListener('mouseover',function(e){
9606          var sc=e.target.closest?e.target.closest('.sig-cell'):null;
9607          var gc=e.target.closest?e.target.closest('.guide-cell'):null;
9608          if(sc){var h=_buildSigHtml(sc);if(h)_showSigTip(h,sc);return;}
9609          if(gc){var h=_buildGuideHtml(gc);if(h){var badge=gc.querySelector('.style-badge')||gc;_showSigTip(h,badge);}return;}
9610          _hideSigTip();
9611        });
9612        sigTbl.addEventListener('mouseleave',function(){
9613          _hideSigTip();
9614        });
9615        sigTbl.addEventListener('mouseout',function(e){
9616          if(!e.relatedTarget||!sigTbl.contains(e.relatedTarget))_hideSigTip();
9617        });
9618      }
9619      // Wire up sortable column headers
9620      var ths=document.querySelectorAll('#style-file-table thead th[data-sort-key]');
9621      for(var i=0;i<ths.length;i++){(function(th){
9622        th.style.cursor='pointer';
9623        th.addEventListener('click',function(){
9624          var key=th.getAttribute('data-sort-key');
9625          if(sftSortKey===key){sftSortDir*=-1;}else{sftSortKey=key;sftSortDir=1;}
9626          for(var j=0;j<ths.length;j++){
9627            ths[j].classList.remove('sft-sort-asc','sft-sort-desc');
9628            var ind=ths[j].querySelector('.style-sort-ind');
9629            if(ind)ind.textContent='\u25BE';
9630          }
9631          th.classList.add(sftSortDir===1?'sft-sort-asc':'sft-sort-desc');
9632          var tind=th.querySelector('.style-sort-ind');
9633          if(tind)tind.textContent=sftSortDir===1?'\u25B2':'\u25BC';
9634          sftApplyFilter();
9635        });
9636      })(ths[i]);}
9637      var searchInput=document.getElementById('sft-search');
9638      if(searchInput){
9639        var sftTimer=null;
9640        searchInput.addEventListener('input',function(){clearTimeout(sftTimer);sftTimer=setTimeout(sftApplyFilter,200);});
9641      }
9642      var pageSel=document.getElementById('sft-page-size');
9643      if(pageSel){pageSel.addEventListener('change',function(){sftCurrentPage=1;renderSftTable();});}
9644      var sftFirstBtn=document.getElementById('sft-first');
9645      var sftPrevBtn=document.getElementById('sft-prev');
9646      var sftNextBtn=document.getElementById('sft-next');
9647      var sftLastBtn=document.getElementById('sft-last');
9648      var sftJumpInput=document.getElementById('sft-page-jump');
9649      if(sftFirstBtn){sftFirstBtn.addEventListener('click',function(){sftCurrentPage=1;renderSftTable();});}
9650      if(sftPrevBtn){sftPrevBtn.addEventListener('click',function(){if(sftCurrentPage>1){sftCurrentPage--;renderSftTable();}});}
9651      if(sftNextBtn){sftNextBtn.addEventListener('click',function(){
9652        var ps=sftGetPageSize();
9653        var totalPages=ps===Infinity?1:Math.ceil(sftFilteredRows.length/ps);
9654        if(sftCurrentPage<totalPages){sftCurrentPage++;renderSftTable();}
9655      });}
9656      if(sftLastBtn){sftLastBtn.addEventListener('click',function(){
9657        var ps=sftGetPageSize();
9658        sftCurrentPage=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9659        renderSftTable();
9660      });}
9661      if(sftJumpInput){
9662        function sftJump(){
9663          var ps=sftGetPageSize();
9664          var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9665          var v=parseInt(sftJumpInput.value,10);
9666          if(!isNaN(v)){sftCurrentPage=Math.max(1,Math.min(v,totalPages));renderSftTable();}
9667        }
9668        sftJumpInput.addEventListener('change',sftJump);
9669        sftJumpInput.addEventListener('keydown',function(e){if(e.key==='Enter')sftJump();});
9670      }
9671    }
9672    function initSigInfoBtn(){
9673      var btn=document.getElementById('sig-info-btn');
9674      if(!btn)return;
9675      btn.addEventListener('click',function(){
9676        var overlay=document.createElement('div');
9677        overlay.className='style-sig-info-overlay';
9678        var GLOSSARY=[
9679          ['Quote Style','Dominant string quote character used in the file (single quotes, double quotes, or mixed)'],
9680          ['Indentation','Leading-whitespace style detected: Tabs, 2-Space, 4-Space, 8-Space, or Mixed'],
9681          ['Brace Style','Opening brace placement: K\u0026R / Attach (same line as statement) or Allman (own line)'],
9682          ['Semicolons','Whether statement-ending semicolons are present (JS/TS). \u201cNone detected\u201d means ASI-style.'],
9683          ['Variable Declarations','Preferred declaration keyword: const/let vs var (JS), short := vs var (Go)'],
9684          ['Function Naming','Dominant function naming convention: snake_case or CamelCase'],
9685          ['Type Hints','Whether Python PEP 484 type annotations (:Type, ->Type) are used in the file'],
9686          ['Wildcard Imports','Presence of import * wildcard import statements (Java/Kotlin)'],
9687          ['Pointer Style','Pointer/reference alignment in C/C++: *var (name-attached) or Type* (type-attached)'],
9688          ['Arrow Functions','Count of arrow function => expressions detected in JS/TS files'],
9689          ['Max Line Length','Character length of the longest line found in the file'],
9690          ['Error Handling','Presence of Go-style if err != nil error-checking patterns'],
9691          ['Type Inference','Whether the C# var keyword is used for implicit type inference'],
9692          ['Frozen String Literal','Whether the # frozen_string_literal: true pragma is present (Ruby)'],
9693          ['Space Before Paren','Spacing convention before opening parentheses in control structures (C/C++)'],
9694          ['Include Guard','Whether #pragma once is used as a header include guard (C/C++)']
9695        ];
9696        var rows='';
9697        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>';});
9698        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>';
9699        document.body.appendChild(overlay);
9700        overlay.addEventListener('click',function(e){if(e.target===overlay||e.target.classList.contains('style-sig-info-close')){overlay.remove();}});
9701      });
9702    }
9703    function init(){initTabs();initStyleTable();initSigInfoBtn();}
9704    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9705  }());
9706  </script>
9707  {% endif %}
9708  <script nonce="{{ nonce }}">
9709  (function(){
9710    var params=new URLSearchParams(location.search);
9711    if(params.get('autoprint')!=='1')return;
9712    var overlay=document.createElement('div');
9713    overlay.id='autoprint-overlay';
9714    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;';
9715    overlay.innerHTML='<div style="font-size:20px;font-weight:800;color:var(--text,#1a1a1a);">Preparing PDF\u2026</div>'
9716      +'<div style="font-size:13px;color:var(--muted,#666);">Use your browser\u2019s print dialog \u2192 <strong>Save as PDF</strong>.</div>'
9717      +'<div style="width:200px;height:4px;border-radius:2px;background:rgba(0,0,0,0.1);overflow:hidden;">'
9718      +'<div id="autoprint-bar" style="height:100%;width:0%;background:#e07b3a;transition:width 1.5s ease;border-radius:2px;"></div></div>';
9719    document.body.appendChild(overlay);
9720    setTimeout(function(){var b=document.getElementById('autoprint-bar');if(b)b.style.width='80%';},50);
9721    var deadline=Date.now()+12000;
9722    function tryPrint(){
9723      if(window.oxSlocChartsReady||Date.now()>deadline){
9724        var b=document.getElementById('autoprint-bar');
9725        if(b)b.style.width='100%';
9726        setTimeout(function(){
9727          overlay.style.display='none';
9728          window.print();
9729        },350);
9730      } else {
9731        setTimeout(tryPrint,150);
9732      }
9733    }
9734    if(document.readyState==='loading'){
9735      document.addEventListener('DOMContentLoaded',function(){setTimeout(tryPrint,250);});
9736    } else {
9737      setTimeout(tryPrint,250);
9738    }
9739    window.addEventListener('afterprint',function(){overlay.remove();});
9740  }());
9741  </script>
9742  <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>
9743  {% if let Some(banner) = report_header_footer %}
9744  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
9745  {% endif %}
9746</body>
9747</html>"##,
9748    ext = "html"
9749)]
9750// Template structs need many bool fields to pass Askama rendering flags.
9751// Fields are consumed by the Askama proc-macro; clippy cannot trace that usage.
9752#[allow(clippy::struct_excessive_bools, dead_code)]
9753struct ReportTemplate<'a> {
9754    nonce: String,
9755    title: String,
9756    browser_title: String,
9757    scan_performed_by: String,
9758    scan_time_pst: String,
9759    tool_version: String,
9760    is_sub_report: bool,
9761    run: &'a AnalysisRun,
9762    language_rows: Vec<LanguageRow>,
9763    file_rows: Vec<FileRow>,
9764    skipped_rows: Vec<FileRow>,
9765    config_json: String,
9766    lang_chart_json: String,
9767    submodule_chart_json: String,
9768    scatter_chart_json: String,
9769    semantic_chart_json: String,
9770    file_size_histogram_json: String,
9771    has_submodule_data: bool,
9772    has_semantic_data: bool,
9773    has_coverage_data: bool,
9774    has_fn_coverage: bool,
9775    has_branch_coverage: bool,
9776    test_files_count: u64,
9777    test_assertion_count: u64,
9778    test_suite_count: u64,
9779    test_density: String,
9780    most_tested_lang: String,
9781    langs_with_tests: usize,
9782    cov_line_pct: String,
9783    cov_fn_pct: String,
9784    cov_branch_pct: String,
9785    cov_line_class: String,
9786    cov_fn_class: String,
9787    cov_branch_class: String,
9788    has_run_warnings: bool,
9789    warning_count: usize,
9790    warning_summary_rows: Vec<WarningSummaryRow>,
9791    warning_opportunity_rows: Vec<WarningOpportunityRow>,
9792    warning_console_full: String,
9793    logo_text_uri: String,
9794    small_logo_uri: String,
9795    /// Data-URI for a custom logo, or None to show the default `OxideSLOC` logo.
9796    custom_logo_uri: Option<String>,
9797    /// Optional company/team name shown instead of "`OxideSLOC`" in the nav header.
9798    company_name: Option<String>,
9799    /// CSS hex accent colour override (e.g. `#3b82f6`), or None for the default.
9800    accent_hex: Option<String>,
9801    /// Text for the header/footer identification banner on every report page.
9802    report_header_footer: Option<String>,
9803    chart_js: &'static str,
9804    run_id_short: String,
9805    /// When the HTML was generated alongside a PDF (e.g. via CLI with both
9806    /// `--html-out` and `--pdf-out`), this holds the relative URL to that PDF.
9807    /// The "View PDF" button navigates directly to it instead of the server route.
9808    standalone_pdf_url: Option<String>,
9809    /// Direct link to the commit on the hosting forge (GitHub, Bitbucket, GitLab, …).
9810    /// `None` when the remote URL is absent or unrecognised.
9811    git_commit_url: Option<String>,
9812    /// Direct link to the branch on the hosting forge.
9813    /// `None` when the remote URL or branch is absent/unrecognised.
9814    git_branch_url: Option<String>,
9815    /// Whether any style data was collected.
9816    has_style_data: bool,
9817    /// Number of language groups in the style summary (0 when none).
9818    style_lang_count: usize,
9819    /// Files scoring below this threshold are highlighted in the per-file table. 0 = off.
9820    style_score_threshold: u8,
9821    /// Serialised JSON for the multi-language style-guide chart (empty string when none).
9822    style_chart_json: String,
9823    /// Serialised JSON for the per-file style table (empty string when none).
9824    style_file_json: String,
9825    /// Aggregate style summary, cloned from `AnalysisRun::style_summary`.
9826    style_summary: Option<StyleSummary>,
9827    /// True when a previous-scan delta was provided (shows the delta panel).
9828    has_delta: bool,
9829    delta_code_added: i64,
9830    delta_code_removed: i64,
9831    delta_unmodified_lines: i64,
9832    delta_files_added: usize,
9833    delta_files_removed: usize,
9834    delta_files_modified: usize,
9835    delta_files_unchanged: usize,
9836    delta_files_total: usize,
9837    prev_code_lines: u64,
9838    prev_scan_count: usize,
9839    prev_scan_label: String,
9840    prev_run_id: String,
9841    /// Whether a COCOMO estimate is available.
9842    has_cocomo: bool,
9843    /// Pre-formatted COCOMO effort string (e.g. "14.32 person-months").
9844    cocomo_effort_str: String,
9845    /// Pre-formatted COCOMO schedule string (e.g. "6.18 months").
9846    cocomo_duration_str: String,
9847    /// Pre-formatted COCOMO average team-size string (e.g. "2.32").
9848    cocomo_staff_str: String,
9849    /// Pre-formatted KSLOC input for COCOMO (e.g. "12.53").
9850    cocomo_ksloc_str: String,
9851    /// Display label for the COCOMO mode (e.g. "Organic").
9852    cocomo_mode_label: String,
9853    /// Tooltip text explaining the selected COCOMO mode.
9854    cocomo_mode_tooltip: String,
9855    /// Unique Lines of Code across all analyzed files.
9856    uloc: u64,
9857    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty string.
9858    dryness_pct_str: String,
9859    /// Number of duplicate file groups detected.
9860    duplicate_group_count: usize,
9861    /// True when an `--activity-window` scan attached per-file git activity.
9862    has_hotspots: bool,
9863    /// Top-N files by `code_lines × recent commits` (empty unless activity was collected).
9864    hotspot_rows: Vec<HotspotRow>,
9865}
9866
9867// ─────────────────────────────────────────────────────────────────────────────
9868// CSV export
9869// ─────────────────────────────────────────────────────────────────────────────
9870
9871fn csv_escape(s: &str) -> String {
9872    if s.contains(',') || s.contains('"') || s.contains('\n') {
9873        format!("\"{}\"", s.replace('"', "\"\""))
9874    } else {
9875        s.to_string()
9876    }
9877}
9878
9879/// Write a two-section CSV: language summary followed by per-file detail.
9880///
9881/// # Errors
9882///
9883/// Returns an error if the file cannot be written.
9884pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
9885    let mut out = String::new();
9886
9887    // ── Section 1: Summary ──────────────────────────────────────────────────
9888    out.push_str("# Summary\r\n");
9889    out.push_str("Metric,Value\r\n");
9890    let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
9891    let _ = write!(
9892        out,
9893        "Timestamp,{}\r\n",
9894        csv_escape(
9895            &run.tool
9896                .timestamp_utc
9897                .format("%Y-%m-%d %H:%M:%S UTC")
9898                .to_string()
9899        )
9900    );
9901    let _ = write!(
9902        out,
9903        "Report Title,{}\r\n",
9904        csv_escape(&run.effective_configuration.reporting.report_title)
9905    );
9906    let _ = write!(
9907        out,
9908        "Files Analyzed,{}\r\n",
9909        run.summary_totals.files_analyzed
9910    );
9911    let _ = write!(
9912        out,
9913        "Files Skipped,{}\r\n",
9914        run.summary_totals.files_skipped
9915    );
9916    let _ = write!(
9917        out,
9918        "Physical Lines,{}\r\n",
9919        run.summary_totals.total_physical_lines
9920    );
9921    let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
9922    let _ = write!(
9923        out,
9924        "Comment Lines,{}\r\n",
9925        run.summary_totals.comment_lines
9926    );
9927    let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
9928    let _ = write!(
9929        out,
9930        "Mixed Lines (separate),{}\r\n",
9931        run.summary_totals.mixed_lines_separate
9932    );
9933
9934    // ── Section 2: Language breakdown ───────────────────────────────────────
9935    out.push_str("\r\n# By Language\r\n");
9936    out.push_str(
9937        "Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
9938    );
9939    for lang in &run.totals_by_language {
9940        let _ = write!(
9941            out,
9942            "{},{},{},{},{},{},{}\r\n",
9943            csv_escape(lang.language.display_name()),
9944            lang.files,
9945            lang.total_physical_lines,
9946            lang.code_lines,
9947            lang.comment_lines,
9948            lang.blank_lines,
9949            lang.mixed_lines_separate,
9950        );
9951    }
9952
9953    // ── Section 3: Per-file detail (if present) ─────────────────────────────
9954    write_csv_per_file_section(&mut out, run);
9955
9956    fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
9957}
9958
9959/// Append the per-file detail section to a CSV buffer. No-op when there are no per-file records.
9960fn write_csv_per_file_section(out: &mut String, run: &AnalysisRun) {
9961    if run.per_file_records.is_empty() {
9962        return;
9963    }
9964    // Only emit the git-activity columns when an --activity-window scan populated them.
9965    let has_activity = run
9966        .per_file_records
9967        .iter()
9968        .any(|r| r.commit_count.is_some());
9969    out.push_str("\r\n# Per File\r\n");
9970    out.push_str(
9971        "Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor",
9972    );
9973    if has_activity {
9974        out.push_str(",Commits,Last Changed");
9975    }
9976    out.push_str("\r\n");
9977    for rec in &run.per_file_records {
9978        let _ = write!(
9979            out,
9980            "{},{},{},{},{},{},{},{},{},{}",
9981            csv_escape(&rec.relative_path),
9982            csv_escape(
9983                &rec.language
9984                    .map(|l| l.display_name().to_string())
9985                    .unwrap_or_default()
9986            ),
9987            rec.size_bytes,
9988            rec.effective_counts.code_lines,
9989            rec.effective_counts.comment_lines,
9990            rec.effective_counts.blank_lines,
9991            rec.raw_line_categories.total_physical_lines,
9992            rec.generated,
9993            rec.minified,
9994            rec.vendor,
9995        );
9996        if has_activity {
9997            let _ = write!(
9998                out,
9999                ",{},{}",
10000                rec.commit_count.map(|c| c.to_string()).unwrap_or_default(),
10001                csv_escape(rec.last_commit_date.as_deref().unwrap_or("")),
10002            );
10003        }
10004        out.push_str("\r\n");
10005    }
10006}
10007
10008/// Write a diff/delta as CSV.
10009///
10010/// # Errors
10011///
10012/// Returns an error if the file cannot be written.
10013pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10014    let s = &cmp.summary;
10015    let mut out = String::new();
10016
10017    out.push_str("# Diff Summary\r\n");
10018    out.push_str("Metric,Value\r\n");
10019    let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
10020    let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
10021    let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
10022    let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
10023    let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
10024    let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
10025    let _ = write!(out, "Files Total,{}\r\n", cmp.files_total);
10026    let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
10027    let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
10028    let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
10029    let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
10030
10031    out.push_str("\r\n# File Deltas\r\n");
10032    out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
10033    for f in &cmp.file_deltas {
10034        let status = match f.status {
10035            sloc_core::FileChangeStatus::Added => "Added",
10036            sloc_core::FileChangeStatus::Removed => "Removed",
10037            sloc_core::FileChangeStatus::Modified => "Modified",
10038            sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10039        };
10040        let _ = write!(
10041            out,
10042            "{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
10043            status,
10044            csv_escape(&f.relative_path),
10045            csv_escape(f.language.as_deref().unwrap_or("")),
10046            f.baseline_code,
10047            f.current_code,
10048            f.code_delta,
10049            f.baseline_comment,
10050            f.current_comment,
10051            f.comment_delta,
10052            f.baseline_blank,
10053            f.current_blank,
10054            f.blank_delta,
10055            f.total_delta,
10056        );
10057    }
10058
10059    fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
10060}
10061
10062// ─────────────────────────────────────────────────────────────────────────────
10063// XLSX export — self-contained, no external crates required.
10064//
10065// An .xlsx file is a ZIP archive containing a set of XML files.  We write the
10066// ZIP with the STORE (uncompressed) method so we only need a CRC-32 routine
10067// and straightforward byte-level framing — both implemented inline below.
10068// ─────────────────────────────────────────────────────────────────────────────
10069
10070fn crc32(data: &[u8]) -> u32 {
10071    let mut crc: u32 = 0xffff_ffff;
10072    for &b in data {
10073        crc ^= u32::from(b);
10074        for _ in 0..8 {
10075            crc = if crc & 1 == 0 {
10076                crc >> 1
10077            } else {
10078                (crc >> 1) ^ 0xedb8_8320
10079            };
10080        }
10081    }
10082    !crc
10083}
10084
10085struct ZipEntry {
10086    name: Vec<u8>,
10087    data: Vec<u8>,
10088    crc: u32,
10089    offset: u32,
10090}
10091
10092#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by caller
10093fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
10094    let crc = crc32(&data);
10095    let offset = buf.len() as u32;
10096    let name_bytes = name.as_bytes().to_vec();
10097    let size = data.len() as u32;
10098
10099    // Local file header (signature 0x04034b50)
10100    buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
10101    buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
10102    buf.extend_from_slice(&0u16.to_le_bytes()); // flags
10103    buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
10104    buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
10105    buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
10106    buf.extend_from_slice(&crc.to_le_bytes());
10107    buf.extend_from_slice(&size.to_le_bytes()); // compressed size
10108    buf.extend_from_slice(&size.to_le_bytes()); // uncompressed size
10109    buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
10110    buf.extend_from_slice(&0u16.to_le_bytes()); // extra field length
10111    buf.extend_from_slice(&name_bytes);
10112    buf.extend_from_slice(&data);
10113
10114    entries.push(ZipEntry {
10115        name: name_bytes,
10116        data,
10117        crc,
10118        offset,
10119    });
10120}
10121
10122#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by ZIP spec limits
10123fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
10124    let central_start = buf.len() as u32;
10125
10126    for e in entries {
10127        let size = e.data.len() as u32;
10128        buf.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); // central dir sig
10129        buf.extend_from_slice(&20u16.to_le_bytes()); // version made by
10130        buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
10131        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
10132        buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
10133        buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
10134        buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
10135        buf.extend_from_slice(&e.crc.to_le_bytes());
10136        buf.extend_from_slice(&size.to_le_bytes());
10137        buf.extend_from_slice(&size.to_le_bytes());
10138        buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
10139        buf.extend_from_slice(&0u16.to_le_bytes()); // extra
10140        buf.extend_from_slice(&0u16.to_le_bytes()); // comment
10141        buf.extend_from_slice(&0u16.to_le_bytes()); // disk start
10142        buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
10143        buf.extend_from_slice(&0u32.to_le_bytes()); // external attrs
10144        buf.extend_from_slice(&e.offset.to_le_bytes());
10145        buf.extend_from_slice(&e.name);
10146    }
10147
10148    let central_size = buf.len() as u32 - central_start;
10149    let n = entries.len() as u16;
10150
10151    // End of central directory record
10152    buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
10153    buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
10154    buf.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir
10155    buf.extend_from_slice(&n.to_le_bytes()); // entries on this disk
10156    buf.extend_from_slice(&n.to_le_bytes()); // total entries
10157    buf.extend_from_slice(&central_size.to_le_bytes());
10158    buf.extend_from_slice(&central_start.to_le_bytes());
10159    buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
10160
10161    buf
10162}
10163
10164fn xml_escape(s: &str) -> String {
10165    s.replace('&', "&amp;")
10166        .replace('<', "&lt;")
10167        .replace('>', "&gt;")
10168        .replace('"', "&quot;")
10169        .replace('\'', "&apos;")
10170}
10171
10172/// Build a worksheet XML with the given header row and data rows.
10173// ── XLSX style-index constants ──────────────────────────────────────────────
10174// Indices into the <cellXfs> table in styles.xml.
10175// 0 = default (unused placeholder)
10176// 1 = HEADER   bold white text, navy fill (#283790), all-side thin border, centered
10177// 2 = BODY     normal text, white fill, thin border
10178// 3 = BODY_ALT normal text, cream fill (#F5EFE8), thin border  (alternating rows)
10179// 4 = NUM      #,##0, right-aligned, white fill, thin border
10180// 5 = NUM_ALT  #,##0, right-aligned, cream fill, thin border   (alternating rows)
10181// 6 = KV_KEY   bold navy text (#283790), warm-surface fill (#FBF7F2), thin border
10182// 7 = KV_VAL   normal text, white fill, thin border  (key-value sheets: Summary)
10183const XLS_HEADER: u32 = 1;
10184const XLS_BODY: u32 = 2;
10185const XLS_BODY_ALT: u32 = 3;
10186const XLS_NUM: u32 = 4;
10187const XLS_NUM_ALT: u32 = 5;
10188const XLS_KV_KEY: u32 = 6;
10189const XLS_KV_VAL: u32 = 7;
10190
10191struct XlSheet<'a> {
10192    name: &'a str,
10193    tab_color: &'a str, // AARRGGBB hex without '#', e.g. "FF283790"
10194    headers: &'a [&'a str],
10195    rows: Vec<Vec<String>>,
10196    col_widths: Vec<f64>, // per-column character widths; last entry used for overflow cols
10197    is_kv: bool,          // key-value layout (Summary): col A = key style, no autofilter
10198}
10199
10200#[allow(clippy::cast_possible_truncation)] // n % 26 fits in u8 by construction
10201fn xl_col_name(idx: usize) -> String {
10202    let mut n = idx + 1;
10203    let mut s = String::new();
10204    while n > 0 {
10205        n -= 1;
10206        s.insert(0, char::from(b'A' + (n % 26) as u8));
10207        n /= 26;
10208    }
10209    s
10210}
10211
10212const fn xl_styles() -> &'static str {
10213    "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
10214<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
10215<numFmts count=\"1\">\
10216<numFmt numFmtId=\"164\" formatCode=\"#,##0\"/>\
10217</numFmts>\
10218<fonts count=\"3\">\
10219<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\
10220<font><b/><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font>\
10221<font><b/><sz val=\"11\"/><color rgb=\"FF283790\"/><name val=\"Calibri\"/></font>\
10222</fonts>\
10223<fills count=\"5\">\
10224<fill><patternFill patternType=\"none\"/></fill>\
10225<fill><patternFill patternType=\"gray125\"/></fill>\
10226<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF283790\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10227<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFF5EFE8\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10228<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFBF7F2\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10229</fills>\
10230<borders count=\"2\">\
10231<border><left/><right/><top/><bottom/><diagonal/></border>\
10232<border>\
10233<left style=\"thin\"><color rgb=\"FFD0B8A0\"/></left>\
10234<right style=\"thin\"><color rgb=\"FFD0B8A0\"/></right>\
10235<top style=\"thin\"><color rgb=\"FFD0B8A0\"/></top>\
10236<bottom style=\"thin\"><color rgb=\"FFD0B8A0\"/></bottom>\
10237<diagonal/>\
10238</border>\
10239</borders>\
10240<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\
10241<cellXfs count=\"8\">\
10242<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\
10243<xf numFmtId=\"0\" fontId=\"1\" fillId=\"2\" borderId=\"1\" xfId=\"0\" \
10244applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10245<alignment horizontal=\"center\" vertical=\"center\"/></xf>\
10246<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10247<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" applyFill=\"1\" applyBorder=\"1\"/>\
10248<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" \
10249applyNumberFormat=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10250<alignment horizontal=\"right\"/></xf>\
10251<xf numFmtId=\"164\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" \
10252applyNumberFormat=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10253<alignment horizontal=\"right\"/></xf>\
10254<xf numFmtId=\"0\" fontId=\"2\" fillId=\"4\" borderId=\"1\" xfId=\"0\" \
10255applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\"/>\
10256<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10257</cellXfs>\
10258</styleSheet>"
10259}
10260
10261fn xl_sheet_xml(sheet: &XlSheet<'_>) -> Vec<u8> {
10262    let ncols = sheet.headers.len();
10263    let ndata = sheet.rows.len();
10264    let last_col = xl_col_name(ncols.saturating_sub(1));
10265    let last_row = ndata + 1;
10266    let range = format!("A1:{last_col}{last_row}");
10267
10268    let mut xml = String::with_capacity(4096 + ndata * 256);
10269    let _ = write!(
10270        xml,
10271        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10272         <worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
10273         <sheetPr><tabColor rgb=\"{tc}\"/></sheetPr>\n\
10274         <dimension ref=\"{rng}\"/>\n\
10275         <sheetViews><sheetView workbookViewId=\"0\">\
10276         <pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>\
10277         <selection pane=\"bottomLeft\" activeCell=\"A2\" sqref=\"A2\"/>\
10278         </sheetView></sheetViews>\n\
10279         <sheetFormatPr defaultRowHeight=\"15\"/>\n",
10280        tc = sheet.tab_color,
10281        rng = range,
10282    );
10283
10284    xl_write_col_widths(&mut xml, &sheet.col_widths, ncols);
10285    xml.push_str("<sheetData>\n");
10286    xl_write_header_row(&mut xml, sheet.headers);
10287    xl_write_data_rows(&mut xml, &sheet.rows, sheet.is_kv);
10288    xml.push_str("</sheetData>\n");
10289    if !sheet.is_kv && ncols > 0 {
10290        let _ = writeln!(xml, "<autoFilter ref=\"{range}\"/>");
10291    }
10292    xml.push_str("</worksheet>");
10293    xml.into_bytes()
10294}
10295
10296fn xl_write_col_widths(xml: &mut String, col_widths: &[f64], ncols: usize) {
10297    if col_widths.is_empty() {
10298        return;
10299    }
10300    let default_w = *col_widths.last().unwrap_or(&10.0);
10301    xml.push_str("<cols>\n");
10302    for ci in 0..ncols {
10303        let w = col_widths.get(ci).copied().unwrap_or(default_w);
10304        let _ = writeln!(
10305            xml,
10306            "  <col min=\"{n}\" max=\"{n}\" width=\"{w:.1}\" customWidth=\"1\"/>",
10307            n = ci + 1
10308        );
10309    }
10310    xml.push_str("</cols>\n");
10311}
10312
10313fn xl_write_header_row(xml: &mut String, headers: &[&str]) {
10314    let _ = write!(xml, "<row r=\"1\" ht=\"18\" customHeight=\"1\">");
10315    for (ci, &h) in headers.iter().enumerate() {
10316        let _ = write!(
10317            xml,
10318            "<c r=\"{}1\" t=\"inlineStr\" s=\"{}\"><is><t>{}</t></is></c>",
10319            xl_col_name(ci),
10320            XLS_HEADER,
10321            xml_escape(h),
10322        );
10323    }
10324    xml.push_str("</row>\n");
10325}
10326
10327const fn xl_cell_style(is_kv: bool, ci: usize, is_num: bool, is_alt: bool) -> u32 {
10328    if is_kv {
10329        if ci == 0 {
10330            XLS_KV_KEY
10331        } else if is_num {
10332            XLS_NUM
10333        } else {
10334            XLS_KV_VAL
10335        }
10336    } else if is_num {
10337        if is_alt { XLS_NUM_ALT } else { XLS_NUM }
10338    } else if is_alt {
10339        XLS_BODY_ALT
10340    } else {
10341        XLS_BODY
10342    }
10343}
10344
10345fn xl_write_data_rows(xml: &mut String, rows: &[Vec<String>], is_kv: bool) {
10346    for (ri, row) in rows.iter().enumerate() {
10347        let row_num = ri + 2;
10348        let is_alt = ri % 2 == 1;
10349        let _ = write!(xml, "<row r=\"{row_num}\">");
10350        for (ci, cell) in row.iter().enumerate() {
10351            let cell_ref = format!("{}{}", xl_col_name(ci), row_num);
10352            let is_num = !cell.is_empty() && cell.parse::<f64>().is_ok();
10353            let s = xl_cell_style(is_kv, ci, is_num, is_alt);
10354            if is_num {
10355                let _ = write!(
10356                    xml,
10357                    "<c r=\"{cell_ref}\" s=\"{s}\"><v>{}</v></c>",
10358                    xml_escape(cell)
10359                );
10360            } else {
10361                let _ = write!(
10362                    xml,
10363                    "<c r=\"{cell_ref}\" t=\"inlineStr\" s=\"{s}\"><is><t>{}</t></is></c>",
10364                    xml_escape(cell),
10365                );
10366            }
10367        }
10368        xml.push_str("</row>\n");
10369    }
10370}
10371
10372fn build_xlsx(sheets: &[XlSheet<'_>]) -> Vec<u8> {
10373    let mut buf: Vec<u8> = Vec::new();
10374    let mut entries: Vec<ZipEntry> = Vec::new();
10375
10376    // ── [Content_Types].xml ─────────────────────────────────────────────────
10377    let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10378    ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
10379    ct.push_str("  <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
10380    ct.push_str("  <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
10381    ct.push_str("  <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
10382    ct.push_str("  <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
10383    for (i, _) in sheets.iter().enumerate() {
10384        let _ = writeln!(
10385            ct,
10386            "  <Override PartName=\"/xl/worksheets/sheet{}.xml\" \
10387             ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
10388            i + 1
10389        );
10390    }
10391    ct.push_str("</Types>");
10392    zip_add(
10393        &mut entries,
10394        &mut buf,
10395        "[Content_Types].xml",
10396        ct.into_bytes(),
10397    );
10398
10399    // ── _rels/.rels ─────────────────────────────────────────────────────────
10400    let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10401<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
10402  <Relationship Id=\"rId1\" \
10403  Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" \
10404  Target=\"xl/workbook.xml\"/>\n\
10405</Relationships>";
10406    zip_add(
10407        &mut entries,
10408        &mut buf,
10409        "_rels/.rels",
10410        rels.as_bytes().to_vec(),
10411    );
10412
10413    // ── xl/workbook.xml ──────────────────────────────────────────────────────
10414    let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10415    wb.push_str(
10416        "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
10417         xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
10418    );
10419    wb.push_str("  <sheets>\n");
10420    for (i, sheet) in sheets.iter().enumerate() {
10421        let _ = writeln!(
10422            wb,
10423            "    <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
10424            xml_escape(sheet.name),
10425            i + 1,
10426            i + 1
10427        );
10428    }
10429    wb.push_str("  </sheets>\n</workbook>");
10430    zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
10431
10432    // ── xl/_rels/workbook.xml.rels ───────────────────────────────────────────
10433    let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10434    wbr.push_str(
10435        "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
10436    );
10437    for (i, _) in sheets.iter().enumerate() {
10438        let _ = writeln!(
10439            wbr,
10440            "  <Relationship Id=\"rId{}\" \
10441             Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
10442             Target=\"worksheets/sheet{}.xml\"/>",
10443            i + 1,
10444            i + 1
10445        );
10446    }
10447    let _ = writeln!(
10448        wbr,
10449        "  <Relationship Id=\"rId{}\" \
10450         Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
10451         Target=\"styles.xml\"/>",
10452        sheets.len() + 1
10453    );
10454    wbr.push_str("</Relationships>");
10455    zip_add(
10456        &mut entries,
10457        &mut buf,
10458        "xl/_rels/workbook.xml.rels",
10459        wbr.into_bytes(),
10460    );
10461
10462    // ── xl/styles.xml ───────────────────────────────────────────────────────
10463    zip_add(
10464        &mut entries,
10465        &mut buf,
10466        "xl/styles.xml",
10467        xl_styles().as_bytes().to_vec(),
10468    );
10469
10470    // ── worksheets ───────────────────────────────────────────────────────────
10471    for (i, sheet) in sheets.iter().enumerate() {
10472        let sheet_xml = xl_sheet_xml(sheet);
10473        let name = format!("xl/worksheets/sheet{}.xml", i + 1);
10474        zip_add(&mut entries, &mut buf, &name, sheet_xml);
10475    }
10476
10477    zip_finish(buf, &entries)
10478}
10479
10480/// Write an analysis run as a multi-sheet Excel workbook.
10481///
10482/// # Errors
10483///
10484/// Returns an error if the file cannot be written.
10485#[allow(clippy::too_many_lines)]
10486pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
10487    // Sheet 1 — Summary
10488    let summary_rows: Vec<Vec<String>> = vec![
10489        vec!["Run ID".into(), run.tool.run_id.clone()],
10490        vec![
10491            "Timestamp".into(),
10492            run.tool
10493                .timestamp_utc
10494                .format("%Y-%m-%d %H:%M:%S UTC")
10495                .to_string(),
10496        ],
10497        vec![
10498            "Report Title".into(),
10499            run.effective_configuration.reporting.report_title.clone(),
10500        ],
10501        vec![
10502            "Files Analyzed".into(),
10503            run.summary_totals.files_analyzed.to_string(),
10504        ],
10505        vec![
10506            "Files Skipped".into(),
10507            run.summary_totals.files_skipped.to_string(),
10508        ],
10509        vec![
10510            "Physical Lines".into(),
10511            run.summary_totals.total_physical_lines.to_string(),
10512        ],
10513        vec![
10514            "Code Lines".into(),
10515            run.summary_totals.code_lines.to_string(),
10516        ],
10517        vec![
10518            "Comment Lines".into(),
10519            run.summary_totals.comment_lines.to_string(),
10520        ],
10521        vec![
10522            "Blank Lines".into(),
10523            run.summary_totals.blank_lines.to_string(),
10524        ],
10525        vec![
10526            "Mixed Lines (separate)".into(),
10527            run.summary_totals.mixed_lines_separate.to_string(),
10528        ],
10529    ];
10530
10531    // Sheet 2 — By Language
10532    let lang_rows: Vec<Vec<String>> = run
10533        .totals_by_language
10534        .iter()
10535        .map(|l| {
10536            vec![
10537                l.language.display_name().to_string(),
10538                l.files.to_string(),
10539                l.total_physical_lines.to_string(),
10540                l.code_lines.to_string(),
10541                l.comment_lines.to_string(),
10542                l.blank_lines.to_string(),
10543                l.mixed_lines_separate.to_string(),
10544            ]
10545        })
10546        .collect();
10547
10548    // Sheet 3 — Per File
10549    let file_rows: Vec<Vec<String>> = run
10550        .per_file_records
10551        .iter()
10552        .map(|r| {
10553            vec![
10554                r.relative_path.clone(),
10555                r.language
10556                    .map(|l| l.display_name().to_string())
10557                    .unwrap_or_default(),
10558                r.size_bytes.to_string(),
10559                r.effective_counts.code_lines.to_string(),
10560                r.effective_counts.comment_lines.to_string(),
10561                r.effective_counts.blank_lines.to_string(),
10562                r.raw_line_categories.total_physical_lines.to_string(),
10563                r.generated.to_string(),
10564                r.minified.to_string(),
10565                r.vendor.to_string(),
10566            ]
10567        })
10568        .collect();
10569
10570    // Sheet 4 — Skipped Files
10571    let skipped_rows: Vec<Vec<String>> = run
10572        .skipped_file_records
10573        .iter()
10574        .map(|r| {
10575            vec![
10576                r.relative_path.clone(),
10577                format!("{:?}", r.status),
10578                r.size_bytes.to_string(),
10579            ]
10580        })
10581        .collect();
10582
10583    let summary_hdrs: &[&str] = &["Metric", "Value"];
10584    let lang_hdrs: &[&str] = &[
10585        "Language",
10586        "Files",
10587        "Physical Lines",
10588        "Code Lines",
10589        "Comments",
10590        "Blank",
10591        "Mixed",
10592    ];
10593    let file_hdrs: &[&str] = &[
10594        "Path",
10595        "Language",
10596        "Size (bytes)",
10597        "Code Lines",
10598        "Comments",
10599        "Blank Lines",
10600        "Physical Lines",
10601        "Generated",
10602        "Minified",
10603        "Vendor",
10604    ];
10605    let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
10606
10607    let sheets = vec![
10608        XlSheet {
10609            name: "Summary",
10610            tab_color: "FF283790",
10611            headers: summary_hdrs,
10612            rows: summary_rows,
10613            col_widths: vec![26.0, 44.0],
10614            is_kv: true,
10615        },
10616        XlSheet {
10617            name: "By Language",
10618            tab_color: "FFB85D33",
10619            headers: lang_hdrs,
10620            rows: lang_rows,
10621            col_widths: vec![20.0, 9.0, 15.0, 13.0, 13.0, 11.0, 11.0],
10622            is_kv: false,
10623        },
10624        XlSheet {
10625            name: "Per File",
10626            tab_color: "FF2A6846",
10627            headers: file_hdrs,
10628            rows: file_rows,
10629            col_widths: vec![48.0, 14.0, 13.0, 13.0, 11.0, 11.0, 15.0, 11.0, 11.0, 9.0],
10630            is_kv: false,
10631        },
10632        XlSheet {
10633            name: "Skipped",
10634            tab_color: "FF7B675B",
10635            headers: skipped_hdrs,
10636            rows: skipped_rows,
10637            col_widths: vec![52.0, 24.0, 13.0],
10638            is_kv: false,
10639        },
10640    ];
10641
10642    let bytes = build_xlsx(&sheets);
10643    fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
10644}
10645
10646/// Write a diff comparison as an Excel workbook.
10647///
10648/// # Errors
10649///
10650/// Returns an error if the file cannot be written.
10651pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10652    let s = &cmp.summary;
10653
10654    let summary_rows: Vec<Vec<String>> = vec![
10655        vec!["Baseline Run".into(), s.baseline_run_id.clone()],
10656        vec!["Current Run".into(), s.current_run_id.clone()],
10657        vec!["Files Added".into(), cmp.files_added.to_string()],
10658        vec!["Files Removed".into(), cmp.files_removed.to_string()],
10659        vec!["Files Modified".into(), cmp.files_modified.to_string()],
10660        vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
10661        vec!["Files Total".into(), cmp.files_total.to_string()],
10662        vec!["Code Δ".into(), s.code_lines_delta.to_string()],
10663        vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
10664        vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
10665        vec!["Total Δ".into(), s.total_lines_delta.to_string()],
10666    ];
10667
10668    let delta_rows: Vec<Vec<String>> = cmp
10669        .file_deltas
10670        .iter()
10671        .map(|f| {
10672            let status = match f.status {
10673                sloc_core::FileChangeStatus::Added => "Added",
10674                sloc_core::FileChangeStatus::Removed => "Removed",
10675                sloc_core::FileChangeStatus::Modified => "Modified",
10676                sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10677            };
10678            vec![
10679                status.to_string(),
10680                f.relative_path.clone(),
10681                f.language.clone().unwrap_or_default(),
10682                f.baseline_code.to_string(),
10683                f.current_code.to_string(),
10684                f.code_delta.to_string(),
10685                f.baseline_comment.to_string(),
10686                f.current_comment.to_string(),
10687                f.comment_delta.to_string(),
10688                f.total_delta.to_string(),
10689            ]
10690        })
10691        .collect();
10692
10693    let summary_hdrs: &[&str] = &["Metric", "Value"];
10694    let delta_hdrs: &[&str] = &[
10695        "Status",
10696        "Path",
10697        "Language",
10698        "Baseline Code",
10699        "Current Code",
10700        "Code Δ",
10701        "Baseline Comment",
10702        "Current Comment",
10703        "Comment Δ",
10704        "Total Δ",
10705    ];
10706
10707    let sheets = vec![
10708        XlSheet {
10709            name: "Diff Summary",
10710            tab_color: "FF283790",
10711            headers: summary_hdrs,
10712            rows: summary_rows,
10713            col_widths: vec![26.0, 44.0],
10714            is_kv: true,
10715        },
10716        XlSheet {
10717            name: "File Deltas",
10718            tab_color: "FFB85D33",
10719            headers: delta_hdrs,
10720            rows: delta_rows,
10721            col_widths: vec![12.0, 48.0, 16.0, 14.0, 14.0, 11.0, 14.0, 14.0, 11.0, 11.0],
10722            is_kv: false,
10723        },
10724    ];
10725
10726    let bytes = build_xlsx(&sheets);
10727    fs::write(path, bytes)
10728        .with_context(|| format!("failed to write diff XLSX to {}", path.display()))
10729}
10730
10731// ── Confluence rendering ────────────────────────────────────────────────────
10732
10733fn html_esc(s: &str) -> String {
10734    s.replace('&', "&amp;")
10735        .replace('<', "&lt;")
10736        .replace('>', "&gt;")
10737        .replace('"', "&quot;")
10738}
10739
10740/// Generates Confluence storage-format XHTML for a scan result page.
10741/// Includes an info panel, summary stats, per-language table, and an optional
10742/// link back to the full oxide-sloc HTML report.
10743#[must_use]
10744pub fn render_confluence_storage(run: &AnalysisRun, report_url: Option<&str>) -> String {
10745    let mut out = String::with_capacity(8192);
10746
10747    let project = run.effective_configuration.reporting.report_title.as_str();
10748    let branch = run.git_branch.as_deref().unwrap_or("—");
10749    let commit = run.git_commit_short.as_deref().unwrap_or("—");
10750    let scanned = run
10751        .tool
10752        .timestamp_utc
10753        .format("%Y-%m-%d %H:%M UTC")
10754        .to_string();
10755
10756    // Info panel macro
10757    out.push_str(
10758        "<ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\">\
10759         <ac:rich-text-body><p>",
10760    );
10761    let _ = write!(
10762        out,
10763        "<strong>Project:</strong> {proj} &nbsp;·&nbsp; \
10764         <strong>Branch:</strong> {branch} &nbsp;·&nbsp; \
10765         <strong>Commit:</strong> {commit} &nbsp;·&nbsp; \
10766         <strong>Scanned:</strong> {scanned}",
10767        proj = html_esc(project),
10768        branch = html_esc(branch),
10769        commit = html_esc(commit),
10770        scanned = html_esc(&scanned),
10771    );
10772    out.push_str("</p></ac:rich-text-body></ac:structured-macro>");
10773
10774    // Summary stats table
10775    out.push_str("<h2>Summary</h2>");
10776    out.push_str(
10777        "<table><thead><tr>\
10778         <th>Files Analyzed</th><th>Code Lines</th><th>Comment Lines</th>\
10779         <th>Blank Lines</th><th>Languages</th>\
10780         </tr></thead><tbody><tr>",
10781    );
10782    let t = &run.summary_totals;
10783    let _ = write!(
10784        out,
10785        "<td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>",
10786        t.files_analyzed,
10787        t.code_lines,
10788        t.comment_lines,
10789        t.blank_lines,
10790        run.totals_by_language.len(),
10791    );
10792    out.push_str("</tr></tbody></table>");
10793
10794    // Per-language breakdown table
10795    if !run.totals_by_language.is_empty() {
10796        out.push_str("<h2>Language Breakdown</h2>");
10797        out.push_str(
10798            "<table><thead><tr>\
10799             <th>Language</th><th>Files</th><th>Code</th><th>Comments</th><th>Blank</th>\
10800             </tr></thead><tbody>",
10801        );
10802        for lang in &run.totals_by_language {
10803            let _ = write!(
10804                out,
10805                "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
10806                html_esc(lang.language.display_name()),
10807                lang.files,
10808                lang.code_lines,
10809                lang.comment_lines,
10810                lang.blank_lines,
10811            );
10812        }
10813        out.push_str("</tbody></table>");
10814    }
10815
10816    // Link back to full report
10817    if let Some(url) = report_url {
10818        let _ = write!(
10819            out,
10820            "<p><strong>Full interactive report:</strong> \
10821             <a href=\"{url}\">{url_disp}</a></p>",
10822            url = html_esc(url),
10823            url_disp = html_esc(url),
10824        );
10825    }
10826
10827    out
10828}
10829
10830/// Generates Confluence wiki markup (legacy syntax) for copy/paste into a
10831/// Confluence page editor.
10832#[must_use]
10833pub fn render_confluence_wiki_markup(run: &AnalysisRun) -> String {
10834    let mut out = String::with_capacity(4096);
10835
10836    let project = run.effective_configuration.reporting.report_title.as_str();
10837    let branch = run.git_branch.as_deref().unwrap_or("—");
10838    let commit = run.git_commit_short.as_deref().unwrap_or("—");
10839    let scanned = run
10840        .tool
10841        .timestamp_utc
10842        .format("%Y-%m-%d %H:%M UTC")
10843        .to_string();
10844
10845    let _ = writeln!(out, "{{info}}");
10846    let _ = writeln!(
10847        out,
10848        "Project: {project}  ·  Branch: {branch}  ·  Commit: {commit}  ·  Scanned: {scanned}"
10849    );
10850    let _ = writeln!(out, "{{info}}");
10851    out.push('\n');
10852
10853    let t = &run.summary_totals;
10854    let _ = writeln!(out, "h2. Summary");
10855    let _ = writeln!(
10856        out,
10857        "||Files Analyzed||Code Lines||Comment Lines||Blank Lines||Languages||"
10858    );
10859    let _ = writeln!(
10860        out,
10861        "|{}|{}|{}|{}|{}|",
10862        t.files_analyzed,
10863        t.code_lines,
10864        t.comment_lines,
10865        t.blank_lines,
10866        run.totals_by_language.len(),
10867    );
10868    out.push('\n');
10869
10870    if !run.totals_by_language.is_empty() {
10871        let _ = writeln!(out, "h2. Language Breakdown");
10872        let _ = writeln!(out, "||Language||Files||Code||Comments||Blank||");
10873        for lang in &run.totals_by_language {
10874            let _ = writeln!(
10875                out,
10876                "|{}|{}|{}|{}|{}|",
10877                lang.language.display_name(),
10878                lang.files,
10879                lang.code_lines,
10880                lang.comment_lines,
10881                lang.blank_lines,
10882            );
10883        }
10884        out.push('\n');
10885    }
10886
10887    let _ = writeln!(
10888        out,
10889        "*Total:* {} code lines · {} files · {} languages",
10890        t.code_lines,
10891        t.files_analyzed,
10892        run.totals_by_language.len(),
10893    );
10894
10895    out
10896}
10897
10898#[cfg(test)]
10899mod tests {
10900    use super::*;
10901    use tempfile::tempdir;
10902
10903    // ── base64_encode ────────────────────────────────────────────────────────────
10904
10905    #[test]
10906    fn base64_encode_empty() {
10907        assert_eq!(base64_encode(b""), "");
10908    }
10909
10910    #[test]
10911    fn base64_encode_one_byte() {
10912        assert_eq!(base64_encode(b"M"), "TQ==");
10913    }
10914
10915    #[test]
10916    fn base64_encode_two_bytes() {
10917        assert_eq!(base64_encode(b"Ma"), "TWE=");
10918    }
10919
10920    #[test]
10921    fn base64_encode_three_bytes_no_padding() {
10922        assert_eq!(base64_encode(b"Man"), "TWFu");
10923    }
10924
10925    #[test]
10926    fn base64_encode_hello() {
10927        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
10928    }
10929
10930    #[test]
10931    fn base64_encode_roundtrip_length_multiple_of_3() {
10932        let data = b"abcdef";
10933        let encoded = base64_encode(data);
10934        assert_eq!(encoded.len(), 8);
10935        assert!(!encoded.contains('='));
10936    }
10937
10938    #[test]
10939    fn base64_encode_all_zeros() {
10940        assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
10941    }
10942
10943    #[test]
10944    fn base64_encode_binary_data() {
10945        let data: Vec<u8> = (0u8..=255).collect();
10946        let encoded = base64_encode(&data);
10947        assert!(!encoded.is_empty());
10948        assert!(
10949            encoded
10950                .chars()
10951                .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
10952        );
10953    }
10954
10955    // ── json_escape ──────────────────────────────────────────────────────────────
10956
10957    #[test]
10958    fn json_escape_no_special_chars() {
10959        assert_eq!(json_escape("hello world"), "hello world");
10960    }
10961
10962    #[test]
10963    fn json_escape_backslash() {
10964        assert_eq!(json_escape(r"path\to\file"), r"path\\to\\file");
10965    }
10966
10967    #[test]
10968    fn json_escape_double_quote() {
10969        assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
10970    }
10971
10972    #[test]
10973    fn json_escape_both_special_chars() {
10974        assert_eq!(json_escape(r#"a\"b"#), r#"a\\\"b"#);
10975    }
10976
10977    #[test]
10978    fn json_escape_empty_string() {
10979        assert_eq!(json_escape(""), "");
10980    }
10981
10982    #[test]
10983    fn json_escape_only_backslashes() {
10984        assert_eq!(json_escape(r"\\"), r"\\\\");
10985    }
10986
10987    // ── coverage_pct_str ─────────────────────────────────────────────────────────
10988
10989    #[test]
10990    fn coverage_pct_str_zero_found_returns_empty() {
10991        assert_eq!(coverage_pct_str(0, 0), "");
10992    }
10993
10994    #[test]
10995    fn coverage_pct_str_full_coverage() {
10996        assert_eq!(coverage_pct_str(100, 100), "100.0");
10997    }
10998
10999    #[test]
11000    fn coverage_pct_str_half_coverage() {
11001        assert_eq!(coverage_pct_str(50, 100), "50.0");
11002    }
11003
11004    #[test]
11005    fn coverage_pct_str_one_decimal_precision() {
11006        let s = coverage_pct_str(7, 10);
11007        assert_eq!(s, "70.0");
11008    }
11009
11010    #[test]
11011    fn coverage_pct_str_zero_hit_but_found() {
11012        assert_eq!(coverage_pct_str(0, 10), "0.0");
11013    }
11014
11015    #[test]
11016    fn coverage_pct_str_non_round_percentage() {
11017        let s = coverage_pct_str(1, 3);
11018        assert!(!s.is_empty());
11019        assert!(s.contains('.'), "result must have decimal point");
11020    }
11021
11022    // ── coverage_class ───────────────────────────────────────────────────────────
11023
11024    #[test]
11025    fn coverage_class_zero_found_is_muted() {
11026        assert_eq!(coverage_class(0, 0), "muted");
11027    }
11028
11029    #[test]
11030    fn coverage_class_100_pct_is_good() {
11031        assert_eq!(coverage_class(100, 100), "good");
11032    }
11033
11034    #[test]
11035    fn coverage_class_80_pct_is_good() {
11036        assert_eq!(coverage_class(80, 100), "good");
11037    }
11038
11039    #[test]
11040    fn coverage_class_79_pct_is_warn() {
11041        assert_eq!(coverage_class(79, 100), "warn");
11042    }
11043
11044    #[test]
11045    fn coverage_class_60_pct_is_warn() {
11046        assert_eq!(coverage_class(60, 100), "warn");
11047    }
11048
11049    #[test]
11050    fn coverage_class_59_pct_is_danger() {
11051        assert_eq!(coverage_class(59, 100), "danger");
11052    }
11053
11054    #[test]
11055    fn coverage_class_zero_hit_is_danger() {
11056        assert_eq!(coverage_class(0, 100), "danger");
11057    }
11058
11059    // ── format_test_density ──────────────────────────────────────────────────────
11060
11061    #[test]
11062    fn format_test_density_zero_code_returns_zero() {
11063        assert_eq!(format_test_density(0, 5), "0.0");
11064    }
11065
11066    #[test]
11067    fn format_test_density_zero_tests_returns_zero() {
11068        assert_eq!(format_test_density(100, 0), "0.0");
11069    }
11070
11071    #[test]
11072    fn format_test_density_both_zero() {
11073        assert_eq!(format_test_density(0, 0), "0.0");
11074    }
11075
11076    #[test]
11077    fn format_test_density_1_test_per_1000_lines() {
11078        assert_eq!(format_test_density(1000, 1), "1.0");
11079    }
11080
11081    #[test]
11082    fn format_test_density_10_tests_per_100_lines() {
11083        assert_eq!(format_test_density(100, 10), "100.0");
11084    }
11085
11086    #[test]
11087    fn format_test_density_fractional() {
11088        let s = format_test_density(1000, 3);
11089        assert!(!s.is_empty());
11090        assert!(s.contains('.'));
11091    }
11092
11093    // ── html_esc ─────────────────────────────────────────────────────────────────
11094
11095    #[test]
11096    fn html_esc_no_special_chars() {
11097        assert_eq!(html_esc("hello"), "hello");
11098    }
11099
11100    #[test]
11101    fn html_esc_ampersand() {
11102        assert_eq!(html_esc("a&b"), "a&amp;b");
11103    }
11104
11105    #[test]
11106    fn html_esc_less_than() {
11107        assert_eq!(html_esc("a<b"), "a&lt;b");
11108    }
11109
11110    #[test]
11111    fn html_esc_greater_than() {
11112        assert_eq!(html_esc("a>b"), "a&gt;b");
11113    }
11114
11115    #[test]
11116    fn html_esc_double_quote() {
11117        assert_eq!(html_esc(r#"a"b"#), "a&quot;b");
11118    }
11119
11120    #[test]
11121    fn html_esc_all_special_chars() {
11122        assert_eq!(
11123            html_esc(r#"<a href="x&y">z</a>"#),
11124            "&lt;a href=&quot;x&amp;y&quot;&gt;z&lt;/a&gt;"
11125        );
11126    }
11127
11128    #[test]
11129    fn html_esc_empty_string() {
11130        assert_eq!(html_esc(""), "");
11131    }
11132
11133    // ── png_data_uri ─────────────────────────────────────────────────────────────
11134
11135    #[test]
11136    fn png_data_uri_has_correct_prefix() {
11137        let uri = png_data_uri(b"\x89PNG\r\n\x1a\n");
11138        assert!(uri.starts_with("data:image/png;base64,"));
11139    }
11140
11141    #[test]
11142    fn png_data_uri_non_empty_for_non_empty_input() {
11143        let uri = png_data_uri(b"fake-png-bytes");
11144        assert!(uri.len() > "data:image/png;base64,".len());
11145    }
11146
11147    // ── load_custom_logo ─────────────────────────────────────────────────────────
11148
11149    #[test]
11150    fn load_custom_logo_nonexistent_file_returns_none() {
11151        let result = load_custom_logo(std::path::Path::new("/nonexistent/__sloc_logo__.png"));
11152        assert!(result.is_none());
11153    }
11154
11155    #[test]
11156    fn load_custom_logo_png_file_returns_data_uri() {
11157        let dir = tempdir().unwrap();
11158        let path = dir.path().join("logo.png");
11159        std::fs::write(&path, b"\x89PNG\r\n\x1a\nfake-png-data").unwrap();
11160        let result = load_custom_logo(&path);
11161        assert!(result.is_some());
11162        let uri = result.unwrap();
11163        assert!(uri.starts_with("data:image/png;base64,"));
11164    }
11165
11166    #[test]
11167    fn load_custom_logo_svg_file_uses_svg_mime() {
11168        let dir = tempdir().unwrap();
11169        let path = dir.path().join("logo.svg");
11170        std::fs::write(&path, b"<svg></svg>").unwrap();
11171        let result = load_custom_logo(&path);
11172        assert!(result.is_some());
11173        let uri = result.unwrap();
11174        assert!(uri.starts_with("data:image/svg+xml;base64,"));
11175    }
11176
11177    #[test]
11178    fn load_custom_logo_unknown_extension_treated_as_png() {
11179        let dir = tempdir().unwrap();
11180        let path = dir.path().join("logo.bin");
11181        std::fs::write(&path, b"some-bytes").unwrap();
11182        let result = load_custom_logo(&path);
11183        assert!(result.is_some());
11184        let uri = result.unwrap();
11185        assert!(uri.starts_with("data:image/png;base64,"));
11186    }
11187}
11188
11189#[cfg(test)]
11190mod coverage_boost_report_tests {
11191    use super::*;
11192    use std::path::Path;
11193
11194    // ── derive_commit_url / derive_branch_url ────────────────────────────────
11195
11196    #[test]
11197    fn derive_commit_url_github_uses_commit_segment() {
11198        let url = derive_commit_url(
11199            "https://github.com/org/repo.git",
11200            "abc1234abc1234abc1234abc1234abc1234abc1234",
11201        );
11202        assert_eq!(
11203            url.as_deref(),
11204            Some("https://github.com/org/repo/commit/abc1234abc1234abc1234abc1234abc1234abc1234")
11205        );
11206    }
11207
11208    #[test]
11209    fn derive_commit_url_bitbucket_uses_commits_plural() {
11210        let url = derive_commit_url("https://bitbucket.org/org/repo.git", "deadbeef");
11211        assert_eq!(
11212            url.as_deref(),
11213            Some("https://bitbucket.org/org/repo/commits/deadbeef")
11214        );
11215    }
11216
11217    #[test]
11218    fn derive_commit_url_gitlab_uses_dash_commit() {
11219        let url = derive_commit_url("https://gitlab.example.com/org/repo.git", "cafe0000");
11220        assert_eq!(
11221            url.as_deref(),
11222            Some("https://gitlab.example.com/org/repo/-/commit/cafe0000")
11223        );
11224    }
11225
11226    #[test]
11227    fn derive_branch_url_github_uses_tree() {
11228        let url = derive_branch_url("https://github.com/org/repo.git", "main");
11229        assert_eq!(
11230            url.as_deref(),
11231            Some("https://github.com/org/repo/tree/main")
11232        );
11233    }
11234
11235    #[test]
11236    fn derive_branch_url_bitbucket_uses_branch_segment() {
11237        let url = derive_branch_url("https://bitbucket.org/org/repo.git", "develop");
11238        assert_eq!(
11239            url.as_deref(),
11240            Some("https://bitbucket.org/org/repo/branch/develop")
11241        );
11242    }
11243
11244    #[test]
11245    fn derive_branch_url_gitlab_uses_dash_tree() {
11246        let url = derive_branch_url("https://gitlab.mycompany.com/org/repo.git", "feature");
11247        assert_eq!(
11248            url.as_deref(),
11249            Some("https://gitlab.mycompany.com/org/repo/-/tree/feature")
11250        );
11251    }
11252
11253    #[test]
11254    fn derive_commit_url_invalid_url_returns_none() {
11255        let url = derive_commit_url("not-a-url", "abc123");
11256        assert!(url.is_none());
11257    }
11258
11259    #[test]
11260    fn normalize_remote_url_variants() {
11261        assert_eq!(
11262            normalize_remote_url("git@github.com:org/repo.git").as_deref(),
11263            Some("https://github.com/org/repo")
11264        );
11265        assert_eq!(
11266            normalize_remote_url("https://gitlab.com/a/b.git").as_deref(),
11267            Some("https://gitlab.com/a/b")
11268        );
11269        assert_eq!(
11270            normalize_remote_url("http://host/x").as_deref(),
11271            Some("http://host/x")
11272        );
11273        assert_eq!(normalize_remote_url("not a url"), None);
11274    }
11275
11276    #[test]
11277    fn classify_and_bucket_helpers() {
11278        assert_eq!(
11279            classify_unsupported_path("README.md"),
11280            "Documentation / text"
11281        );
11282        assert_eq!(
11283            classify_unsupported_path("pkg.json"),
11284            "JSON manifests and config"
11285        );
11286        assert_eq!(
11287            classify_unsupported_path("Cargo.toml"),
11288            "Project metadata and packaging"
11289        );
11290        assert_eq!(classify_unsupported_path("page.html"), "HTML templates");
11291        assert_eq!(classify_unsupported_path("notes.txt"), "Plain text assets");
11292        assert_eq!(
11293            classify_unsupported_path("data.xyz"),
11294            "Other unsupported text formats"
11295        );
11296        assert_eq!(
11297            classify_unsupported_path("Makefile_noext"),
11298            "Extensionless or custom text files"
11299        );
11300        // bucket_description + bucket_recommendation for each known label.
11301        for label in [
11302            "Documentation / text",
11303            "JSON manifests and config",
11304            "Project metadata and packaging",
11305            "HTML templates",
11306            "Plain text assets",
11307            "Extensionless or custom text files",
11308            "Unknown bucket",
11309        ] {
11310            assert!(!bucket_description(label).is_empty());
11311            assert!(!bucket_recommendation(label).is_empty());
11312        }
11313    }
11314
11315    #[test]
11316    fn summarize_warnings_groups_categories() {
11317        let warnings = vec![
11318            "file 'a.md': unsupported or undetected language".to_string(),
11319            "file 'b.bin': binary file skipped by default".to_string(),
11320            "file 'c.min.js': minified file skipped by policy".to_string(),
11321            "file 'big.txt': file exceeded max_file_size_bytes".to_string(),
11322        ];
11323        let rows = summarize_warnings(&warnings);
11324        assert!(!rows.is_empty(), "warnings should summarize into buckets");
11325    }
11326
11327    #[test]
11328    fn pdf_number_and_string_formatters() {
11329        assert_eq!(pdf_fmt_full(0), "0");
11330        assert!(pdf_fmt_full(1_234_567).contains('1'));
11331        // pdf_safe_str must not panic on non-ASCII / control chars.
11332        let s = pdf_safe_str("héllo\tworld\u{1F600}");
11333        assert!(!s.is_empty());
11334    }
11335
11336    #[test]
11337    fn file_url_produces_uri() {
11338        let url = file_url(Path::new("/tmp/report.html"));
11339        assert!(url.starts_with("file://") || url.contains("report.html"));
11340    }
11341
11342    #[test]
11343    fn browser_discovery_is_callable_without_panicking() {
11344        // With no SLOC_BROWSER set, discovery walks the candidate list and
11345        // returns None (no browser in the test sandbox) — exercising the loop.
11346        // FIXME: Audit that the environment access only happens in single-threaded code.
11347        unsafe { std::env::remove_var("SLOC_BROWSER") };
11348        // FIXME: Audit that the environment access only happens in single-threaded code.
11349        unsafe { std::env::remove_var("BROWSER") };
11350        let _ = discover_browser();
11351        let _ = discover_browser_from_env();
11352        #[cfg(windows)]
11353        let _ = windows_browser_candidates();
11354        #[cfg(not(windows))]
11355        let _ = linux_browser_candidates();
11356        // With a bogus SLOC_BROWSER, normalize_browser_env_path is exercised.
11357        // FIXME: Audit that the environment access only happens in single-threaded code.
11358        unsafe { std::env::set_var("SLOC_BROWSER", "/no/such/browser/path") };
11359        let _ = discover_browser_from_env();
11360        let p = normalize_browser_env_path("\"/quoted/path/chrome\"");
11361        assert!(p.to_string_lossy().contains("chrome"));
11362        // FIXME: Audit that the environment access only happens in single-threaded code.
11363        unsafe { std::env::remove_var("SLOC_BROWSER") };
11364    }
11365
11366    #[test]
11367    fn which_in_path_returns_none_for_missing() {
11368        assert!(which_in_path("definitely-not-a-real-exe-xyz123").is_none());
11369    }
11370
11371    #[test]
11372    fn write_pdf_from_html_without_browser_errors_gracefully() {
11373        // FIXME: Audit that the environment access only happens in single-threaded code.
11374        unsafe { std::env::remove_var("SLOC_BROWSER") };
11375        // FIXME: Audit that the environment access only happens in single-threaded code.
11376        unsafe { std::env::remove_var("BROWSER") };
11377        let dir = std::env::temp_dir().join("sloc_report_pdf_test");
11378        let _ = std::fs::create_dir_all(&dir);
11379        let html = dir.join("in.html");
11380        std::fs::write(&html, "<html><body>hi</body></html>").unwrap();
11381        let out = dir.join("out.pdf");
11382        // No browser present → Err, but exercises discovery + early validation.
11383        let res = write_pdf_from_html(&html, &out);
11384        // Either a real browser exists (Ok) or not (Err); both are acceptable.
11385        let _ = res;
11386        let _ = std::fs::remove_dir_all(&dir);
11387    }
11388
11389    // ── helvetica_advance ────────────────────────────────────────────────────────
11390
11391    #[test]
11392    fn helvetica_advance_uppercase_a_differs_by_weight() {
11393        assert_eq!(helvetica_advance('A', true), 722);
11394        assert_eq!(helvetica_advance('A', false), 667);
11395    }
11396
11397    #[test]
11398    fn helvetica_advance_uppercase_w_same_both_weights() {
11399        assert_eq!(helvetica_advance('W', true), 944);
11400        assert_eq!(helvetica_advance('W', false), 944);
11401    }
11402
11403    #[test]
11404    fn helvetica_advance_lowercase_i_differs_by_weight() {
11405        assert_eq!(helvetica_advance('i', true), 278);
11406        assert_eq!(helvetica_advance('i', false), 222);
11407    }
11408
11409    #[test]
11410    fn helvetica_advance_digits_are_556_both_weights() {
11411        for d in '0'..='9' {
11412            assert_eq!(helvetica_advance(d, true), 556, "bold digit {d}");
11413            assert_eq!(helvetica_advance(d, false), 556, "regular digit {d}");
11414        }
11415    }
11416
11417    #[test]
11418    fn helvetica_advance_middle_dot_is_278() {
11419        assert_eq!(helvetica_advance('\u{00B7}', true), 278);
11420        assert_eq!(helvetica_advance('\u{00B7}', false), 278);
11421    }
11422
11423    #[test]
11424    fn helvetica_advance_unknown_char_returns_nonzero_fallback() {
11425        let bold_fb = helvetica_advance('\u{1F600}', true);
11426        let reg_fb = helvetica_advance('\u{1F600}', false);
11427        assert_eq!(bold_fb, 556);
11428        assert_eq!(reg_fb, 500);
11429    }
11430
11431    // ── helvetica_width_mm ───────────────────────────────────────────────────────
11432
11433    #[test]
11434    fn helvetica_width_mm_empty_is_zero() {
11435        assert!(helvetica_width_mm("", 10.0, false).abs() < f32::EPSILON);
11436        assert!(helvetica_width_mm("", 10.0, true).abs() < f32::EPSILON);
11437    }
11438
11439    #[test]
11440    fn helvetica_width_mm_scales_linearly_with_pt_size() {
11441        let w6 = helvetica_width_mm("Hello", 6.0, false);
11442        let w12 = helvetica_width_mm("Hello", 12.0, false);
11443        assert!(
11444            2.0_f32.mul_add(-w6, w12).abs() < 1e-4,
11445            "width must be proportional to pt size"
11446        );
11447    }
11448
11449    #[test]
11450    fn helvetica_width_mm_bold_a_wider_than_regular_a() {
11451        let bold = helvetica_width_mm("A", 10.0, true);
11452        let reg = helvetica_width_mm("A", 10.0, false);
11453        assert!(
11454            bold > reg,
11455            "bold 'A' (722) must be wider than regular 'A' (667)"
11456        );
11457    }
11458
11459    #[test]
11460    fn helvetica_width_mm_single_char_matches_manual_calculation() {
11461        // 'A' regular advance = 667; width_mm = 667 * 10.0 * (25.4/72.0) / 1000.0
11462        let expected = 667.0_f32 * 10.0 * (25.4 / 72.0) / 1000.0;
11463        let got = helvetica_width_mm("A", 10.0, false);
11464        assert!((got - expected).abs() < 1e-4);
11465    }
11466}