Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33    collections::{HashMap, VecDeque},
34    fmt::Write,
35    fs,
36    net::{IpAddr, SocketAddr},
37    path::{Path, PathBuf},
38    process::Stdio,
39    sync::{Arc, OnceLock},
40    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46    body::Body,
47    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48    http::{header, HeaderValue, Request, StatusCode},
49    middleware::{self, Next},
50    response::{Html, IntoResponse, Response},
51    routing::{get, post},
52    Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{
59    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
60    MixedLinePolicy,
61};
62use sloc_git::ScheduleStore;
63
64#[derive(Clone)]
65pub(crate) struct CspNonce(pub(crate) String);
66
67static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
68static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
69
70use sloc_core::{
71    analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
72    CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
73    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
74};
75use sloc_report::{
76    render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
77    write_pdf_from_run, ReportDeltaContext,
78};
79const MAX_CONCURRENT_ANALYSES: usize = 4;
80
81/// Windows-only helpers that force the native file-picker dialog into the
82/// foreground instead of appearing minimised behind other windows.
83///
84/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
85/// foreground thread so that windows created on our thread inherit focus; and
86/// (b) spin a polling watcher that finds the dialog by title and calls
87/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
88#[cfg(target_os = "windows")]
89#[allow(clippy::upper_case_acronyms)]
90#[allow(dead_code)]
91mod win_dialog_focus {
92    #[cfg(feature = "native-dialog")]
93    use std::mem::size_of;
94
95    type HWND = *mut core::ffi::c_void;
96    type DWORD = u32;
97    type UINT = u32;
98    type BOOL = i32;
99
100    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
101    #[cfg(feature = "native-dialog")]
102    #[repr(C)]
103    #[allow(non_snake_case)]
104    struct FLASHWINFO {
105        cbSize: UINT,
106        hwnd: HWND,
107        dwFlags: DWORD,
108        uCount: UINT,
109        dwTimeout: DWORD,
110    }
111
112    #[cfg(feature = "native-dialog")]
113    const FLASHW_ALL: DWORD = 0x3;
114    #[cfg(feature = "native-dialog")]
115    const FLASHW_TIMERNOFG: DWORD = 0xC;
116
117    #[link(name = "user32")]
118    extern "system" {
119        fn GetForegroundWindow() -> HWND;
120        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
121        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
122        fn BringWindowToTop(hWnd: HWND) -> BOOL;
123        fn SetWindowPos(
124            hWnd: HWND,
125            hWndAfter: HWND,
126            x: i32,
127            y: i32,
128            cx: i32,
129            cy: i32,
130            flags: UINT,
131        ) -> BOOL;
132        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
133        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
134        #[cfg(feature = "native-dialog")]
135        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
136        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
137        fn FindWindowExW(
138            hWndParent: HWND,
139            hWndChildAfter: HWND,
140            lpszClass: *const u16,
141            lpszWindow: *const u16,
142        ) -> HWND;
143        // Undocumented but present on all Windows versions since XP; bypasses
144        // the foreground-lock that blocks SetForegroundWindow from non-foreground
145        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
146        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
147    }
148
149    #[link(name = "kernel32")]
150    extern "system" {
151        #[cfg(feature = "native-dialog")]
152        fn GetCurrentThreadId() -> DWORD;
153    }
154
155    #[link(name = "shell32")]
156    extern "system" {
157        // Opens a folder (or file) via the Windows shell.  Passing the current
158        // foreground window as `hwnd` gives the new window proper activation
159        // context so it surfaces in the foreground without needing
160        // AttachThreadInput or SetForegroundWindow hacks.
161        fn ShellExecuteW(
162            hwnd: HWND,
163            lpOperation: *const u16,
164            lpFile: *const u16,
165            lpParameters: *const u16,
166            lpDirectory: *const u16,
167            nShowCmd: i32,
168        ) -> isize; // HINSTANCE (>32 = success)
169    }
170
171    /// Attaches our thread's input to the foreground window's thread so that
172    /// windows created on our thread inherit foreground focus.  Returns the
173    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
174    /// the thread was already the foreground thread.
175    #[cfg(feature = "native-dialog")]
176    pub fn attach_to_foreground() -> DWORD {
177        unsafe {
178            let fg_hwnd = GetForegroundWindow();
179            if fg_hwnd.is_null() {
180                return 0;
181            }
182            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
183            let my_tid = GetCurrentThreadId();
184            if fg_tid == my_tid {
185                return 0;
186            }
187            AttachThreadInput(my_tid, fg_tid, 1);
188            fg_tid
189        }
190    }
191
192    /// Undoes `attach_to_foreground`.
193    #[cfg(feature = "native-dialog")]
194    pub fn detach_from_foreground(fg_tid: DWORD) {
195        if fg_tid == 0 {
196            return;
197        }
198        unsafe {
199            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
200        }
201    }
202
203    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
204        let mut existing = std::collections::HashSet::new();
205        let mut prev: HWND = core::ptr::null_mut();
206        loop {
207            let w = FindWindowExW(
208                core::ptr::null_mut(),
209                prev,
210                class_w.as_ptr(),
211                core::ptr::null(),
212            );
213            if w.is_null() {
214                break;
215            }
216            existing.insert(w as usize);
217            prev = w;
218        }
219        existing
220    }
221
222    unsafe fn find_new_explorer_hwnd(
223        class_w: &[u16],
224        existing: &std::collections::HashSet<usize>,
225    ) -> Option<HWND> {
226        let mut prev: HWND = core::ptr::null_mut();
227        loop {
228            let w = FindWindowExW(
229                core::ptr::null_mut(),
230                prev,
231                class_w.as_ptr(),
232                core::ptr::null(),
233            );
234            if w.is_null() {
235                return None;
236            }
237            if !existing.contains(&(w as usize)) {
238                return Some(w);
239            }
240            prev = w;
241        }
242    }
243
244    unsafe fn bring_to_front(hwnd: HWND) {
245        // SW_RESTORE = 9 — same sequence as flash_dialog_when_ready.
246        // SwitchToThisWindow bypasses foreground-lock so the window surfaces
247        // regardless of which process currently has focus.
248        ShowWindow(hwnd, 9);
249        SwitchToThisWindow(hwnd, 1);
250        SetForegroundWindow(hwnd);
251        BringWindowToTop(hwnd);
252    }
253
254    /// Opens `path` in Windows Explorer and forces it to the foreground.
255    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
256    /// caller is not the foreground process (the browser is).  After launching,
257    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
258    /// an undocumented API that bypasses Windows' foreground-lock restriction
259    /// so the window surfaces regardless of which process currently has focus.
260    pub fn open_folder_foreground(path: std::path::PathBuf) {
261        std::thread::spawn(move || {
262            use std::os::windows::ffi::OsStrExt;
263
264            let op: Vec<u16> = "explore\0".encode_utf16().collect();
265            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
266            path_w.push(0);
267            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
268
269            unsafe {
270                // Snapshot every existing Explorer window before we launch so
271                // we can identify the newly created one.
272                let existing = snapshot_explorer_hwnds(&class_w);
273                let fg_hwnd = GetForegroundWindow();
274                // SW_SHOWNORMAL = 1
275                ShellExecuteW(
276                    fg_hwnd,
277                    op.as_ptr(),
278                    path_w.as_ptr(),
279                    core::ptr::null(),
280                    core::ptr::null(),
281                    1,
282                );
283
284                // Poll up to ~3 s for a new CabinetWClass window to appear,
285                // then use SwitchToThisWindow (bypasses foreground-lock) to
286                // bring it in front of the browser and everything else.
287                for _ in 0..40 {
288                    std::thread::sleep(std::time::Duration::from_millis(75));
289                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
290                        bring_to_front(w);
291                        return;
292                    }
293                }
294
295                // Fallback: Explorer reused an existing window — bring whichever
296                // CabinetWClass window is first in Z-order to the front.
297                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
298                if !w.is_null() {
299                    bring_to_front(w);
300                }
301            }
302        });
303    }
304
305    /// Spawns a short-lived watcher thread that polls for a dialog window
306    /// matching `title` and, once found, forces it to the foreground and
307    /// flashes its taskbar button until the user interacts with it.
308    #[cfg(feature = "native-dialog")]
309    pub fn flash_dialog_when_ready(title: String) {
310        std::thread::spawn(move || {
311            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
312            for _ in 0..40 {
313                std::thread::sleep(std::time::Duration::from_millis(80));
314                unsafe {
315                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
316                    if !hwnd.is_null() {
317                        SetForegroundWindow(hwnd);
318                        BringWindowToTop(hwnd);
319                        #[allow(non_snake_case)]
320                        FlashWindowEx(&FLASHWINFO {
321                            // size_of returns usize; Win32 struct field is u32 (UINT).
322                            // struct size fits trivially within u32.
323                            #[allow(clippy::cast_possible_truncation)]
324                            cbSize: size_of::<FLASHWINFO>() as UINT,
325                            hwnd,
326                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
327                            uCount: 3,
328                            dwTimeout: 0,
329                        });
330                        break;
331                    }
332                }
333            }
334        });
335    }
336}
337
338/// Sliding-window rate limiter keyed by client IP.
339/// Uses only std primitives — no external crate required.
340pub(crate) struct IpRateLimiter {
341    window: Duration,
342    max_requests: usize,
343    pub(crate) auth_lockout_threshold: u32,
344    auth_lockout_window: Duration,
345    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
346    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
347}
348
349impl IpRateLimiter {
350    pub(crate) fn new(
351        window: Duration,
352        max_requests: usize,
353        auth_lockout_threshold: u32,
354        auth_lockout_window: Duration,
355    ) -> Self {
356        Self {
357            window,
358            max_requests,
359            auth_lockout_threshold,
360            auth_lockout_window,
361            state: std::sync::Mutex::new(HashMap::new()),
362            auth_failures: std::sync::Mutex::new(HashMap::new()),
363        }
364    }
365
366    // The MutexGuard `state` must live as long as `bucket` borrows from it,
367    // so it cannot be dropped any earlier than the end of the inner block.
368    #[allow(clippy::significant_drop_tightening)]
369    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
370        let now = Instant::now();
371        let cutoff = now.checked_sub(self.window).unwrap_or(now);
372        let mut state = self
373            .state
374            .lock()
375            .unwrap_or_else(std::sync::PoisonError::into_inner);
376        if state.len() > 10_000 {
377            state.retain(|_, bucket| {
378                while bucket.front().is_some_and(|t| *t <= cutoff) {
379                    bucket.pop_front();
380                }
381                !bucket.is_empty()
382            });
383        }
384        let bucket = state.entry(ip).or_default();
385        while bucket.front().is_some_and(|t| *t <= cutoff) {
386            bucket.pop_front();
387        }
388        if bucket.len() >= self.max_requests {
389            false
390        } else {
391            bucket.push_back(now);
392            true
393        }
394    }
395
396    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
397        let now = Instant::now();
398        let mut map = self
399            .auth_failures
400            .lock()
401            .unwrap_or_else(std::sync::PoisonError::into_inner);
402        map.entry(ip)
403            .and_modify(|e| {
404                e.0 += 1;
405                e.1 = now;
406            })
407            .or_insert_with(|| (1, now));
408    }
409
410    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
411        let mut map = self
412            .auth_failures
413            .lock()
414            .unwrap_or_else(std::sync::PoisonError::into_inner);
415        let expired = map
416            .get(&ip)
417            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
418        if expired {
419            map.remove(&ip);
420            return false;
421        }
422        map.get(&ip)
423            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
424    }
425
426    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
427        let map = self
428            .auth_failures
429            .lock()
430            .unwrap_or_else(std::sync::PoisonError::into_inner);
431        map.get(&ip).map_or(0, |e| {
432            self.auth_lockout_window
433                .checked_sub(e.1.elapsed())
434                .map_or(0, |r| r.as_secs())
435        })
436    }
437
438    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
439        tokio::spawn(async move {
440            let mut interval = tokio::time::interval(Duration::from_mins(1));
441            interval.tick().await; // consume the immediate first tick
442            loop {
443                interval.tick().await;
444                let now = Instant::now();
445                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
446                {
447                    let mut state = limiter
448                        .state
449                        .lock()
450                        .unwrap_or_else(std::sync::PoisonError::into_inner);
451                    state.retain(|_, bucket| {
452                        while bucket.front().is_some_and(|t| *t <= cutoff) {
453                            bucket.pop_front();
454                        }
455                        !bucket.is_empty()
456                    });
457                }
458                {
459                    let mut auth = limiter
460                        .auth_failures
461                        .lock()
462                        .unwrap_or_else(std::sync::PoisonError::into_inner);
463                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
464                }
465            }
466        });
467    }
468}
469
470/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
471/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
472/// files but never triggers a scan.
473fn spawn_upload_staging_cleanup() {
474    tokio::spawn(async move {
475        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
476            .ok()
477            .and_then(|v| v.parse().ok())
478            .unwrap_or(4);
479        let ttl_secs = ttl_hours * 3600;
480        let mut interval = tokio::time::interval(Duration::from_hours(1));
481        interval.tick().await; // consume the immediate first tick
482        loop {
483            interval.tick().await;
484            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
485            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
486                continue;
487            };
488            while let Ok(Some(entry)) = dir.next_entry().await {
489                let path = entry.path();
490                let age_secs = tokio::fs::metadata(&path)
491                    .await
492                    .ok()
493                    .and_then(|m| m.modified().ok())
494                    .and_then(|t| t.elapsed().ok())
495                    .map_or(0, |d| d.as_secs());
496                if age_secs > ttl_secs {
497                    tracing::debug!(
498                        event = "upload_staging_cleanup",
499                        path = %path.display(),
500                        age_secs,
501                        "removing stale upload staging directory"
502                    );
503                    let _ = tokio::fs::remove_dir_all(&path).await;
504                }
505            }
506        }
507    });
508}
509
510/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
511#[derive(Clone, Debug, Default)]
512struct RunResultContext {
513    prev_entry: Option<RegistryEntry>,
514    prev_scan_count: usize,
515    project_path: String,
516    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
517    cocomo_mode: String,
518    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
519    complexity_alert: u32,
520    /// Whether duplicate files should be excluded from displayed SLOC totals.
521    #[allow(dead_code)]
522    exclude_duplicates: bool,
523}
524
525/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
526#[derive(Clone)]
527enum AsyncRunState {
528    Running {
529        started_at: std::time::Instant,
530        cancel_token: Arc<std::sync::atomic::AtomicBool>,
531        phase: Arc<std::sync::Mutex<String>>,
532        files_done: Arc<std::sync::atomic::AtomicUsize>,
533        files_total: Arc<std::sync::atomic::AtomicUsize>,
534    },
535    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
536    Complete {
537        run_id: String,
538    },
539    Failed {
540        message: String,
541    },
542    Cancelled,
543}
544
545/// A saved scan configuration profile — stores the form parameters so users can
546/// re-run a favourite scan with one click.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548struct ScanProfile {
549    id: String,
550    name: String,
551    created_at: String,
552    /// The raw scan-form parameters serialized as JSON.
553    params: serde_json::Value,
554}
555
556#[derive(Debug, Clone, Default, Serialize, Deserialize)]
557struct ScanProfileStore {
558    profiles: Vec<ScanProfile>,
559}
560
561impl ScanProfileStore {
562    fn load(path: &std::path::Path) -> Self {
563        fs::read_to_string(path)
564            .ok()
565            .and_then(|s| serde_json::from_str(&s).ok())
566            .unwrap_or_default()
567    }
568
569    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
570        if let Some(parent) = path.parent() {
571            fs::create_dir_all(parent)?;
572        }
573        let json = serde_json::to_string_pretty(self)?;
574        fs::write(path, json)?;
575        Ok(())
576    }
577}
578
579#[derive(Clone)]
580pub(crate) struct AppState {
581    pub(crate) base_config: AppConfig,
582    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
583    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
584    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
585    pub(crate) registry_path: PathBuf,
586    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
587    pub(crate) server_mode: bool,
588    pub(crate) tls_enabled: bool,
589    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
590    pub(crate) rate_limiter: Arc<IpRateLimiter>,
591    pub(crate) trust_proxy: bool,
592    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
593    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
594    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
595    /// Directory where remote repositories are cloned for git-browser scans.
596    pub(crate) git_clones_dir: PathBuf,
597    /// Persisted list of webhook / poll schedules.
598    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
599    pub(crate) schedules_path: PathBuf,
600    /// Named scan profiles saved by the user via the web UI.
601    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
602    pub(crate) scan_profiles_path: PathBuf,
603    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
604    /// Persisted Confluence integration settings.
605    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
606    pub(crate) confluence_path: PathBuf,
607    /// Directories the user has pinned for auto-scanning of external reports.
608    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
609    pub(crate) watched_dirs_path: PathBuf,
610    /// Persisted auto-cleanup policy (age/count limits + interval).
611    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
612    pub(crate) cleanup_policy_path: PathBuf,
613    /// Handle for the running cleanup background task; replaced on policy change.
614    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
615}
616
617type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
618
619/// Parameters for the fire-and-forget HTML + PDF background task.
620
621#[derive(Clone, Debug)]
622pub(crate) struct RunArtifacts {
623    output_dir: PathBuf,
624    html_path: Option<PathBuf>,
625    pdf_path: Option<PathBuf>,
626    json_path: Option<PathBuf>,
627    csv_path: Option<PathBuf>,
628    xlsx_path: Option<PathBuf>,
629    scan_config_path: Option<PathBuf>,
630    report_title: String,
631    result_context: RunResultContext,
632}
633
634#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
635fn build_router(state: AppState) -> Router {
636    let protected = Router::new()
637        .route("/", get(splash))
638        .route("/scan-setup", get(scan_setup_handler))
639        .route("/scan", get(index))
640        .route("/analyze", post(analyze_handler))
641        .route("/preview", get(preview_handler))
642        .route("/api/suggest-coverage", get(api_suggest_coverage))
643        .route("/pick-directory", get(pick_directory_handler))
644        .route("/open-path", get(open_path_handler))
645        .route("/pick-file", get(pick_file_handler))
646        .route(
647            "/api/upload-directory",
648            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
649        )
650        .route(
651            "/api/upload-file",
652            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
653        )
654        .route(
655            "/api/upload-tarball",
656            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
657            // The handler also enforces this limit during streaming so both layers agree.
658            post(upload_tarball_handler)
659                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
660        )
661        .route("/locate-report", post(locate_report_handler))
662        .route("/locate-reports-dir", post(locate_reports_dir_handler))
663        .route("/relocate-scan", post(relocate_scan_handler))
664        .route("/watched-dirs/add", post(add_watched_dir_handler))
665        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
666        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
667        .route("/view-reports", get(history_handler))
668        .route("/compare-scans", get(compare_select_handler))
669        .route("/compare", get(compare_handler))
670        .route("/multi-compare", get(multi_compare_handler))
671        .route("/images/{folder}/{file}", get(image_handler))
672        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
673        .route("/api/metrics/latest", get(api_metrics_latest_handler))
674        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
675        .route("/api/metrics/history", get(api_metrics_history_handler))
676        .route("/api/metrics/churn", get(api_metrics_churn_handler))
677        .route(
678            "/api/metrics/submodules",
679            get(api_metrics_submodules_handler),
680        )
681        .route("/api/ingest", post(api_ingest_handler))
682        .route("/api/project-history", get(project_history_handler))
683        .route("/trend-reports", get(trend_report_handler))
684        .route("/test-metrics", get(test_metrics_handler))
685        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
686        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
687        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
688        .route("/runs/result/{run_id}", get(async_run_result_handler))
689        .route("/embed/summary", get(embed_handler))
690        // ── Git browser ────────────────────────────────────────────────────────
691        .route("/git-browser", get(git_browser::git_browser_handler))
692        .route("/api/git/refs", get(git_browser::api_list_refs))
693        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
694        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
695        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
696        // The request body is the full rendered HTML report, whose size scales
697        // with file count — large repos (Compare Scans, Files, Trend, Test
698        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
699        .route(
700            "/export/pdf",
701            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
702        )
703        // ── Config export / import ─────────────────────────────────────────────
704        .route("/export-config", get(export_config_handler))
705        .route("/import-config", post(import_config_handler))
706        // ── Scan profiles ──────────────────────────────────────────────────────
707        .route("/api/scan-profiles", get(api_list_scan_profiles))
708        .route("/api/scan-profiles", post(api_save_scan_profile))
709        .route(
710            "/api/scan-profiles/{id}",
711            axum::routing::delete(api_delete_scan_profile),
712        )
713        // ── Integrations (webhooks + Confluence) ──────────────────────────────
714        .route("/integrations", get(integrations::integrations_handler))
715        .route(
716            "/webhook-setup",
717            get(|| async { axum::response::Redirect::permanent("/integrations") }),
718        )
719        .route(
720            "/confluence-setup",
721            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
722        )
723        .route("/api/schedules", get(git_webhook::api_list_schedules))
724        .route("/api/schedules", post(git_webhook::api_create_schedule))
725        .route(
726            "/api/schedules",
727            axum::routing::delete(git_webhook::api_delete_schedule),
728        )
729        .route(
730            "/api/confluence/config",
731            get(confluence::api_get_confluence_config),
732        )
733        .route(
734            "/api/confluence/config",
735            post(confluence::api_save_confluence_config),
736        )
737        .route(
738            "/api/confluence/test",
739            post(confluence::api_test_confluence),
740        )
741        .route(
742            "/api/confluence/post",
743            post(confluence::api_post_to_confluence),
744        )
745        .route(
746            "/api/confluence/wiki-markup",
747            get(confluence::api_wiki_markup),
748        )
749        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
750        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
751        .route(
752            "/api/runs/{run_id}",
753            axum::routing::delete(delete_run_handler),
754        )
755        .route("/api/runs/cleanup", post(cleanup_runs_handler))
756        // ── Auto-cleanup policy ────────────────────────────────────────────────
757        .route(
758            "/api/cleanup-policy",
759            get(api_get_cleanup_policy)
760                .post(api_save_cleanup_policy)
761                .delete(api_delete_cleanup_policy),
762        )
763        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
764        // ── REST API reference page ────────────────────────────────────────────
765        .route("/api-docs", get(api_docs_handler))
766        // ── Prometheus metrics — behind API-key auth ───────────────────────────
767        .route("/metrics", get(metrics_handler))
768        .route_layer(middleware::from_fn_with_state(
769            state.clone(),
770            auth::require_api_key,
771        ));
772
773    protected
774        .route("/healthz", get(healthz))
775        .route("/api/health", get(healthz))
776        .route("/api/version", get(api_version_handler))
777        .route("/api/openapi.yaml", get(openapi_yaml_handler))
778        .route("/llms.txt", get(llms_txt_handler))
779        .route("/llms-full.txt", get(llms_full_txt_handler))
780        .route("/badge/{metric}", get(badge_handler))
781        .route("/static/chart.js", get(chart_js_handler))
782        .route("/static/chart-report.js", get(report_chart_js_handler))
783        .route("/auth/login", get(auth::auth_login_get))
784        .route("/auth/login", post(auth::auth_login_post))
785        .route("/auth/logout", post(auth::auth_logout))
786        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
787        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
788        .route(
789            "/webhooks/github",
790            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
791        )
792        .route(
793            "/webhooks/gitlab",
794            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
795        )
796        .route(
797            "/webhooks/bitbucket",
798            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
799        )
800        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
801        .layer(middleware::from_fn(csrf_protect))
802        .layer(middleware::from_fn_with_state(
803            state.clone(),
804            add_security_headers,
805        ))
806        .layer(build_cors_layer(state.server_mode))
807        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
808        .with_state(state)
809}
810
811/// Bearer token used by `make_test_router_server_mode()` test routers.
812/// Tests that exercise server-mode paths must include this key in their requests.
813pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
814
815/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
816/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
817/// builders below start from this and override only the fields they care about.
818///
819/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
820fn test_app_state(tmp_subdir: &str) -> AppState {
821    std::env::set_var("SLOC_HEADLESS", "1");
822    let tmp = std::env::temp_dir().join(tmp_subdir);
823    AppState {
824        base_config: AppConfig::default(),
825        artifacts: Arc::new(Mutex::new(HashMap::new())),
826        async_runs: Arc::new(Mutex::new(HashMap::new())),
827        registry: Arc::new(Mutex::new(ScanRegistry::default())),
828        registry_path: tmp.join("registry.json"),
829        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
830        server_mode: false,
831        tls_enabled: false,
832        api_keys: Arc::new(vec![]),
833        rate_limiter: Arc::new(IpRateLimiter::new(
834            Duration::from_mins(1),
835            600,
836            10,
837            Duration::from_hours(1),
838        )),
839        trust_proxy: false,
840        trusted_proxy_ips: vec![],
841        git_clones_dir: tmp.join("git-clones"),
842        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
843        schedules_path: tmp.join("schedules.json"),
844        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
845        scan_profiles_path: tmp.join("scan_profiles.json"),
846        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
847        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
848        confluence_path: tmp.join("confluence_config.json"),
849        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
850        watched_dirs_path: tmp.join("watched_dirs.json"),
851        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
852        cleanup_policy_path: tmp.join("cleanup_policy.json"),
853        cleanup_task_handle: Arc::new(Mutex::new(None)),
854    }
855}
856
857/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
858pub fn make_test_router() -> Router {
859    build_router(test_app_state("sloc_test"))
860}
861
862/// Test router with one API key pre-loaded. Used by auth integration tests.
863pub fn make_test_router_with_key(api_key: &str) -> Router {
864    let mut state = test_app_state("sloc_test_key");
865    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
866    build_router(state)
867}
868
869/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
870/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
871/// preview restrictions.
872pub fn make_test_router_server_mode() -> Router {
873    let mut state = test_app_state("sloc_test_server");
874    state.server_mode = true;
875    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
876        TEST_SERVER_MODE_API_KEY.to_owned(),
877    ))]);
878    build_router(state)
879}
880
881/// Server-mode test router with `allowed_scan_roots` configured. Exercises the
882/// `validate_server_scan_path` allow/deny branches (in-root success, unresolved
883/// path, and out-of-root rejection) that the empty-roots router cannot reach.
884pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
885    let mut state = test_app_state("sloc_test_server_roots");
886    state.server_mode = true;
887    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
888        TEST_SERVER_MODE_API_KEY.to_owned(),
889    ))]);
890    state.base_config.discovery.allowed_scan_roots = roots;
891    build_router(state)
892}
893
894/// Test router where the analysis semaphore is pre-exhausted (0 permits).
895/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
896pub fn make_test_router_exhausted_semaphore() -> Router {
897    let mut state = test_app_state("sloc_test_exhaust");
898    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
899    build_router(state)
900}
901
902/// Test router with a very tight rate limit (3 req/min). The third request from
903/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
904pub fn make_test_router_tight_rate_limit() -> Router {
905    let mut state = test_app_state("sloc_test_rate");
906    state.rate_limiter = Arc::new(IpRateLimiter::new(
907        Duration::from_mins(1),
908        2,
909        5,
910        Duration::from_secs(5),
911    ));
912    build_router(state)
913}
914
915/// Test router with a very tight auth lockout (threshold=2, window=200ms).
916/// Used by tests that need to trigger and verify the auth lockout response.
917pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
918    let mut state = test_app_state("sloc_test_auth_lockout");
919    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
920    state.rate_limiter = Arc::new(IpRateLimiter::new(
921        Duration::from_mins(1),
922        600,
923        2,                          // 2 failures triggers lockout
924        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
925    ));
926    build_router(state)
927}
928
929struct RuntimeSecurityConfig {
930    api_keys: Vec<secrecy::SecretBox<String>>,
931    tls_cert: Option<String>,
932    tls_key: Option<String>,
933    tls_enabled: bool,
934    trust_proxy: bool,
935    trusted_proxy_ips: Vec<IpAddr>,
936    rate_limiter: Arc<IpRateLimiter>,
937}
938
939fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
940    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
941        .or_else(|_| std::env::var("SLOC_API_KEY"))
942        .unwrap_or_default()
943        .split(',')
944        .map(str::trim)
945        .filter(|s| !s.is_empty())
946        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
947        .collect();
948    if server_mode && api_keys.is_empty() {
949        println!(
950            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
951             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
952        );
953    }
954    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
955    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
956    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
957    if server_mode && !tls_enabled {
958        println!(
959            "WARNING: TLS is not configured. Traffic is cleartext. \
960             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
961             or terminate TLS at a reverse proxy (nginx, caddy)."
962        );
963    }
964    if server_mode {
965        println!(
966            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
967             to restrict cross-origin access (comma-separated)."
968        );
969    }
970    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
971    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
972        .unwrap_or_default()
973        .split(',')
974        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
975        .collect();
976    if trust_proxy {
977        if trusted_proxy_ips.is_empty() {
978            println!(
979                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
980                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
981                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
982            );
983        } else {
984            println!(
985                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
986                trusted_proxy_ips
987                    .iter()
988                    .map(std::string::ToString::to_string)
989                    .collect::<Vec<_>>()
990                    .join(", ")
991            );
992        }
993    } else if server_mode {
994        println!(
995            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
996             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
997             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
998             enable per-client rate limiting via X-Forwarded-For."
999        );
1000    }
1001    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1002        println!(
1003            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1004             DISABLED for all git operations. Remove this variable before production use."
1005        );
1006    }
1007    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1008        .ok()
1009        .and_then(|v| v.parse::<u32>().ok())
1010        .unwrap_or(10);
1011    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1012        .ok()
1013        .and_then(|v| v.parse::<u64>().ok())
1014        .unwrap_or(3600);
1015    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1016    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1017    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1018    let default_rpm: usize = if server_mode { 120 } else { 600 };
1019    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1020        .ok()
1021        .and_then(|v| v.parse::<usize>().ok())
1022        .unwrap_or(default_rpm);
1023    let rate_limiter = Arc::new(IpRateLimiter::new(
1024        Duration::from_mins(1),
1025        rate_limit_rpm,
1026        auth_lockout_threshold,
1027        Duration::from_secs(auth_lockout_secs),
1028    ));
1029    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1030    RuntimeSecurityConfig {
1031        api_keys,
1032        tls_cert,
1033        tls_key,
1034        tls_enabled,
1035        trust_proxy,
1036        trusted_proxy_ips,
1037        rate_limiter,
1038    }
1039}
1040
1041/// # Errors
1042///
1043/// Returns an error if the server fails to bind to the configured address or
1044/// if the TLS configuration cannot be loaded.
1045///
1046/// # Panics
1047///
1048/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1049#[allow(clippy::too_many_lines)]
1050pub async fn serve(config: AppConfig) -> Result<()> {
1051    let bind_address = config.web.bind_address.clone();
1052    let server_mode = config.web.server_mode;
1053    let output_root = resolve_output_root(None);
1054    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1055    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1056        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1057    let mut registry = ScanRegistry::load(&registry_path);
1058    registry.prune_stale();
1059    let _ = registry.save(&registry_path);
1060
1061    let sec = load_runtime_security_config(server_mode);
1062    spawn_upload_staging_cleanup();
1063
1064    let git_clones_dir = resolve_git_clones_dir(&output_root);
1065    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1066        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1067    let schedules = ScheduleStore::load(&schedules_path);
1068    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1069        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1070    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1071    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1072        |_| output_root.join("confluence_config.json"),
1073        PathBuf::from,
1074    );
1075    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1076    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1077        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1078    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1079    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1080        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1081    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1082
1083    let state = AppState {
1084        base_config: config,
1085        artifacts: Arc::new(Mutex::new(HashMap::new())),
1086        async_runs: Arc::new(Mutex::new(HashMap::new())),
1087        registry: Arc::new(Mutex::new(registry)),
1088        registry_path,
1089        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1090        server_mode,
1091        tls_enabled: sec.tls_enabled,
1092        api_keys: Arc::new(sec.api_keys),
1093        rate_limiter: sec.rate_limiter,
1094        trust_proxy: sec.trust_proxy,
1095        trusted_proxy_ips: sec.trusted_proxy_ips,
1096        git_clones_dir,
1097        schedules: Arc::new(Mutex::new(schedules)),
1098        schedules_path,
1099        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1100        scan_profiles_path,
1101        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1102        confluence: Arc::new(Mutex::new(confluence)),
1103        confluence_path,
1104        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1105        watched_dirs_path,
1106        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1107        cleanup_policy_path,
1108        cleanup_task_handle: Arc::new(Mutex::new(None)),
1109    };
1110
1111    restart_poll_schedules(&state).await;
1112    warn_insecure_gitlab_webhooks(&state).await;
1113
1114    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1115    {
1116        let enabled = state
1117            .cleanup_policy
1118            .lock()
1119            .await
1120            .policy
1121            .as_ref()
1122            .is_some_and(|p| p.enabled);
1123        if enabled {
1124            let handle = spawn_cleanup_policy_task(state.clone());
1125            *state.cleanup_task_handle.lock().await = Some(handle);
1126        }
1127    }
1128
1129    let app = build_router(state.clone());
1130
1131    // Try the configured port first, then step up through a few alternatives.
1132    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1133    // kernel zombie (visible in netstat but owned by no living process).  Rather
1134    // than failing, we auto-select the next free port and tell the user.
1135    let preferred: SocketAddr = bind_address
1136        .parse()
1137        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1138    let (listener, addr) = {
1139        let candidates = (0u16..=9).map(|offset| {
1140            let mut a = preferred;
1141            a.set_port(preferred.port().saturating_add(offset));
1142            a
1143        });
1144        let mut found = None;
1145        for candidate in candidates {
1146            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1147                found = Some((l, candidate));
1148                break;
1149            }
1150        }
1151        found.ok_or_else(|| {
1152            anyhow::anyhow!(
1153                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1154                bind_address,
1155                preferred.port(),
1156                preferred.port().saturating_add(9)
1157            )
1158        })?
1159    };
1160    if addr != preferred {
1161        eprintln!(
1162            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1163             using {} instead.",
1164            preferred.port(),
1165            addr.port()
1166        );
1167    }
1168
1169    if sec.tls_enabled {
1170        let cert_path = sec
1171            .tls_cert
1172            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1173        let key_path = sec
1174            .tls_key
1175            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1176        let tls_config = build_tls_config(&cert_path, &key_path)
1177            .context("failed to load TLS certificate/key")?;
1178        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1179
1180        let url = format!("https://{addr}/");
1181        println!("OxideSLOC server running at {url} (TLS)");
1182        println!("Use Ctrl+C to stop.");
1183
1184        return serve_tls(listener, app, acceptor, server_mode).await;
1185    }
1186
1187    let url = format!("http://{addr}/");
1188    log_startup_url(&url, server_mode);
1189
1190    axum::serve(
1191        listener,
1192        app.into_make_service_with_connect_info::<SocketAddr>(),
1193    )
1194    .with_graceful_shutdown(shutdown_signal(server_mode))
1195    .await
1196    .context("web server terminated unexpectedly")
1197}
1198
1199/// Discover the primary non-loopback IPv4 address by asking the OS which
1200/// outbound interface it would use to reach a public address.  No packets are
1201/// sent — the UDP socket is only used to query the routing table.
1202fn primary_lan_ip() -> Option<String> {
1203    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1204    socket.connect("8.8.8.8:80").ok()?;
1205    let addr = socket.local_addr().ok()?;
1206    let ip = addr.ip();
1207    if ip.is_loopback() {
1208        return None;
1209    }
1210    Some(ip.to_string())
1211}
1212
1213/// Print the startup URL and, in local mode, open the browser and schedule it.
1214fn log_startup_url(url: &str, server_mode: bool) {
1215    if server_mode {
1216        println!("OxideSLOC server running at {url}");
1217        println!("Use Ctrl+C to stop.");
1218    } else {
1219        println!("OxideSLOC local web UI running at {url}");
1220        println!("Press Ctrl+C to stop the server.");
1221        let open_url = url.to_owned();
1222        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1223    }
1224}
1225
1226/// Open the given URL in the default system browser.
1227fn open_browser_tab(url: &str) {
1228    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1229    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1230    // first quoted token as a window title — both are fragile and shell-parsed. The
1231    // url.dll handler receives the URL as a single, non-shell argument.
1232    #[cfg(target_os = "windows")]
1233    let _ = std::process::Command::new("rundll32")
1234        .args(["url.dll,FileProtocolHandler", url])
1235        .stdout(Stdio::null())
1236        .stderr(Stdio::null())
1237        .spawn();
1238    #[cfg(target_os = "macos")]
1239    let _ = std::process::Command::new("open")
1240        .arg(url)
1241        .stdout(Stdio::null())
1242        .stderr(Stdio::null())
1243        .spawn();
1244    #[cfg(target_os = "linux")]
1245    let _ = std::process::Command::new("xdg-open")
1246        .arg(url)
1247        .stdout(Stdio::null())
1248        .stderr(Stdio::null())
1249        .spawn();
1250}
1251
1252/// Graceful-shutdown future: resolves on Ctrl-C.
1253async fn shutdown_signal(server_mode: bool) {
1254    if tokio::signal::ctrl_c().await.is_ok() {
1255        println!();
1256        if server_mode {
1257            println!("Shutting down OxideSLOC server...");
1258        } else {
1259            println!("Shutting down OxideSLOC local web UI...");
1260        }
1261        println!("Server stopped cleanly.");
1262    }
1263}
1264
1265/// Load a rustls `ServerConfig` from PEM certificate and key files.
1266fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1267    use rustls_pki_types::pem::PemObject;
1268    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1269
1270    let cert_bytes =
1271        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1272    let key_bytes =
1273        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1274
1275    let cert_chain: Vec<CertificateDer<'static>> =
1276        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1277            .collect::<std::result::Result<_, _>>()
1278            .context("failed to parse TLS certificates")?;
1279
1280    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1281        .context("failed to parse TLS private key")?;
1282
1283    rustls::ServerConfig::builder()
1284        .with_no_client_auth()
1285        .with_single_cert(cert_chain, key)
1286        .context("failed to build TLS server config")
1287}
1288
1289/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1290async fn serve_tls(
1291    listener: tokio::net::TcpListener,
1292    app: Router,
1293    acceptor: tokio_rustls::TlsAcceptor,
1294    server_mode: bool,
1295) -> Result<()> {
1296    use hyper_util::rt::{TokioExecutor, TokioIo};
1297    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1298    use hyper_util::service::TowerToHyperService;
1299    use tower::{Service, ServiceExt};
1300
1301    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1302
1303    loop {
1304        tokio::select! {
1305            biased;
1306            _ = tokio::signal::ctrl_c() => {
1307                println!();
1308                if server_mode {
1309                    println!("Shutting down OxideSLOC server...");
1310                } else {
1311                    println!("Shutting down OxideSLOC local web UI...");
1312                }
1313                println!("Server stopped cleanly.");
1314                return Ok(());
1315            }
1316            result = listener.accept() => {
1317                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1318                let acceptor = acceptor.clone();
1319                let mut factory = make_svc.clone();
1320
1321                tokio::spawn(async move {
1322                    let tls = match acceptor.accept(tcp).await {
1323                        Ok(s) => s,
1324                        Err(e) => {
1325                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1326                            return;
1327                        }
1328                    };
1329                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1330                        Ok(f) => match Service::call(f, peer_addr).await {
1331                            Ok(s) => s,
1332                            Err(_) => return,
1333                        },
1334                        Err(_) => return,
1335                    };
1336                    let io = TokioIo::new(tls);
1337                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1338                        .serve_connection(io, TowerToHyperService::new(svc))
1339                        .await
1340                    {
1341                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1342                    }
1343                });
1344            }
1345        }
1346    }
1347}
1348
1349// auth moved to auth.rs
1350
1351fn build_cors_layer(server_mode: bool) -> CorsLayer {
1352    if server_mode {
1353        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1354            .unwrap_or_default()
1355            .split(',')
1356            .filter(|s| !s.is_empty())
1357            .filter_map(|s| s.trim().parse().ok())
1358            .collect();
1359        if allowed.is_empty() {
1360            return CorsLayer::new();
1361        }
1362        CorsLayer::new()
1363            .allow_origin(AllowOrigin::list(allowed))
1364            .allow_methods(AllowMethods::list([
1365                axum::http::Method::GET,
1366                axum::http::Method::POST,
1367            ]))
1368            .allow_headers(AllowHeaders::list([
1369                axum::http::header::AUTHORIZATION,
1370                axum::http::header::CONTENT_TYPE,
1371            ]))
1372    } else {
1373        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1374            let s = origin.to_str().unwrap_or("");
1375            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1376        }))
1377    }
1378}
1379
1380async fn add_security_headers(
1381    State(state): State<AppState>,
1382    mut req: Request<Body>,
1383    next: Next,
1384) -> Response {
1385    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1386    req.extensions_mut().insert(CspNonce(nonce.clone()));
1387    let mut resp = next.run(req).await;
1388    inject_page_fade_into_html(&mut resp, &nonce).await;
1389    let h = resp.headers_mut();
1390    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1391    h.insert(
1392        "X-Content-Type-Options",
1393        HeaderValue::from_static("nosniff"),
1394    );
1395    h.insert(
1396        "Referrer-Policy",
1397        HeaderValue::from_static("strict-origin-when-cross-origin"),
1398    );
1399    let csp = format!(
1400        "default-src 'self'; \
1401         base-uri 'self'; \
1402         form-action 'self'; \
1403         style-src 'self' 'unsafe-inline'; \
1404         img-src 'self' data: blob:; \
1405         script-src 'self' 'nonce-{nonce}'; \
1406         font-src 'self' data:; \
1407         object-src 'none'; \
1408         frame-ancestors 'none'"
1409    );
1410    h.insert(
1411        "Content-Security-Policy",
1412        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1413            HeaderValue::from_static(
1414                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1415            )
1416        }),
1417    );
1418    h.insert(
1419        "X-Permitted-Cross-Domain-Policies",
1420        HeaderValue::from_static("none"),
1421    );
1422    h.insert(
1423        "Permissions-Policy",
1424        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1425    );
1426    h.insert(
1427        "Cross-Origin-Opener-Policy",
1428        HeaderValue::from_static("same-origin"),
1429    );
1430    h.insert(
1431        "Cross-Origin-Resource-Policy",
1432        HeaderValue::from_static("same-origin"),
1433    );
1434    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1435    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1436    h.insert(
1437        "Cross-Origin-Embedder-Policy",
1438        HeaderValue::from_static("require-corp"),
1439    );
1440    if state.tls_enabled {
1441        h.insert(
1442            "Strict-Transport-Security",
1443            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1444        );
1445    }
1446    resp
1447}
1448
1449/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1450///
1451/// On state-changing methods, browser-driven cookie-authenticated requests must
1452/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1453/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1454///
1455/// Deliberately exempt:
1456/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1457/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1458///   ambient, so it is not CSRF-exploitable.
1459/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1460/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1461///   a browser performing a CSRF attack always sends `Origin`.
1462async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1463    use axum::http::Method;
1464
1465    let is_state_changing = matches!(
1466        *req.method(),
1467        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1468    );
1469    let path = req.uri().path();
1470    let has_token_auth = req.headers().contains_key("X-API-Key")
1471        || req
1472            .headers()
1473            .get(header::AUTHORIZATION)
1474            .and_then(|v| v.to_str().ok())
1475            .is_some_and(|v| v.starts_with("Bearer "));
1476
1477    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1478        return next.run(req).await;
1479    }
1480
1481    let headers = req.headers();
1482    let header_str = |name: &header::HeaderName| {
1483        headers
1484            .get(name)
1485            .and_then(|v| v.to_str().ok())
1486            .map(str::to_owned)
1487    };
1488    let origin = header_str(&header::ORIGIN);
1489    let referer = header_str(&header::REFERER);
1490    let host = header_str(&header::HOST);
1491
1492    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1493    let authority_of = |url: &str| -> Option<String> {
1494        url.split_once("://")
1495            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1496    };
1497
1498    let source_authority = origin
1499        .as_deref()
1500        .and_then(authority_of)
1501        .or_else(|| referer.as_deref().and_then(authority_of));
1502
1503    match (source_authority, host) {
1504        // Neither Origin nor Referer present: treat as a non-browser client.
1505        (None, _) => next.run(req).await,
1506        (Some(src), Some(h)) if src == h => next.run(req).await,
1507        (Some(src), host) => {
1508            tracing::warn!(
1509                event = "csrf_rejected",
1510                path = %path,
1511                origin = %src,
1512                host = ?host,
1513                "Cross-origin state-changing request rejected (CSRF guard)"
1514            );
1515            (
1516                StatusCode::FORBIDDEN,
1517                "403 Forbidden — cross-origin request rejected\n",
1518            )
1519                .into_response()
1520        }
1521    }
1522}
1523
1524/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1525/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1526/// is overkill — a short opacity fade gives a smooth page-to-page transition
1527/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1528/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1529/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1530/// light-mode flash for dark-theme users.
1531fn page_fade_html(nonce: &str) -> String {
1532    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1533    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1534    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1535    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1536    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1537    // through the entire body parse, which reads as a delay before navigation "begins"
1538    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1539    // 0 -> 1 cleanly, with no pre-paint hold.
1540    const STYLE: &str = r"<style>
1541@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1542.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1543body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1544@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1545</style>";
1546    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1547    // The click handler gives immediate feedback by fading the *content* out the moment a
1548    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1549    // preventDefault or delay navigation — the browser navigates instantly and the fade
1550    // plays opportunistically during the natural fetch window, so no latency is added.
1551    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1552    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1553    // if the click was actually a download (no unload) or the page is restored from bfcache.
1554    const JS: &str = r"(function(){try{if(localStorage.getItem('sloc-dark')==='1'&&document.body)document.body.classList.add('dark-theme');}catch(e){}function leave(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;if(!a)return;if(a.target&&a.target!=='_self')return;if(a.hasAttribute('download'))return;var href=a.getAttribute('href');if(!href||href.charAt(0)==='#')return;if(/^(mailto:|tel:|javascript:)/i.test(href))return;var u;try{u=new URL(a.href,location.href);}catch(_){return;}if(u.origin!==location.origin)return;if(u.pathname===location.pathname&&u.search===location.search)return;var b=document.body;if(!b)return;b.classList.add('sloc-leaving');setTimeout(function(){b.classList.remove('sloc-leaving');},1400);}document.addEventListener('click',leave);window.addEventListener('pageshow',function(){if(document.body)document.body.classList.remove('sloc-leaving');});})();";
1555    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1556}
1557
1558/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1559/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1560/// `<script>` — meant to be spliced in immediately after `<body>`.
1561///
1562/// It pairs the spinner with a **visibility gate**: from the first byte the page
1563/// content is held at `visibility:hidden` (only the overlay paints), so the user
1564/// never sees a half-rendered flash while charts/tables are still settling. On
1565/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1566/// still-opaque overlay, which then fades out one frame later — so the reveal is
1567/// of a finished page, with no glitch on either side of the transition.
1568///
1569/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1570/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1571/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1572fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1573    const TPL: &str = r#"<style nonce="__N__">
1574html.sloc-pending body{visibility:hidden;}
1575html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1576#rpt-loading-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .45s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
1577#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1578body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1579body.pdf-mode #rpt-loading-overlay{display:none!important;}
1580.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1581.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;}
1582.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;}
1583@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1584@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1585body.dark-theme .rpt-bg-blob{opacity:.36;}
1586.rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:20px;width:380px;max-width:88vw;padding:42px 50px 34px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:24px;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;}
1587@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1588body.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);}
1589.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1590.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1591.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1592.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));}
1593@keyframes rpt-spin{to{transform:rotate(360deg);}}
1594.rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
1595body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1596body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1597.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1598.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;}
1599@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1600.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1601.rpt-dot:nth-child(2){animation-delay:.28s;}
1602.rpt-dot:nth-child(3){animation-delay:.56s;}
1603@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1604.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1605.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1606.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;}
1607body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1608@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;}}
1609</style>
1610<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1611<script nonce="__N__">document.documentElement.classList.add('sloc-pending');try{if(localStorage.getItem('sloc-dark')==='1'||localStorage.getItem('oxide-sloc-theme')==='dark')document.body.classList.add('dark-theme');}catch(e){}</script>
1612<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1613  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1614  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1615  <div class="rpt-load-card">
1616    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1617    <div class="rpt-spinner-wrap">
1618      <div class="rpt-spinner-track"></div>
1619      <div class="rpt-spinner"></div>
1620      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1621    </div>
1622    <div class="rpt-loading-text"><span class="rpt-load-word">Loading comparison</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
1623    <div class="rpt-status" id="rpt-status">__LABEL__</div>
1624    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1625  </div>
1626</div>
1627<script nonce="__N__">
1628(function(){
1629  var ov=document.getElementById('rpt-loading-overlay');
1630  var root=document.documentElement;
1631  function reveal(){root.classList.remove('sloc-pending');}
1632  if(!ov){reveal();return;}
1633  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1634  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1635  var mi=0,prog=0,done=false,start=Date.now();
1636  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1637  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1638  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1639  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1640  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1641  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1642  setProg(8);
1643  var msgTimer=setInterval(nextMsg,700);
1644  var progTimer=setInterval(function(){var cap=99;if(prog<cap){var step=(cap-prog)*0.05+0.4;setProg(Math.min(cap,prog+step));}},90);
1645  // These pages draw charts into known SVG containers that start empty and are
1646  // filled by JS once layout is available (some only after a ResizeObserver pass
1647  // post-`load`). Treat the page as ready only once every chart container present
1648  // actually has rendered content, so the overlay never lifts on a half-drawn page.
1649  function chartsRendered(){
1650    var sel=['#cmp-tl-svg','#mc-chart'];
1651    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1652    return true;
1653  }
1654  function finish(){
1655    if(done)return;done=true;
1656    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1657    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1658    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1659    reveal();
1660    requestAnimationFrame(function(){requestAnimationFrame(function(){
1661      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1662    });});
1663  }
1664  // Wait for `load` (resources + first layout), then poll until the charts have
1665  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1666  function afterLoad(){
1667    var loadAt=Date.now();
1668    (function poll(){
1669      if(done)return;
1670      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1671        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1672        return;
1673      }
1674      requestAnimationFrame(poll);
1675    })();
1676  }
1677  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1678  // Absolute safety net: never let the gate/overlay get stuck.
1679  setTimeout(function(){if(!done)finish();},HARD_CAP);
1680})();
1681</script>"#;
1682    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1683}
1684
1685/// Shared toast-notification assets + a global PDF-export helper, spliced into
1686/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
1687/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
1688/// placed just before `</body>`.
1689///
1690/// It defines two globals:
1691/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
1692///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
1693///   toast stays up until its returned handle's `.dismiss()` is called.
1694/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
1695///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
1696///   `/export/pdf`, triggers the download, then raises a success or error toast and
1697///   restores the button. Centralising this guarantees identical, obvious feedback
1698///   everywhere instead of a silent `alert()`-only failure path.
1699fn sloc_toast_assets(nonce: &str) -> String {
1700    const TPL: &str = r##"<style nonce="__N__">
1701#sloc-toast-wrap{position:fixed;right:18px;top:18px;z-index:11000;display:flex;flex-direction:column;gap:10px;max-width:min(380px,calc(100vw - 36px));pointer-events:none;}
1702.sloc-toast{pointer-events:auto;display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:12px;background:#fcfaf7;color:#2f241c;border:1px solid #dfcfbf;box-shadow:0 12px 32px rgba(77,44,20,0.22);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;font-weight:600;line-height:1.35;opacity:0;transform:translateY(12px) scale(.96);transition:opacity .26s ease,transform .26s cubic-bezier(.22,.68,0,1.12);}
1703.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1704.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1705.sloc-toast-ico{flex:0 0 auto;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;font-style:normal;}
1706.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1707.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1708.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1709.sloc-toast-success{border-color:#bfe0cc;}
1710.sloc-toast-error{border-color:#e6b3b3;}
1711.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1712.sloc-toast-spin{flex:0 0 auto;width:18px;height:18px;border-radius:50%;border:2.5px solid rgba(196,92,16,.25);border-top-color:#c45c10;animation:sloc-toast-spin .7s linear infinite;}
1713@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1714.sloc-toast-x{flex:0 0 auto;background:none;border:none;color:inherit;opacity:.5;cursor:pointer;font-size:16px;line-height:1;padding:0 2px;margin:-1px -2px 0 2px;}
1715.sloc-toast-x:hover{opacity:1;}
1716body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1717body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1718body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1719body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1720@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1721</style>
1722<script nonce="__N__">
1723(function(){
1724  if(window.slocToast)return;
1725  function wrap(){
1726    var w=document.getElementById('sloc-toast-wrap');
1727    if(!w){w=document.createElement('div');w.id='sloc-toast-wrap';w.setAttribute('aria-live','polite');w.setAttribute('aria-atomic','false');(document.body||document.documentElement).appendChild(w);}
1728    return w;
1729  }
1730  window.slocToast=function(msg,opts){
1731    opts=opts||{};
1732    var type=opts.type||'info';
1733    var loading=type==='loading';
1734    var t=document.createElement('div');
1735    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1736    t.setAttribute('role',type==='error'?'alert':'status');
1737    var ico=loading
1738      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1739      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1740    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1741    t.querySelector('.sloc-toast-msg').textContent=String(msg);
1742    wrap().appendChild(t);
1743    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1744    var gone=false,timer=null;
1745    function close(){
1746      if(gone)return;gone=true;if(timer)clearTimeout(timer);
1747      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1748      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1749    }
1750    t.querySelector('.sloc-toast-x').addEventListener('click',close);
1751    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1752    if(ttl>0)timer=setTimeout(close,ttl);
1753    return {dismiss:close,el:t};
1754  };
1755  window.slocExportPdf=function(o){
1756    o=o||{};
1757    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1758    if(btn&&btn.disabled)return;
1759    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1760    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1761    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1762      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1763      .then(function(blob){
1764        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1765        document.body.appendChild(a);a.click();document.body.removeChild(a);
1766        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1767        load.dismiss();
1768        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1769      })
1770      .catch(function(e){
1771        load.dismiss();
1772        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1773      })
1774      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1775  };
1776})();
1777</script>"##;
1778    TPL.replace("__N__", nonce)
1779}
1780
1781/// Buffer an HTML response body and splice the page fade-in right after the
1782/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
1783/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
1784/// branded loading spinner for slow renders).
1785async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1786    let is_html = resp
1787        .headers()
1788        .get(header::CONTENT_TYPE)
1789        .and_then(|v| v.to_str().ok())
1790        .is_some_and(|v| v.starts_with("text/html"));
1791    if !is_html {
1792        return;
1793    }
1794    let body = std::mem::replace(resp.body_mut(), Body::empty());
1795    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1796        return;
1797    };
1798    let html = match String::from_utf8(bytes.to_vec()) {
1799        Ok(s) => s,
1800        Err(e) => {
1801            *resp.body_mut() = Body::from(e.into_bytes());
1802            return;
1803        }
1804    };
1805    if html.contains("id=\"rpt-loading-overlay\"") {
1806        *resp.body_mut() = Body::from(html);
1807        return;
1808    }
1809    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
1810    // avoids allocating a lowercased copy of the whole document on every request.
1811    // Fall back to a case-insensitive scan only if that fails (rare/never).
1812    let insert_at = html
1813        .find("<body")
1814        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1815        .or_else(|| {
1816            let lower = html.to_ascii_lowercase();
1817            lower
1818                .find("<body")
1819                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1820        });
1821    let new_html = match insert_at {
1822        Some(at) => {
1823            let mut out = String::with_capacity(html.len() + 1024);
1824            out.push_str(&html[..at]);
1825            out.push_str(&page_fade_html(nonce));
1826            out.push_str(&html[at..]);
1827            out
1828        }
1829        None => html,
1830    };
1831    resp.headers_mut().remove(header::CONTENT_LENGTH);
1832    *resp.body_mut() = Body::from(new_html);
1833}
1834
1835async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1836    let peer_ip = req
1837        .extensions()
1838        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1839        .map(|c| c.0.ip());
1840
1841    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1842    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1843    // header spoofing from direct connections.
1844    let ip = peer_ip
1845        .and_then(|peer| {
1846            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1847                req.headers()
1848                    .get("X-Forwarded-For")
1849                    .and_then(|v| v.to_str().ok())
1850                    .and_then(|s| s.split(',').next())
1851                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1852            } else {
1853                None
1854            }
1855        })
1856        .or(peer_ip)
1857        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1858
1859    if !state.rate_limiter.is_allowed(ip) {
1860        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1861            path = %req.uri().path(), "Rate limit exceeded");
1862        return (
1863            StatusCode::TOO_MANY_REQUESTS,
1864            [(header::RETRY_AFTER, "60")],
1865            "429 Too Many Requests\n",
1866        )
1867            .into_response();
1868    }
1869    next.run(req).await
1870}
1871
1872async fn splash(
1873    State(state): State<AppState>,
1874    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1875) -> impl IntoResponse {
1876    let lan_ip = if state.server_mode {
1877        primary_lan_ip()
1878    } else {
1879        None
1880    };
1881    let port = state
1882        .base_config
1883        .web
1884        .bind_address
1885        .rsplit(':')
1886        .next()
1887        .and_then(|p| p.parse::<u16>().ok())
1888        .unwrap_or(4317);
1889    let has_api_key = !state.api_keys.is_empty();
1890    let template = SplashTemplate {
1891        csp_nonce,
1892        server_mode: state.server_mode,
1893        lan_ip,
1894        port,
1895        version: env!("CARGO_PKG_VERSION"),
1896        has_api_key,
1897    };
1898    Html(
1899        template
1900            .render()
1901            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1902    )
1903}
1904
1905async fn index(
1906    State(state): State<AppState>,
1907    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1908    Query(query): Query<IndexQuery>,
1909) -> impl IntoResponse {
1910    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1911        let policy = query
1912            .mixed_line_policy
1913            .unwrap_or_else(|| "code_only".to_string());
1914        let behavior = query
1915            .binary_file_behavior
1916            .unwrap_or_else(|| "skip".to_string());
1917        let cfg = ScanConfig {
1918            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1919            path: query.path.unwrap_or_default(),
1920            include_globs: query.include_globs.unwrap_or_default(),
1921            exclude_globs: query.exclude_globs.unwrap_or_default(),
1922            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1923            mixed_line_policy: policy,
1924            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1925                != Some("off"),
1926            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1927            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1928            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1929                != Some("disabled"),
1930            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1931            binary_file_behavior: behavior,
1932            output_dir: query.output_dir.unwrap_or_default(),
1933            report_title: query.report_title.unwrap_or_default(),
1934            continuation_line_policy: query
1935                .continuation_line_policy
1936                .unwrap_or_else(default_each_physical_line),
1937            blank_in_block_comment_policy: query
1938                .blank_in_block_comment_policy
1939                .unwrap_or_else(default_count_as_comment),
1940            count_compiler_directives: query.count_compiler_directives.as_deref()
1941                != Some("disabled"),
1942            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
1943            style_col_threshold: query
1944                .style_col_threshold
1945                .as_deref()
1946                .and_then(|s| s.parse().ok())
1947                .unwrap_or(80),
1948            style_score_threshold: query
1949                .style_score_threshold
1950                .as_deref()
1951                .and_then(|s| s.parse().ok())
1952                .unwrap_or(0),
1953            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
1954            coverage_file: query.coverage_file.unwrap_or_default(),
1955            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
1956            complexity_alert: query
1957                .complexity_alert
1958                .as_deref()
1959                .and_then(|s| s.parse().ok())
1960                .unwrap_or(0),
1961            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
1962            activity_window: query
1963                .activity_window
1964                .as_deref()
1965                .and_then(|s| s.parse().ok())
1966                .unwrap_or(90),
1967        };
1968        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1969    } else {
1970        "{}".to_string()
1971    };
1972
1973    let git_repo = query.git_repo.unwrap_or_default();
1974    let git_ref = query.git_ref.unwrap_or_default();
1975
1976    let git_label = make_git_label(&git_repo, &git_ref);
1977    let git_output_dir = if git_label.is_empty() {
1978        String::new()
1979    } else {
1980        desktop_dir().join(&git_label).display().to_string()
1981    };
1982    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1983    let git_output_dir_json =
1984        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1985
1986    let template = IndexTemplate {
1987        version: env!("CARGO_PKG_VERSION"),
1988        prefill_json,
1989        csp_nonce,
1990        git_repo,
1991        git_ref,
1992        git_label_json,
1993        git_output_dir_json,
1994        server_mode: state.server_mode,
1995    };
1996
1997    Html(
1998        template
1999            .render()
2000            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2001    )
2002}
2003
2004async fn scan_setup_handler(
2005    State(state): State<AppState>,
2006    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2007) -> impl IntoResponse {
2008    let recent_scans_json = {
2009        let arr: Vec<serde_json::Value> = {
2010            let reg = state.registry.lock().await;
2011            reg.entries
2012                .iter()
2013                .rev()
2014                .take(6)
2015                .map(|e| {
2016                    let run_dir = e
2017                        .html_path
2018                        .as_ref()
2019                        .or(e.json_path.as_ref())
2020                        .and_then(|p| p.parent().map(PathBuf::from));
2021                    let config_val: Option<serde_json::Value> = run_dir
2022                        .and_then(|d| find_scan_config_in_dir(&d))
2023                        .and_then(|p| fs::read_to_string(&p).ok())
2024                        .and_then(|s| serde_json::from_str(&s).ok());
2025                    serde_json::json!({
2026                        "project_label": e.project_label,
2027                        "timestamp": fmt_la_time(e.timestamp_utc),
2028                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2029                        "config": config_val,
2030                    })
2031                })
2032                .collect()
2033        };
2034        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2035    };
2036
2037    let template = ScanSetupTemplate {
2038        version: env!("CARGO_PKG_VERSION"),
2039        recent_scans_json,
2040        csp_nonce,
2041    };
2042    Html(
2043        template
2044            .render()
2045            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2046    )
2047}
2048
2049async fn healthz() -> &'static str {
2050    "ok"
2051}
2052
2053async fn api_version_handler() -> impl IntoResponse {
2054    axum::Json(serde_json::json!({
2055        "name": "oxide-sloc",
2056        "version": env!("CARGO_PKG_VERSION"),
2057    }))
2058}
2059
2060// ── Prometheus metrics ────────────────────────────────────────────────────────
2061
2062fn prom_runs_total() -> &'static prometheus::IntCounter {
2063    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2064    COUNTER.get_or_init(|| {
2065        prometheus::register_int_counter!(
2066            "oxide_sloc_runs_total",
2067            "Total number of completed analysis runs"
2068        )
2069        .expect("failed to register oxide_sloc_runs_total counter")
2070    })
2071}
2072
2073async fn metrics_handler() -> impl IntoResponse {
2074    use prometheus::Encoder as _;
2075    let mut buf = Vec::new();
2076    let encoder = prometheus::TextEncoder::new();
2077    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2078    (
2079        [(
2080            axum::http::header::CONTENT_TYPE,
2081            "text/plain; version=0.0.4; charset=utf-8",
2082        )],
2083        buf,
2084    )
2085}
2086
2087static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2088
2089async fn openapi_yaml_handler() -> impl IntoResponse {
2090    (
2091        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2092        OPENAPI_YAML,
2093    )
2094}
2095
2096static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2097static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2098
2099async fn llms_txt_handler() -> impl IntoResponse {
2100    (
2101        [
2102            (
2103                axum::http::header::CONTENT_TYPE,
2104                "text/plain; charset=utf-8",
2105            ),
2106            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2107        ],
2108        LLMS_TXT,
2109    )
2110}
2111
2112async fn llms_full_txt_handler() -> impl IntoResponse {
2113    (
2114        [
2115            (
2116                axum::http::header::CONTENT_TYPE,
2117                "text/plain; charset=utf-8",
2118            ),
2119            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2120        ],
2121        LLMS_FULL_TXT,
2122    )
2123}
2124
2125async fn api_docs_handler(
2126    State(state): State<AppState>,
2127    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2128) -> impl IntoResponse {
2129    let has_api_key = !state.api_keys.is_empty();
2130    Html(
2131        ApiDocsTemplate {
2132            has_api_key,
2133            csp_nonce,
2134            version: env!("CARGO_PKG_VERSION"),
2135        }
2136        .render()
2137        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2138    )
2139}
2140
2141async fn chart_js_handler() -> impl IntoResponse {
2142    (
2143        [
2144            (
2145                header::CONTENT_TYPE,
2146                "application/javascript; charset=utf-8",
2147            ),
2148            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2149        ],
2150        CHART_JS,
2151    )
2152}
2153
2154async fn report_chart_js_handler() -> impl IntoResponse {
2155    (
2156        [
2157            (
2158                header::CONTENT_TYPE,
2159                "application/javascript; charset=utf-8",
2160            ),
2161            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2162        ],
2163        REPORT_CHART_JS,
2164    )
2165}
2166
2167#[derive(Debug, Deserialize)]
2168struct AnalyzeForm {
2169    path: String,
2170    git_repo: Option<String>,
2171    git_ref: Option<String>,
2172    mixed_line_policy: Option<MixedLinePolicy>,
2173    python_docstrings_as_comments: Option<String>,
2174    generated_file_detection: Option<String>,
2175    minified_file_detection: Option<String>,
2176    vendor_directory_detection: Option<String>,
2177    include_lockfiles: Option<String>,
2178    binary_file_behavior: Option<BinaryFileBehavior>,
2179    output_dir: Option<String>,
2180    report_title: Option<String>,
2181    report_header_footer: Option<String>,
2182    include_globs: Option<String>,
2183    exclude_globs: Option<String>,
2184    submodule_breakdown: Option<String>,
2185    coverage_file: Option<String>,
2186    continuation_line_policy: Option<ContinuationLinePolicy>,
2187    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2188    count_compiler_directives: Option<String>,
2189    style_col_threshold: Option<String>,
2190    style_analysis_enabled: Option<String>,
2191    style_score_threshold: Option<String>,
2192    style_lang_scope: Option<String>,
2193    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2194    cocomo_mode: Option<String>,
2195    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2196    complexity_alert: Option<String>,
2197    /// Whether to exclude duplicate files from displayed SLOC totals.
2198    exclude_duplicates: Option<String>,
2199    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2200    activity_window: Option<String>,
2201}
2202
2203#[allow(clippy::struct_excessive_bools)]
2204#[derive(Debug, Serialize, Deserialize, Clone)]
2205struct ScanConfig {
2206    oxide_sloc_version: String,
2207    path: String,
2208    include_globs: String,
2209    exclude_globs: String,
2210    submodule_breakdown: bool,
2211    mixed_line_policy: String,
2212    python_docstrings_as_comments: bool,
2213    generated_file_detection: bool,
2214    minified_file_detection: bool,
2215    vendor_directory_detection: bool,
2216    include_lockfiles: bool,
2217    binary_file_behavior: String,
2218    output_dir: String,
2219    report_title: String,
2220    // IEEE 1045-1992 and advanced fields added in later release
2221    #[serde(default = "default_each_physical_line")]
2222    continuation_line_policy: String,
2223    #[serde(default = "default_count_as_comment")]
2224    blank_in_block_comment_policy: String,
2225    #[serde(default = "default_true_bool")]
2226    count_compiler_directives: bool,
2227    #[serde(default = "default_true_bool")]
2228    style_analysis_enabled: bool,
2229    #[serde(default = "default_style_col_threshold")]
2230    style_col_threshold: u16,
2231    #[serde(default)]
2232    style_score_threshold: u8,
2233    #[serde(default = "default_all_scope")]
2234    style_lang_scope: String,
2235    #[serde(default)]
2236    coverage_file: String,
2237    #[serde(default = "default_organic")]
2238    cocomo_mode: String,
2239    #[serde(default)]
2240    complexity_alert: u32,
2241    #[serde(default)]
2242    exclude_duplicates: bool,
2243    /// Git hotspots activity window in days (on by default; 0 = disabled).
2244    #[serde(default = "default_activity_window")]
2245    activity_window: u32,
2246}
2247
2248const fn default_activity_window() -> u32 {
2249    90
2250}
2251
2252fn default_each_physical_line() -> String {
2253    "each_physical_line".to_string()
2254}
2255fn default_count_as_comment() -> String {
2256    "count_as_comment".to_string()
2257}
2258const fn default_true_bool() -> bool {
2259    true
2260}
2261const fn default_style_col_threshold() -> u16 {
2262    80
2263}
2264fn default_all_scope() -> String {
2265    "all".to_string()
2266}
2267fn default_organic() -> String {
2268    "organic".to_string()
2269}
2270
2271#[derive(Debug, Deserialize, Default)]
2272struct IndexQuery {
2273    path: Option<String>,
2274    include_globs: Option<String>,
2275    exclude_globs: Option<String>,
2276    submodule_breakdown: Option<String>,
2277    mixed_line_policy: Option<String>,
2278    python_docstrings_as_comments: Option<String>,
2279    generated_file_detection: Option<String>,
2280    minified_file_detection: Option<String>,
2281    vendor_directory_detection: Option<String>,
2282    include_lockfiles: Option<String>,
2283    binary_file_behavior: Option<String>,
2284    output_dir: Option<String>,
2285    report_title: Option<String>,
2286    prefilled: Option<String>,
2287    git_repo: Option<String>,
2288    git_ref: Option<String>,
2289    // IEEE 1045-1992 and advanced fields
2290    continuation_line_policy: Option<String>,
2291    blank_in_block_comment_policy: Option<String>,
2292    count_compiler_directives: Option<String>,
2293    style_analysis_enabled: Option<String>,
2294    style_col_threshold: Option<String>,
2295    style_score_threshold: Option<String>,
2296    style_lang_scope: Option<String>,
2297    coverage_file: Option<String>,
2298    cocomo_mode: Option<String>,
2299    complexity_alert: Option<String>,
2300    exclude_duplicates: Option<String>,
2301    activity_window: Option<String>,
2302}
2303
2304#[derive(Debug, Deserialize)]
2305struct PreviewQuery {
2306    path: Option<String>,
2307    include_globs: Option<String>,
2308    exclude_globs: Option<String>,
2309}
2310
2311#[cfg(feature = "native-dialog")]
2312#[derive(Debug, Deserialize)]
2313struct PickDirectoryQuery {
2314    kind: Option<String>,
2315    current: Option<String>,
2316}
2317
2318#[cfg(not(feature = "native-dialog"))]
2319#[derive(Debug, Deserialize)]
2320struct PickDirectoryQuery {}
2321
2322#[derive(Debug, Deserialize, Default)]
2323struct ArtifactQuery {
2324    download: Option<String>,
2325}
2326
2327#[cfg(feature = "native-dialog")]
2328#[derive(Debug, Serialize)]
2329struct PickDirectoryResponse {
2330    selected_path: Option<String>,
2331    cancelled: bool,
2332}
2333
2334#[cfg(feature = "native-dialog")]
2335async fn pick_directory_handler(
2336    State(state): State<AppState>,
2337    Query(query): Query<PickDirectoryQuery>,
2338) -> Response {
2339    if state.server_mode {
2340        return StatusCode::NOT_FOUND.into_response();
2341    }
2342    // Return immediately without opening a dialog in headless / CI environments.
2343    if std::env::var("SLOC_HEADLESS").is_ok() {
2344        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2345            .into_response();
2346    }
2347
2348    let is_coverage = query.kind.as_deref() == Some("coverage");
2349    let title = match query.kind.as_deref() {
2350        Some("output") => "Select output directory",
2351        Some("reports") => "Select folder containing saved reports",
2352        Some("coverage") => "Select LCOV coverage file",
2353        _ => "Select project directory",
2354    }
2355    .to_owned();
2356    let current = query.current.clone();
2357
2358    let picked = tokio::task::spawn_blocking(move || {
2359        // Windows: attach to the foreground thread so the dialog inherits focus,
2360        // and kick off a watcher that flashes the dialog once it appears.
2361        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2362        let fg_tid = win_dialog_focus::attach_to_foreground();
2363        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2364        win_dialog_focus::flash_dialog_when_ready(title.clone());
2365
2366        let mut dialog = rfd::FileDialog::new().set_title(&title);
2367        if let Some(current) = current.as_deref() {
2368            let resolved = resolve_input_path(current);
2369            let seed = if resolved.is_dir() {
2370                Some(resolved)
2371            } else {
2372                resolved.parent().map(Path::to_path_buf)
2373            };
2374            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2375                dialog = dialog.set_directory(seed_dir);
2376            }
2377        }
2378        let result = if is_coverage {
2379            dialog
2380                .add_filter(
2381                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2382                    &["info", "lcov", "xml", "json"],
2383                )
2384                .pick_file()
2385        } else {
2386            dialog.pick_folder()
2387        };
2388
2389        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2390        win_dialog_focus::detach_from_foreground(fg_tid);
2391
2392        result
2393    })
2394    .await
2395    .unwrap_or(None);
2396
2397    Json(PickDirectoryResponse {
2398        selected_path: picked.as_ref().map(|p| display_path(p)),
2399        cancelled: picked.is_none(),
2400    })
2401    .into_response()
2402}
2403
2404#[cfg(not(feature = "native-dialog"))]
2405async fn pick_directory_handler(
2406    State(_state): State<AppState>,
2407    Query(_query): Query<PickDirectoryQuery>,
2408) -> Response {
2409    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2410}
2411
2412#[cfg(feature = "native-dialog")]
2413async fn pick_file_handler(State(state): State<AppState>) -> Response {
2414    if state.server_mode {
2415        return StatusCode::NOT_FOUND.into_response();
2416    }
2417    if std::env::var("SLOC_HEADLESS").is_ok() {
2418        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2419            .into_response();
2420    }
2421    let picked = tokio::task::spawn_blocking(|| {
2422        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2423        let fg_tid = win_dialog_focus::attach_to_foreground();
2424        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2425        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2426
2427        let result = rfd::FileDialog::new()
2428            .set_title("Select HTML report")
2429            .add_filter("HTML report", &["html"])
2430            .pick_file();
2431
2432        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2433        win_dialog_focus::detach_from_foreground(fg_tid);
2434
2435        result
2436    })
2437    .await
2438    .unwrap_or(None);
2439    Json(PickDirectoryResponse {
2440        selected_path: picked.as_ref().map(|p| display_path(p)),
2441        cancelled: picked.is_none(),
2442    })
2443    .into_response()
2444}
2445
2446#[cfg(not(feature = "native-dialog"))]
2447async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2448    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2449}
2450
2451// ── Browser-upload handlers (server mode only) ────────────────────────────────
2452
2453/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2454/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2455fn is_upload_tmp_path(path: &Path) -> bool {
2456    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2457    path.starts_with(&upload_root)
2458}
2459
2460/// Returns true when `path` is the built-in sample or test-fixture directory.
2461/// These paths ship with the server binary and are always safe to scan/preview.
2462fn is_sample_path(path: &Path) -> bool {
2463    let root = workspace_root();
2464    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2465}
2466
2467/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2468fn upload_base_dir() -> PathBuf {
2469    std::env::temp_dir().join("oxide-sloc-uploads")
2470}
2471
2472/// Returns the staging path for a given upload id inside the base dir.
2473fn upload_staging_path(id: &str) -> PathBuf {
2474    upload_base_dir().join(id)
2475}
2476
2477/// Validate basic field constraints on a directory-upload request.
2478/// Returns an error `Response` if the request should be rejected immediately.
2479#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2480fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2481    const MAX_FILES: usize = 50_000;
2482    if body.files.is_empty() {
2483        return Err((
2484            StatusCode::BAD_REQUEST,
2485            Json(serde_json::json!({"error": "No files received"})),
2486        )
2487            .into_response());
2488    }
2489    if body.files.len() > MAX_FILES {
2490        return Err((
2491            StatusCode::PAYLOAD_TOO_LARGE,
2492            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2493        )
2494            .into_response());
2495    }
2496    Ok(())
2497}
2498
2499/// Resolve or create the staging directory for a directory upload.
2500/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2501fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2502    match id {
2503        Some(id)
2504            if !id.is_empty()
2505                && id.len() <= 36
2506                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2507        {
2508            (id.to_string(), upload_staging_path(id))
2509        }
2510        _ => {
2511            let new_id = uuid::Uuid::new_v4().to_string();
2512            let staging = upload_staging_path(&new_id);
2513            (new_id, staging)
2514        }
2515    }
2516}
2517
2518/// Decode, size-check, and write one uploaded file entry into `staging`.
2519/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2520/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2521/// cleaning up `staging` before propagating the error.
2522#[allow(clippy::result_large_err)]
2523async fn stage_decoded_entry(
2524    entry: &UploadedFile,
2525    staging: &Path,
2526    total_bytes: &mut usize,
2527    project_root: &mut Option<PathBuf>,
2528) -> Result<(), Response> {
2529    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2530
2531    let Ok(data) = base64::Engine::decode(
2532        &base64::engine::general_purpose::STANDARD,
2533        entry.content.as_bytes(),
2534    ) else {
2535        return Ok(());
2536    };
2537
2538    *total_bytes += data.len();
2539    if *total_bytes > MAX_TOTAL_BYTES {
2540        return Err((
2541            StatusCode::PAYLOAD_TOO_LARGE,
2542            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2543        )
2544            .into_response());
2545    }
2546
2547    let rel = std::path::Path::new(&entry.path);
2548    if project_root.is_none() {
2549        if let Some(first) = rel.components().next() {
2550            *project_root = Some(staging.join(first.as_os_str()));
2551        }
2552    }
2553
2554    let dest = staging.join(rel);
2555    if let Some(parent) = dest.parent() {
2556        if tokio::fs::create_dir_all(parent).await.is_err() {
2557            return Err((
2558                StatusCode::INTERNAL_SERVER_ERROR,
2559                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2560            )
2561                .into_response());
2562        }
2563    }
2564
2565    if tokio::fs::write(&dest, &data).await.is_err() {
2566        return Err((
2567            StatusCode::INTERNAL_SERVER_ERROR,
2568            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2569        )
2570            .into_response());
2571    }
2572
2573    Ok(())
2574}
2575
2576/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2577/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2578/// an error `Response` on failure (staging dir is cleaned up before returning).
2579async fn write_upload_files(
2580    files: &[UploadedFile],
2581    staging: &Path,
2582    upload_id: &str,
2583) -> Result<(usize, Option<PathBuf>), Response> {
2584    let mut total_bytes: usize = 0;
2585    let mut project_root: Option<PathBuf> = None;
2586
2587    for entry in files {
2588        let rel = std::path::Path::new(&entry.path);
2589        if rel
2590            .components()
2591            .any(|c| matches!(c, std::path::Component::ParentDir))
2592        {
2593            // Reject the entire upload on the first path traversal attempt.
2594            let _ = tokio::fs::remove_dir_all(staging).await;
2595            tracing::warn!(
2596                event = "upload_path_traversal",
2597                upload_id = %upload_id,
2598                path = %entry.path,
2599                "Upload rejected: path traversal component detected"
2600            );
2601            return Err((
2602                StatusCode::BAD_REQUEST,
2603                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2604            )
2605                .into_response());
2606        }
2607
2608        if let Err(resp) =
2609            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2610        {
2611            let _ = tokio::fs::remove_dir_all(staging).await;
2612            return Err(resp);
2613        }
2614    }
2615
2616    Ok((files.len(), project_root))
2617}
2618
2619/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2620/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2621fn parse_tarball_size_caps() -> (u64, u64) {
2622    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2623        .ok()
2624        .and_then(|v| v.parse().ok())
2625        .unwrap_or(2048_u64)
2626        * 1024
2627        * 1024;
2628    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2629        .ok()
2630        .and_then(|v| v.parse().ok())
2631        .unwrap_or(10_240_u64)
2632        * 1024
2633        * 1024;
2634    (compressed, decompressed)
2635}
2636
2637/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
2638/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
2639/// are rejected before the streaming handler is invoked.
2640fn tarball_http_body_limit_bytes() -> usize {
2641    std::env::var("SLOC_MAX_TARBALL_MB")
2642        .ok()
2643        .and_then(|v| v.parse::<usize>().ok())
2644        .unwrap_or(2048)
2645        .saturating_mul(1024 * 1024)
2646}
2647
2648/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2649/// Returns the number of compressed bytes written, or an error `Response`.
2650/// Cleans up `dest_path` on error.
2651#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2652async fn stream_body_to_file(
2653    body: axum::body::Body,
2654    dest_path: &Path,
2655    max_bytes: u64,
2656) -> Result<u64, Response> {
2657    use http_body_util::BodyExt as _;
2658    use tokio::io::AsyncWriteExt as _;
2659
2660    let mut file = match tokio::fs::File::create(dest_path).await {
2661        Ok(f) => f,
2662        Err(e) => {
2663            tracing::error!(
2664                event = "upload_io_error",
2665                "failed to create tarball temp file: {e}"
2666            );
2667            return Err((
2668                StatusCode::INTERNAL_SERVER_ERROR,
2669                Json(serde_json::json!({"error": "Upload initialization failed"})),
2670            )
2671                .into_response());
2672        }
2673    };
2674
2675    let mut body = body;
2676    let mut written: u64 = 0;
2677    loop {
2678        match body.frame().await {
2679            None => break,
2680            Some(Err(e)) => {
2681                let _ = tokio::fs::remove_file(dest_path).await;
2682                return Err((
2683                    StatusCode::BAD_REQUEST,
2684                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2685                )
2686                    .into_response());
2687            }
2688            Some(Ok(frame)) => {
2689                if let Ok(data) = frame.into_data() {
2690                    written += data.len() as u64;
2691                    if written > max_bytes {
2692                        let _ = tokio::fs::remove_file(dest_path).await;
2693                        return Err((
2694                            StatusCode::PAYLOAD_TOO_LARGE,
2695                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2696                        )
2697                            .into_response());
2698                    }
2699                    if let Err(e) = file.write_all(&data).await {
2700                        let _ = tokio::fs::remove_file(dest_path).await;
2701                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2702                        return Err((
2703                            StatusCode::INTERNAL_SERVER_ERROR,
2704                            Json(serde_json::json!({"error": "Upload write failed"})),
2705                        )
2706                            .into_response());
2707                    }
2708                }
2709            }
2710        }
2711    }
2712    drop(file);
2713    Ok(written)
2714}
2715
2716/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2717/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2718/// on failure (staging dir is cleaned up before returning).
2719#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2720async fn extract_tarball_to_staging(
2721    tarball_path: &Path,
2722    staging: &Path,
2723    max_decompressed_bytes: u64,
2724) -> Result<(), Response> {
2725    let staging_clone = staging.to_path_buf();
2726    let tarball_clone = tarball_path.to_path_buf();
2727    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2728        let file = std::fs::File::open(&tarball_clone)?;
2729        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2730        let limited = SizeLimitReader {
2731            inner: gz,
2732            remaining: max_decompressed_bytes,
2733        };
2734        let mut archive = tar::Archive::new(limited);
2735        archive.set_overwrite(true);
2736        archive.set_preserve_permissions(false);
2737        std::fs::create_dir_all(&staging_clone)?;
2738        archive.unpack(&staging_clone)?;
2739        Ok(())
2740    })
2741    .await;
2742    let _ = tokio::fs::remove_file(tarball_path).await;
2743
2744    match extract_result {
2745        Ok(Ok(())) => Ok(()),
2746        Ok(Err(e)) => {
2747            let _ = tokio::fs::remove_dir_all(staging).await;
2748            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2749            tracing::warn!(
2750                event = "upload_extract_error",
2751                "tarball extraction failed: {e:#}"
2752            );
2753            let (status, msg) = if is_size_limit {
2754                (
2755                    StatusCode::PAYLOAD_TOO_LARGE,
2756                    "Archive exceeds the decompressed size limit",
2757                )
2758            } else {
2759                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2760            };
2761            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2762        }
2763        Err(e) => {
2764            let _ = tokio::fs::remove_dir_all(staging).await;
2765            tracing::error!(
2766                event = "upload_extract_panic",
2767                "tarball extraction task panicked: {e}"
2768            );
2769            Err((
2770                StatusCode::INTERNAL_SERVER_ERROR,
2771                Json(serde_json::json!({"error": "Archive extraction failed"})),
2772            )
2773                .into_response())
2774        }
2775    }
2776}
2777
2778/// If `staging` contains exactly one top-level directory, return its path
2779/// (the common case when the archive was created with `webkitRelativePath`).
2780/// Otherwise return `None`.
2781async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2782    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2783    let first = entries.next_entry().await.ok()??;
2784    if !first.path().is_dir() {
2785        return None;
2786    }
2787    if entries.next_entry().await.unwrap_or(None).is_some() {
2788        return None;
2789    }
2790    Some(first.path())
2791}
2792
2793/// Request body for `POST /api/upload-directory`.
2794///
2795/// Each entry carries a relative path (identical to the browser's
2796/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2797/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2798/// avoids pulling in a `multipart` library that is not in the vendor archive.
2799#[derive(Deserialize)]
2800struct UploadDirRequest {
2801    files: Vec<UploadedFile>,
2802    /// If provided, append this batch to an existing upload session instead of
2803    /// creating a new staging directory. Must be a plain UUID (no path separators).
2804    upload_id: Option<String>,
2805}
2806
2807#[derive(Deserialize)]
2808struct UploadedFile {
2809    /// `webkitRelativePath` value from the browser File object.
2810    path: String,
2811    /// Raw file bytes encoded as standard base64.
2812    content: String,
2813}
2814
2815/// POST /api/upload-directory
2816///
2817/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2818/// Saves all files to a temp staging directory preserving their relative paths,
2819/// then returns the server-side root directory path so the caller can populate
2820/// the scan-path field and run a normal analysis.
2821///
2822/// Only available in server mode; returns 404 in local mode (use the native
2823/// rfd dialog instead).
2824async fn upload_directory_handler(
2825    State(state): State<AppState>,
2826    Json(body): Json<UploadDirRequest>,
2827) -> Response {
2828    if !state.server_mode {
2829        return StatusCode::NOT_FOUND.into_response();
2830    }
2831    if let Err(resp) = validate_upload_dir_request(&body) {
2832        return resp;
2833    }
2834    // Reuse an existing staging dir when the client sends a continuation batch,
2835    // otherwise create a fresh one. Validate the id to prevent path traversal.
2836    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2837    match write_upload_files(&body.files, &staging, &upload_id).await {
2838        Ok((file_count, project_root)) => {
2839            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2840            Json(serde_json::json!({
2841                "tmp_path": scan_root.to_string_lossy(),
2842                "file_count": file_count,
2843                "upload_id": upload_id.clone()
2844            }))
2845            .into_response()
2846        }
2847        Err(resp) => resp,
2848    }
2849}
2850
2851/// Request body for `POST /api/upload-file`.
2852#[derive(Deserialize)]
2853struct UploadFileRequest {
2854    /// Original filename (used only to preserve the extension).
2855    filename: String,
2856    /// File bytes encoded as standard base64.
2857    content: String,
2858}
2859
2860/// POST /api/upload-file
2861///
2862/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2863/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2864/// Only available in server mode.
2865async fn upload_file_handler(
2866    State(state): State<AppState>,
2867    Json(body): Json<UploadFileRequest>,
2868) -> Response {
2869    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2870
2871    if !state.server_mode {
2872        return StatusCode::NOT_FOUND.into_response();
2873    }
2874
2875    let Ok(data) = base64::Engine::decode(
2876        &base64::engine::general_purpose::STANDARD,
2877        body.content.as_bytes(),
2878    ) else {
2879        return (
2880            StatusCode::BAD_REQUEST,
2881            Json(serde_json::json!({"error": "Invalid base64 content"})),
2882        )
2883            .into_response();
2884    };
2885
2886    if data.len() > MAX_FILE_BYTES {
2887        return (
2888            StatusCode::PAYLOAD_TOO_LARGE,
2889            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2890        )
2891            .into_response();
2892    }
2893
2894    // Sanitise: strip any directory component from the filename.
2895    let filename = std::path::Path::new(&body.filename)
2896        .file_name()
2897        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2898
2899    let upload_id = uuid::Uuid::new_v4();
2900    let staging = std::env::temp_dir()
2901        .join("oxide-sloc-uploads")
2902        .join(upload_id.to_string());
2903
2904    if tokio::fs::create_dir_all(&staging).await.is_err() {
2905        return (
2906            StatusCode::INTERNAL_SERVER_ERROR,
2907            Json(serde_json::json!({"error": "Failed to create staging directory"})),
2908        )
2909            .into_response();
2910    }
2911
2912    let dest = staging.join(&filename);
2913    if tokio::fs::write(&dest, &data).await.is_err() {
2914        let _ = tokio::fs::remove_dir_all(&staging).await;
2915        return (
2916            StatusCode::INTERNAL_SERVER_ERROR,
2917            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2918        )
2919            .into_response();
2920    }
2921
2922    Json(serde_json::json!({
2923        "tmp_path": dest.to_string_lossy(),
2924        "upload_id": upload_id.to_string()
2925    }))
2926    .into_response()
2927}
2928
2929/// POST /api/upload-tarball
2930///
2931/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
2932/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
2933/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
2934/// project root. The two size fields power the "Original / Compressed project size" display in the
2935/// web UI.
2936///
2937/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
2938/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
2939/// cap during decompression. The browser-side JS creates the archive one file at a time using
2940/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
2941/// project size.
2942/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
2943/// decompressed. Wraps any `std::io::Read` source.
2944struct SizeLimitReader<R> {
2945    inner: R,
2946    remaining: u64,
2947}
2948impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
2949    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2950        if self.remaining == 0 {
2951            return Err(std::io::Error::other("decompressed size limit exceeded"));
2952        }
2953        let n = self.inner.read(buf)?;
2954        self.remaining = self.remaining.saturating_sub(n as u64);
2955        Ok(n)
2956    }
2957}
2958
2959async fn upload_tarball_handler(
2960    State(state): State<AppState>,
2961    request: axum::extract::Request,
2962) -> Response {
2963    if !state.server_mode {
2964        return StatusCode::NOT_FOUND.into_response();
2965    }
2966
2967    let upload_id = uuid::Uuid::new_v4().to_string();
2968    let upload_base = upload_base_dir();
2969    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
2970    let staging = upload_staging_path(&upload_id);
2971    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
2972
2973    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
2974        tracing::error!(
2975            event = "upload_io_error",
2976            "failed to create upload base dir: {e}"
2977        );
2978        return (
2979            StatusCode::INTERNAL_SERVER_ERROR,
2980            Json(serde_json::json!({"error": "Upload initialization failed"})),
2981        )
2982            .into_response();
2983    }
2984
2985    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
2986    let compressed_bytes =
2987        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
2988            Ok(n) => n,
2989            Err(resp) => return resp,
2990        };
2991
2992    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
2993    if let Err(resp) =
2994        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
2995    {
2996        return resp;
2997    }
2998
2999    // ── 3. Find the project root inside the staging dir ───────────────────────
3000    // If the tar contained a single top-level directory (the common case when the
3001    // browser uses `webkitRelativePath`), return that as the scan root so the path
3002    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3003    let scan_root = find_single_top_dir(&staging)
3004        .await
3005        .unwrap_or_else(|| staging.clone());
3006
3007    // Compute original (uncompressed) size of the extracted tree.
3008    let original_bytes = tokio::task::spawn_blocking({
3009        let p = scan_root.clone();
3010        move || dir_size_bytes(&p)
3011    })
3012    .await
3013    .unwrap_or(0);
3014
3015    Json(serde_json::json!({
3016        "tmp_path": scan_root.to_string_lossy(),
3017        "upload_id": upload_id,
3018        "compressed_bytes": compressed_bytes,
3019        "original_bytes": original_bytes,
3020    }))
3021    .into_response()
3022}
3023
3024#[derive(Deserialize)]
3025struct LocateReportForm {
3026    file_path: String,
3027    #[serde(default)]
3028    redirect_url: Option<String>,
3029    #[serde(default)]
3030    expected_run_id: Option<String>,
3031}
3032
3033/// Render a view-reports error page and return it as a `Response`.
3034fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3035    let html = ErrorTemplate {
3036        message: message.into(),
3037        last_report_url: Some("/view-reports".to_string()),
3038        last_report_label: Some("View Reports".to_string()),
3039        run_id: None,
3040        error_code: None,
3041        csp_nonce: csp_nonce.to_owned(),
3042        version: env!("CARGO_PKG_VERSION"),
3043    }
3044    .render()
3045    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3046    Html(html).into_response()
3047}
3048
3049/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3050fn registry_entry_from_run(
3051    run: &AnalysisRun,
3052    json_path: PathBuf,
3053    html_path: PathBuf,
3054) -> RegistryEntry {
3055    let project_label = run.input_roots.first().map_or_else(
3056        || "Unknown Project".to_string(),
3057        |r| sanitize_project_label(r),
3058    );
3059    RegistryEntry {
3060        run_id: run.tool.run_id.clone(),
3061        timestamp_utc: run.tool.timestamp_utc,
3062        project_label,
3063        input_roots: run.input_roots.clone(),
3064        json_path: Some(json_path),
3065        html_path: Some(html_path),
3066        pdf_path: None,
3067        summary: ScanSummarySnapshot::from(&run.summary_totals),
3068        csv_path: None,
3069        xlsx_path: None,
3070        git_branch: None,
3071        git_commit: None,
3072        git_commit_long: None,
3073        git_author: None,
3074        git_tags: None,
3075        git_nearest_tag: None,
3076        git_commit_date: None,
3077    }
3078}
3079
3080/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3081/// immediately without requiring a server restart.
3082pub(crate) async fn register_artifacts_in_registry(
3083    state: &AppState,
3084    label: &str,
3085    run: &AnalysisRun,
3086    artifacts: &RunArtifacts,
3087) {
3088    let Some(json_path) = artifacts.json_path.clone() else {
3089        return;
3090    };
3091    let Some(html_path) = artifacts.html_path.clone() else {
3092        return;
3093    };
3094    let mut entry = registry_entry_from_run(run, json_path, html_path);
3095    entry.project_label = label.to_owned();
3096    let mut reg = state.registry.lock().await;
3097    reg.add_entry(entry);
3098    let _ = reg.save(&state.registry_path);
3099}
3100
3101fn is_html_report_file(p: &Path) -> bool {
3102    p.is_file()
3103        && p.extension()
3104            .and_then(|x| x.to_str())
3105            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3106        && p.file_name()
3107            .and_then(|n| n.to_str())
3108            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3109}
3110
3111fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3112    fs::read_dir(dir)
3113        .ok()?
3114        .flatten()
3115        .map(|e| e.path())
3116        .find(|p| is_html_report_file(p))
3117}
3118
3119fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3120    if let Some(f) = find_html_report_in_dir(dir) {
3121        return Some(f);
3122    }
3123    if let Ok(rd) = fs::read_dir(dir) {
3124        for entry in rd.flatten() {
3125            let sub = entry.path();
3126            if sub.is_dir() {
3127                if let Some(f) = find_html_report_in_dir(&sub) {
3128                    return Some(f);
3129                }
3130            }
3131        }
3132    }
3133    None
3134}
3135
3136/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3137/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3138///
3139/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3140#[allow(clippy::result_large_err)]
3141fn validate_locate_request(
3142    state: &AppState,
3143    file_path: &str,
3144    csp_nonce: &str,
3145) -> Result<(PathBuf, PathBuf), Response> {
3146    let raw = PathBuf::from(file_path);
3147
3148    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3149    let html_path = if raw.is_dir() {
3150        let found = find_html_report_in_tree(&raw);
3151        match found {
3152            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3153            None => {
3154                return Err(locate_report_error(
3155                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3156                     the folder that contains your scan output (result_*.html or report_*.html).",
3157                    csp_nonce,
3158                ));
3159            }
3160        }
3161    } else {
3162        let file_ext = raw
3163            .extension()
3164            .and_then(|e| e.to_str())
3165            .unwrap_or("")
3166            .to_ascii_lowercase();
3167        if file_ext != "html" {
3168            return Err(locate_report_error(
3169                "Please select the scan output folder, or an .html report file directly.",
3170                csp_nonce,
3171            ));
3172        }
3173        match fs::canonicalize(&raw) {
3174            Ok(p) => strip_unc_prefix(p),
3175            Err(_) => {
3176                return Err(locate_report_error(
3177                    "Report file not found or path is invalid.",
3178                    csp_nonce,
3179                ));
3180            }
3181        }
3182    };
3183
3184    if state.server_mode {
3185        let output_root = resolve_output_root(None);
3186        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3187        if !html_path.starts_with(&canonical_root) {
3188            return Err(locate_report_error(
3189                "Report file must be within the configured output directory.",
3190                csp_nonce,
3191            ));
3192        }
3193    }
3194    let parent = match html_path.parent() {
3195        Some(p) => p.to_path_buf(),
3196        None => {
3197            return Err(locate_report_error(
3198                "Report file has no parent directory.",
3199                csp_nonce,
3200            ));
3201        }
3202    };
3203    Ok((html_path, parent))
3204}
3205
3206/// JSON-or-HTML error for `locate_report_handler` error paths.
3207fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3208    if want_json {
3209        (
3210            StatusCode::UNPROCESSABLE_ENTITY,
3211            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3212        )
3213            .into_response()
3214    } else {
3215        locate_report_error(msg, csp_nonce)
3216    }
3217}
3218
3219/// JSON-or-redirect success for locate/relocate handler success paths.
3220fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3221    if want_json {
3222        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3223    } else {
3224        axum::response::Redirect::to(redirect).into_response()
3225    }
3226}
3227
3228/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3229/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3230fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3231    for jpath in candidates {
3232        if let Ok(run) = read_json(jpath) {
3233            if expected.is_empty() || run.tool.run_id == expected {
3234                return Some((jpath.clone(), run.tool.run_id));
3235            }
3236        }
3237    }
3238    None
3239}
3240
3241fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3242    html_path
3243        .parent()
3244        .and_then(|p| p.parent())
3245        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3246}
3247
3248fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3249    let mut hits = collect_result_json_candidates(scan_root);
3250    if hits.is_empty() {
3251        hits = collect_result_json_candidates(parent);
3252    }
3253    hits.sort();
3254    hits
3255}
3256
3257#[allow(clippy::too_many_lines)]
3258async fn locate_report_handler(
3259    State(state): State<AppState>,
3260    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3261    headers: axum::http::HeaderMap,
3262    Form(form): Form<LocateReportForm>,
3263) -> impl IntoResponse {
3264    let want_json = headers
3265        .get(axum::http::header::ACCEPT)
3266        .and_then(|v| v.to_str().ok())
3267        .is_some_and(|v| v.contains("application/json"));
3268
3269    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3270        Ok(v) => v,
3271        Err(resp) => {
3272            if want_json {
3273                return locate_handler_err(
3274                    true,
3275                    "No HTML report file found in the selected folder. \
3276                     Make sure you selected the folder that contains your \
3277                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3278                        .to_string(),
3279                    &csp_nonce,
3280                );
3281            }
3282            return resp;
3283        }
3284    };
3285
3286    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3287    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3288    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3289    let scan_root: &Path = &scan_root_owned;
3290    let json_candidates = gather_json_candidates(scan_root, &parent);
3291
3292    // If the expected_run_id was provided, find a JSON that matches it exactly.
3293    let expected_run_id = form
3294        .expected_run_id
3295        .as_deref()
3296        .unwrap_or("")
3297        .trim()
3298        .to_string();
3299
3300    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3301
3302    // If we have candidates but none matched the expected run_id, surface a clear error.
3303    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3304        let actual = json_candidates
3305            .iter()
3306            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3307            .unwrap_or_else(|| "unknown".to_string());
3308        return locate_handler_err(
3309            want_json,
3310            format!(
3311                "This folder contains a different scan.\n\n\
3312                 Expected run ID : {expected_run_id}\n\
3313                 Found run ID    : {actual}\n\n\
3314                 Please select the folder that contains the correct scan output."
3315            ),
3316            &csp_nonce,
3317        );
3318    }
3319
3320    let safe_redirect = form
3321        .redirect_url
3322        .as_deref()
3323        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3324        .unwrap_or("/view-reports?linked=1")
3325        .to_string();
3326
3327    let mut reg = state.registry.lock().await;
3328
3329    if let Some((json_path, run_id)) = matched_json {
3330        // Match by run_id in the registry (works even after files are moved).
3331        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3332            entry.html_path = Some(html_path);
3333            entry.json_path = Some(json_path);
3334            let _ = reg.save(&state.registry_path);
3335            drop(reg);
3336            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3337            state.artifacts.lock().await.remove(&run_id);
3338            return redirect_or_json_ok(want_json, &safe_redirect);
3339        }
3340        // No existing entry — build one from the JSON.
3341        match read_json(&json_path) {
3342            Ok(run) => {
3343                let entry = registry_entry_from_run(&run, json_path, html_path);
3344                reg.add_entry(entry);
3345                let _ = reg.save(&state.registry_path);
3346                drop(reg);
3347                state.artifacts.lock().await.remove(&run_id);
3348                return redirect_or_json_ok(want_json, &safe_redirect);
3349            }
3350            Err(e) => {
3351                drop(reg);
3352                return locate_handler_err(
3353                    want_json,
3354                    format!(
3355                        "Found the scan folder but could not parse the result JSON.\n\n\
3356                         The file may have been saved by an older version of OxideSLOC. \
3357                         Re-running the analysis will create a fresh, compatible record.\n\n\
3358                         Error: {e}"
3359                    ),
3360                    &csp_nonce,
3361                );
3362            }
3363        }
3364    }
3365
3366    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3367    if let Some(entry) = reg
3368        .entries
3369        .iter_mut()
3370        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3371    {
3372        entry.html_path = Some(html_path.clone());
3373        let _ = reg.save(&state.registry_path);
3374        drop(reg);
3375        state.artifacts.lock().await.remove(&expected_run_id);
3376        return redirect_or_json_ok(want_json, &safe_redirect);
3377    }
3378
3379    drop(reg);
3380    let hint = if state.server_mode {
3381        String::new()
3382    } else {
3383        format!(
3384            "\n\nSearched folder : {}\nHTML found      : {}",
3385            scan_root.display(),
3386            html_path.display()
3387        )
3388    };
3389    locate_handler_err(
3390        want_json,
3391        format!(
3392            "Could not link this report.\n\n\
3393             No result_*.json was found in the selected folder. \
3394             Make sure you selected the top-level scan output folder \
3395             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3396        ),
3397        &csp_nonce,
3398    )
3399}
3400
3401/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3402fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3403    fs::read_dir(dir)
3404        .ok()?
3405        .flatten()
3406        .map(|e| e.path())
3407        .find(|p| {
3408            p.is_file()
3409                && p.file_stem()
3410                    .and_then(|n| n.to_str())
3411                    .is_some_and(|n| n.starts_with("result"))
3412                && p.extension()
3413                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3414        })
3415}
3416
3417#[derive(Deserialize)]
3418struct LocateReportsDirForm {
3419    folder_path: String,
3420}
3421
3422#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3423async fn locate_reports_dir_handler(
3424    State(state): State<AppState>,
3425    Form(form): Form<LocateReportsDirForm>,
3426) -> impl IntoResponse {
3427    if state.server_mode {
3428        return StatusCode::NOT_FOUND.into_response();
3429    }
3430    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3431        Ok(p) => strip_unc_prefix(p),
3432        Err(_) => {
3433            return axum::response::Redirect::to(
3434                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3435            )
3436            .into_response();
3437        }
3438    };
3439    if !folder.is_dir() {
3440        return axum::response::Redirect::to(
3441            "/view-reports?error=Selected+path+is+not+a+directory.",
3442        )
3443        .into_response();
3444    }
3445
3446    let candidates = collect_result_json_candidates(&folder);
3447
3448    if candidates.is_empty() {
3449        return axum::response::Redirect::to(
3450            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3451        )
3452        .into_response();
3453    }
3454
3455    let mut linked_count: usize = 0;
3456    let mut reg = state.registry.lock().await;
3457    for json_path in candidates {
3458        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3459            continue;
3460        };
3461        if is_dir_already_registered(&reg, &parent) {
3462            continue;
3463        }
3464        let Some(entry) = build_registry_entry_from_json(json_path) else {
3465            continue;
3466        };
3467        reg.add_entry(entry);
3468        linked_count += 1;
3469    }
3470    let _ = reg.save(&state.registry_path);
3471    drop(reg);
3472
3473    if linked_count == 0 {
3474        return axum::response::Redirect::to(
3475            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3476        )
3477        .into_response();
3478    }
3479    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3480}
3481
3482#[derive(Deserialize)]
3483struct RelocateScanForm {
3484    run_id: String,
3485    folder_path: String,
3486    redirect_url: String,
3487}
3488
3489/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3490/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3491fn relocate_folder_err(
3492    want_json: bool,
3493    status: StatusCode,
3494    msg: &str,
3495    run_id: &str,
3496    folder_hint: &str,
3497    redirect_url: &str,
3498    csp_nonce: &str,
3499) -> Response {
3500    if want_json {
3501        (
3502            status,
3503            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3504        )
3505            .into_response()
3506    } else {
3507        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3508    }
3509}
3510
3511#[allow(clippy::too_many_lines)]
3512async fn relocate_scan_handler(
3513    State(state): State<AppState>,
3514    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3515    headers: axum::http::HeaderMap,
3516    Form(form): Form<RelocateScanForm>,
3517) -> impl IntoResponse {
3518    let want_json = headers
3519        .get(axum::http::header::ACCEPT)
3520        .and_then(|v| v.to_str().ok())
3521        .is_some_and(|v| v.contains("application/json"));
3522    if state.server_mode {
3523        return StatusCode::NOT_FOUND.into_response();
3524    }
3525
3526    let run_id = form.run_id.trim().to_string();
3527    let redirect_url = form.redirect_url.trim().to_string();
3528
3529    let run_exists = {
3530        let reg = state.registry.lock().await;
3531        reg.find_by_run_id(&run_id).is_some()
3532    };
3533    if !run_exists {
3534        if want_json {
3535            return (
3536                StatusCode::NOT_FOUND,
3537                axum::Json(serde_json::json!({
3538                    "ok": false,
3539                    "message": format!("Run ID '{run_id}' not found in registry.")
3540                })),
3541            )
3542                .into_response();
3543        }
3544        let html = ErrorTemplate {
3545            message: format!("Run ID '{run_id}' not found in registry."),
3546            last_report_url: Some("/compare-scans".to_string()),
3547            last_report_label: Some("Compare Scans".to_string()),
3548            run_id: Some(run_id.clone()),
3549            error_code: Some(404),
3550            csp_nonce: csp_nonce.clone(),
3551            version: env!("CARGO_PKG_VERSION"),
3552        }
3553        .render()
3554        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3555        return Html(html).into_response();
3556    }
3557
3558    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3559        Ok(p) => strip_unc_prefix(p),
3560        Err(_) => {
3561            return relocate_folder_err(
3562                want_json,
3563                StatusCode::UNPROCESSABLE_ENTITY,
3564                "Folder not found or path is invalid.",
3565                &run_id,
3566                form.folder_path.trim(),
3567                &redirect_url,
3568                &csp_nonce,
3569            );
3570        }
3571    };
3572    if !folder.is_dir() {
3573        return relocate_folder_err(
3574            want_json,
3575            StatusCode::UNPROCESSABLE_ENTITY,
3576            "Selected path is not a directory.",
3577            &run_id,
3578            &folder.display().to_string(),
3579            &redirect_url,
3580            &csp_nonce,
3581        );
3582    }
3583
3584    let json_candidates = find_result_files_by_ext(&folder, "json");
3585    if json_candidates.is_empty() {
3586        let msg = format!(
3587            "No result JSON files found in the selected folder.\nSearched: {}",
3588            folder.display()
3589        );
3590        return relocate_folder_err(
3591            want_json,
3592            StatusCode::UNPROCESSABLE_ENTITY,
3593            &msg,
3594            &run_id,
3595            &folder.display().to_string(),
3596            &redirect_url,
3597            &csp_nonce,
3598        );
3599    }
3600
3601    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3602        let msg = format!(
3603            "No matching scan found in the selected folder.\n\
3604             The JSON files present do not contain run ID: {run_id}\n\
3605             Searched: {}",
3606            folder.display()
3607        );
3608        return relocate_folder_err(
3609            want_json,
3610            StatusCode::UNPROCESSABLE_ENTITY,
3611            &msg,
3612            &run_id,
3613            &folder.display().to_string(),
3614            &redirect_url,
3615            &csp_nonce,
3616        );
3617    };
3618
3619    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3620    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3621    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3622
3623    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3624        redirect_url
3625    } else {
3626        "/compare-scans".to_string()
3627    };
3628    redirect_or_json_ok(want_json, &safe_redirect)
3629}
3630
3631fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3632    let mut out = Vec::new();
3633    collect_scan_files_by_ext(folder, ext, &mut out);
3634    if let Ok(rd) = fs::read_dir(folder) {
3635        for entry in rd.flatten() {
3636            let sub = entry.path();
3637            if sub.is_dir() {
3638                collect_scan_files_by_ext(&sub, ext, &mut out);
3639            }
3640        }
3641    }
3642    out
3643}
3644
3645fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3646    let Ok(rd) = fs::read_dir(dir) else { return };
3647    for entry in rd.flatten() {
3648        let p = entry.path();
3649        if p.is_file()
3650            && p.file_stem()
3651                .and_then(|n| n.to_str())
3652                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3653            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3654        {
3655            out.push(p);
3656        }
3657    }
3658}
3659
3660fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3661    candidates
3662        .iter()
3663        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3664        .cloned()
3665}
3666
3667/// Return the best folder hint for the relocate page.
3668/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
3669/// point at the parent — the actual top-level output directory — so the user
3670/// selects the root folder rather than the subfolder.
3671fn output_folder_hint(json_path: &std::path::Path) -> String {
3672    let Some(direct_parent) = json_path.parent() else {
3673        return String::new();
3674    };
3675    let parent_name = direct_parent
3676        .file_name()
3677        .and_then(|n| n.to_str())
3678        .unwrap_or("");
3679    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3680        direct_parent.parent().map_or_else(
3681            || direct_parent.display().to_string(),
3682            |p| p.display().to_string(),
3683        )
3684    } else {
3685        direct_parent.display().to_string()
3686    }
3687}
3688
3689async fn update_run_file_paths(
3690    state: &AppState,
3691    run_id: &str,
3692    json_path: PathBuf,
3693    html_path: Option<PathBuf>,
3694    pdf_path: Option<PathBuf>,
3695) {
3696    {
3697        let mut reg = state.registry.lock().await;
3698        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3699            entry.json_path = Some(json_path.clone());
3700            if let Some(ref hp) = html_path {
3701                entry.html_path = Some(hp.clone());
3702            }
3703            if let Some(ref pp) = pdf_path {
3704                entry.pdf_path = Some(pp.clone());
3705            }
3706        }
3707        let _ = reg.save(&state.registry_path);
3708    }
3709    // Also patch the in-memory artifacts map so the result page picks up the
3710    // new paths without requiring a server restart.
3711    {
3712        let mut map = state.artifacts.lock().await;
3713        if let Some(arts) = map.get_mut(run_id) {
3714            arts.json_path = Some(json_path);
3715            if let Some(hp) = html_path {
3716                arts.html_path = Some(hp);
3717            }
3718            if let Some(pp) = pdf_path {
3719                arts.pdf_path = Some(pp);
3720            }
3721        }
3722    }
3723}
3724
3725fn missing_scan_relocate_response(
3726    message: &str,
3727    run_id: &str,
3728    folder_hint: &str,
3729    redirect_url: &str,
3730    server_mode: bool,
3731    csp_nonce: &str,
3732) -> axum::response::Response {
3733    let html = RelocateScanTemplate {
3734        message: message.to_string(),
3735        run_id: run_id.to_string(),
3736        folder_hint: folder_hint.to_string(),
3737        redirect_url: redirect_url.to_string(),
3738        server_mode,
3739        csp_nonce: csp_nonce.to_owned(),
3740        version: env!("CARGO_PKG_VERSION"),
3741    }
3742    .render()
3743    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3744    (StatusCode::NOT_FOUND, Html(html)).into_response()
3745}
3746
3747// ── Watched-directory helpers ─────────────────────────────────────────────────
3748
3749/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3750fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3751    fs::read_dir(dir)
3752        .ok()?
3753        .flatten()
3754        .map(|e| e.path())
3755        .find(|p| {
3756            p.is_file()
3757                && p.extension()
3758                    .and_then(|e| e.to_str())
3759                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3760        })
3761}
3762
3763/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
3764/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
3765/// (`<scan_dir>/json/result*.json`).
3766fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3767    let mut out = Vec::new();
3768    if let Some(j) = find_result_json_in_dir(sub) {
3769        out.push(j);
3770    }
3771    let json_sub = sub.join("json");
3772    if json_sub.is_dir() {
3773        if let Some(j) = find_result_json_in_dir(&json_sub) {
3774            out.push(j);
3775        }
3776    }
3777    out
3778}
3779
3780fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3781    let mut candidates = Vec::new();
3782    if let Some(j) = find_result_json_in_dir(folder) {
3783        candidates.push(j);
3784    }
3785    let Ok(dir_entries) = fs::read_dir(folder) else {
3786        return candidates;
3787    };
3788    for entry in dir_entries.flatten() {
3789        let sub = entry.path();
3790        if sub.is_dir() {
3791            candidates.extend(subdir_result_json_candidates(&sub));
3792        }
3793    }
3794    candidates
3795}
3796
3797fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3798    reg.entries.iter().any(|e| {
3799        let dir_match = e
3800            .json_path
3801            .as_ref()
3802            .and_then(|p| p.parent())
3803            .is_some_and(|p| p == parent)
3804            || e.html_path
3805                .as_ref()
3806                .and_then(|p| p.parent())
3807                .is_some_and(|p| p == parent);
3808        dir_match
3809            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3810                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3811    })
3812}
3813
3814fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3815    let json_dir = json_path.parent()?.to_path_buf();
3816    // If the JSON lives inside a directory named "json", the scan root is its parent
3817    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
3818    let (html_path, pdf_path, csv_path, xlsx_path) =
3819        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3820            let scan_root = json_dir.parent()?;
3821            let html = find_html_report_in_dir(&scan_root.join("html"))
3822                .or_else(|| find_html_report_in_dir(scan_root));
3823            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3824            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3825            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3826            (html, pdf, csv, xlsx)
3827        } else {
3828            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3829                rd.flatten()
3830                    .map(|e| e.path())
3831                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3832            });
3833            (html, None, None, None)
3834        };
3835    let run = read_json(&json_path).ok()?;
3836    let project_label = run.input_roots.first().map_or_else(
3837        || "Unknown Project".to_string(),
3838        |r| sanitize_project_label(r),
3839    );
3840    Some(RegistryEntry {
3841        run_id: run.tool.run_id.clone(),
3842        timestamp_utc: run.tool.timestamp_utc,
3843        project_label,
3844        input_roots: run.input_roots.clone(),
3845        json_path: Some(json_path),
3846        html_path,
3847        pdf_path,
3848        csv_path,
3849        xlsx_path,
3850        summary: ScanSummarySnapshot::from(&run.summary_totals),
3851        git_branch: run.git_branch.clone(),
3852        git_commit: run.git_commit_short.clone(),
3853        git_commit_long: run.git_commit_long.clone(),
3854        git_author: run.git_commit_author.clone(),
3855        git_tags: run.git_tags.clone(),
3856        git_nearest_tag: run.git_nearest_tag.clone(),
3857        git_commit_date: run.git_commit_date,
3858    })
3859}
3860
3861/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3862/// Returns the number of newly linked entries.
3863fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3864    let mut linked = 0usize;
3865    for json_path in collect_result_json_candidates(folder) {
3866        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3867            continue;
3868        };
3869        if is_dir_already_registered(reg, &parent) {
3870            continue;
3871        }
3872        let Some(entry) = build_registry_entry_from_json(json_path) else {
3873            continue;
3874        };
3875        reg.add_entry(entry);
3876        linked += 1;
3877    }
3878    linked
3879}
3880
3881/// Scan all watched directories (plus the default output root) into `reg`.
3882async fn auto_scan_watched_dirs(state: &AppState) {
3883    let dirs: Vec<PathBuf> = {
3884        let wd = state.watched_dirs.lock().await;
3885        wd.dirs.clone()
3886    };
3887    if dirs.is_empty() {
3888        return;
3889    }
3890    let mut reg = state.registry.lock().await;
3891    let mut total = 0usize;
3892    for dir in &dirs {
3893        if dir.is_dir() {
3894            total += scan_folder_into_registry(dir, &mut reg);
3895        }
3896    }
3897    if total > 0 {
3898        let _ = reg.save(&state.registry_path);
3899    }
3900}
3901
3902// ── Watched-dir route forms ───────────────────────────────────────────────────
3903
3904#[derive(Deserialize)]
3905struct WatchedDirForm {
3906    folder_path: String,
3907    #[serde(default = "default_redirect")]
3908    redirect_to: String,
3909}
3910
3911fn default_redirect() -> String {
3912    "/view-reports".to_string()
3913}
3914
3915#[derive(Deserialize)]
3916struct WatchedDirRefreshForm {
3917    #[serde(default = "default_redirect")]
3918    redirect_to: String,
3919}
3920
3921// ── Watched-dir helpers ───────────────────────────────────────────────────────
3922
3923/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
3924fn safe_redirect(dest: &str) -> &str {
3925    if dest.starts_with('/') {
3926        dest
3927    } else {
3928        "/"
3929    }
3930}
3931
3932// ── Watched-dir handlers ──────────────────────────────────────────────────────
3933
3934async fn add_watched_dir_handler(
3935    State(state): State<AppState>,
3936    Form(form): Form<WatchedDirForm>,
3937) -> impl IntoResponse {
3938    if state.server_mode {
3939        return StatusCode::NOT_FOUND.into_response();
3940    }
3941    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
3942        strip_unc_prefix(p)
3943    } else {
3944        let dest = format!(
3945            "{}?error=Folder+not+found+or+path+is+invalid.",
3946            safe_redirect(&form.redirect_to)
3947        );
3948        return axum::response::Redirect::to(&dest).into_response();
3949    };
3950    if !folder.is_dir() {
3951        let dest = format!(
3952            "{}?error=Selected+path+is+not+a+directory.",
3953            safe_redirect(&form.redirect_to)
3954        );
3955        return axum::response::Redirect::to(&dest).into_response();
3956    }
3957
3958    // Persist the watched directory.
3959    {
3960        let mut wd = state.watched_dirs.lock().await;
3961        wd.add(folder.clone());
3962        let _ = wd.save(&state.watched_dirs_path);
3963    }
3964
3965    // Immediately scan the folder and add any new reports.
3966    let linked = {
3967        let mut reg = state.registry.lock().await;
3968        let n = scan_folder_into_registry(&folder, &mut reg);
3969        if n > 0 {
3970            let _ = reg.save(&state.registry_path);
3971        }
3972        n
3973    };
3974
3975    let dest = if linked > 0 {
3976        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
3977    } else {
3978        format!(
3979            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
3980            safe_redirect(&form.redirect_to)
3981        )
3982    };
3983    axum::response::Redirect::to(&dest).into_response()
3984}
3985
3986async fn remove_watched_dir_handler(
3987    State(state): State<AppState>,
3988    Form(form): Form<WatchedDirForm>,
3989) -> impl IntoResponse {
3990    if state.server_mode {
3991        return StatusCode::NOT_FOUND.into_response();
3992    }
3993    let folder = PathBuf::from(&form.folder_path);
3994    {
3995        let mut wd = state.watched_dirs.lock().await;
3996        wd.remove(&folder);
3997        let _ = wd.save(&state.watched_dirs_path);
3998    }
3999    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4000}
4001
4002async fn refresh_watched_dirs_handler(
4003    State(state): State<AppState>,
4004    Form(form): Form<WatchedDirRefreshForm>,
4005) -> impl IntoResponse {
4006    if state.server_mode {
4007        return StatusCode::NOT_FOUND.into_response();
4008    }
4009    let dirs: Vec<PathBuf> = {
4010        let wd = state.watched_dirs.lock().await;
4011        wd.dirs.clone()
4012    };
4013    let mut total = 0usize;
4014    {
4015        let mut reg = state.registry.lock().await;
4016        reg.prune_stale();
4017        for dir in &dirs {
4018            if dir.is_dir() {
4019                total += scan_folder_into_registry(dir, &mut reg);
4020            }
4021        }
4022        let _ = reg.save(&state.registry_path);
4023    }
4024    let dest = if total > 0 {
4025        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4026    } else {
4027        safe_redirect(&form.redirect_to).to_owned()
4028    };
4029    axum::response::Redirect::to(&dest).into_response()
4030}
4031
4032#[derive(Debug, Deserialize)]
4033struct OpenPathQuery {
4034    path: Option<String>,
4035}
4036
4037fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4038    let mut ancestor = std::path::Path::new(raw);
4039    loop {
4040        match ancestor.parent() {
4041            Some(p) => {
4042                ancestor = p;
4043                if ancestor.is_dir() {
4044                    break;
4045                }
4046            }
4047            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4048        }
4049    }
4050    Ok(ancestor.to_path_buf())
4051}
4052
4053async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4054    match tokio::fs::canonicalize(raw).await {
4055        Ok(canonical) if canonical.is_file() => canonical
4056            .parent()
4057            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4058                Ok(p.to_path_buf())
4059            }),
4060        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4061        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4062        Err(_) => find_existing_ancestor(raw),
4063    }
4064}
4065
4066async fn open_path_handler(
4067    State(state): State<AppState>,
4068    Query(query): Query<OpenPathQuery>,
4069) -> impl IntoResponse {
4070    if state.server_mode {
4071        return Json(serde_json::json!({
4072            "server_mode_disabled": true,
4073            "message": "Opening a path in the file manager is only available in local desktop mode."
4074        }))
4075        .into_response();
4076    }
4077    // Skip the OS file-manager call in headless / CI environments.
4078    if std::env::var("SLOC_HEADLESS").is_ok() {
4079        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4080    }
4081    let raw = match query.path.as_deref() {
4082        Some(p) if !p.is_empty() => p,
4083        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4084    };
4085
4086    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4087    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4088    // so the file explorer still opens somewhere useful.
4089    let target = match resolve_open_target(raw).await {
4090        Ok(p) => p,
4091        Err((code, msg)) => return (code, msg).into_response(),
4092    };
4093
4094    #[cfg(target_os = "windows")]
4095    win_dialog_focus::open_folder_foreground(target);
4096    #[cfg(target_os = "macos")]
4097    let _ = std::process::Command::new("open")
4098        .arg(&target)
4099        .stdout(Stdio::null())
4100        .stderr(Stdio::null())
4101        .spawn();
4102    #[cfg(target_os = "linux")]
4103    {
4104        let folder_name = target
4105            .file_name()
4106            .and_then(|n| n.to_str())
4107            .map(str::to_owned);
4108        let _ = std::process::Command::new("xdg-open")
4109            .arg(&target)
4110            .stdout(Stdio::null())
4111            .stderr(Stdio::null())
4112            .spawn();
4113        // Best-effort: raise the file manager window once it appears.
4114        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4115        // installed; failures are silently discarded.
4116        if let Some(name) = folder_name {
4117            std::thread::spawn(move || {
4118                std::thread::sleep(std::time::Duration::from_millis(800));
4119                let _ = std::process::Command::new("wmctrl")
4120                    .args(["-a", &name])
4121                    .stdout(Stdio::null())
4122                    .stderr(Stdio::null())
4123                    .spawn();
4124            });
4125        }
4126    }
4127
4128    Json(serde_json::json!({"ok": true})).into_response()
4129}
4130
4131async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4132    let (content_type, bytes): (&'static str, &'static [u8]) =
4133        match (folder.as_str(), file.as_str()) {
4134            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4135            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4136            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4137            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4138            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4139            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4140            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4141            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4142            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4143            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4144            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4145            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4146            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4147            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4148            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4149            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4150            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4151            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4152            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4153            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4154            _ => return StatusCode::NOT_FOUND.into_response(),
4155        };
4156    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4157}
4158
4159/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4160/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4161/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4162/// complexity low; the fail-closed semantics are unchanged.
4163fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4164    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4165    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4166    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4167    // sample/upload locations are permitted; everything else is rejected.
4168    let Ok(canonical) = fs::canonicalize(resolved) else {
4169        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4170            return Err(Html(
4171                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4172            ));
4173        }
4174        return Ok(());
4175    };
4176    // Upload temp dirs and built-in sample/fixture paths are always safe.
4177    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4178        return Ok(());
4179    }
4180    let config = &state.base_config;
4181    if config.discovery.allowed_scan_roots.is_empty() {
4182        return Err(Html(
4183            r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4184        ));
4185    }
4186    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4187        fs::canonicalize(root)
4188            .ok()
4189            .is_some_and(|r| canonical.starts_with(&r))
4190    });
4191    if !allowed {
4192        return Err(Html(
4193            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4194        ));
4195    }
4196    Ok(())
4197}
4198
4199async fn preview_handler(
4200    State(state): State<AppState>,
4201    Query(query): Query<PreviewQuery>,
4202) -> impl IntoResponse {
4203    let raw_path = query
4204        .path
4205        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
4206    let resolved = resolve_input_path(&raw_path);
4207
4208    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4209    // binary whose working directory is not the project root), return a clear message
4210    // instead of an opaque OS error from build_preview_html.
4211    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4212        return Html(
4213            r#"<div class="preview-error">Sample directory not available on this server.
4214            Enter a path to a project directory or upload files using Browse.</div>"#
4215                .to_string(),
4216        );
4217    }
4218
4219    if state.server_mode {
4220        if let Err(resp) = authorize_preview_path(&state, &resolved) {
4221            return resp;
4222        }
4223    }
4224
4225    let include_patterns = split_patterns(query.include_globs.as_deref());
4226    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4227
4228    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4229        Ok(html) => Html(html),
4230        Err(err) => Html(format!(
4231            r#"<div class="preview-error">Preview failed: {}</div>"#,
4232            escape_html(&err.to_string())
4233        )),
4234    }
4235}
4236
4237#[derive(Debug, Deserialize, Default)]
4238struct SuggestCoverageQuery {
4239    path: Option<String>,
4240}
4241
4242#[derive(Serialize)]
4243struct SuggestCoverageResponse {
4244    found: Option<String>,
4245    tool: Option<&'static str>,
4246    hint: Option<&'static str>,
4247}
4248
4249async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4250    const CANDIDATES: &[&str] = &[
4251        // LCOV — cargo-llvm-cov, gcov, lcov
4252        "coverage/lcov.info",
4253        "lcov.info",
4254        "target/llvm-cov/lcov.info",
4255        "target/coverage/lcov.info",
4256        "target/debug/coverage/lcov.info",
4257        "coverage/coverage.lcov",
4258        "build/coverage/lcov.info",
4259        "reports/lcov.info",
4260        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4261        "coverage.xml",
4262        "coverage/coverage.xml",
4263        "target/site/cobertura/coverage.xml",
4264        "build/reports/coverage/coverage.xml",
4265        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4266        "target/site/jacoco/jacoco.xml",
4267        "build/reports/jacoco/test/jacocoTestReport.xml",
4268        "build/reports/jacoco/jacocoTestReport.xml",
4269        "build/jacoco/jacoco.xml",
4270        // coverage.py native JSON — `coverage json`
4271        "coverage.json",
4272        "coverage/coverage.json",
4273    ];
4274    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4275    let found = CANDIDATES
4276        .iter()
4277        .map(|rel| root.join(rel))
4278        .find(|p| p.is_file())
4279        .map(|p| display_path(&p));
4280
4281    let (tool, hint) = detect_coverage_tool(&root);
4282    Json(SuggestCoverageResponse { found, tool, hint })
4283}
4284
4285/// Inspect the project root for known build/package files and return the most likely coverage
4286/// tool name and the shell command needed to generate a coverage file.
4287fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4288    if root.join("Cargo.toml").is_file() {
4289        return (
4290            Some("cargo-llvm-cov"),
4291            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4292        );
4293    }
4294    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4295        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4296    }
4297    if root.join("pom.xml").is_file() {
4298        return (Some("jacoco"), Some("mvn test jacoco:report"));
4299    }
4300    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4301        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4302    }
4303    (None, None)
4304}
4305
4306/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4307#[allow(clippy::result_large_err)]
4308fn validate_server_scan_path(
4309    config: &sloc_config::AppConfig,
4310    resolved_path: &Path,
4311    csp_nonce: &str,
4312) -> Result<(), Response> {
4313    if config.discovery.allowed_scan_roots.is_empty() {
4314        let template = ErrorTemplate {
4315            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4316                      Set allowed_scan_roots in the server config to permit scanning."
4317                .to_string(),
4318            last_report_url: None,
4319            last_report_label: None,
4320            run_id: None,
4321            error_code: Some(403),
4322            csp_nonce: csp_nonce.to_owned(),
4323            version: env!("CARGO_PKG_VERSION"),
4324        };
4325        return Err((
4326            StatusCode::FORBIDDEN,
4327            Html(
4328                template
4329                    .render()
4330                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4331            ),
4332        )
4333            .into_response());
4334    }
4335    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4336    // location) we must NOT fall back to the raw, un-normalised path — a textual
4337    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4338    // allowlist. A non-resolvable scan target is rejected outright.
4339    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4340        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4341            "Scan path does not resolve to a real location");
4342        let template = ErrorTemplate {
4343            message: "The requested path could not be resolved to a real directory.".to_string(),
4344            last_report_url: None,
4345            last_report_label: None,
4346            run_id: None,
4347            error_code: Some(403),
4348            csp_nonce: csp_nonce.to_owned(),
4349            version: env!("CARGO_PKG_VERSION"),
4350        };
4351        return Err((
4352            StatusCode::FORBIDDEN,
4353            Html(
4354                template
4355                    .render()
4356                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4357            ),
4358        )
4359            .into_response());
4360    };
4361    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4362        fs::canonicalize(root)
4363            .ok()
4364            .is_some_and(|r| canonical.starts_with(&r))
4365    });
4366    if !allowed {
4367        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4368            "Scan path not in allowed_scan_roots");
4369        let template = ErrorTemplate {
4370            message: "The requested path is not within an allowed scan directory.".to_string(),
4371            last_report_url: None,
4372            last_report_label: None,
4373            run_id: None,
4374            error_code: Some(403),
4375            csp_nonce: csp_nonce.to_owned(),
4376            version: env!("CARGO_PKG_VERSION"),
4377        };
4378        return Err((
4379            StatusCode::FORBIDDEN,
4380            Html(
4381                template
4382                    .render()
4383                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4384            ),
4385        )
4386            .into_response());
4387    }
4388    Ok(())
4389}
4390
4391/// Exclude the output directory from scanning so artifacts don't pollute counts.
4392fn apply_output_dir_exclusions(
4393    config: &mut sloc_config::AppConfig,
4394    project_path: &str,
4395    raw_output_dir: &str,
4396) {
4397    let project_root = resolve_input_path(project_path);
4398    let raw_out = raw_output_dir.trim();
4399    let resolved_out = if raw_out.is_empty() {
4400        project_root.join("sloc")
4401    } else if Path::new(raw_out).is_absolute() {
4402        PathBuf::from(raw_out)
4403    } else {
4404        workspace_root().join(raw_out)
4405    };
4406    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4407        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4408            let dir = first.to_string();
4409            if !config.discovery.excluded_directories.contains(&dir) {
4410                config.discovery.excluded_directories.push(dir);
4411            }
4412        }
4413    }
4414    if !config
4415        .discovery
4416        .excluded_directories
4417        .iter()
4418        .any(|d| d == "sloc")
4419    {
4420        config
4421            .discovery
4422            .excluded_directories
4423            .push("sloc".to_string());
4424    }
4425}
4426
4427/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4428const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4429    ScanSummarySnapshot {
4430        files_analyzed: run.summary_totals.files_analyzed,
4431        files_skipped: run.summary_totals.files_skipped,
4432        total_physical_lines: run.summary_totals.total_physical_lines,
4433        code_lines: run.summary_totals.code_lines,
4434        comment_lines: run.summary_totals.comment_lines,
4435        blank_lines: run.summary_totals.blank_lines,
4436        functions: run.summary_totals.functions,
4437        classes: run.summary_totals.classes,
4438        variables: run.summary_totals.variables,
4439        imports: run.summary_totals.imports,
4440        test_count: run.summary_totals.test_count,
4441        coverage_lines_found: run.summary_totals.coverage_lines_found,
4442        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4443        coverage_functions_found: run.summary_totals.coverage_functions_found,
4444        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4445        coverage_branches_found: run.summary_totals.coverage_branches_found,
4446        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4447    }
4448}
4449
4450/// Build the `RegistryEntry` for the just-completed scan run.
4451pub(crate) fn build_run_registry_entry(
4452    run: &AnalysisRun,
4453    run_id: &str,
4454    project_label: &str,
4455    artifacts: &RunArtifacts,
4456) -> RegistryEntry {
4457    RegistryEntry {
4458        run_id: run_id.to_owned(),
4459        timestamp_utc: run.tool.timestamp_utc,
4460        project_label: project_label.to_owned(),
4461        input_roots: run.input_roots.clone(),
4462        json_path: artifacts.json_path.clone(),
4463        html_path: artifacts.html_path.clone(),
4464        pdf_path: artifacts.pdf_path.clone(),
4465        csv_path: artifacts.csv_path.clone(),
4466        xlsx_path: artifacts.xlsx_path.clone(),
4467        summary: summary_snapshot_from_run(run),
4468        git_branch: run.git_branch.clone(),
4469        git_commit: run.git_commit_short.clone(),
4470        git_commit_long: run.git_commit_long.clone(),
4471        git_author: run.git_commit_author.clone(),
4472        git_tags: run.git_tags.clone(),
4473        git_nearest_tag: run.git_nearest_tag.clone(),
4474        git_commit_date: run.git_commit_date.clone(),
4475    }
4476}
4477
4478/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4479fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4480    if let Some(policy) = form.mixed_line_policy {
4481        config.analysis.mixed_line_policy = policy;
4482    }
4483    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4484    config.analysis.generated_file_detection =
4485        form.generated_file_detection.as_deref() != Some("disabled");
4486    config.analysis.minified_file_detection =
4487        form.minified_file_detection.as_deref() != Some("disabled");
4488    config.analysis.vendor_directory_detection =
4489        form.vendor_directory_detection.as_deref() != Some("disabled");
4490    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4491    if let Some(binary_behavior) = form.binary_file_behavior {
4492        config.analysis.binary_file_behavior = binary_behavior;
4493    }
4494    apply_report_opts(config, form);
4495    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4496    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4497    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4498    if let Some(policy) = form.continuation_line_policy {
4499        config.analysis.continuation_line_policy = policy;
4500    }
4501    if let Some(policy) = form.blank_in_block_comment_policy {
4502        config.analysis.blank_in_block_comment_policy = policy;
4503    }
4504    config.analysis.count_compiler_directives =
4505        form.count_compiler_directives.as_deref() != Some("disabled");
4506    apply_style_threshold(config, form);
4507    apply_coverage_path(config, form);
4508}
4509
4510fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4511    if let Some(report_title) = form.report_title.as_deref() {
4512        let trimmed = report_title.trim();
4513        if !trimmed.is_empty() {
4514            config.reporting.report_title = trimmed.to_string();
4515        }
4516    }
4517    if let Some(hf) = form.report_header_footer.as_deref() {
4518        let trimmed = hf.trim();
4519        config.reporting.report_header_footer = if trimmed.is_empty() {
4520            None
4521        } else {
4522            Some(trimmed.to_string())
4523        };
4524    }
4525}
4526
4527fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4528    apply_style_col_threshold(config, form);
4529    apply_style_analysis_enabled(config, form);
4530    apply_style_score_threshold(config, form);
4531    apply_style_lang_scope(config, form);
4532    apply_activity_window(config, form);
4533}
4534
4535fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4536    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4537        if let Ok(t) = threshold_str.parse::<u16>() {
4538            if t == 80 || t == 100 || t == 120 {
4539                config.analysis.style_col_threshold = t;
4540            }
4541        }
4542    }
4543}
4544
4545fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4546    if let Some(v) = form.style_analysis_enabled.as_deref() {
4547        config.analysis.style_analysis_enabled = v != "disabled";
4548    }
4549}
4550
4551fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4552    if let Some(v) = form.style_score_threshold.as_deref() {
4553        if let Ok(t) = v.parse::<u8>() {
4554            config.analysis.style_score_threshold = t.min(100);
4555        }
4556    }
4557}
4558
4559fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4560    if let Some(v) = form.style_lang_scope.as_deref() {
4561        let scope = v.trim();
4562        if scope == "c_family" || scope == "all" {
4563            config.analysis.style_lang_scope = scope.to_string();
4564        }
4565    }
4566}
4567
4568fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4569    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
4570    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
4571    if let Some(w) = form.activity_window.as_deref() {
4572        let w = w.trim();
4573        if !w.is_empty() {
4574            if let Ok(days) = w.parse::<u32>() {
4575                config.analysis.activity_window_days = Some(days);
4576            }
4577        }
4578    }
4579}
4580
4581fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4582    if let Some(cov) = &form.coverage_file {
4583        let trimmed = cov.trim();
4584        if !trimmed.is_empty() {
4585            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4586        }
4587    }
4588}
4589
4590/// Fire-and-forget: generate the PDF in a background task if one is pending.
4591/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4592/// an error instead of spinning indefinitely.
4593fn spawn_pdf_background(
4594    pending_pdf: PendingPdf,
4595    run_id: String,
4596    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4597) {
4598    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4599        tokio::spawn(async move {
4600            let result = tokio::task::spawn_blocking(move || {
4601                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4602                if cleanup_src {
4603                    let _ = fs::remove_file(&pdf_src);
4604                }
4605                r
4606            })
4607            .await;
4608            let failed = match result {
4609                Ok(Ok(())) => false,
4610                Ok(Err(err)) => {
4611                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4612                    true
4613                }
4614                Err(err) => {
4615                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4616                    true
4617                }
4618            };
4619            if failed {
4620                let mut map = artifacts.lock().await;
4621                if let Some(entry) = map.get_mut(&run_id) {
4622                    entry.pdf_path = None;
4623                }
4624            }
4625        });
4626    }
4627}
4628
4629/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
4630/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
4631/// result page can show an error on the next visit instead of spinning indefinitely.
4632fn spawn_native_pdf_background(
4633    json_path: PathBuf,
4634    pdf_dest: PathBuf,
4635    run_id: String,
4636    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4637) {
4638    tokio::spawn(async move {
4639        let result = tokio::task::spawn_blocking(move || {
4640            let run = sloc_core::read_json(&json_path)?;
4641            write_pdf_from_run(&run, &pdf_dest)
4642        })
4643        .await;
4644        let failed = match result {
4645            Ok(Ok(())) => false,
4646            Ok(Err(err)) => {
4647                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4648                true
4649            }
4650            Err(err) => {
4651                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4652                true
4653            }
4654        };
4655        if failed {
4656            let mut map = artifacts.lock().await;
4657            if let Some(entry) = map.get_mut(&run_id) {
4658                entry.pdf_path = None;
4659            }
4660        }
4661    });
4662}
4663
4664/// Sum the code lines added in this comparison (new + grown files).
4665fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4666    cmp.file_deltas
4667        .iter()
4668        .map(|f| match f.status {
4669            FileChangeStatus::Added => f.current_code,
4670            FileChangeStatus::Modified => f.code_delta.max(0),
4671            _ => 0,
4672        })
4673        .sum()
4674}
4675
4676/// Sum the code lines removed in this comparison (deleted + shrunk files).
4677fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4678    cmp.file_deltas
4679        .iter()
4680        .map(|f| match f.status {
4681            FileChangeStatus::Removed => f.baseline_code,
4682            FileChangeStatus::Modified => (-f.code_delta).max(0),
4683            _ => 0,
4684        })
4685        .sum()
4686}
4687
4688/// Sum the code lines present in both scans without any change (Unchanged files).
4689fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4690    cmp.file_deltas
4691        .iter()
4692        .filter(|f| f.status == FileChangeStatus::Unchanged)
4693        .map(|f| f.current_code)
4694        .sum()
4695}
4696
4697/// Sum the code lines residing in files that were modified between the two scans.
4698fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4699    cmp.file_deltas
4700        .iter()
4701        .filter(|f| f.status == FileChangeStatus::Modified)
4702        .map(|f| f.current_code)
4703        .sum()
4704}
4705
4706/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
4707fn build_submodule_row(
4708    s: &sloc_core::SubmoduleSummary,
4709    run: &AnalysisRun,
4710    run_id: &str,
4711    run_dir: &Path,
4712) -> SubmoduleRow {
4713    let safe = sanitize_project_label(&s.name);
4714    let artifact_key = format!("sub_{safe}");
4715    let pdf_artifact_key = format!("sub_{safe}_pdf");
4716    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4717        let parent_path = run
4718            .input_roots
4719            .first()
4720            .map_or("", std::string::String::as_str);
4721        let sub_run = build_sub_run(run, s, parent_path);
4722        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4723        render_sub_report_html(&sub_run, Some(&pdf_server_url))
4724            .ok()
4725            .and_then(|sub_html| {
4726                let sub_dir = run_dir.join("submodules");
4727                let _ = fs::create_dir_all(&sub_dir);
4728                let html_path = sub_dir.join(format!("{artifact_key}.html"));
4729                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4730                    // Pre-generate the sub-report PDF using the programmatic renderer
4731                    // so "View PDF" never needs to spawn Chrome for submodules.
4732                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4733                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
4734                    Some(format!("/runs/{artifact_key}/{run_id}"))
4735                } else {
4736                    None
4737                }
4738            })
4739    } else {
4740        None
4741    };
4742    SubmoduleRow {
4743        name: s.name.clone(),
4744        relative_path: s.relative_path.clone(),
4745        files_analyzed: s.files_analyzed,
4746        code_lines: s.code_lines,
4747        comment_lines: s.comment_lines,
4748        blank_lines: s.blank_lines,
4749        total_physical_lines: s.total_physical_lines,
4750        html_url,
4751    }
4752}
4753
4754// Immediately returns a wait page and runs the analysis in a background tokio task.
4755// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4756#[allow(clippy::similar_names)]
4757#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4758#[allow(clippy::too_many_lines)]
4759async fn analyze_handler(
4760    State(state): State<AppState>,
4761    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4762    Form(form): Form<AnalyzeForm>,
4763) -> impl IntoResponse {
4764    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4765        let template = ErrorTemplate {
4766            message: format!(
4767                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4768             Please wait a moment and try again."
4769            ),
4770            last_report_url: None,
4771            last_report_label: None,
4772            run_id: None,
4773            error_code: Some(503),
4774            csp_nonce: csp_nonce.clone(),
4775            version: env!("CARGO_PKG_VERSION"),
4776        };
4777        return (
4778            StatusCode::SERVICE_UNAVAILABLE,
4779            Html(
4780                template
4781                    .render()
4782                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4783            ),
4784        )
4785            .into_response();
4786    };
4787
4788    let mut config = state.base_config.clone();
4789
4790    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4791    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4792    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4793
4794    if !is_git_mode {
4795        let resolved_path = resolve_input_path(&form.path);
4796        if state.server_mode
4797            && !is_upload_tmp_path(&resolved_path)
4798            && !is_sample_path(&resolved_path)
4799        {
4800            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4801                return resp;
4802            }
4803        }
4804        config.discovery.root_paths = vec![resolved_path];
4805    }
4806
4807    apply_form_to_config(&mut config, &form);
4808    apply_output_dir_exclusions(
4809        &mut config,
4810        &form.path,
4811        form.output_dir.as_deref().unwrap_or(""),
4812    );
4813
4814    // Generate a wait_id now (before spawning) so the client can poll for status.
4815    let wait_id = uuid::Uuid::new_v4().to_string();
4816    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4817
4818    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4819    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4820    let task_cancel = Arc::clone(&cancel_token);
4821
4822    // Phase tracker: updated by run_analysis_task at key checkpoints.
4823    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4824    let task_phase = Arc::clone(&phase);
4825
4826    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4827    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4828    let task_files_done = Arc::clone(&files_done);
4829    let task_files_total = Arc::clone(&files_total);
4830
4831    // Register Running state before building the task struct so the semaphore permit
4832    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4833    {
4834        let mut runs = state.async_runs.lock().await;
4835        runs.insert(
4836            wait_id.clone(),
4837            AsyncRunState::Running {
4838                started_at: std::time::Instant::now(),
4839                cancel_token,
4840                phase,
4841                files_done,
4842                files_total,
4843            },
4844        );
4845    }
4846
4847    let task = AnalysisTask {
4848        sem_permit,
4849        state: state.clone(),
4850        wait_id: wait_id.clone(),
4851        config,
4852        cancel: task_cancel,
4853        phase: task_phase,
4854        files_done: task_files_done,
4855        files_total: task_files_total,
4856        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4857        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4858        project_path: form.path.clone(),
4859        // In server mode the client-supplied output_dir is ignored — artifacts are
4860        // always written under the server's configured output root so remote users
4861        // cannot direct writes to arbitrary filesystem paths.
4862        output_dir: if state.server_mode {
4863            None
4864        } else {
4865            form.output_dir.clone()
4866        },
4867        clones_dir: state.git_clones_dir.clone(),
4868        cocomo_mode: form
4869            .cocomo_mode
4870            .clone()
4871            .unwrap_or_else(|| "organic".to_string()),
4872        complexity_alert: form
4873            .complexity_alert
4874            .as_deref()
4875            .and_then(|s| s.parse::<u32>().ok())
4876            .unwrap_or(0),
4877        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
4878    };
4879
4880    tokio::spawn(run_analysis_task(task));
4881
4882    let template = ScanWaitTemplate {
4883        version: env!("CARGO_PKG_VERSION"),
4884        wait_id_json,
4885        project_path: form.path.clone(),
4886        csp_nonce,
4887    };
4888    let html = template
4889        .render()
4890        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
4891    let mut response = Html(html).into_response();
4892    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
4893        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
4894            response.headers_mut().insert(name, val);
4895        }
4896    }
4897    response
4898}
4899
4900struct AnalysisTask {
4901    sem_permit: tokio::sync::OwnedSemaphorePermit,
4902    state: AppState,
4903    wait_id: String,
4904    config: AppConfig,
4905    cancel: Arc<std::sync::atomic::AtomicBool>,
4906    phase: Arc<std::sync::Mutex<String>>,
4907    files_done: Arc<std::sync::atomic::AtomicUsize>,
4908    files_total: Arc<std::sync::atomic::AtomicUsize>,
4909    git_repo: Option<String>,
4910    git_ref: Option<String>,
4911    project_path: String,
4912    output_dir: Option<String>,
4913    clones_dir: PathBuf,
4914    cocomo_mode: String,
4915    complexity_alert: u32,
4916    exclude_duplicates: bool,
4917}
4918
4919#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
4920async fn run_analysis_task(task: AnalysisTask) {
4921    let _permit = task.sem_permit;
4922
4923    let cancel_sb = Arc::clone(&task.cancel);
4924    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
4925    let clones_dir_sb = task.clones_dir;
4926    // Save the upload staging path before config is moved into spawn_blocking.
4927    let upload_staging_root = task
4928        .config
4929        .discovery
4930        .root_paths
4931        .first()
4932        .filter(|p| is_upload_tmp_path(p))
4933        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
4934        .map(PathBuf::from);
4935    let config_sb = task.config;
4936    let progress_sb = sloc_core::ProgressCounters {
4937        files_done: Arc::clone(&task.files_done),
4938        files_total: Arc::clone(&task.files_total),
4939    };
4940    if let Ok(mut p) = task.phase.lock() {
4941        *p = "Scanning files".to_string();
4942    }
4943    let analysis_result = tokio::task::spawn_blocking(move || {
4944        run_analysis_blocking(
4945            config_sb,
4946            git_repo_sb,
4947            git_ref_sb,
4948            clones_dir_sb,
4949            cancel_sb,
4950            Some(progress_sb),
4951        )
4952    })
4953    .await
4954    .map_err(|err| anyhow::anyhow!(err.to_string()))
4955    .and_then(|result| result);
4956
4957    if let Ok(mut p) = task.phase.lock() {
4958        *p = "Writing reports".to_string();
4959    }
4960
4961    // If cancelled while running, discard results and mark as cancelled.
4962    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
4963        let mut runs = task.state.async_runs.lock().await;
4964        // Only overwrite if still Running (don't clobber a Complete that snuck in).
4965        if matches!(
4966            runs.get(&task.wait_id),
4967            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
4968        ) {
4969            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4970        }
4971        drop(runs);
4972        return;
4973    }
4974
4975    let run = match analysis_result {
4976        Ok(v) => v,
4977        Err(err) => {
4978            // Distinguish user-cancelled from real failure.
4979            if err.to_string().contains("analysis cancelled") {
4980                let mut runs = task.state.async_runs.lock().await;
4981                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
4982                drop(runs);
4983                return;
4984            }
4985            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
4986            let mut runs = task.state.async_runs.lock().await;
4987            runs.insert(
4988                task.wait_id.clone(),
4989                AsyncRunState::Failed {
4990                    message: "Analysis failed. Check that the path exists and is readable."
4991                        .to_string(),
4992                },
4993            );
4994            drop(runs);
4995            return;
4996        }
4997    };
4998
4999    let run_id = run.tool.run_id.clone();
5000    tracing::info!(event = "scan_complete", run_id = %run_id,
5001        path = %task.project_path, files = run.summary_totals.files_analyzed,
5002        "Analysis finished");
5003
5004    let prev_entry: Option<RegistryEntry> = {
5005        let reg = task.state.registry.lock().await;
5006        reg.entries_for_roots(&run.input_roots)
5007            .into_iter()
5008            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5009            .cloned()
5010    };
5011
5012    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5013        prev.json_path
5014            .as_ref()
5015            .and_then(|p| read_json(p).ok())
5016            .map(|prev_run| compute_delta(&prev_run, &run))
5017    });
5018    let prev_scan_count: usize = {
5019        let reg = task.state.registry.lock().await;
5020        reg.entries_for_roots(&run.input_roots)
5021            .iter()
5022            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5023            .count()
5024    };
5025
5026    // Build the HTML report now that delta is available, so the artifact
5027    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5028    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5029        .as_ref()
5030        .zip(prev_entry.as_ref())
5031        .map(|(cmp, prev)| ReportDeltaContext {
5032            delta_code_added: sum_added_code_lines(cmp),
5033            delta_code_removed: sum_removed_code_lines(cmp),
5034            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5035            delta_files_added: cmp.files_added,
5036            delta_files_removed: cmp.files_removed,
5037            delta_files_modified: cmp.files_modified,
5038            delta_files_unchanged: cmp.files_unchanged,
5039            prev_code_lines: prev.summary.code_lines,
5040            prev_scan_count: prev_scan_count + 1,
5041            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5042            prev_run_id: Some(prev.run_id.clone()),
5043            current_run_id: Some(run_id.clone()),
5044        });
5045    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5046        Ok(h) => h,
5047        Err(err) => {
5048            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5049            let mut runs = task.state.async_runs.lock().await;
5050            runs.insert(
5051                task.wait_id.clone(),
5052                AsyncRunState::Failed {
5053                    message: "Failed to render HTML report.".to_string(),
5054                },
5055            );
5056            drop(runs);
5057            return;
5058        }
5059    };
5060
5061    let output_root = resolve_output_root(task.output_dir.as_deref());
5062    let project_label = derive_project_label(
5063        task.git_repo.as_deref(),
5064        task.git_ref.as_deref(),
5065        &task.project_path,
5066    );
5067    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5068    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5069
5070    let result_context = RunResultContext {
5071        prev_entry: prev_entry.clone(),
5072        prev_scan_count,
5073        project_path: task.project_path.clone(),
5074        cocomo_mode: task.cocomo_mode.clone(),
5075        complexity_alert: task.complexity_alert,
5076        exclude_duplicates: task.exclude_duplicates,
5077    };
5078
5079    let artifact_result = persist_run_artifacts(
5080        &run,
5081        &report_html,
5082        &run_dir,
5083        &run.effective_configuration.reporting.report_title,
5084        &file_stem,
5085        result_context,
5086    );
5087
5088    let (artifacts, pending_pdf) = match artifact_result {
5089        Ok(v) => v,
5090        Err(err) => {
5091            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5092            let mut runs = task.state.async_runs.lock().await;
5093            runs.insert(
5094                task.wait_id.clone(),
5095                AsyncRunState::Failed {
5096                    message: "Failed to save report artifacts. Check available disk space."
5097                        .to_string(),
5098                },
5099            );
5100            drop(runs);
5101            return;
5102        }
5103    };
5104
5105    {
5106        let mut map = task.state.artifacts.lock().await;
5107        map.insert(run_id.clone(), artifacts.clone());
5108    }
5109
5110    {
5111        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5112        let mut reg = task.state.registry.lock().await;
5113        reg.add_entry(entry);
5114        let _ = reg.save(&task.state.registry_path);
5115    }
5116
5117    if let Some(ref cfg_path) = artifacts.scan_config_path {
5118        save_scan_config_json(
5119            cfg_path,
5120            &run,
5121            &task.project_path,
5122            task.output_dir.as_deref(),
5123            &task.cocomo_mode,
5124            task.complexity_alert,
5125            task.exclude_duplicates,
5126        );
5127    }
5128
5129    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5130
5131    prom_runs_total().inc();
5132
5133    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5134    let mut runs = task.state.async_runs.lock().await;
5135    runs.insert(
5136        task.wait_id.clone(),
5137        AsyncRunState::Complete {
5138            run_id: run_id.clone(),
5139        },
5140    );
5141    drop(runs);
5142
5143    // Remove the client-upload staging directory after a successful scan so
5144    // that uploaded project files don't accumulate in the OS temp directory.
5145    if let Some(staging) = upload_staging_root {
5146        let _ = tokio::fs::remove_dir_all(staging).await;
5147    }
5148
5149    let _ = scan_delta;
5150}
5151
5152fn save_scan_config_json(
5153    cfg_path: &std::path::Path,
5154    run: &sloc_core::AnalysisRun,
5155    project_path: &str,
5156    output_dir: Option<&str>,
5157    cocomo_mode: &str,
5158    complexity_alert: u32,
5159    exclude_duplicates: bool,
5160) {
5161    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5162        .ok()
5163        .and_then(|v| v.as_str().map(String::from))
5164        .unwrap_or_else(|| "code_only".to_string());
5165    let behavior_str =
5166        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5167            .ok()
5168            .and_then(|v| v.as_str().map(String::from))
5169            .unwrap_or_else(|| "skip".to_string());
5170    let continuation_policy_str = serde_json::to_value(
5171        run.effective_configuration
5172            .analysis
5173            .continuation_line_policy,
5174    )
5175    .ok()
5176    .and_then(|v| v.as_str().map(String::from))
5177    .unwrap_or_else(default_each_physical_line);
5178    let blank_policy_str = serde_json::to_value(
5179        run.effective_configuration
5180            .analysis
5181            .blank_in_block_comment_policy,
5182    )
5183    .ok()
5184    .and_then(|v| v.as_str().map(String::from))
5185    .unwrap_or_else(default_count_as_comment);
5186    let scan_cfg = ScanConfig {
5187        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5188        path: project_path.to_string(),
5189        include_globs: run
5190            .effective_configuration
5191            .discovery
5192            .include_globs
5193            .join("\n"),
5194        exclude_globs: run
5195            .effective_configuration
5196            .discovery
5197            .exclude_globs
5198            .join("\n"),
5199        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5200        mixed_line_policy: policy_str,
5201        python_docstrings_as_comments: run
5202            .effective_configuration
5203            .analysis
5204            .python_docstrings_as_comments,
5205        generated_file_detection: run
5206            .effective_configuration
5207            .analysis
5208            .generated_file_detection,
5209        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5210        vendor_directory_detection: run
5211            .effective_configuration
5212            .analysis
5213            .vendor_directory_detection,
5214        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5215        binary_file_behavior: behavior_str,
5216        output_dir: output_dir.unwrap_or("").to_string(),
5217        report_title: run.effective_configuration.reporting.report_title.clone(),
5218        continuation_line_policy: continuation_policy_str,
5219        blank_in_block_comment_policy: blank_policy_str,
5220        count_compiler_directives: run
5221            .effective_configuration
5222            .analysis
5223            .count_compiler_directives,
5224        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5225        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5226        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5227        style_lang_scope: run
5228            .effective_configuration
5229            .analysis
5230            .style_lang_scope
5231            .clone(),
5232        coverage_file: run
5233            .effective_configuration
5234            .analysis
5235            .coverage_file
5236            .as_ref()
5237            .map(|p| p.display().to_string())
5238            .unwrap_or_default(),
5239        cocomo_mode: cocomo_mode.to_string(),
5240        complexity_alert,
5241        exclude_duplicates,
5242        activity_window: run
5243            .effective_configuration
5244            .analysis
5245            .activity_window_days
5246            .unwrap_or(0),
5247    };
5248    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5249        let _ = std::fs::write(cfg_path, json);
5250    }
5251}
5252
5253#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5254fn run_analysis_blocking(
5255    mut config: AppConfig,
5256    git_repo: Option<String>,
5257    git_ref: Option<String>,
5258    clones_dir: PathBuf,
5259    cancel: Arc<std::sync::atomic::AtomicBool>,
5260    progress: Option<sloc_core::ProgressCounters>,
5261) -> Result<sloc_core::AnalysisRun> {
5262    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5263        let dest = git_clone_dest(&repo, &clones_dir);
5264        sloc_git::clone_or_fetch(&repo, &dest)?;
5265        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5266        sloc_git::create_worktree(&dest, &refname, &wt)?;
5267        config.discovery.root_paths = vec![wt.clone()];
5268        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5269        let _ = sloc_git::destroy_worktree(&dest, &wt);
5270        let mut run = run?;
5271        if run.git_branch.is_none() {
5272            run.git_branch = Some(refname);
5273        }
5274        return Ok(run);
5275    }
5276    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5277}
5278
5279fn derive_project_label(
5280    git_repo: Option<&str>,
5281    git_ref: Option<&str>,
5282    fallback_path: &str,
5283) -> String {
5284    match (
5285        git_repo.filter(|s| !s.is_empty()),
5286        git_ref.filter(|s| !s.is_empty()),
5287    ) {
5288        (Some(repo), Some(refname)) => {
5289            let repo_name = repo
5290                .trim_end_matches('/')
5291                .trim_end_matches(".git")
5292                .rsplit('/')
5293                .next()
5294                .unwrap_or("repo");
5295            sanitize_project_label(&format!("{repo_name}_{refname}"))
5296        }
5297        _ => sanitize_project_label(fallback_path),
5298    }
5299}
5300
5301fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5302    let commit = commit_short.unwrap_or("").trim();
5303    if commit.is_empty() {
5304        project_label.to_string()
5305    } else {
5306        format!("{project_label}_{commit}")
5307    }
5308}
5309
5310// ── Async scan status + result handlers ──────────────────────────────────────
5311
5312#[derive(Serialize)]
5313#[serde(tag = "state", rename_all = "snake_case")]
5314enum AsyncRunStatusResponse {
5315    Running {
5316        elapsed_secs: u64,
5317        phase: String,
5318        files_done: u64,
5319        files_total: u64,
5320    },
5321    Complete {
5322        run_id: String,
5323    },
5324    Failed {
5325        message: String,
5326    },
5327    Cancelled,
5328}
5329
5330async fn async_run_status_handler(
5331    State(state): State<AppState>,
5332    AxumPath(wait_id): AxumPath<String>,
5333) -> Response {
5334    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5335    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5336        return error::bad_request("invalid wait_id");
5337    }
5338    let run_state = {
5339        let runs = state.async_runs.lock().await;
5340        runs.get(&wait_id).cloned()
5341    };
5342    match run_state {
5343        None => error::not_found("run not found"),
5344        Some(AsyncRunState::Running {
5345            started_at,
5346            phase,
5347            files_done,
5348            files_total,
5349            ..
5350        }) => {
5351            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5352            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5353                let mut runs = state.async_runs.lock().await;
5354                runs.insert(
5355                    wait_id,
5356                    AsyncRunState::Failed {
5357                        message: "Analysis timed out after 2 hours.".to_string(),
5358                    },
5359                );
5360                drop(runs);
5361                return Json(AsyncRunStatusResponse::Failed {
5362                    message: "Analysis timed out after 2 hours.".to_string(),
5363                })
5364                .into_response();
5365            }
5366            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5367            Json(AsyncRunStatusResponse::Running {
5368                elapsed_secs: started_at.elapsed().as_secs(),
5369                phase: phase_str,
5370                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5371                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5372            })
5373            .into_response()
5374        }
5375        Some(AsyncRunState::Complete { run_id }) => {
5376            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5377        }
5378        Some(AsyncRunState::Failed { message }) => {
5379            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5380        }
5381        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5382    }
5383}
5384
5385async fn cancel_run_handler(
5386    State(state): State<AppState>,
5387    AxumPath(wait_id): AxumPath<String>,
5388) -> Response {
5389    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5390        return error::bad_request("invalid wait_id");
5391    }
5392    let mut runs = state.async_runs.lock().await;
5393    let resp = match runs.get(&wait_id) {
5394        Some(AsyncRunState::Running { cancel_token, .. }) => {
5395            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5396            runs.insert(wait_id, AsyncRunState::Cancelled);
5397            StatusCode::OK.into_response()
5398        }
5399        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5400        _ => error::not_found("run not found"),
5401    };
5402    drop(runs);
5403    resp
5404}
5405
5406async fn async_run_result_handler(
5407    State(state): State<AppState>,
5408    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5409    AxumPath(run_id): AxumPath<String>,
5410) -> Response {
5411    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5412        return StatusCode::BAD_REQUEST.into_response();
5413    }
5414
5415    let artifacts = {
5416        let map = state.artifacts.lock().await;
5417        map.get(&run_id).cloned()
5418    };
5419    let artifacts = if let Some(a) = artifacts {
5420        a
5421    } else {
5422        let reg = state.registry.lock().await;
5423        if let Some(entry) = reg.find_by_run_id(&run_id) {
5424            recover_artifacts_from_registry(entry)
5425        } else {
5426            let html = ErrorTemplate {
5427                message: format!(
5428                    "Report not found. Run ID {} is not in the scan history.",
5429                    &run_id[..run_id.len().min(8)]
5430                ),
5431                last_report_url: Some("/view-reports".to_string()),
5432                last_report_label: Some("View Reports".to_string()),
5433                run_id: Some(run_id.clone()),
5434                error_code: Some(404),
5435                csp_nonce: csp_nonce.clone(),
5436                version: env!("CARGO_PKG_VERSION"),
5437            }
5438            .render()
5439            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5440            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5441        }
5442    };
5443
5444    let json_path = if let Some(p) = &artifacts.json_path {
5445        p.clone()
5446    } else {
5447        let html = ErrorTemplate {
5448            message: "JSON result was not saved for this run.".to_string(),
5449            last_report_url: Some("/view-reports".to_string()),
5450            last_report_label: Some("View Reports".to_string()),
5451            run_id: Some(run_id.clone()),
5452            error_code: Some(404),
5453            csp_nonce: csp_nonce.clone(),
5454            version: env!("CARGO_PKG_VERSION"),
5455        }
5456        .render()
5457        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5458        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5459    };
5460
5461    let Ok(run) = read_json(&json_path) else {
5462        let folder_hint = output_folder_hint(&json_path);
5463        let redirect_url = format!("/runs/result/{run_id}");
5464        return missing_scan_relocate_response(
5465            &format!(
5466                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5467                 deleted. Browse to the folder containing your scan output to reconnect it.",
5468                json_path.display()
5469            ),
5470            &run_id,
5471            &folder_hint,
5472            &redirect_url,
5473            state.server_mode,
5474            &csp_nonce,
5475        );
5476    };
5477
5478    let confluence_configured = {
5479        let store = state.confluence.lock().await;
5480        store.is_configured()
5481    };
5482
5483    render_result_page(
5484        &run,
5485        &artifacts,
5486        &run_id,
5487        &csp_nonce,
5488        confluence_configured,
5489        state.server_mode,
5490    )
5491}
5492
5493/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5494fn json_escape(s: &str) -> String {
5495    s.replace('\\', "\\\\").replace('"', "\\\"")
5496}
5497
5498/// Per-language line/symbol totals summed across every language in a run.
5499struct LangTotals {
5500    physical_lines: u64,
5501    code_lines: u64,
5502    comment_lines: u64,
5503    blank_lines: u64,
5504    mixed_lines: u64,
5505    functions: u64,
5506    classes: u64,
5507    variables: u64,
5508    imports: u64,
5509}
5510
5511fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5512    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5513        run.totals_by_language.iter().map(f).sum()
5514    };
5515    LangTotals {
5516        physical_lines: s(|r| r.total_physical_lines),
5517        code_lines: s(|r| r.code_lines),
5518        comment_lines: s(|r| r.comment_lines),
5519        blank_lines: s(|r| r.blank_lines),
5520        mixed_lines: s(|r| r.mixed_lines_separate),
5521        functions: s(|r| r.functions),
5522        classes: s(|r| r.classes),
5523        variables: s(|r| r.variables),
5524        imports: s(|r| r.imports),
5525    }
5526}
5527
5528/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5529struct DeltaFields {
5530    prev_fa_str: String,
5531    prev_fs_str: String,
5532    prev_pl_str: String,
5533    prev_cl_str: String,
5534    prev_cml_str: String,
5535    prev_bl_str: String,
5536    delta_fa_str: String,
5537    delta_fa_class: String,
5538    delta_fs_str: String,
5539    delta_fs_class: String,
5540    delta_pl_str: String,
5541    delta_pl_class: String,
5542    delta_cl_str: String,
5543    delta_cl_class: String,
5544    delta_cml_str: String,
5545    delta_cml_class: String,
5546    delta_bl_str: String,
5547    delta_bl_class: String,
5548    delta_lines_added: Option<i64>,
5549    delta_lines_removed: Option<i64>,
5550    delta_lines_net_str: String,
5551    delta_lines_net_class: String,
5552}
5553
5554// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5555// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5556// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5557// from those field names and obscure the 1:1 mapping.
5558#[allow(
5559    clippy::similar_names,
5560    reason = "locals mirror template-bound struct fields"
5561)]
5562fn compute_delta_fields(
5563    prev_entry: Option<&RegistryEntry>,
5564    totals: &LangTotals,
5565    files_analyzed: u64,
5566    files_skipped: u64,
5567    scan_delta: Option<&sloc_core::ScanComparison>,
5568) -> DeltaFields {
5569    let prev_sum = prev_entry.map(|e| &e.summary);
5570    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5571
5572    let (delta_fa_str, delta_fa_class) =
5573        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5574    let (delta_fs_str, delta_fs_class) =
5575        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5576    let (delta_pl_str, delta_pl_class) = summary_delta(
5577        totals.physical_lines,
5578        prev_sum.map(|s| s.total_physical_lines),
5579    );
5580    let (delta_cl_str, delta_cl_class) =
5581        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5582    let (delta_cml_str, delta_cml_class) =
5583        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5584    let (delta_bl_str, delta_bl_class) =
5585        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5586
5587    let delta_lines_added = scan_delta.map(sum_added_code_lines);
5588    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5589    let (delta_lines_net_str, delta_lines_net_class) =
5590        match (delta_lines_added, delta_lines_removed) {
5591            (Some(a), Some(r)) => {
5592                let net = a - r;
5593                (fmt_delta(net), delta_class(net).to_string())
5594            }
5595            _ => ("\u{2014}".to_string(), "na".to_string()),
5596        };
5597
5598    DeltaFields {
5599        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5600        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5601        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5602        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5603        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5604        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5605        delta_fa_str,
5606        delta_fa_class: delta_fa_class.to_string(),
5607        delta_fs_str,
5608        delta_fs_class: delta_fs_class.to_string(),
5609        delta_pl_str,
5610        delta_pl_class: delta_pl_class.to_string(),
5611        delta_cl_str,
5612        delta_cl_class: delta_cl_class.to_string(),
5613        delta_cml_str,
5614        delta_cml_class: delta_cml_class.to_string(),
5615        delta_bl_str,
5616        delta_bl_class: delta_bl_class.to_string(),
5617        delta_lines_added,
5618        delta_lines_removed,
5619        delta_lines_net_str,
5620        delta_lines_net_class,
5621    }
5622}
5623
5624/// Count of unchanged code lines in a scan comparison.
5625fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5626    scan_delta
5627        .file_deltas
5628        .iter()
5629        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5630        .map(|f| {
5631            #[allow(clippy::cast_sign_loss)]
5632            let n = f.current_code as u64;
5633            n
5634        })
5635        .sum()
5636}
5637
5638fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5639    run.git_remote_url
5640        .as_deref()
5641        .zip(run.git_commit_long.as_deref())
5642        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5643}
5644
5645fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5646    run.git_remote_url
5647        .as_deref()
5648        .zip(run.git_branch.as_deref())
5649        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5650}
5651
5652fn scan_performed_by(run: &AnalysisRun) -> String {
5653    run.environment.ci_name.clone().unwrap_or_else(|| {
5654        format!(
5655            "{} / {}",
5656            run.environment.initiator_username, run.environment.initiator_hostname
5657        )
5658    })
5659}
5660
5661/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
5662fn build_lang_chart_json(run: &AnalysisRun) -> String {
5663    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5664    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5665    let entries: Vec<String> = langs
5666        .into_iter()
5667        .take(12)
5668        .map(|l| {
5669            let name = json_escape(l.language.display_name());
5670            format!(
5671                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5672                name,
5673                l.code_lines,
5674                l.comment_lines,
5675                l.blank_lines,
5676                l.total_physical_lines,
5677                l.functions,
5678                l.classes,
5679                l.variables,
5680                l.imports,
5681                l.files,
5682            )
5683        })
5684        .collect();
5685    format!("[{}]", entries.join(","))
5686}
5687
5688/// Per-language files-vs-lines points as a JSON array for the scatter chart.
5689fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5690    let entries: Vec<String> = run
5691        .totals_by_language
5692        .iter()
5693        .map(|l| {
5694            let name = json_escape(l.language.display_name());
5695            format!(
5696                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5697                name, l.files, l.code_lines, l.total_physical_lines,
5698            )
5699        })
5700        .collect();
5701    format!("[{}]", entries.join(","))
5702}
5703
5704/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
5705fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5706    let entries: Vec<String> = run
5707        .totals_by_language
5708        .iter()
5709        .filter(|l| {
5710            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5711        })
5712        .map(|l| {
5713            let name = json_escape(l.language.display_name());
5714            format!(
5715                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5716                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5717            )
5718        })
5719        .collect();
5720    format!("[{}]", entries.join(","))
5721}
5722
5723/// Per-submodule line counts as a JSON array for the submodule chart.
5724fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5725    let entries: Vec<String> = run
5726        .submodule_summaries
5727        .iter()
5728        .map(|s| {
5729            let name = json_escape(&s.name);
5730            format!(
5731                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5732                name,
5733                s.code_lines,
5734                s.comment_lines,
5735                s.blank_lines,
5736                s.total_physical_lines,
5737                s.files_analyzed,
5738            )
5739        })
5740        .collect();
5741    format!("[{}]", entries.join(","))
5742}
5743
5744/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
5745#[allow(clippy::cast_precision_loss)]
5746fn cov_pct_str(hit: u64, found: u64) -> String {
5747    if found > 0 {
5748        format!("{:.1}", hit as f64 / found as f64 * 100.0)
5749    } else {
5750        String::new()
5751    }
5752}
5753
5754/// `hit / found` summary string, or empty when nothing was found.
5755fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5756    if found > 0 {
5757        format!("{hit} / {found}")
5758    } else {
5759        String::new()
5760    }
5761}
5762
5763const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5764    use sloc_core::CocomoMode;
5765    match mode {
5766        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5767        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5768        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5769    }
5770}
5771
5772const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5773    use sloc_core::CocomoMode;
5774    match mode {
5775        CocomoMode::Organic => "Organic",
5776        CocomoMode::SemiDetached => "Semi-detached",
5777        CocomoMode::Embedded => "Embedded",
5778    }
5779}
5780
5781const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5782    use sloc_core::CocomoMode;
5783    match mode {
5784        CocomoMode::Organic => {
5785            "Organic: A small team working on a well-understood project in a familiar \
5786             environment with minimal external constraints. Suited for internal tools, \
5787             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5788        }
5789        CocomoMode::SemiDetached => {
5790            "Semi-detached: A mixed team with varying experience tackling a project with \
5791             moderate novelty and some rigid constraints. Typical for compilers, transaction \
5792             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5793        }
5794        CocomoMode::Embedded => {
5795            "Embedded: Tight hardware, software, or operational constraints requiring \
5796             significant innovation and deep integration work. Typical for real-time control \
5797             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5798        }
5799    }
5800}
5801
5802/// COCOMO display strings recomputed for the scan-wizard-selected mode.
5803struct CocomoFields {
5804    has_cocomo: bool,
5805    effort_str: String,
5806    duration_str: String,
5807    staff_str: String,
5808    ksloc_str: String,
5809    mode_label: String,
5810    mode_tooltip: String,
5811}
5812
5813#[allow(clippy::cast_precision_loss)]
5814fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5815    use sloc_core::CocomoMode;
5816    let mode = match mode_str {
5817        "semi_detached" => CocomoMode::SemiDetached,
5818        "embedded" => CocomoMode::Embedded,
5819        _ => CocomoMode::Organic,
5820    };
5821    let (a, b, c, d) = cocomo_coefficients(mode);
5822    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5823    let effort = a * ksloc.powf(b);
5824    let duration = c * effort.powf(d);
5825    let staff = if duration > 0.0 {
5826        effort / duration
5827    } else {
5828        0.0
5829    };
5830    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5831    let mode_label = cocomo_mode_label(mode).to_string();
5832    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5833    if run.summary_totals.code_lines > 0 {
5834        CocomoFields {
5835            has_cocomo: true,
5836            effort_str: round2(effort),
5837            duration_str: round2(duration),
5838            staff_str: round2(staff),
5839            ksloc_str: round2(ksloc),
5840            mode_label,
5841            mode_tooltip,
5842        }
5843    } else {
5844        CocomoFields {
5845            has_cocomo: false,
5846            effort_str: String::new(),
5847            duration_str: String::new(),
5848            staff_str: String::new(),
5849            ksloc_str: String::new(),
5850            mode_label,
5851            mode_tooltip,
5852        }
5853    }
5854}
5855
5856#[allow(clippy::too_many_lines)]
5857#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
5858#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
5859fn render_result_page(
5860    run: &AnalysisRun,
5861    artifacts: &RunArtifacts,
5862    run_id: &str,
5863    csp_nonce: &str,
5864    confluence_configured: bool,
5865    server_mode: bool,
5866) -> Response {
5867    let ctx = &artifacts.result_context;
5868    let prev_entry = &ctx.prev_entry;
5869    let prev_scan_count = ctx.prev_scan_count;
5870    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
5871    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
5872    // field is never blank.
5873    let project_path_owned = if ctx.project_path.is_empty() {
5874        run.input_roots.join(", ")
5875    } else {
5876        ctx.project_path.clone()
5877    };
5878    let project_path = &project_path_owned;
5879
5880    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5881        prev.json_path
5882            .as_ref()
5883            .and_then(|p| read_json(p).ok())
5884            .map(|prev_run| compute_delta(&prev_run, run))
5885    });
5886
5887    let files_analyzed = run.per_file_records.len() as u64;
5888    let files_skipped = run.skipped_file_records.len() as u64;
5889    let totals = sum_lang_totals(run);
5890
5891    let DeltaFields {
5892        prev_fa_str,
5893        prev_fs_str,
5894        prev_pl_str,
5895        prev_cl_str,
5896        prev_cml_str,
5897        prev_bl_str,
5898        delta_fa_str,
5899        delta_fa_class,
5900        delta_fs_str,
5901        delta_fs_class,
5902        delta_pl_str,
5903        delta_pl_class,
5904        delta_cl_str,
5905        delta_cl_class,
5906        delta_cml_str,
5907        delta_cml_class,
5908        delta_bl_str,
5909        delta_bl_class,
5910        delta_lines_added,
5911        delta_lines_removed,
5912        delta_lines_net_str,
5913        delta_lines_net_class,
5914    } = compute_delta_fields(
5915        prev_entry.as_ref(),
5916        &totals,
5917        files_analyzed,
5918        files_skipped,
5919        scan_delta.as_ref(),
5920    );
5921
5922    let run_dir = artifacts.output_dir.clone();
5923    let git_branch = run.git_branch.clone();
5924    let git_commit = run.git_commit_short.clone();
5925    let git_commit_long = run.git_commit_long.clone();
5926    let git_author = run.git_commit_author.clone();
5927    let git_commit_url = git_commit_url_for(run);
5928    let git_branch_url = git_branch_url_for(run);
5929    let scan_performed_by = scan_performed_by(run);
5930    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
5931    let os_display = format!(
5932        "{} / {}",
5933        run.environment.operating_system, run.environment.architecture
5934    );
5935    let test_count = run.summary_totals.test_count;
5936
5937    // ── New metrics ──────────────────────────────────────────────────────────
5938    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
5939    let lsloc = run.summary_totals.lsloc;
5940    let uloc = run.uloc;
5941    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
5942    let duplicate_group_count = run.duplicate_groups.len();
5943
5944    // Re-compute COCOMO with the mode selected in the scan wizard.
5945    let ctx = &artifacts.result_context;
5946    let CocomoFields {
5947        has_cocomo,
5948        effort_str: cocomo_effort_str,
5949        duration_str: cocomo_duration_str,
5950        staff_str: cocomo_staff_str,
5951        ksloc_str: cocomo_ksloc_str,
5952        mode_label: cocomo_mode_label,
5953        mode_tooltip: cocomo_mode_tooltip,
5954    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
5955    let complexity_alert = ctx.complexity_alert;
5956
5957    let template = ResultTemplate {
5958        version: env!("CARGO_PKG_VERSION"),
5959        report_title: run.effective_configuration.reporting.report_title.clone(),
5960        project_path: project_path.clone(),
5961        output_dir: display_path(&artifacts.output_dir),
5962        run_id: run_id.to_owned(),
5963        run_id_short: run_id
5964            .split('-')
5965            .next_back()
5966            .unwrap_or(run_id)
5967            .chars()
5968            .take(7)
5969            .collect(),
5970        files_analyzed,
5971        files_skipped,
5972        physical_lines: totals.physical_lines,
5973        code_lines: totals.code_lines,
5974        comment_lines: totals.comment_lines,
5975        blank_lines: totals.blank_lines,
5976        mixed_lines: totals.mixed_lines,
5977        functions: totals.functions,
5978        classes: totals.classes,
5979        variables: totals.variables,
5980        imports: totals.imports,
5981        html_url: artifacts
5982            .html_path
5983            .as_ref()
5984            .map(|_| format!("/runs/html/{run_id}")),
5985        pdf_url: artifacts
5986            .pdf_path
5987            .as_ref()
5988            .map(|_| format!("/runs/pdf/{run_id}")),
5989        json_url: artifacts
5990            .json_path
5991            .as_ref()
5992            .map(|_| format!("/runs/json/{run_id}")),
5993        html_download_url: artifacts
5994            .html_path
5995            .as_ref()
5996            .map(|_| format!("/runs/html/{run_id}?download=1")),
5997        pdf_download_url: artifacts
5998            .pdf_path
5999            .as_ref()
6000            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6001        json_download_url: artifacts
6002            .json_path
6003            .as_ref()
6004            .map(|_| format!("/runs/json/{run_id}?download=1")),
6005        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6006        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6007        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6008        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6009        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6010        prev_fa_str,
6011        prev_fs_str,
6012        prev_pl_str,
6013        prev_cl_str,
6014        prev_cml_str,
6015        prev_bl_str,
6016        delta_fa_str,
6017        delta_fa_class,
6018        delta_fs_str,
6019        delta_fs_class,
6020        delta_pl_str,
6021        delta_pl_class,
6022        delta_cl_str,
6023        delta_cl_class,
6024        delta_cml_str,
6025        delta_cml_class,
6026        delta_bl_str,
6027        delta_bl_class,
6028        delta_lines_added,
6029        delta_lines_removed,
6030        delta_lines_net_str,
6031        delta_lines_net_class,
6032        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6033        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6034        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6035        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6036        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6037        git_branch,
6038        git_branch_url,
6039        git_commit,
6040        git_commit_long,
6041        git_author,
6042        git_commit_url,
6043        scan_performed_by,
6044        scan_time_display,
6045        os_display,
6046        test_count,
6047        test_assertion_count: run.summary_totals.test_assertion_count,
6048        current_scan_number: prev_scan_count + 1,
6049        prev_scan_count,
6050        submodule_rows: run
6051            .submodule_summaries
6052            .iter()
6053            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6054            .collect(),
6055        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6056        scan_config_url: format!("/runs/scan-config/{run_id}"),
6057        lang_chart_json: build_lang_chart_json(run),
6058        scatter_chart_json: build_scatter_chart_json(run),
6059        semantic_chart_json: build_semantic_chart_json(run),
6060        submodule_chart_json: build_submodule_chart_json(run),
6061        has_submodule_data: !run.submodule_summaries.is_empty(),
6062        has_semantic_data: run
6063            .totals_by_language
6064            .iter()
6065            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6066        csp_nonce: csp_nonce.to_owned(),
6067        confluence_configured,
6068        server_mode,
6069        report_header_footer: run
6070            .effective_configuration
6071            .reporting
6072            .report_header_footer
6073            .clone(),
6074        is_offline: false,
6075        cyclomatic_complexity,
6076        lsloc,
6077        uloc,
6078        dryness_pct_str,
6079        duplicate_group_count,
6080        has_cocomo,
6081        cocomo_effort_str,
6082        cocomo_duration_str,
6083        cocomo_staff_str,
6084        cocomo_ksloc_str,
6085        cocomo_mode_label,
6086        cocomo_mode_tooltip,
6087        complexity_alert,
6088        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6089        cov_line_pct: cov_pct_str(
6090            run.summary_totals.coverage_lines_hit,
6091            run.summary_totals.coverage_lines_found,
6092        ),
6093        cov_fn_pct: cov_pct_str(
6094            run.summary_totals.coverage_functions_hit,
6095            run.summary_totals.coverage_functions_found,
6096        ),
6097        cov_branch_pct: cov_pct_str(
6098            run.summary_totals.coverage_branches_hit,
6099            run.summary_totals.coverage_branches_found,
6100        ),
6101        cov_lines_summary: cov_lines_summary_str(
6102            run.summary_totals.coverage_lines_hit,
6103            run.summary_totals.coverage_lines_found,
6104        ),
6105    };
6106
6107    Html(
6108        template
6109            .render()
6110            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6111    )
6112    .into_response()
6113}
6114
6115fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6116    let slug: String = report_title
6117        .chars()
6118        .map(|c| {
6119            if c.is_alphanumeric() || c == '-' {
6120                c.to_ascii_lowercase()
6121            } else {
6122                '_'
6123            }
6124        })
6125        .collect::<String>()
6126        .split('_')
6127        .filter(|s| !s.is_empty())
6128        .collect::<Vec<_>>()
6129        .join("_");
6130
6131    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6132
6133    if slug.is_empty() {
6134        format!("report_{short_id}.pdf")
6135    } else {
6136        format!("{slug}_{short_id}.pdf")
6137    }
6138}
6139
6140#[derive(Serialize)]
6141struct PdfStatusResponse {
6142    ready: bool,
6143}
6144
6145/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6146/// Clients poll this to update the button state without page reloads.
6147async fn pdf_status_handler(
6148    State(state): State<AppState>,
6149    AxumPath(run_id): AxumPath<String>,
6150) -> Response {
6151    let pdf_path = {
6152        let registry = state.artifacts.lock().await;
6153        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6154    };
6155    let pdf_path = if pdf_path.is_some() {
6156        pdf_path
6157    } else {
6158        let reg = state.registry.lock().await;
6159        reg.find_by_run_id(&run_id)
6160            .map(recover_artifacts_from_registry)
6161            .and_then(|a| a.pdf_path)
6162    };
6163    let ready = pdf_path.is_some_and(|p| p.exists());
6164    Json(PdfStatusResponse { ready }).into_response()
6165}
6166
6167/// GET /`api/runs/:run_id/bundle`
6168///
6169/// Streams a gzip-compressed tar archive containing every artifact in the run's
6170/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6171/// is built in memory so it never touches a temp file.
6172async fn download_bundle_handler(
6173    State(state): State<AppState>,
6174    AxumPath(run_id): AxumPath<String>,
6175) -> Response {
6176    // Resolve output directory from in-memory cache or persisted registry.
6177    let output_dir = {
6178        let cache = state.artifacts.lock().await;
6179        cache.get(&run_id).map(|a| a.output_dir.clone())
6180    };
6181    let output_dir = if let Some(d) = output_dir {
6182        d
6183    } else {
6184        let reg = state.registry.lock().await;
6185        match reg.find_by_run_id(&run_id) {
6186            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6187            None => {
6188                return (
6189                    StatusCode::NOT_FOUND,
6190                    Json(serde_json::json!({"error": "Run not found"})),
6191                )
6192                    .into_response();
6193            }
6194        }
6195    };
6196
6197    if !output_dir.exists() {
6198        return (
6199            StatusCode::NOT_FOUND,
6200            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6201        )
6202            .into_response();
6203    }
6204
6205    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6206    let run_id_clone = run_id.clone();
6207    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6208        use flate2::{write::GzEncoder, Compression};
6209        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6210        {
6211            let mut tar = tar::Builder::new(&mut enc);
6212            tar.follow_symlinks(false);
6213            // Append every regular file in the output directory, skipping
6214            // sub-directories (the output dir is always flat).
6215            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6216                for entry in entries.filter_map(Result::ok) {
6217                    let p = entry.path();
6218                    if p.is_file() {
6219                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6220                        let archive_path = format!("{run_id_clone}/{name}");
6221                        tar.append_path_with_name(&p, &archive_path)?;
6222                    }
6223                }
6224            }
6225            tar.finish()?;
6226        }
6227        Ok(enc.finish()?)
6228    })
6229    .await;
6230
6231    match archive_result {
6232        Ok(Ok(bytes)) => {
6233            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6234            axum::response::Response::builder()
6235                .status(StatusCode::OK)
6236                .header("Content-Type", "application/gzip")
6237                .header(
6238                    "Content-Disposition",
6239                    format!("attachment; filename=\"{filename}\""),
6240                )
6241                .header("Content-Length", bytes.len().to_string())
6242                .body(axum::body::Body::from(bytes))
6243                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6244        }
6245        Ok(Err(e)) => (
6246            StatusCode::INTERNAL_SERVER_ERROR,
6247            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6248        )
6249            .into_response(),
6250        Err(e) => (
6251            StatusCode::INTERNAL_SERVER_ERROR,
6252            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6253        )
6254            .into_response(),
6255    }
6256}
6257
6258/// DELETE /`api/runs/:run_id`
6259///
6260/// Removes all on-disk artifacts for the run and purges the run from the
6261/// in-memory cache and the persisted registry. Returns 204 on success.
6262async fn delete_run_handler(
6263    State(state): State<AppState>,
6264    AxumPath(run_id): AxumPath<String>,
6265) -> Response {
6266    // Resolve output directory.
6267    let output_dir = {
6268        let mut cache = state.artifacts.lock().await;
6269        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6270        cache.remove(&run_id);
6271        dir
6272    };
6273    let output_dir = if let Some(d) = output_dir {
6274        d
6275    } else {
6276        let reg = state.registry.lock().await;
6277        reg.find_by_run_id(&run_id)
6278            .map(|e| recover_artifacts_from_registry(e).output_dir)
6279            .unwrap_or_default()
6280    };
6281
6282    // Remove from persisted registry.
6283    {
6284        let mut reg = state.registry.lock().await;
6285        reg.entries.retain(|e| e.run_id != run_id);
6286        let _ = reg.save(&state.registry_path);
6287    }
6288
6289    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6290    // a prior delete may have already removed the directory.
6291    if output_dir.exists() {
6292        match tokio::fs::remove_dir_all(&output_dir).await {
6293            Ok(()) => {}
6294            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6295            Err(e) => {
6296                return (
6297                    StatusCode::INTERNAL_SERVER_ERROR,
6298                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6299                )
6300                    .into_response();
6301            }
6302        }
6303    }
6304
6305    StatusCode::NO_CONTENT.into_response()
6306}
6307
6308/// POST /api/runs/cleanup
6309///
6310/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6311/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6312async fn cleanup_runs_handler(
6313    State(state): State<AppState>,
6314    Json(body): Json<serde_json::Value>,
6315) -> Response {
6316    let days = body
6317        .get("older_than_days")
6318        .and_then(serde_json::Value::as_u64)
6319        .unwrap_or(30)
6320        .max(1);
6321
6322    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6323
6324    // Collect expired entries from the registry.
6325    let expired: Vec<(String, PathBuf)> = {
6326        let reg = state.registry.lock().await;
6327        reg.entries
6328            .iter()
6329            .filter(|e| e.timestamp_utc < cutoff)
6330            .map(|e| {
6331                let arts = recover_artifacts_from_registry(e);
6332                (e.run_id.clone(), arts.output_dir)
6333            })
6334            .collect()
6335    };
6336
6337    let mut deleted = 0usize;
6338    for (run_id, output_dir) in &expired {
6339        // Remove from in-memory cache.
6340        state.artifacts.lock().await.remove(run_id);
6341        // Delete on-disk artifacts (non-fatal if already gone).
6342        if output_dir.exists() {
6343            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6344                eprintln!(
6345                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6346                    output_dir.display()
6347                );
6348                continue;
6349            }
6350        }
6351        deleted += 1;
6352    }
6353
6354    // Purge expired run IDs from the registry in one pass.
6355    let expired_ids: std::collections::HashSet<&str> =
6356        expired.iter().map(|(id, _)| id.as_str()).collect();
6357    {
6358        let mut reg = state.registry.lock().await;
6359        reg.entries
6360            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6361        let _ = reg.save(&state.registry_path);
6362    }
6363
6364    Json(serde_json::json!({ "deleted": deleted })).into_response()
6365}
6366
6367/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6368/// abort it when the policy is updated or disabled.
6369fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6370    tokio::spawn(async move {
6371        loop {
6372            let interval_secs = {
6373                let store = state.cleanup_policy.lock().await;
6374                match &store.policy {
6375                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6376                    _ => break,
6377                }
6378            };
6379            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6380            let n = run_auto_cleanup(&state).await;
6381            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6382        }
6383    })
6384}
6385
6386fn collect_runs_to_delete(
6387    reg: &ScanRegistry,
6388    max_age_days: Option<u32>,
6389    max_run_count: Option<u32>,
6390) -> std::collections::HashSet<String> {
6391    let mut to_delete = std::collections::HashSet::new();
6392    if let Some(days) = max_age_days {
6393        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6394        for e in &reg.entries {
6395            if e.timestamp_utc < cutoff {
6396                to_delete.insert(e.run_id.clone());
6397            }
6398        }
6399    }
6400    if let Some(max_count) = max_run_count {
6401        // entries are sorted newest-first; skip the ones we keep
6402        for e in reg.entries.iter().skip(max_count as usize) {
6403            to_delete.insert(e.run_id.clone());
6404        }
6405    }
6406    to_delete
6407}
6408
6409async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6410    let output_dir = {
6411        let mut cache = state.artifacts.lock().await;
6412        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6413        cache.remove(run_id);
6414        d
6415    };
6416    let output_dir = if let Some(d) = output_dir {
6417        d
6418    } else {
6419        let reg = state.registry.lock().await;
6420        reg.find_by_run_id(run_id)
6421            .map(|e| recover_artifacts_from_registry(e).output_dir)
6422            .unwrap_or_default()
6423    };
6424    if output_dir.exists() {
6425        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6426    }
6427}
6428
6429/// Core cleanup logic shared by the background task and the "Run Now" handler.
6430/// Applies both the age limit and the count limit, then updates `last_run_at`.
6431/// Returns the number of runs deleted.
6432async fn run_auto_cleanup(state: &AppState) -> u32 {
6433    let (max_age_days, max_run_count) = {
6434        let store = state.cleanup_policy.lock().await;
6435        match &store.policy {
6436            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6437            _ => return 0,
6438        }
6439    };
6440
6441    let to_delete = {
6442        let reg = state.registry.lock().await;
6443        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6444    };
6445
6446    for run_id in &to_delete {
6447        delete_run_artifacts(state, run_id).await;
6448    }
6449
6450    // Purge from registry.
6451    if !to_delete.is_empty() {
6452        let mut reg = state.registry.lock().await;
6453        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6454        let _ = reg.save(&state.registry_path);
6455    }
6456
6457    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6458    {
6459        let mut store = state.cleanup_policy.lock().await;
6460        store.last_run_at = Some(chrono::Utc::now());
6461        store.last_run_deleted = Some(deleted);
6462        let _ = store.save(&state.cleanup_policy_path);
6463    }
6464    deleted
6465}
6466
6467// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6468
6469/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6470async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6471    let store = state.cleanup_policy.lock().await;
6472    Json(serde_json::json!({
6473        "policy": store.policy,
6474        "last_run_at": store.last_run_at,
6475        "last_run_deleted": store.last_run_deleted,
6476    }))
6477    .into_response()
6478}
6479
6480/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6481async fn api_save_cleanup_policy(
6482    State(state): State<AppState>,
6483    Json(body): Json<CleanupPolicy>,
6484) -> Response {
6485    // Abort any running task so the new interval takes effect immediately.
6486    {
6487        let mut handle = state.cleanup_task_handle.lock().await;
6488        if let Some(h) = handle.take() {
6489            h.abort();
6490        }
6491    }
6492    {
6493        let mut store = state.cleanup_policy.lock().await;
6494        store.policy = Some(body.clone());
6495        if let Err(e) = store.save(&state.cleanup_policy_path) {
6496            return (
6497                StatusCode::INTERNAL_SERVER_ERROR,
6498                Json(serde_json::json!({"error": e.to_string()})),
6499            )
6500                .into_response();
6501        }
6502    }
6503    if body.enabled {
6504        let handle = spawn_cleanup_policy_task(state.clone());
6505        *state.cleanup_task_handle.lock().await = Some(handle);
6506    }
6507    StatusCode::NO_CONTENT.into_response()
6508}
6509
6510/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6511async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6512    let deleted = run_auto_cleanup(&state).await;
6513    Json(serde_json::json!({ "deleted": deleted })).into_response()
6514}
6515
6516/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6517async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6518    {
6519        let mut handle = state.cleanup_task_handle.lock().await;
6520        if let Some(h) = handle.take() {
6521            h.abort();
6522        }
6523    }
6524    {
6525        let mut store = state.cleanup_policy.lock().await;
6526        store.policy = None;
6527        let _ = store.save(&state.cleanup_policy_path);
6528    }
6529    StatusCode::NO_CONTENT.into_response()
6530}
6531
6532/// Serve the HTML artifact for a run — view or download.
6533/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6534/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6535/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6536/// Only called for browser views; downloads keep the self-contained inline version.
6537fn swap_inline_chart_js_for_static(html: String) -> String {
6538    let Some(head_end) = html.find("</head>") else {
6539        return html;
6540    };
6541    let Some(script_start) = html[..head_end].rfind("<script") else {
6542        return html;
6543    };
6544    let Some(close_offset) = html[script_start..].find("</script>") else {
6545        return html;
6546    };
6547    let block_end = script_start + close_offset + "</script>".len();
6548    format!(
6549        "{}<script src=\"/static/chart-report.js\"></script>{}",
6550        &html[..script_start],
6551        &html[block_end..]
6552    )
6553}
6554
6555/// current-request Content-Security-Policy nonce check.
6556fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6557    // Find the first nonce value that was baked in at render time.
6558    let Some(start) = html.find("nonce=\"") else {
6559        // Reports generated before nonce support was added have bare <style> and <script>
6560        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
6561        // the inline blocks — without it the browser blocks all CSS and JS.
6562        return html
6563            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6564            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6565    };
6566    let value_start = start + 7; // len(r#"nonce=""#) == 7
6567    let Some(end_offset) = html[value_start..].find('"') else {
6568        return html.to_owned();
6569    };
6570    let old_nonce = &html[value_start..value_start + end_offset];
6571    html.replace(
6572        &format!("nonce=\"{old_nonce}\""),
6573        &format!("nonce=\"{new_nonce}\""),
6574    )
6575}
6576
6577fn serve_html_artifact(
6578    path: &Path,
6579    wants_download: bool,
6580    csp_nonce: &str,
6581    run_id: &str,
6582    server_mode: bool,
6583) -> Response {
6584    match fs::read_to_string(path) {
6585        Ok(raw) => {
6586            // Patch the saved nonce so inline styles/scripts pass CSP.
6587            let content = patch_html_nonce(&raw, csp_nonce);
6588            if wants_download {
6589                // Keep the self-contained inline version for downloads (opened as file://).
6590                (
6591                    [
6592                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6593                        (
6594                            header::CONTENT_DISPOSITION,
6595                            "attachment; filename=report.html",
6596                        ),
6597                    ],
6598                    content,
6599                )
6600                    .into_response()
6601            } else {
6602                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
6603                // browser caches it after the first view; the HTML response also shrinks.
6604                Html(swap_inline_chart_js_for_static(content)).into_response()
6605            }
6606        }
6607        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6608            let filename = path.file_name().map_or_else(
6609                || "report.html".to_string(),
6610                |n| n.to_string_lossy().into_owned(),
6611            );
6612            let html = LocateFileTemplate {
6613                run_id: run_id.to_owned(),
6614                artifact_type: "html".to_string(),
6615                expected_filename: filename,
6616                server_mode,
6617                csp_nonce: csp_nonce.to_owned(),
6618                version: env!("CARGO_PKG_VERSION"),
6619            }
6620            .render()
6621            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6622            (StatusCode::NOT_FOUND, Html(html)).into_response()
6623        }
6624        Err(err) => {
6625            let filename = path.file_name().map_or_else(
6626                || "report.html".to_string(),
6627                |n| n.to_string_lossy().into_owned(),
6628            );
6629            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6630            let html = ErrorTemplate {
6631                message: msg,
6632                last_report_url: Some("/view-reports".to_string()),
6633                last_report_label: Some("View Reports".to_string()),
6634                run_id: None,
6635                error_code: Some(404),
6636                csp_nonce: csp_nonce.to_owned(),
6637                version: env!("CARGO_PKG_VERSION"),
6638            }
6639            .render()
6640            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6641            (StatusCode::NOT_FOUND, Html(html)).into_response()
6642        }
6643    }
6644}
6645
6646/// Serve the PDF artifact for a run — inline or download.
6647fn serve_pdf_artifact(
6648    path: &Path,
6649    report_title: &str,
6650    run_id: &str,
6651    wants_download: bool,
6652    csp_nonce: &str,
6653) -> Response {
6654    match fs::read(path) {
6655        Ok(bytes) => {
6656            let filename = build_pdf_filename(report_title, run_id);
6657            let disposition = if wants_download {
6658                format!("attachment; filename=\"{filename}\"")
6659            } else {
6660                format!("inline; filename=\"{filename}\"")
6661            };
6662            (
6663                [
6664                    (header::CONTENT_TYPE, "application/pdf".to_string()),
6665                    (header::CONTENT_DISPOSITION, disposition),
6666                ],
6667                bytes,
6668            )
6669                .into_response()
6670        }
6671        Err(err) => {
6672            let filename = path.file_name().map_or_else(
6673                || "report.pdf".to_string(),
6674                |n| n.to_string_lossy().into_owned(),
6675            );
6676            let msg = format!(
6677                "PDF report '{filename}' could not be read.\n\n\
6678                 Error: {err}\n\n\
6679                 If you moved or renamed the output folder, the stored path is now stale. \
6680                 Use 'Open PDF folder' from the results page to browse the output directory."
6681            );
6682            let html = ErrorTemplate {
6683                message: msg,
6684                last_report_url: Some("/view-reports".to_string()),
6685                last_report_label: Some("View Reports".to_string()),
6686                run_id: Some(run_id.to_owned()),
6687                error_code: Some(404),
6688                csp_nonce: csp_nonce.to_owned(),
6689                version: env!("CARGO_PKG_VERSION"),
6690            }
6691            .render()
6692            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6693            (StatusCode::NOT_FOUND, Html(html)).into_response()
6694        }
6695    }
6696}
6697
6698/// Serve the JSON artifact for a run — view or download.
6699fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6700    match fs::read(path) {
6701        Ok(bytes) => {
6702            if wants_download {
6703                (
6704                    [
6705                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6706                        (
6707                            header::CONTENT_DISPOSITION,
6708                            "attachment; filename=result.json",
6709                        ),
6710                    ],
6711                    bytes,
6712                )
6713                    .into_response()
6714            } else {
6715                (
6716                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6717                    bytes,
6718                )
6719                    .into_response()
6720            }
6721        }
6722        Err(err) => {
6723            let filename = path.file_name().map_or_else(
6724                || "result.json".to_string(),
6725                |n| n.to_string_lossy().into_owned(),
6726            );
6727            let msg = format!(
6728                "JSON result '{filename}' could not be read.\n\n\
6729                 Error: {err}\n\n\
6730                 If you moved or renamed the output folder, the stored path is now stale. \
6731                 Use 'Open JSON folder' from the results page to browse the output directory."
6732            );
6733            let html = ErrorTemplate {
6734                message: msg,
6735                last_report_url: Some("/view-reports".to_string()),
6736                last_report_label: Some("View Reports".to_string()),
6737                run_id: None,
6738                error_code: Some(404),
6739                csp_nonce: csp_nonce.to_owned(),
6740                version: env!("CARGO_PKG_VERSION"),
6741            }
6742            .render()
6743            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6744            (StatusCode::NOT_FOUND, Html(html)).into_response()
6745        }
6746    }
6747}
6748
6749/// Recover a `RunArtifacts` from the persisted registry for a run ID.
6750fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6751    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
6752    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
6753    let output_dir = entry
6754        .html_path
6755        .as_ref()
6756        .or(entry.json_path.as_ref())
6757        .or(entry.pdf_path.as_ref())
6758        .or(entry.csv_path.as_ref())
6759        .or(entry.xlsx_path.as_ref())
6760        .and_then(|p| {
6761            let parent = p.parent()?;
6762            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6763            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
6764            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6765                parent.parent().map(PathBuf::from)
6766            } else {
6767                Some(parent.to_path_buf())
6768            }
6769        })
6770        .unwrap_or_default();
6771    // Recover pdf_path: use the persisted one, or look for report.pdf
6772    // adjacent to html/json if only the old entries lack it.
6773    let pdf_path = entry.pdf_path.clone().or_else(|| {
6774        let candidate = output_dir.join("report.pdf");
6775        candidate.exists().then_some(candidate)
6776    });
6777    // csv_path / xlsx_path: persisted paths take precedence; fall back to
6778    // scanning the run directory for files matching the expected patterns so
6779    // that runs created before this feature still surface their artifacts.
6780    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6781        // Check excel/ subfolder (new layout) then root (old layout).
6782        for dir in &[output_dir.join("excel"), output_dir.clone()] {
6783            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6784                entries
6785                    .filter_map(std::result::Result::ok)
6786                    .find(|e| {
6787                        let n = e.file_name();
6788                        let n = n.to_string_lossy();
6789                        n.starts_with("report_") && n.ends_with(ext)
6790                    })
6791                    .map(|e| e.path())
6792            }) {
6793                return Some(p);
6794            }
6795        }
6796        None
6797    };
6798
6799    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6800    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6801    RunArtifacts {
6802        output_dir: output_dir.clone(),
6803        html_path: entry.html_path.clone(),
6804        pdf_path,
6805        json_path: entry.json_path.clone(),
6806        csv_path,
6807        xlsx_path,
6808        scan_config_path: find_scan_config_in_dir(&output_dir),
6809        report_title: entry.project_label.clone(),
6810        result_context: RunResultContext::default(),
6811    }
6812}
6813
6814#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
6815async fn resolve_artifact_set(
6816    state: &AppState,
6817    run_id: &str,
6818    csp_nonce: &str,
6819) -> Result<RunArtifacts, Response> {
6820    let cached = state.artifacts.lock().await.get(run_id).cloned();
6821    if let Some(a) = cached {
6822        return Ok(a);
6823    }
6824    let reg = state.registry.lock().await;
6825    if let Some(entry) = reg.find_by_run_id(run_id) {
6826        return Ok(recover_artifacts_from_registry(entry));
6827    }
6828    drop(reg);
6829    let short_id = &run_id[..run_id.len().min(8)];
6830    let hint = if matches!(
6831        run_id,
6832        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6833    ) {
6834        format!(
6835            " The URL format appears to be reversed \u{2014} \
6836             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6837             Use the View Reports page to navigate to your scan."
6838        )
6839    } else {
6840        " The report may have been deleted or the report directory moved. \
6841         Use View Reports to browse your scan history."
6842            .to_string()
6843    };
6844    let error_html = ErrorTemplate {
6845        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6846        last_report_url: Some("/view-reports".to_string()),
6847        last_report_label: Some("View Reports".to_string()),
6848        run_id: None,
6849        error_code: Some(404),
6850        csp_nonce: csp_nonce.to_owned(),
6851        version: env!("CARGO_PKG_VERSION"),
6852    }
6853    .render()
6854    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6855    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6856}
6857
6858/// Return the path to a run's PDF, queuing background generation when it is missing.
6859///
6860/// Returns `Ok(path)` when the PDF is known (it may still be generating).
6861/// Returns `Err(response)` when there is no JSON source to regenerate from.
6862async fn resolve_or_queue_pdf(
6863    state: &AppState,
6864    pdf_path: Option<PathBuf>,
6865    json_path: Option<PathBuf>,
6866    output_dir: PathBuf,
6867    run_id: &str,
6868    report_title: &str,
6869    csp_nonce: &str,
6870) -> Result<PathBuf, Response> {
6871    if let Some(p) = pdf_path {
6872        return Ok(p);
6873    }
6874    let Some(json_src) = json_path.filter(|p| p.exists()) else {
6875        let msg = "PDF report was not generated for this run. \
6876                   Re-run the analysis with PDF output enabled."
6877            .to_string();
6878        let html = ErrorTemplate {
6879            message: msg,
6880            last_report_url: Some(format!("/runs/html/{run_id}")),
6881            last_report_label: Some("View HTML Report".to_string()),
6882            run_id: Some(run_id.to_string()),
6883            error_code: Some(404),
6884            csp_nonce: csp_nonce.to_string(),
6885            version: env!("CARGO_PKG_VERSION"),
6886        }
6887        .render()
6888        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
6889        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
6890    };
6891    let pdf_filename = build_pdf_filename(report_title, run_id);
6892    let pdf_dest = output_dir.join(&pdf_filename);
6893    if !pdf_dest.exists() {
6894        // Record the pending path so concurrent requests show the spinner.
6895        {
6896            let mut map = state.artifacts.lock().await;
6897            if let Some(entry) = map.get_mut(run_id) {
6898                entry.pdf_path = Some(pdf_dest.clone());
6899            }
6900        }
6901        {
6902            let mut reg = state.registry.lock().await;
6903            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6904                e.pdf_path = Some(pdf_dest.clone());
6905            }
6906            let _ = reg.save(&state.registry_path);
6907        }
6908        spawn_native_pdf_background(
6909            json_src,
6910            pdf_dest.clone(),
6911            run_id.to_string(),
6912            state.artifacts.clone(),
6913        );
6914    }
6915    Ok(pdf_dest)
6916}
6917
6918/// Self-refreshing "please wait" page shown while the background PDF task is still running.
6919fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
6920    let html = format!(
6921                    "<!doctype html><html lang=\"en\"><head>\
6922                     <meta charset=utf-8>\
6923                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
6924                     <meta http-equiv=\"refresh\" content=\"5\">\
6925                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
6926                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
6927                     <style nonce=\"{csp_nonce}\">\
6928                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
6929                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
6930                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
6931                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
6932                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
6933                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
6934                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
6935                     background:var(--bg);color:var(--text);}}\
6936                     .top-nav{{position:sticky;top:0;z-index:30;\
6937                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
6938                     border-bottom:1px solid rgba(255,255,255,0.12);\
6939                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
6940                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
6941                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
6942                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
6943                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
6944                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
6945                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
6946                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
6947                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
6948                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
6949                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
6950                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
6951                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
6952                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
6953                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
6954                     justify-content:center;min-height:38px;border-radius:999px;\
6955                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
6956                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
6957                     .theme-toggle .icon-sun{{display:none;}}\
6958                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
6959                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
6960                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
6961                     display:flex;align-items:center;justify-content:center;\
6962                     min-height:calc(100vh - 56px);}}\
6963                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
6964                     .panel{{background:var(--surface);border:1px solid var(--line);\
6965                     border-radius:var(--radius);box-shadow:var(--shadow);\
6966                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
6967                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
6968                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
6969                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
6970                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
6971                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
6972                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
6973                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
6974                     min-height:42px;padding:0 20px;border-radius:14px;\
6975                     border:1px solid var(--line-strong);text-decoration:none;\
6976                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
6977                     .back-link:hover{{background:var(--line);}}\
6978                     </style></head>\
6979                     <body>\
6980                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
6981                       <a class=\"brand\" href=\"/\">\
6982                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
6983                         <div class=\"brand-copy\">\
6984                           <div class=\"brand-title\">OxideSLOC</div>\
6985                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
6986                         </div>\
6987                       </a>\
6988                       <div class=\"nav-right\">\
6989                         <a class=\"nav-pill\" href=\"/\">Home</a>\
6990                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
6991                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
6992                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
6993                           <svg class=\"icon-moon\" viewBox=\"0 0 24 24\"><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>\
6994                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
6995                           <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>\
6996                         </button>\
6997                       </div>\
6998                     </div></div>\
6999                     <div class=\"page\"><div class=\"panel\">\
7000                       <div class=\"spin-ring\"></div>\
7001                       <h1>Generating PDF\u{2026}</h1>\
7002                       <p>The PDF is being generated from the scan results.<br>\
7003                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7004                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7005                     </div></div>\
7006                     <script nonce=\"{csp_nonce}\">\
7007                     (function(){{\
7008                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7009                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7010                       var t=document.getElementById(\"theme-toggle\");\
7011                       if(t)t.addEventListener(\"click\",function(){{\
7012                         var d=b.classList.toggle(\"dark-theme\");\
7013                         localStorage.setItem(k,d?\"dark\":\"light\");\
7014                       }});\
7015                     }})();\
7016                     </script>\
7017                     </body></html>"
7018    );
7019    Html(html).into_response()
7020}
7021
7022/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7023fn render_error_artifact_html(
7024    message: String,
7025    last_report_url: Option<String>,
7026    last_report_label: Option<String>,
7027    run_id: Option<String>,
7028    error_code: Option<u16>,
7029    csp_nonce: &str,
7030) -> String {
7031    ErrorTemplate {
7032        message,
7033        last_report_url,
7034        last_report_label,
7035        run_id,
7036        error_code,
7037        csp_nonce: csp_nonce.to_owned(),
7038        version: env!("CARGO_PKG_VERSION"),
7039    }
7040    .render()
7041    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7042}
7043
7044/// Read a file and serve it as an attachment download.
7045fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7046    fs::read(path).map_or_else(
7047        |_| StatusCode::NOT_FOUND.into_response(),
7048        |bytes| {
7049            let filename = path.file_name().map_or_else(
7050                || fallback_filename.to_string(),
7051                |n| n.to_string_lossy().into_owned(),
7052            );
7053            (
7054                [
7055                    (header::CONTENT_TYPE, content_type.to_string()),
7056                    (
7057                        header::CONTENT_DISPOSITION,
7058                        format!("attachment; filename=\"{filename}\""),
7059                    ),
7060                ],
7061                bytes,
7062            )
7063                .into_response()
7064        },
7065    )
7066}
7067
7068fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7069    let Some(path) = csv_path else {
7070        let html = render_error_artifact_html(
7071            "CSV report was not generated for this run, or was not recorded in \
7072             the scan registry."
7073                .to_string(),
7074            Some(format!("/runs/html/{run_id}")),
7075            Some("View HTML Report".to_string()),
7076            Some(run_id.to_string()),
7077            Some(404),
7078            csp_nonce,
7079        );
7080        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7081    };
7082    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7083}
7084
7085fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7086    let Some(path) = xlsx_path else {
7087        let html = render_error_artifact_html(
7088            "Excel report was not generated for this run, or was not recorded in \
7089             the scan registry."
7090                .to_string(),
7091            Some(format!("/runs/html/{run_id}")),
7092            Some("View HTML Report".to_string()),
7093            Some(run_id.to_string()),
7094            Some(404),
7095            csp_nonce,
7096        );
7097        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7098    };
7099    serve_binary_download(
7100        &path,
7101        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7102        "report.xlsx",
7103    )
7104}
7105
7106fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7107    let path = artifact_set
7108        .scan_config_path
7109        .as_deref()
7110        .map(std::path::Path::to_path_buf)
7111        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7112        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7113    fs::read(&path).map_or_else(
7114        |_| StatusCode::NOT_FOUND.into_response(),
7115        |bytes| {
7116            (
7117                [
7118                    (
7119                        header::CONTENT_TYPE,
7120                        "application/json; charset=utf-8".to_string(),
7121                    ),
7122                    (
7123                        header::CONTENT_DISPOSITION,
7124                        "attachment; filename=\"scan-config.json\"".to_string(),
7125                    ),
7126                ],
7127                bytes,
7128            )
7129                .into_response()
7130        },
7131    )
7132}
7133
7134/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7135/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7136/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7137/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7138async fn serve_submodule_pdf_arm(
7139    artifact: &str,
7140    artifact_set: RunArtifacts,
7141    wants_download: bool,
7142    run_id: &str,
7143    csp_nonce: &str,
7144) -> Response {
7145    // "sub_benchmark_pdf" → base = "sub_benchmark"
7146    let base = artifact.trim_end_matches("_pdf");
7147    let sub_dir = artifact_set.output_dir.join("submodules");
7148    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7149
7150    if !pdf_path.exists() {
7151        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7152        let derived_safe = base.trim_start_matches("sub_");
7153        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7154            let parent_run = read_json(jp).ok()?;
7155            let sub = parent_run
7156                .submodule_summaries
7157                .iter()
7158                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7159                .clone();
7160            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7161            Some((parent_run, sub, parent_path))
7162        });
7163
7164        if let Some((parent_run, sub, parent_path)) = rebuilt {
7165            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7166            let pp = pdf_path.clone();
7167            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7168        }
7169    }
7170
7171    if !pdf_path.exists() {
7172        let html = render_error_artifact_html(
7173            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7174             enabled."
7175                .to_string(),
7176            Some("/view-reports".to_string()),
7177            Some("View Reports".to_string()),
7178            Some(run_id.to_string()),
7179            Some(404),
7180            csp_nonce,
7181        );
7182        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7183    }
7184
7185    serve_pdf_artifact(
7186        &pdf_path,
7187        &artifact_set.report_title,
7188        run_id,
7189        wants_download,
7190        csp_nonce,
7191    )
7192}
7193
7194fn serve_submodule_arm(
7195    artifact: &str,
7196    artifact_set: &RunArtifacts,
7197    wants_download: bool,
7198    csp_nonce: &str,
7199    run_id: &str,
7200    server_mode: bool,
7201) -> Response {
7202    if artifact.len() > 128
7203        || !artifact
7204            .chars()
7205            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7206    {
7207        return StatusCode::BAD_REQUEST.into_response();
7208    }
7209    let filename = format!("{artifact}.html");
7210    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7211    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7212    let path = if new_layout.exists() {
7213        new_layout
7214    } else {
7215        artifact_set.output_dir.join(&filename)
7216    };
7217    if !path.exists() {
7218        let html = render_error_artifact_html(
7219            format!(
7220                "Sub-report '{artifact}' was not found in the run directory.\n\
7221                 Re-run the analysis with 'Detect and separate git submodules' \
7222                 and HTML output enabled."
7223            ),
7224            Some("/view-reports".to_string()),
7225            Some("View Reports".to_string()),
7226            Some(run_id.to_string()),
7227            Some(404),
7228            csp_nonce,
7229        );
7230        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7231    }
7232    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7233}
7234
7235async fn serve_pdf_arm(
7236    state: &AppState,
7237    artifact_set: RunArtifacts,
7238    wants_download: bool,
7239    run_id: &str,
7240    csp_nonce: &str,
7241) -> Response {
7242    let report_title = artifact_set.report_title.clone();
7243    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7244    let stale_html_name = artifact_set
7245        .html_path
7246        .as_deref()
7247        .and_then(|p| p.file_name())
7248        .map(|n| n.to_string_lossy().into_owned());
7249    let path = match resolve_or_queue_pdf(
7250        state,
7251        artifact_set.pdf_path,
7252        artifact_set.json_path.clone(),
7253        artifact_set.output_dir.clone(),
7254        run_id,
7255        &report_title,
7256        csp_nonce,
7257    )
7258    .await
7259    {
7260        Ok(p) => p,
7261        Err(r) => return r,
7262    };
7263    if !path.exists() {
7264        // Distinguish a stale registry path (folder moved) from an in-progress
7265        // background generation. Only show the locate page when the PDF was
7266        // already recorded in the registry but the file is now missing.
7267        if had_pdf_in_registry {
7268            if let Some(expected_filename) = stale_html_name {
7269                let html = LocateFileTemplate {
7270                    run_id: run_id.to_string(),
7271                    artifact_type: "pdf".to_string(),
7272                    expected_filename,
7273                    server_mode: state.server_mode,
7274                    csp_nonce: csp_nonce.to_string(),
7275                    version: env!("CARGO_PKG_VERSION"),
7276                }
7277                .render()
7278                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7279                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7280            }
7281        }
7282        return pdf_generating_response(run_id, csp_nonce);
7283    }
7284    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7285}
7286
7287async fn artifact_handler(
7288    State(state): State<AppState>,
7289    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7290    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7291    Query(query): Query<ArtifactQuery>,
7292) -> Response {
7293    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7294        Ok(a) => a,
7295        Err(r) => return r,
7296    };
7297
7298    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7299
7300    match artifact.as_str() {
7301        "html" => {
7302            let Some(path) = artifact_set.html_path else {
7303                return StatusCode::NOT_FOUND.into_response();
7304            };
7305            serve_html_artifact(
7306                &path,
7307                wants_download,
7308                &csp_nonce,
7309                &run_id,
7310                state.server_mode,
7311            )
7312        }
7313        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7314        "json" => {
7315            let Some(path) = artifact_set.json_path else {
7316                let html = render_error_artifact_html(
7317                    "JSON result was not generated for this run, or was not recorded in \
7318                     the scan registry. Re-run the analysis with JSON output enabled."
7319                        .to_string(),
7320                    Some("/view-reports".to_string()),
7321                    Some("View Reports".to_string()),
7322                    Some(run_id.clone()),
7323                    Some(404),
7324                    &csp_nonce,
7325                );
7326                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7327            };
7328            serve_json_artifact(&path, wants_download, &csp_nonce)
7329        }
7330        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7331        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7332        "scan-config" => serve_scan_config_arm(&artifact_set),
7333        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7334            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7335                .await
7336        }
7337        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7338            &artifact,
7339            &artifact_set,
7340            wants_download,
7341            &csp_nonce,
7342            &run_id,
7343            state.server_mode,
7344        ),
7345        _ => StatusCode::NOT_FOUND.into_response(),
7346    }
7347}
7348
7349// ── History ───────────────────────────────────────────────────────────────────
7350
7351struct SubmoduleLinkRow {
7352    name: String,
7353    url: String,
7354}
7355
7356struct HistoryEntryRow {
7357    run_id: String,
7358    run_id_short: String,
7359    timestamp: String,
7360    timestamp_utc_ms: i64,
7361    project_label: String,
7362    project_path: String,
7363    files_analyzed: u64,
7364    files_skipped: u64,
7365    code_lines: u64,
7366    comment_lines: u64,
7367    blank_lines: u64,
7368    total_physical_lines: u64,
7369    functions: u64,
7370    classes: u64,
7371    variables: u64,
7372    imports: u64,
7373    test_count: u64,
7374    git_branch: String,
7375    git_commit: String,
7376    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7377    git_commit_long: String,
7378    has_html: bool,
7379    has_json: bool,
7380    has_pdf: bool,
7381    submodule_links: Vec<SubmoduleLinkRow>,
7382    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7383    submodule_names_csv: String,
7384}
7385
7386/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7387fn nth_weekday_of_month(
7388    year: i32,
7389    month: u32,
7390    weekday: chrono::Weekday,
7391    n: u32,
7392) -> chrono::NaiveDate {
7393    use chrono::Datelike;
7394    let mut count = 0u32;
7395    let mut day = 1u32;
7396    loop {
7397        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7398        if d.weekday() == weekday {
7399            count += 1;
7400            if count == n {
7401                return d;
7402            }
7403        }
7404        day += 1;
7405    }
7406}
7407
7408/// Returns true if `dt` falls within US Pacific Daylight Time.
7409/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7410/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7411fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7412    use chrono::{Datelike, TimeZone};
7413    let year = dt.year();
7414    let dst_start = chrono::Utc.from_utc_datetime(
7415        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7416            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7417    );
7418    let dst_end = chrono::Utc.from_utc_datetime(
7419        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7420            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7421    );
7422    dt >= dst_start && dt < dst_end
7423}
7424
7425fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7426    if is_pacific_dst(dt) {
7427        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7428            .format("%Y-%m-%d %H:%M PDT")
7429            .to_string()
7430    } else {
7431        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7432            .format("%Y-%m-%d %H:%M PST")
7433            .to_string()
7434    }
7435}
7436
7437/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7438fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7439    let (offset, tz) = if is_pacific_dst(dt) {
7440        (
7441            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7442            "PDT",
7443        )
7444    } else {
7445        (
7446            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7447            "PST",
7448        )
7449    };
7450    format!(
7451        "{} {tz}",
7452        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7453    )
7454}
7455
7456fn fmt_git_date(iso: &str) -> Option<String> {
7457    chrono::DateTime::parse_from_rfc3339(iso)
7458        .ok()
7459        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7460}
7461
7462/// Recover the full-length commit SHA for a registry entry whose stored record
7463/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7464///
7465/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7466/// is serialized after the per-file records, near the end of the file. We read a
7467/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7468/// known short SHA — this disambiguates the super-repo commit from any submodule
7469/// commits that also appear. Returns `None` if the file is unreadable or no match.
7470fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7471    use std::io::{Read, Seek, SeekFrom};
7472    if short.is_empty() {
7473        return None;
7474    }
7475    let len = std::fs::metadata(path).ok()?.len();
7476    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7477    let start = len.saturating_sub(TAIL);
7478    let mut file = std::fs::File::open(path).ok()?;
7479    file.seek(SeekFrom::Start(start)).ok()?;
7480    let mut buf = Vec::new();
7481    file.read_to_end(&mut buf).ok()?;
7482    let text = String::from_utf8_lossy(&buf);
7483    let short_lower = short.to_ascii_lowercase();
7484    let key = "\"git_commit_long\"";
7485    let mut found: Option<String> = None;
7486    let mut cursor = 0usize;
7487    while let Some(idx) = text[cursor..].find(key) {
7488        let after_key = cursor + idx + key.len();
7489        cursor = after_key;
7490        let rest = &text[after_key..];
7491        let Some(colon) = rest.find(':') else { break };
7492        let value_region = rest[colon + 1..].trim_start();
7493        // Skip `null` (or any non-string) values without consuming the next field.
7494        if let Some(open) = value_region.strip_prefix('"') {
7495            if let Some(close) = open.find('"') {
7496                let val = &open[..close];
7497                if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7498                    found = Some(val.to_string());
7499                }
7500            }
7501        }
7502    }
7503    found
7504}
7505
7506fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7507    reg.entries
7508        .iter()
7509        .map(|e| {
7510            let submodule_links = {
7511                let mut links: Vec<SubmoduleLinkRow> = vec![];
7512                let sub_dir = e
7513                    .html_path
7514                    .as_ref()
7515                    .and_then(|p| p.parent())
7516                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7517                if let Some(dir) = sub_dir {
7518                    if let Ok(rd) = std::fs::read_dir(dir) {
7519                        for entry_res in rd.flatten() {
7520                            let fname = entry_res.file_name();
7521                            let fname_str = fname.to_string_lossy();
7522                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7523                                let stem = &fname_str[..fname_str.len() - 5];
7524                                let display = stem[4..].replace('-', " ");
7525                                links.push(SubmoduleLinkRow {
7526                                    name: display,
7527                                    url: format!("/runs/{stem}/{}", e.run_id),
7528                                });
7529                            }
7530                        }
7531                    }
7532                }
7533                links.sort_by(|a, b| a.name.cmp(&b.name));
7534                links
7535            };
7536            let submodule_names_csv = submodule_links
7537                .iter()
7538                .map(|l| l.name.as_str())
7539                .collect::<Vec<_>>()
7540                .join(",");
7541            HistoryEntryRow {
7542                run_id: e.run_id.clone(),
7543                run_id_short: e
7544                    .run_id
7545                    .split('-')
7546                    .next_back()
7547                    .unwrap_or(&e.run_id)
7548                    .chars()
7549                    .take(7)
7550                    .collect(),
7551                timestamp: fmt_la_time(e.timestamp_utc),
7552                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7553                project_label: e.project_label.clone(),
7554                project_path: e
7555                    .input_roots
7556                    .first()
7557                    .map(|s| sanitize_path_str(s))
7558                    .unwrap_or_default(),
7559                files_analyzed: e.summary.files_analyzed,
7560                files_skipped: e.summary.files_skipped,
7561                code_lines: e.summary.code_lines,
7562                comment_lines: e.summary.comment_lines,
7563                blank_lines: e.summary.blank_lines,
7564                total_physical_lines: e.summary.total_physical_lines,
7565                functions: e.summary.functions,
7566                classes: e.summary.classes,
7567                variables: e.summary.variables,
7568                imports: e.summary.imports,
7569                test_count: e.summary.test_count,
7570                git_branch: e.git_branch.clone().unwrap_or_default(),
7571                git_commit: e.git_commit.clone().unwrap_or_default(),
7572                git_commit_long: {
7573                    let short = e.git_commit.clone().unwrap_or_default();
7574                    e.git_commit_long
7575                        .clone()
7576                        .filter(|s| !s.is_empty())
7577                        .or_else(|| {
7578                            e.json_path
7579                                .as_ref()
7580                                .and_then(|p| extract_long_commit_from_json(p, &short))
7581                        })
7582                        .unwrap_or(short)
7583                },
7584                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7585                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7586                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7587                submodule_links,
7588                submodule_names_csv,
7589            }
7590        })
7591        .collect()
7592}
7593
7594#[derive(Deserialize, Default)]
7595struct HistoryQuery {
7596    linked: Option<String>,
7597    error: Option<String>,
7598}
7599
7600async fn history_handler(
7601    State(state): State<AppState>,
7602    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7603    Query(query): Query<HistoryQuery>,
7604) -> impl IntoResponse {
7605    // Auto-scan all watched directories before rendering so the list stays fresh.
7606    auto_scan_watched_dirs(&state).await;
7607    let watched_dirs: Vec<String> = {
7608        let wd = state.watched_dirs.lock().await;
7609        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7610    };
7611    let mut entries = {
7612        let reg = state.registry.lock().await;
7613        make_history_rows(&reg)
7614    };
7615    entries.retain(|e| e.has_html);
7616    let total_scans = entries.len();
7617    let linked_count = query
7618        .linked
7619        .as_deref()
7620        .and_then(|s| s.parse::<usize>().ok())
7621        .unwrap_or(0);
7622    let browse_error = query.error.filter(|s| !s.is_empty());
7623    let template = HistoryTemplate {
7624        version: env!("CARGO_PKG_VERSION"),
7625        entries,
7626        total_scans,
7627        linked_count,
7628        browse_error,
7629        watched_dirs,
7630        csp_nonce,
7631        server_mode: state.server_mode,
7632    };
7633    Html(
7634        template
7635            .render()
7636            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7637    )
7638    .into_response()
7639}
7640
7641async fn compare_select_handler(
7642    State(state): State<AppState>,
7643    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7644) -> impl IntoResponse {
7645    auto_scan_watched_dirs(&state).await;
7646    let watched_dirs: Vec<String> = {
7647        let wd = state.watched_dirs.lock().await;
7648        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7649    };
7650    let mut entries = {
7651        let reg = state.registry.lock().await;
7652        make_history_rows(&reg)
7653    };
7654    entries.retain(|e| e.has_json);
7655    let total_scans = entries.len();
7656    let template = CompareSelectTemplate {
7657        version: env!("CARGO_PKG_VERSION"),
7658        entries,
7659        total_scans,
7660        watched_dirs,
7661        csp_nonce,
7662        server_mode: state.server_mode,
7663    };
7664    Html(
7665        template
7666            .render()
7667            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7668    )
7669    .into_response()
7670}
7671
7672// ── Compare ───────────────────────────────────────────────────────────────────
7673
7674#[derive(Deserialize, Default)]
7675struct CompareQuery {
7676    a: Option<String>,
7677    b: Option<String>,
7678    /// Optional submodule name to scope the comparison to one submodule.
7679    sub: Option<String>,
7680    /// "super" to exclude all submodule files and show only the super-repo.
7681    scope: Option<String>,
7682}
7683
7684struct CompareFileDeltaRow {
7685    relative_path: String,
7686    language: String,
7687    status: String,
7688    baseline_code: i64,
7689    current_code: i64,
7690    baseline_code_display: String,
7691    current_code_display: String,
7692    code_delta_str: String,
7693    code_delta_class: String,
7694    comment_delta_str: String,
7695    comment_delta_class: String,
7696    total_delta_str: String,
7697    total_delta_class: String,
7698}
7699
7700/// Recompute `summary_totals` from the current `per_file_records` slice.
7701/// Used when `per_file_records` has been narrowed to a submodule subset.
7702fn recompute_summary_from_records(run: &mut AnalysisRun) {
7703    let mut totals = SummaryTotals::default();
7704    for r in &run.per_file_records {
7705        if r.language.is_some() {
7706            totals.files_analyzed += 1;
7707        }
7708        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7709        totals.code_lines += r.effective_counts.code_lines;
7710        totals.comment_lines += r.effective_counts.comment_lines;
7711        totals.blank_lines += r.effective_counts.blank_lines;
7712        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7713        totals.functions += r.raw_line_categories.functions;
7714        totals.classes += r.raw_line_categories.classes;
7715        totals.variables += r.raw_line_categories.variables;
7716        totals.imports += r.raw_line_categories.imports;
7717        totals.test_count += r.raw_line_categories.test_count;
7718        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7719        totals.test_suite_count += r.raw_line_categories.test_suite_count;
7720        if let Some(cov) = &r.coverage {
7721            totals.coverage_lines_found += u64::from(cov.lines_found);
7722            totals.coverage_lines_hit += u64::from(cov.lines_hit);
7723            totals.coverage_functions_found += u64::from(cov.functions_found);
7724            totals.coverage_functions_hit += u64::from(cov.functions_hit);
7725            totals.coverage_branches_found += u64::from(cov.branches_found);
7726            totals.coverage_branches_hit += u64::from(cov.branches_hit);
7727        }
7728    }
7729    totals.files_considered = totals.files_analyzed;
7730    run.summary_totals = totals;
7731}
7732
7733fn fmt_delta(n: i64) -> String {
7734    if n > 0 {
7735        format!("+{n}")
7736    } else {
7737        format!("{n}")
7738    }
7739}
7740
7741fn delta_class(n: i64) -> &'static str {
7742    use std::cmp::Ordering;
7743    match n.cmp(&0) {
7744        Ordering::Greater => "pos",
7745        Ordering::Less => "neg",
7746        Ordering::Equal => "zero",
7747    }
7748}
7749
7750// ratio/percentage display, precision loss acceptable
7751#[allow(clippy::cast_precision_loss)]
7752fn fmt_pct(delta: i64, baseline: u64) -> String {
7753    if baseline == 0 {
7754        return "—".to_string();
7755    }
7756    #[allow(clippy::cast_precision_loss)]
7757    let pct = (delta as f64 / baseline as f64) * 100.0;
7758    if pct > 0.049 {
7759        format!("+{pct:.1}%")
7760    } else if pct < -0.049 {
7761        format!("{pct:.1}%")
7762    } else {
7763        "±0%".to_string()
7764    }
7765}
7766
7767/// Returns (`display_string`, `css_class`) for a numeric change column cell.
7768fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7769    prev.map_or_else(
7770        || ("—".to_string(), "na"),
7771        |p| {
7772            #[allow(clippy::cast_possible_wrap)]
7773            let d = curr as i64 - p as i64;
7774            (fmt_delta(d), delta_class(d))
7775        },
7776    )
7777}
7778
7779#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
7780fn load_scan_for_compare(
7781    json_path: &std::path::Path,
7782    scan_label: &str,
7783    run_id: &str,
7784    server_mode: bool,
7785    compare_url: &str,
7786    csp_nonce: &str,
7787) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7788    match read_json(json_path) {
7789        Ok(r) => Ok(r),
7790        Err(e) => {
7791            if server_mode {
7792                let html = ErrorTemplate {
7793                    message: format!(
7794                        "Could not load {scan_label} scan data. The scan output folder may have \
7795                         been moved, renamed, or deleted. Re-running the analysis will create \
7796                         fresh comparison data."
7797                    ),
7798                    last_report_url: Some("/compare-scans".to_string()),
7799                    last_report_label: Some("Compare Scans".to_string()),
7800                    run_id: Some(run_id.to_owned()),
7801                    error_code: Some(404),
7802                    csp_nonce: csp_nonce.to_owned(),
7803                    version: env!("CARGO_PKG_VERSION"),
7804                }
7805                .render()
7806                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7807                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7808            }
7809            let msg = format!(
7810                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7811                json_path.display()
7812            );
7813            let folder_hint = output_folder_hint(json_path);
7814            Err(missing_scan_relocate_response(
7815                &msg,
7816                run_id,
7817                &folder_hint,
7818                compare_url,
7819                false,
7820                csp_nonce,
7821            ))
7822        }
7823    }
7824}
7825
7826struct ChurnStats {
7827    new_scope: bool,
7828    scope_flag: bool,
7829    churn_rate_str: String,
7830    churn_rate_class: String,
7831}
7832
7833fn compute_churn_stats(
7834    baseline_code: u64,
7835    current_code: u64,
7836    lines_added: i64,
7837    lines_removed: i64,
7838) -> ChurnStats {
7839    let new_scope = baseline_code == 0 && current_code > 0;
7840    #[allow(clippy::cast_precision_loss)]
7841    let churn_pct = if baseline_code > 0 {
7842        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7843    } else {
7844        0.0
7845    };
7846    #[allow(clippy::cast_precision_loss)]
7847    let scope_flag =
7848        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7849    let churn_rate_str = if new_scope {
7850        "New".to_string()
7851    } else if baseline_code > 0 {
7852        format!("{churn_pct:.1}%")
7853    } else {
7854        "—".to_string()
7855    };
7856    let churn_rate_class = if new_scope || churn_pct > 20.0 {
7857        "high".to_string()
7858    } else if churn_pct > 5.0 {
7859        "med".to_string()
7860    } else {
7861        "low".to_string()
7862    };
7863    ChurnStats {
7864        new_scope,
7865        scope_flag,
7866        churn_rate_str,
7867        churn_rate_class,
7868    }
7869}
7870
7871/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
7872/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
7873/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
7874fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7875    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
7876    if !has_data {
7877        return String::new();
7878    }
7879    let base_str = s
7880        .baseline_coverage_line_pct
7881        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7882    let curr_str = s
7883        .current_coverage_line_pct
7884        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
7885    let (delta_str, cls) = match s.coverage_line_pct_delta {
7886        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
7887        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
7888        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
7889        None => ("\u{2014}".into(), "zero"),
7890    };
7891    format!(
7892        r#"<div class="delta-card">
7893          <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo.<br>Positive delta = more lines instrumented and hit.<br>Only shown when at least one scan has coverage data.</div>
7894          <div class="delta-card-label">Line coverage</div>
7895          <div class="delta-card-from">Before: {base_str}</div>
7896          <div class="delta-card-to">{curr_str}</div>
7897          <span class="delta-card-change {cls}">{delta_str}</span>
7898        </div>"#
7899    )
7900}
7901
7902/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
7903#[allow(clippy::ref_option)]
7904fn narrow_run_pair_by_scope(
7905    mut baseline: AnalysisRun,
7906    mut current: AnalysisRun,
7907    active_sub: &Option<String>,
7908    super_scope: bool,
7909) -> (AnalysisRun, AnalysisRun) {
7910    if let Some(ref sub_name) = active_sub {
7911        baseline
7912            .per_file_records
7913            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7914        current
7915            .per_file_records
7916            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7917        recompute_summary_from_records(&mut baseline);
7918        recompute_summary_from_records(&mut current);
7919    } else if super_scope {
7920        baseline.per_file_records.retain(|f| f.submodule.is_none());
7921        current.per_file_records.retain(|f| f.submodule.is_none());
7922        recompute_summary_from_records(&mut baseline);
7923        recompute_summary_from_records(&mut current);
7924    }
7925    (baseline, current)
7926}
7927
7928/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
7929#[allow(clippy::ref_option)]
7930fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
7931    if let Some(ref sub_name) = active_sub {
7932        for run in runs.iter_mut() {
7933            run.per_file_records
7934                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
7935            recompute_summary_from_records(run);
7936        }
7937    } else if super_scope {
7938        for run in runs.iter_mut() {
7939            run.per_file_records.retain(|f| f.submodule.is_none());
7940            recompute_summary_from_records(run);
7941        }
7942    }
7943}
7944
7945#[allow(clippy::too_many_lines)]
7946async fn compare_handler(
7947    State(state): State<AppState>,
7948    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7949    Query(query): Query<CompareQuery>,
7950) -> impl IntoResponse {
7951    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
7952    // redirect to the history page where the user can select two runs.
7953    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
7954        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
7955        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
7956    };
7957
7958    let (maybe_a, maybe_b) = {
7959        let reg = state.registry.lock().await;
7960        (
7961            reg.find_by_run_id(&run_id_a).cloned(),
7962            reg.find_by_run_id(&run_id_b).cloned(),
7963        )
7964    };
7965
7966    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
7967        let html = ErrorTemplate {
7968            message: "One or both run IDs were not found in scan history. \
7969                      The runs may have been deleted or the registry may have been reset."
7970                .to_string(),
7971            last_report_url: Some("/compare-scans".to_string()),
7972            last_report_label: Some("Compare Scans".to_string()),
7973            run_id: None,
7974            error_code: None,
7975            csp_nonce: csp_nonce.clone(),
7976            version: env!("CARGO_PKG_VERSION"),
7977        }
7978        .render()
7979        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
7980        return Html(html).into_response();
7981    };
7982
7983    // Ensure older scan is always the baseline.
7984    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
7985        (entry_a, entry_b)
7986    } else {
7987        (entry_b, entry_a)
7988    };
7989
7990    // If query params were in the wrong order, redirect to canonical URL so the
7991    // browser always shows the same URL for the same two scans regardless of how
7992    // the user arrived here (Full diff button vs. Compare Scans selection).
7993    if baseline_entry.run_id != run_id_a {
7994        let canonical = format!(
7995            "/compare?a={}&b={}",
7996            baseline_entry.run_id, current_entry.run_id
7997        );
7998        return axum::response::Redirect::to(&canonical).into_response();
7999    }
8000
8001    let (Some(base_json), Some(curr_json)) = (
8002        baseline_entry.json_path.as_ref(),
8003        current_entry.json_path.as_ref(),
8004    ) else {
8005        let html = ErrorTemplate {
8006            message: "Full comparison requires JSON scan data, which was not saved for one or \
8007                      both of these runs. JSON is now always saved for new scans — re-run the \
8008                      affected projects to enable comparisons."
8009                .to_string(),
8010            last_report_url: Some("/compare-scans".to_string()),
8011            last_report_label: Some("Compare Scans".to_string()),
8012            run_id: None,
8013            error_code: None,
8014            csp_nonce: csp_nonce.clone(),
8015            version: env!("CARGO_PKG_VERSION"),
8016        }
8017        .render()
8018        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8019        return Html(html).into_response();
8020    };
8021
8022    let compare_url = format!(
8023        "/compare?a={}&b={}",
8024        baseline_entry.run_id, current_entry.run_id
8025    );
8026
8027    let baseline_run = match load_scan_for_compare(
8028        base_json,
8029        "baseline",
8030        &baseline_entry.run_id,
8031        state.server_mode,
8032        &compare_url,
8033        &csp_nonce,
8034    ) {
8035        Ok(r) => r,
8036        Err(resp) => return resp,
8037    };
8038    let current_run = match load_scan_for_compare(
8039        curr_json,
8040        "current",
8041        &current_entry.run_id,
8042        state.server_mode,
8043        &compare_url,
8044        &csp_nonce,
8045    ) {
8046        Ok(r) => r,
8047        Err(resp) => return resp,
8048    };
8049
8050    let active_submodule = query.sub.clone();
8051    let super_scope_active = query.scope.as_deref() == Some("super");
8052
8053    let submodule_options = baseline_run
8054        .submodule_summaries
8055        .iter()
8056        .chain(current_run.submodule_summaries.iter())
8057        .map(|s| s.name.clone())
8058        .collect::<std::collections::BTreeSet<_>>()
8059        .into_iter()
8060        .collect::<Vec<_>>();
8061    let has_any_submodule_data = !submodule_options.is_empty();
8062
8063    // Narrow per_file_records when a scope is active, then recompute totals.
8064    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8065        baseline_run,
8066        current_run,
8067        &active_submodule,
8068        super_scope_active,
8069    );
8070
8071    let comparison = compute_delta(&effective_baseline, &effective_current);
8072
8073    let file_rows: Vec<CompareFileDeltaRow> = comparison
8074        .file_deltas
8075        .iter()
8076        .map(|d| CompareFileDeltaRow {
8077            relative_path: d.relative_path.clone(),
8078            language: d.language.clone().unwrap_or_else(|| "—".into()),
8079            status: match d.status {
8080                FileChangeStatus::Added => "added".into(),
8081                FileChangeStatus::Removed => "removed".into(),
8082                FileChangeStatus::Modified => "modified".into(),
8083                FileChangeStatus::Unchanged => "unchanged".into(),
8084            },
8085            baseline_code: d.baseline_code,
8086            current_code: d.current_code,
8087            baseline_code_display: if d.status == FileChangeStatus::Added {
8088                "—".into()
8089            } else {
8090                d.baseline_code.to_string()
8091            },
8092            current_code_display: if d.status == FileChangeStatus::Removed {
8093                "—".into()
8094            } else {
8095                d.current_code.to_string()
8096            },
8097            code_delta_str: fmt_delta(d.code_delta),
8098            code_delta_class: delta_class(d.code_delta).into(),
8099            comment_delta_str: fmt_delta(d.comment_delta),
8100            comment_delta_class: delta_class(d.comment_delta).into(),
8101            total_delta_str: fmt_delta(d.total_delta),
8102            total_delta_class: delta_class(d.total_delta).into(),
8103        })
8104        .collect();
8105
8106    let project_path = baseline_entry
8107        .input_roots
8108        .first()
8109        .map(|s| sanitize_path_str(s))
8110        .unwrap_or_default();
8111    let lines_added = sum_added_code_lines(&comparison);
8112    let lines_removed = sum_removed_code_lines(&comparison);
8113    let churn = compute_churn_stats(
8114        comparison.summary.baseline_code,
8115        comparison.summary.current_code,
8116        lines_added,
8117        lines_removed,
8118    );
8119    let s = &comparison.summary;
8120    let template = CompareTemplate {
8121        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8122        version: env!("CARGO_PKG_VERSION"),
8123        project_label: baseline_entry.project_label.clone(),
8124        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8125        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8126        baseline_run_id: baseline_entry.run_id.clone(),
8127        current_run_id: current_entry.run_id.clone(),
8128        baseline_run_id_short: baseline_entry
8129            .run_id
8130            .split('-')
8131            .next_back()
8132            .unwrap_or(&baseline_entry.run_id)
8133            .chars()
8134            .take(7)
8135            .collect(),
8136        current_run_id_short: current_entry
8137            .run_id
8138            .split('-')
8139            .next_back()
8140            .unwrap_or(&current_entry.run_id)
8141            .chars()
8142            .take(7)
8143            .collect(),
8144        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8145        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8146        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8147        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8148        project_path: project_path.clone(),
8149        baseline_code: s.baseline_code,
8150        current_code: s.current_code,
8151        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8152        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8153        baseline_files: s.baseline_files,
8154        current_files: s.current_files,
8155        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8156        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8157        baseline_comments: s.baseline_comments,
8158        current_comments: s.current_comments,
8159        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8160        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8161        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8162        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8163        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8164        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8165        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8166        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8167        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8168        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8169        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8170        code_lines_added: lines_added,
8171        code_lines_removed: lines_removed,
8172        new_scope: churn.new_scope,
8173        churn_rate_str: churn.churn_rate_str,
8174        churn_rate_class: churn.churn_rate_class,
8175        scope_flag: churn.scope_flag,
8176        files_added: comparison.files_added,
8177        files_removed: comparison.files_removed,
8178        files_modified: comparison.files_modified,
8179        files_unchanged: comparison.files_unchanged,
8180        file_rows,
8181        baseline_git_author: baseline_entry.git_author.clone(),
8182        current_git_author: current_entry.git_author.clone(),
8183        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8184        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8185        baseline_git_tags: baseline_entry.git_tags.clone(),
8186        current_git_tags: current_entry.git_tags.clone(),
8187        baseline_git_commit_date: baseline_entry
8188            .git_commit_date
8189            .as_deref()
8190            .and_then(fmt_git_date),
8191        current_git_commit_date: current_entry
8192            .git_commit_date
8193            .as_deref()
8194            .and_then(fmt_git_date),
8195        project_name: project_path
8196            .rsplit(['/', '\\'])
8197            .find(|s| !s.is_empty())
8198            .unwrap_or(&project_path)
8199            .to_string(),
8200        submodule_options,
8201        has_any_submodule_data,
8202        active_submodule,
8203        super_scope_active,
8204        toast_assets: sloc_toast_assets(&csp_nonce),
8205        csp_nonce,
8206        coverage_delta_card: build_coverage_delta_card(s),
8207        baseline_test_count: effective_baseline.summary_totals.test_count,
8208        current_test_count: effective_current.summary_totals.test_count,
8209        baseline_coverage_pct: s.baseline_coverage_line_pct,
8210        current_coverage_pct: s.current_coverage_line_pct,
8211    };
8212
8213    Html(
8214        template
8215            .render()
8216            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8217    )
8218    .into_response()
8219}
8220
8221// ── Badge endpoint ────────────────────────────────────────────────────────────
8222// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8223// pages, Jira descriptions, etc.
8224//
8225// GET /badge/<metric>?label=<override>&color=<hex>
8226// Metrics: code-lines  files  comment-lines  blank-lines
8227
8228fn format_number(n: u64) -> String {
8229    let s = n.to_string();
8230    let mut out = String::with_capacity(s.len() + s.len() / 3);
8231    let len = s.len();
8232    for (i, c) in s.chars().enumerate() {
8233        if i > 0 && (len - i).is_multiple_of(3) {
8234            out.push(',');
8235        }
8236        out.push(c);
8237    }
8238    out
8239}
8240
8241const fn badge_char_width(c: char) -> f64 {
8242    match c {
8243        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8244        'm' | 'w' => 9.0,
8245        ' ' => 4.0,
8246        _ => 6.5,
8247    }
8248}
8249
8250#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8251fn badge_text_px(text: &str) -> u32 {
8252    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8253}
8254
8255fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8256    let lw = badge_text_px(label) + 20;
8257    let rw = badge_text_px(value) + 20;
8258    let total = lw + rw;
8259    let lx = lw / 2;
8260    let rx = lw + rw / 2;
8261    let le = escape_html(label);
8262    let ve = escape_html(value);
8263    let ce = escape_html(color);
8264    format!(
8265        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8266  <rect width="{total}" height="20" fill="#555"/>
8267  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8268  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8269    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8270    <text x="{lx}" y="13">{le}</text>
8271    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8272    <text x="{rx}" y="13">{ve}</text>
8273  </g>
8274</svg>"##
8275    )
8276}
8277
8278#[derive(Deserialize)]
8279struct BadgeQuery {
8280    label: Option<String>,
8281    color: Option<String>,
8282}
8283
8284async fn badge_handler(
8285    State(state): State<AppState>,
8286    AxumPath(metric): AxumPath<String>,
8287    Query(query): Query<BadgeQuery>,
8288) -> Response {
8289    let entry = {
8290        let reg = state.registry.lock().await;
8291        reg.entries.first().cloned()
8292    };
8293
8294    let Some(entry) = entry else {
8295        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8296        return (
8297            [
8298                (header::CONTENT_TYPE, "image/svg+xml"),
8299                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8300            ],
8301            svg,
8302        )
8303            .into_response();
8304    };
8305
8306    let (default_label, value, default_color) = match metric.as_str() {
8307        "code-lines" => (
8308            "code lines",
8309            format_number(entry.summary.code_lines),
8310            "#4a78ee",
8311        ),
8312        "files" => (
8313            "files analyzed",
8314            format_number(entry.summary.files_analyzed),
8315            "#4a9862",
8316        ),
8317        "comment-lines" => (
8318            "comment lines",
8319            format_number(entry.summary.comment_lines),
8320            "#b35428",
8321        ),
8322        "blank-lines" => (
8323            "blank lines",
8324            format_number(entry.summary.blank_lines),
8325            "#7a5db0",
8326        ),
8327        _ => return StatusCode::NOT_FOUND.into_response(),
8328    };
8329
8330    let label = query.label.as_deref().unwrap_or(default_label);
8331    let color = query.color.as_deref().unwrap_or(default_color);
8332    let svg = render_badge_svg(label, &value, color);
8333
8334    (
8335        [
8336            (header::CONTENT_TYPE, "image/svg+xml"),
8337            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8338        ],
8339        svg,
8340    )
8341        .into_response()
8342}
8343
8344// ── Metrics API ───────────────────────────────────────────────────────────────
8345// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8346// Confluence automation, Jira webhooks, etc.
8347//
8348// GET /api/metrics/latest
8349// GET /api/metrics/<run_id>
8350
8351#[derive(Serialize)]
8352struct ApiCoverageBlock {
8353    lines_found: u64,
8354    lines_hit: u64,
8355    line_pct: f64,
8356    functions_found: u64,
8357    functions_hit: u64,
8358    function_pct: f64,
8359    branches_found: u64,
8360    branches_hit: u64,
8361    branch_pct: f64,
8362}
8363
8364#[derive(Serialize)]
8365struct ApiMetricsResponse {
8366    run_id: String,
8367    timestamp: String,
8368    project: String,
8369    summary: ApiSummaryPayload,
8370    languages: Vec<ApiLanguageRow>,
8371    #[serde(skip_serializing_if = "Option::is_none")]
8372    coverage: Option<ApiCoverageBlock>,
8373}
8374
8375#[derive(Serialize)]
8376struct ApiSummaryPayload {
8377    files_analyzed: u64,
8378    files_skipped: u64,
8379    code_lines: u64,
8380    comment_lines: u64,
8381    blank_lines: u64,
8382    total_physical_lines: u64,
8383    functions: u64,
8384    classes: u64,
8385    variables: u64,
8386    imports: u64,
8387}
8388
8389#[derive(Serialize)]
8390struct ApiLanguageRow {
8391    name: String,
8392    files: u64,
8393    code_lines: u64,
8394    comment_lines: u64,
8395    blank_lines: u64,
8396    functions: u64,
8397    classes: u64,
8398    variables: u64,
8399    imports: u64,
8400}
8401
8402async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8403    let entry = {
8404        let reg = state.registry.lock().await;
8405        reg.entries.first().cloned()
8406    };
8407    entry.map_or_else(
8408        || error::not_found("no scans recorded yet"),
8409        |e| build_metrics_response(&e),
8410    )
8411}
8412
8413async fn api_metrics_run_handler(
8414    State(state): State<AppState>,
8415    AxumPath(run_id): AxumPath<String>,
8416) -> Response {
8417    let entry = {
8418        let reg = state.registry.lock().await;
8419        reg.find_by_run_id(&run_id).cloned()
8420    };
8421    entry.map_or_else(
8422        || error::not_found("run not found"),
8423        |e| build_metrics_response(&e),
8424    )
8425}
8426
8427fn build_metrics_response(entry: &RegistryEntry) -> Response {
8428    let languages: Vec<ApiLanguageRow> = entry
8429        .json_path
8430        .as_ref()
8431        .and_then(|p| read_json(p).ok())
8432        .map(|run| {
8433            run.totals_by_language
8434                .iter()
8435                .map(|l| ApiLanguageRow {
8436                    name: l.language.display_name().to_string(),
8437                    files: l.files,
8438                    code_lines: l.code_lines,
8439                    comment_lines: l.comment_lines,
8440                    blank_lines: l.blank_lines,
8441                    functions: l.functions,
8442                    classes: l.classes,
8443                    variables: l.variables,
8444                    imports: l.imports,
8445                })
8446                .collect()
8447        })
8448        .unwrap_or_default();
8449
8450    let s = &entry.summary;
8451    let coverage = if s.coverage_lines_found > 0 {
8452        let pct = |hit: u64, found: u64| -> f64 {
8453            if found == 0 {
8454                0.0
8455            } else {
8456                #[allow(clippy::cast_precision_loss)]
8457                let v = (hit as f64 / found as f64) * 100.0;
8458                (v * 10.0).round() / 10.0
8459            }
8460        };
8461        Some(ApiCoverageBlock {
8462            lines_found: s.coverage_lines_found,
8463            lines_hit: s.coverage_lines_hit,
8464            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8465            functions_found: s.coverage_functions_found,
8466            functions_hit: s.coverage_functions_hit,
8467            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8468            branches_found: s.coverage_branches_found,
8469            branches_hit: s.coverage_branches_hit,
8470            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8471        })
8472    } else {
8473        None
8474    };
8475    Json(ApiMetricsResponse {
8476        run_id: entry.run_id.clone(),
8477        timestamp: entry.timestamp_utc.to_rfc3339(),
8478        project: entry.project_label.clone(),
8479        summary: ApiSummaryPayload {
8480            files_analyzed: s.files_analyzed,
8481            files_skipped: s.files_skipped,
8482            code_lines: s.code_lines,
8483            comment_lines: s.comment_lines,
8484            blank_lines: s.blank_lines,
8485            total_physical_lines: s.total_physical_lines,
8486            functions: s.functions,
8487            classes: s.classes,
8488            variables: s.variables,
8489            imports: s.imports,
8490        },
8491        languages,
8492        coverage,
8493    })
8494    .into_response()
8495}
8496
8497// ── Project history API ───────────────────────────────────────────────────────
8498// Protected. Called by the wizard JS when the project path changes, so the UI
8499// can show a "scanned N times before" badge without a full page reload.
8500//
8501// GET /api/project-history?path=<project_root>
8502
8503#[derive(Deserialize)]
8504struct ProjectHistoryQuery {
8505    path: Option<String>,
8506}
8507
8508#[derive(Serialize)]
8509struct ProjectHistoryResponse {
8510    scan_count: usize,
8511    last_scan_id: Option<String>,
8512    last_scan_timestamp: Option<String>,
8513    last_scan_code_lines: Option<u64>,
8514    last_git_branch: Option<String>,
8515    last_git_commit: Option<String>,
8516}
8517
8518/// Return true if `entry` matches either an exact root path or an upload-staging
8519/// path with the same project name (needed because each upload gets a fresh UUID dir).
8520fn entry_matches_project(
8521    entry: &RegistryEntry,
8522    root_str: &str,
8523    upload_root: &str,
8524    upload_name_suffix: Option<&str>,
8525) -> bool {
8526    if entry.input_roots.iter().any(|r| r == root_str) {
8527        return true;
8528    }
8529    if let Some(suffix) = upload_name_suffix {
8530        return entry
8531            .input_roots
8532            .iter()
8533            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8534    }
8535    false
8536}
8537
8538async fn project_history_handler(
8539    State(state): State<AppState>,
8540    Query(query): Query<ProjectHistoryQuery>,
8541) -> Response {
8542    let path = query.path.unwrap_or_default();
8543    let resolved = resolve_input_path(&path);
8544    let root_str = resolved.to_string_lossy().replace('\\', "/");
8545
8546    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8547    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8548    // previous scans of the same project. Fall back to matching by project name within the
8549    // uploads staging directory so Scan History populates correctly across uploads.
8550    let upload_root = std::env::temp_dir()
8551        .join("oxide-sloc-uploads")
8552        .to_string_lossy()
8553        .replace('\\', "/");
8554    let upload_name_suffix: Option<String> =
8555        if state.server_mode && root_str.starts_with(&upload_root) {
8556            resolved
8557                .file_name()
8558                .and_then(|n| n.to_str())
8559                .map(|name| format!("/{name}"))
8560        } else {
8561            None
8562        };
8563    let suffix_ref = upload_name_suffix.as_deref();
8564
8565    let entries: Vec<_> = {
8566        let reg = state.registry.lock().await;
8567        reg.entries
8568            .iter()
8569            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8570            .cloned()
8571            .collect()
8572    };
8573    let scan_count = entries.len();
8574    let last = entries.first();
8575    let last_scan_id = last.map(|e| e.run_id.clone());
8576    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8577    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8578    let last_git_branch = last.and_then(|e| e.git_branch.clone());
8579    let last_git_commit = last.and_then(|e| e.git_commit.clone());
8580
8581    Json(ProjectHistoryResponse {
8582        scan_count,
8583        last_scan_id,
8584        last_scan_timestamp,
8585        last_scan_code_lines,
8586        last_git_branch,
8587        last_git_commit,
8588    })
8589    .into_response()
8590}
8591
8592// ── Metrics history API ───────────────────────────────────────────────────────
8593// Protected. Returns a JSON array of lightweight scan snapshots for plotting
8594// trend charts.
8595//
8596// GET /api/metrics/history?root=<path>&limit=<n>
8597
8598#[derive(Deserialize)]
8599struct MetricsHistoryQuery {
8600    root: Option<String>,
8601    limit: Option<usize>,
8602    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
8603    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
8604    submodule: Option<String>,
8605}
8606
8607#[derive(Serialize)]
8608struct MetricsSubmoduleLink {
8609    name: String,
8610    url: String,
8611}
8612
8613#[derive(Serialize)]
8614struct MetricsHistoryEntry {
8615    run_id: String,
8616    run_id_short: String,
8617    timestamp: String,
8618    commit: Option<String>,
8619    branch: Option<String>,
8620    tags: Vec<String>,
8621    nearest_tag: Option<String>,
8622    code_lines: u64,
8623    comment_lines: u64,
8624    blank_lines: u64,
8625    physical_lines: u64,
8626    files_analyzed: u64,
8627    files_skipped: u64,
8628    test_count: u64,
8629    project_label: String,
8630    html_url: Option<String>,
8631    has_pdf: bool,
8632    submodule_links: Vec<MetricsSubmoduleLink>,
8633    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
8634    #[serde(skip_serializing_if = "Option::is_none")]
8635    coverage_line_pct: Option<f64>,
8636}
8637
8638fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8639    let mut links: Vec<MetricsSubmoduleLink> = vec![];
8640    let sub_dir = e
8641        .html_path
8642        .as_ref()
8643        .and_then(|p| p.parent())
8644        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8645    let Some(dir) = sub_dir else { return links };
8646    let Ok(rd) = std::fs::read_dir(dir) else {
8647        return links;
8648    };
8649    for entry_res in rd.flatten() {
8650        let fname = entry_res.file_name();
8651        let fname_str = fname.to_string_lossy();
8652        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8653            let stem = &fname_str[..fname_str.len() - 5];
8654            let display = stem[4..].replace('-', " ");
8655            links.push(MetricsSubmoduleLink {
8656                name: display,
8657                url: format!("/runs/{stem}/{}", e.run_id),
8658            });
8659        }
8660    }
8661    links.sort_by(|a, b| a.name.cmp(&b.name));
8662    links
8663}
8664
8665fn apply_submodule_filter(
8666    base: MetricsHistoryEntry,
8667    filter: &str,
8668    e: &sloc_core::history::RegistryEntry,
8669) -> Option<MetricsHistoryEntry> {
8670    let json_path = e.json_path.as_ref()?;
8671    let json_str = std::fs::read_to_string(json_path).ok()?;
8672    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8673    let sub = run
8674        .submodule_summaries
8675        .iter()
8676        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8677    let safe = sanitize_project_label(&sub.name);
8678    let artifact_key = format!("sub_{safe}");
8679    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8680        || base.html_url.clone(),
8681        |run_dir| {
8682            let sub_path = run_dir.join(format!("{artifact_key}.html"));
8683            if sub_path.exists() {
8684                Some(format!("/runs/{artifact_key}/{}", e.run_id))
8685            } else {
8686                base.html_url.clone()
8687            }
8688        },
8689    );
8690
8691    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
8692    // basic SLOC totals, so test_count and coverage must be computed from file records.
8693    let sub_files: Vec<_> = run
8694        .per_file_records
8695        .iter()
8696        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8697        .collect();
8698    let test_count: u64 = sub_files
8699        .iter()
8700        .map(|r| r.raw_line_categories.test_count)
8701        .sum();
8702    #[allow(clippy::cast_precision_loss)]
8703    let coverage_line_pct: Option<f64> = {
8704        let found: u64 = sub_files
8705            .iter()
8706            .filter_map(|r| r.coverage.as_ref())
8707            .map(|c| u64::from(c.lines_found))
8708            .sum();
8709        let hit: u64 = sub_files
8710            .iter()
8711            .filter_map(|r| r.coverage.as_ref())
8712            .map(|c| u64::from(c.lines_hit))
8713            .sum();
8714        if found > 0 {
8715            let pct = (hit as f64 / found as f64) * 100.0;
8716            Some((pct * 10.0).round() / 10.0)
8717        } else {
8718            None
8719        }
8720    };
8721
8722    Some(MetricsHistoryEntry {
8723        code_lines: sub.code_lines,
8724        comment_lines: sub.comment_lines,
8725        blank_lines: sub.blank_lines,
8726        physical_lines: sub.total_physical_lines,
8727        files_analyzed: sub.files_analyzed,
8728        files_skipped: 0,
8729        test_count,
8730        html_url: sub_html_url,
8731        has_pdf: false,
8732        submodule_links: vec![],
8733        coverage_line_pct,
8734        ..base
8735    })
8736}
8737
8738#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
8739async fn api_metrics_history_handler(
8740    State(state): State<AppState>,
8741    Query(query): Query<MetricsHistoryQuery>,
8742) -> Response {
8743    let limit = query.limit.unwrap_or(50).min(500);
8744    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8745
8746    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8747        let reg = state.registry.lock().await;
8748        reg.entries
8749            .iter()
8750            .filter(|e| {
8751                query.root.as_ref().is_none_or(|root| {
8752                    let resolved = resolve_input_path(root);
8753                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8754                    e.input_roots.iter().any(|r| r == &root_str)
8755                })
8756            })
8757            .take(limit)
8758            .cloned()
8759            .collect()
8760    };
8761
8762    let entries: Vec<MetricsHistoryEntry> = candidate_entries
8763        .into_iter()
8764        .filter_map(|e| {
8765            let tags = e
8766                .git_tags
8767                .as_deref()
8768                .map(|s| {
8769                    s.split(',')
8770                        .map(|t| t.trim().to_string())
8771                        .filter(|t| !t.is_empty())
8772                        .collect()
8773                })
8774                .unwrap_or_default();
8775            let html_url = e
8776                .html_path
8777                .as_ref()
8778                .filter(|p| p.exists())
8779                .map(|_| format!("/runs/html/{}", e.run_id));
8780            let nearest_tag = e.git_nearest_tag.clone();
8781            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8782            let run_id_short: String = e
8783                .run_id
8784                .split('-')
8785                .next_back()
8786                .unwrap_or(&e.run_id)
8787                .chars()
8788                .take(7)
8789                .collect();
8790            let submodule_links = build_entry_submodule_links(&e);
8791            #[allow(clippy::cast_precision_loss)]
8792            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8793                let pct = (e.summary.coverage_lines_hit as f64
8794                    / e.summary.coverage_lines_found as f64)
8795                    * 100.0;
8796                Some((pct * 10.0).round() / 10.0)
8797            } else {
8798                None
8799            };
8800            let base = MetricsHistoryEntry {
8801                run_id: e.run_id.clone(),
8802                run_id_short,
8803                timestamp: e.timestamp_utc.to_rfc3339(),
8804                commit: e.git_commit.clone(),
8805                branch: e.git_branch.clone(),
8806                tags,
8807                nearest_tag,
8808                code_lines: e.summary.code_lines,
8809                comment_lines: e.summary.comment_lines,
8810                blank_lines: e.summary.blank_lines,
8811                physical_lines: e.summary.total_physical_lines,
8812                files_analyzed: e.summary.files_analyzed,
8813                files_skipped: e.summary.files_skipped,
8814                test_count: e.summary.test_count,
8815                project_label: e.project_label.clone(),
8816                html_url,
8817                has_pdf,
8818                submodule_links,
8819                coverage_line_pct,
8820            };
8821            if let Some(ref filter) = submodule_filter {
8822                apply_submodule_filter(base, filter, &e)
8823            } else {
8824                Some(base)
8825            }
8826        })
8827        .collect();
8828
8829    Json(entries).into_response()
8830}
8831
8832/// One scan's code churn versus the previous scan of the same project.
8833#[derive(Serialize)]
8834struct ChurnEntry {
8835    run_id: String,
8836    added: i64,
8837    removed: i64,
8838    modified: i64,
8839    unmodified: i64,
8840}
8841
8842// GET /api/metrics/churn?root=<path>&limit=<n>
8843// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
8844// comparing each scan to the previous scan of the same project. Loads per-file JSON
8845// artifacts, so it is intended for export-time use rather than every page load.
8846async fn api_metrics_churn_handler(
8847    State(state): State<AppState>,
8848    Query(query): Query<MetricsHistoryQuery>,
8849) -> Response {
8850    let limit = query.limit.unwrap_or(200).min(500);
8851    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8852        let reg = state.registry.lock().await;
8853        reg.entries
8854            .iter()
8855            .filter(|e| {
8856                query.root.as_ref().is_none_or(|root| {
8857                    let resolved = resolve_input_path(root);
8858                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8859                    e.input_roots.iter().any(|r| r == &root_str)
8860                })
8861            })
8862            .take(limit)
8863            .cloned()
8864            .collect()
8865    };
8866    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8867        std::collections::HashMap::new();
8868    for e in candidate_entries {
8869        by_project
8870            .entry(e.project_label.clone())
8871            .or_default()
8872            .push(e);
8873    }
8874    let mut out: Vec<ChurnEntry> = Vec::new();
8875    for (_proj, mut entries) in by_project {
8876        entries.sort_by_key(|e| e.timestamp_utc);
8877        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
8878        for e in &entries {
8879            let curr = e
8880                .json_path
8881                .as_ref()
8882                .and_then(|path| sloc_core::read_json(path).ok());
8883            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
8884                let cmp = sloc_core::compute_delta(prev, cur);
8885                out.push(ChurnEntry {
8886                    run_id: e.run_id.clone(),
8887                    added: sum_added_code_lines(&cmp),
8888                    removed: sum_removed_code_lines(&cmp),
8889                    modified: sum_modified_code_lines(&cmp),
8890                    unmodified: sum_unmodified_code_lines(&cmp),
8891                });
8892            } else {
8893                out.push(ChurnEntry {
8894                    run_id: e.run_id.clone(),
8895                    added: 0,
8896                    removed: 0,
8897                    modified: 0,
8898                    unmodified: 0,
8899                });
8900            }
8901            if curr.is_some() {
8902                prev_run = curr;
8903            }
8904        }
8905    }
8906    Json(out).into_response()
8907}
8908
8909// GET /api/metrics/submodules?root=<path>
8910// Returns the union of distinct submodule names found across all saved scan JSON artifacts
8911// for the given project root (or all roots if omitted).
8912#[derive(Deserialize)]
8913struct MetricsSubmodulesQuery {
8914    root: Option<String>,
8915}
8916
8917#[derive(Serialize)]
8918struct SubmoduleEntry {
8919    name: String,
8920    relative_path: String,
8921}
8922
8923async fn api_metrics_submodules_handler(
8924    State(state): State<AppState>,
8925    Query(query): Query<MetricsSubmodulesQuery>,
8926) -> Response {
8927    let json_paths: Vec<std::path::PathBuf> = {
8928        let reg = state.registry.lock().await;
8929        reg.entries
8930            .iter()
8931            .filter(|e| {
8932                query.root.as_ref().is_none_or(|root| {
8933                    let resolved = resolve_input_path(root);
8934                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8935                    e.input_roots.iter().any(|r| r == &root_str)
8936                })
8937            })
8938            .filter_map(|e| e.json_path.clone())
8939            .collect()
8940    };
8941
8942    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8943    let mut result: Vec<SubmoduleEntry> = Vec::new();
8944
8945    for path in &json_paths {
8946        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
8947            continue;
8948        };
8949        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
8950            continue;
8951        };
8952        for sub in &run.submodule_summaries {
8953            if seen.insert(sub.name.clone()) {
8954                result.push(SubmoduleEntry {
8955                    name: sub.name.clone(),
8956                    relative_path: sub.relative_path.clone(),
8957                });
8958            }
8959        }
8960    }
8961
8962    result.sort_by(|a, b| a.name.cmp(&b.name));
8963    Json(result).into_response()
8964}
8965
8966// ── CI ingest endpoint ────────────────────────────────────────────────────────
8967// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
8968// server stores and displays results without cloning or scanning anything itself.
8969//
8970// POST /api/ingest?label=<optional_display_name>
8971// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
8972// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
8973
8974#[derive(Deserialize)]
8975struct IngestQuery {
8976    label: Option<String>,
8977}
8978
8979#[derive(Serialize)]
8980struct IngestResponse {
8981    run_id: String,
8982    view_url: String,
8983}
8984
8985async fn api_ingest_handler(
8986    State(state): State<AppState>,
8987    Query(q): Query<IngestQuery>,
8988    Json(run): Json<sloc_core::AnalysisRun>,
8989) -> Response {
8990    let label = q.label.unwrap_or_else(|| {
8991        run.input_roots
8992            .first()
8993            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
8994    });
8995
8996    let label_for_task = label.clone();
8997    let result = tokio::task::spawn_blocking(move || {
8998        let html = render_html(&run)?;
8999        let run_id = run.tool.run_id.clone();
9000        let run_id_safe = run_id.len() <= 128
9001            && !run_id.is_empty()
9002            && run_id
9003                .chars()
9004                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9005        if !run_id_safe {
9006            anyhow::bail!(
9007                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9008            );
9009        }
9010        let project_label = sanitize_project_label(&label_for_task);
9011        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9012        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9013            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9014            _ => project_label,
9015        };
9016        let (artifacts, _pending_pdf) = persist_run_artifacts(
9017            &run,
9018            &html,
9019            &output_dir,
9020            &label_for_task,
9021            &file_stem,
9022            RunResultContext::default(),
9023        )?;
9024        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9025    })
9026    .await;
9027
9028    match result {
9029        Ok(Ok((run_id, artifacts, run))) => {
9030            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9031            (
9032                StatusCode::CREATED,
9033                Json(IngestResponse {
9034                    view_url: format!("/view-reports?run_id={run_id}"),
9035                    run_id,
9036                }),
9037            )
9038                .into_response()
9039        }
9040        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9041        Err(e) => error::internal(&format!("{e}")),
9042    }
9043}
9044
9045// ── Multi-compare page ────────────────────────────────────────────────────────
9046// GET /multi-compare?runs=id1,id2,id3,...
9047
9048fn html_escape(s: &str) -> String {
9049    s.replace('&', "&amp;")
9050        .replace('<', "&lt;")
9051        .replace('>', "&gt;")
9052        .replace('"', "&quot;")
9053}
9054
9055#[allow(clippy::cast_precision_loss)]
9056fn fmt_num(n: i64) -> String {
9057    let a = n.unsigned_abs();
9058    if a >= 1_000_000 {
9059        let v = n as f64 / 1_000_000.0;
9060        let s = format!("{v:.1}");
9061        format!("{}M", s.trim_end_matches(".0"))
9062    } else if a >= 10_000 {
9063        let v = n as f64 / 1_000.0;
9064        let s = format!("{v:.1}");
9065        format!("{}K", s.trim_end_matches(".0"))
9066    } else {
9067        let sign = if n < 0 { "-" } else { "" };
9068        if a < 1_000 {
9069            return format!("{sign}{a}");
9070        }
9071        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9072    }
9073}
9074
9075fn fmt_comma(n: i64) -> String {
9076    let sign = if n < 0 { "-" } else { "" };
9077    let a = n.unsigned_abs();
9078    if a < 1_000 {
9079        return format!("{sign}{a}");
9080    }
9081    let s = a.to_string();
9082    let bytes = s.as_bytes();
9083    let len = bytes.len();
9084    let mut out = String::with_capacity(len + len / 3);
9085    for (i, &b) in bytes.iter().enumerate() {
9086        if i > 0 && (len - i).is_multiple_of(3) {
9087            out.push(',');
9088        }
9089        out.push(b as char);
9090    }
9091    format!("{sign}{out}")
9092}
9093
9094/// Insert thousands separators into the integer portion of a number's textual form.
9095///
9096/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9097/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9098/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9099/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9100fn group_thousands(s: &str) -> String {
9101    let (sign, rest) = match s.as_bytes().first() {
9102        Some(b'-') => ("-", &s[1..]),
9103        Some(b'+') => ("+", &s[1..]),
9104        _ => ("", s),
9105    };
9106    let (int_part, frac_part) = match rest.split_once('.') {
9107        Some((i, f)) => (i, Some(f)),
9108        None => (rest, None),
9109    };
9110    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9111        return s.to_string();
9112    }
9113    let bytes = int_part.as_bytes();
9114    let len = bytes.len();
9115    let mut grouped = String::with_capacity(len + len / 3);
9116    for (i, &b) in bytes.iter().enumerate() {
9117        if i > 0 && (len - i).is_multiple_of(3) {
9118            grouped.push(',');
9119        }
9120        grouped.push(b as char);
9121    }
9122    frac_part.map_or_else(
9123        || format!("{sign}{grouped}"),
9124        |f| format!("{sign}{grouped}.{f}"),
9125    )
9126}
9127
9128/// Custom Askama filters available to templates in this crate.
9129mod filters {
9130    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9131    // (a `&self` `execute` method returning `Result`), not on our own source.
9132    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9133    use askama::{Result, Values};
9134
9135    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9136    ///
9137    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9138    /// (dashes, "No prior scan", etc.) passes through untouched.
9139    #[askama::filter_fn]
9140    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9141        Ok(super::group_thousands(&value.to_string()))
9142    }
9143}
9144
9145#[derive(Deserialize, Default)]
9146struct MultiCompareQuery {
9147    runs: Option<String>,
9148    /// "super" to show only super-repo files (exclude all submodule files)
9149    scope: Option<String>,
9150    /// Submodule name to narrow the comparison to one submodule
9151    sub: Option<String>,
9152}
9153
9154#[allow(clippy::too_many_lines)]
9155async fn multi_compare_handler(
9156    State(state): State<AppState>,
9157    Query(params): Query<MultiCompareQuery>,
9158    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9159) -> impl IntoResponse {
9160    let run_ids: Vec<String> = params
9161        .runs
9162        .as_deref()
9163        .unwrap_or("")
9164        .split(',')
9165        .map(|s| s.trim().to_string())
9166        .filter(|s| !s.is_empty())
9167        .collect();
9168
9169    if run_ids.len() < 2 {
9170        return Html(
9171            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9172             <a href=\"/compare-scans\">Go back</a></p>",
9173        )
9174        .into_response();
9175    }
9176    if run_ids.len() > 20 {
9177        return Html(
9178            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9179             at once. <a href=\"/compare-scans\">Go back</a></p>",
9180        )
9181        .into_response();
9182    }
9183
9184    // Look up each run_id in the registry.
9185    let entries: Vec<Option<RegistryEntry>> = {
9186        let reg = state.registry.lock().await;
9187        run_ids
9188            .iter()
9189            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9190            .collect()
9191    };
9192
9193    for (i, entry) in entries.iter().enumerate() {
9194        if entry.is_none() {
9195            let html = format!(
9196                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9197                 found. <a href=\"/compare-scans\">Go back</a></p>",
9198                run_ids[i]
9199            );
9200            return Html(html).into_response();
9201        }
9202    }
9203
9204    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9205
9206    for entry in &entries {
9207        if entry.json_path.is_none() {
9208            let html = format!(
9209                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9210                 JSON data — re-run the analysis to enable comparison. \
9211                 <a href=\"/compare-scans\">Go back</a></p>",
9212                &entry.run_id
9213            );
9214            return Html(html).into_response();
9215        }
9216    }
9217
9218    // Sort chronologically.
9219    entries.sort_by_key(|e| e.timestamp_utc);
9220
9221    // Load JSON for each entry.
9222    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9223    for entry in &entries {
9224        let path = entry.json_path.as_ref().unwrap();
9225        match read_json(path) {
9226            Ok(r) => runs.push(r),
9227            Err(e) => {
9228                let html = format!(
9229                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9230                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9231                    &entry.run_id
9232                );
9233                return Html(html).into_response();
9234            }
9235        }
9236    }
9237
9238    // Collect submodule names from all runs.
9239    let all_sub_names: Vec<String> = {
9240        let mut set = std::collections::BTreeSet::new();
9241        for r in &runs {
9242            for s in &r.submodule_summaries {
9243                set.insert(s.name.clone());
9244            }
9245        }
9246        set.into_iter().collect()
9247    };
9248    let has_submodule_data = !all_sub_names.is_empty();
9249    let active_submodule = params.sub.clone();
9250    let super_scope_active = params.scope.as_deref() == Some("super");
9251
9252    // Narrow per_file_records when a scope is active, then recompute totals.
9253    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9254
9255    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9256    let project_label = entries
9257        .first()
9258        .map_or("", |e| e.project_label.as_str())
9259        .to_string();
9260    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9261    let multi = compute_multi_delta(&run_refs);
9262    let html = multi_compare_page(
9263        &multi,
9264        &project_label,
9265        env!("CARGO_PKG_VERSION"),
9266        &csp_nonce,
9267        has_submodule_data,
9268        &all_sub_names,
9269        &runs_csv,
9270        super_scope_active,
9271        active_submodule.as_deref(),
9272        &entries,
9273    );
9274    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9275    // copy after a rebuild would silently mask UI fixes.
9276    (
9277        [(axum::http::header::CACHE_CONTROL, "no-store")],
9278        Html(html),
9279    )
9280        .into_response()
9281}
9282
9283const fn multi_delta_class(n: i64) -> &'static str {
9284    match n {
9285        1.. => "pos",
9286        ..=-1 => "neg",
9287        0 => "zero",
9288    }
9289}
9290
9291fn multi_fmt_delta(n: i64) -> String {
9292    if n > 0 {
9293        format!("+{n}")
9294    } else {
9295        format!("{n}")
9296    }
9297}
9298
9299/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9300fn js_escape(s: &str) -> String {
9301    use std::fmt::Write as _;
9302    let mut out = String::with_capacity(s.len() + 2);
9303    for c in s.chars() {
9304        match c {
9305            '"' => out.push_str("\\\""),
9306            '\\' => out.push_str("\\\\"),
9307            '\n' => out.push_str("\\n"),
9308            '\r' => out.push_str("\\r"),
9309            '\t' => out.push_str("\\t"),
9310            c if (c as u32) < 0x20 => {
9311                let _ = write!(out, "\\u{:04x}", c as u32);
9312            }
9313            c => out.push(c),
9314        }
9315    }
9316    out
9317}
9318
9319/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9320fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9321    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9322        return (
9323            "&mdash;".to_string(),
9324            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9325        );
9326    };
9327    let cd = entry
9328        .git_commit_date
9329        .as_deref()
9330        .and_then(fmt_git_date)
9331        .unwrap_or_else(|| "&mdash;".to_string());
9332    let au = entry.git_author.as_deref().map_or_else(
9333        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9334        |a| {
9335            format!(
9336                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9337                 <span class=\"cmp-author-handle\"></span></span>",
9338                html_escape(a)
9339            )
9340        },
9341    );
9342    (cd, au)
9343}
9344
9345/// Render the scope badge chip for a scan card header.
9346fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9347    active_sub.map_or_else(
9348        || {
9349            if super_scope_active {
9350                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9351            } else {
9352                "<span class=\"mc-scope-tag mc-scope-full\">\
9353                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9354                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9355                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9356                 <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\
9357                 </svg> Full scan</span>"
9358                    .to_string()
9359            }
9360        },
9361        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9362    )
9363}
9364
9365/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9366fn build_mc_scan_strip(
9367    multi: &MultiScanComparison,
9368    entries: &[RegistryEntry],
9369    n: usize,
9370    is_many: bool,
9371    active_sub: Option<&str>,
9372    super_scope_active: bool,
9373    project_label: &str,
9374) -> String {
9375    use std::fmt::Write as _;
9376    let mut scan_strip = String::new();
9377    for (i, pt) in multi.points.iter().enumerate() {
9378        let ts_ms = pt.timestamp.timestamp_millis();
9379        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9380        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9381        let branch = pt.git_branch.as_deref().unwrap_or("");
9382        let report_link = format!("/runs/html/{}", pt.run_id);
9383        let branch_html = if branch.is_empty() {
9384            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9385        } else {
9386            format!(
9387                "<span class=\"mc-card-branch\">{}</span>",
9388                html_escape(branch)
9389            )
9390        };
9391        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9392        let tags_html = pt
9393            .git_tags
9394            .as_deref()
9395            .filter(|t| !t.is_empty())
9396            .map(|t| {
9397                let chips = t
9398                    .split(',')
9399                    .filter(|s| !s.is_empty())
9400                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9401                    .collect::<Vec<_>>()
9402                    .join(" ");
9403                format!(
9404                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9405                     <span class=\"mc-row-val\">{chips}</span></div>"
9406                )
9407            })
9408            .unwrap_or_default();
9409        let nearest = pt
9410            .git_nearest_tag
9411            .as_deref()
9412            .map(|t| format!("near {}", html_escape(t)))
9413            .unwrap_or_default();
9414        let arrow = if i < n - 1 && !is_many {
9415            "<div class='mc-arrow'>&#8594;</div>"
9416        } else {
9417            ""
9418        };
9419        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9420        let nearest_html = if nearest.is_empty() {
9421            String::new()
9422        } else {
9423            format!(
9424                "<span class=\"mc-card-nearest-wrap\">\
9425                 <span class=\"mc-card-nearest\">{nearest}</span>\
9426                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9427                 </span>"
9428            )
9429        };
9430        write!(
9431            scan_strip,
9432            r#"<div class="mc-card">
9433              <div class="mc-card-header">
9434                <div class="mc-card-num">Scan {num}</div>
9435                <div class="mc-card-project-col">
9436                  <div class="mc-card-project">{project_label}</div>
9437                  {scope_badge}
9438                </div>
9439              </div>
9440              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9441              <div class="mc-card-rows">
9442                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9443                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9444                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9445                <div class="mc-card-row"><span class="mc-row-label">Scanned on:</span><span class="mc-row-val mc-ts-local" data-utc-ms="{ts_ms}">{ts}</span></div>
9446                {tags_html}
9447              </div>
9448              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9449            </div>{arrow}"#,
9450            num = i + 1,
9451            commit = html_escape(commit),
9452            commit_date = commit_date_html,
9453            ts_ms = ts_ms,
9454            code = fmt_num(pt.code_lines),
9455            scope_badge = scope_badge,
9456            nearest_html = nearest_html,
9457        )
9458        .unwrap();
9459    }
9460    scan_strip
9461}
9462
9463/// Build the metric progression table (thead + tbody) for multi-compare.
9464#[allow(clippy::too_many_lines)]
9465fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9466    use std::fmt::Write as _;
9467    struct MetricRow<'a> {
9468        label: &'a str,
9469        values: Vec<i64>,
9470        seq_deltas: Vec<i64>,
9471        net_delta: i64,
9472    }
9473    let rows: Vec<MetricRow<'_>> = vec![
9474        MetricRow {
9475            label: "Code Lines",
9476            values: multi.points.iter().map(|p| p.code_lines).collect(),
9477            seq_deltas: multi
9478                .sequential_deltas
9479                .iter()
9480                .map(|d| d.summary.code_lines_delta)
9481                .collect(),
9482            net_delta: multi.total_delta.code_lines_delta,
9483        },
9484        MetricRow {
9485            label: "Files Analyzed",
9486            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9487            seq_deltas: multi
9488                .sequential_deltas
9489                .iter()
9490                .map(|d| d.summary.files_analyzed_delta)
9491                .collect(),
9492            net_delta: multi.total_delta.files_analyzed_delta,
9493        },
9494        MetricRow {
9495            label: "Comment Lines",
9496            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9497            seq_deltas: multi
9498                .sequential_deltas
9499                .iter()
9500                .map(|d| d.summary.comment_lines_delta)
9501                .collect(),
9502            net_delta: multi.total_delta.comment_lines_delta,
9503        },
9504        MetricRow {
9505            label: "Blank Lines",
9506            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9507            seq_deltas: multi
9508                .sequential_deltas
9509                .iter()
9510                .map(|d| d.summary.blank_lines_delta)
9511                .collect(),
9512            net_delta: multi.total_delta.blank_lines_delta,
9513        },
9514        MetricRow {
9515            label: "Tests",
9516            values: multi.points.iter().map(|p| p.test_count).collect(),
9517            seq_deltas: multi
9518                .points
9519                .windows(2)
9520                .map(|pts| pts[1].test_count - pts[0].test_count)
9521                .collect(),
9522            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9523                - multi.points.first().map_or(0, |f| f.test_count),
9524        },
9525    ];
9526    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9527    for i in 0..n {
9528        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9529        if i < n - 1 {
9530            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9531        }
9532    }
9533    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9534    let mut metrics_tbody = String::new();
9535    for row in &rows {
9536        metrics_tbody.push_str("<tr>");
9537        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9538        for i in 0..n {
9539            write!(
9540                metrics_tbody,
9541                "<td class='mc-val-col'>{}</td>",
9542                fmt_comma(row.values[i])
9543            )
9544            .unwrap();
9545            if i < n - 1 {
9546                let d = row.seq_deltas[i];
9547                write!(
9548                    metrics_tbody,
9549                    "<td class='mc-delta-col {cls}'>{val}</td>",
9550                    cls = multi_delta_class(d),
9551                    val = multi_fmt_delta(d)
9552                )
9553                .unwrap();
9554            }
9555        }
9556        let nd = row.net_delta;
9557        write!(
9558            metrics_tbody,
9559            "<td class='mc-net-col {cls}'>{val}</td>",
9560            cls = multi_delta_class(nd),
9561            val = multi_fmt_delta(nd)
9562        )
9563        .unwrap();
9564        metrics_tbody.push_str("</tr>");
9565    }
9566    (metrics_thead, metrics_tbody)
9567}
9568
9569/// Build the JS-embeddable points JSON array for the multi-compare chart.
9570fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9571    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9572    for (i, pt) in multi.points.iter().enumerate() {
9573        let commit = pt.git_commit.as_deref().unwrap_or("");
9574        let branch = pt.git_branch.as_deref().unwrap_or("");
9575        let tags = pt.git_tags.as_deref().unwrap_or("");
9576        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9577        let scanned_ms = pt.timestamp.timestamp_millis();
9578        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9579        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9580        let commit_date = entry
9581            .and_then(|e| e.git_commit_date.as_deref())
9582            .and_then(fmt_git_date)
9583            .unwrap_or_default();
9584        let author = entry
9585            .and_then(|e| e.git_author.as_deref())
9586            .unwrap_or("")
9587            .to_string();
9588        let cov = pt
9589            .coverage_line_pct
9590            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9591        parts.push(format!(
9592            r#"{{"run_id":"{run_id}","commit":"{commit}","branch":"{branch}","tags":"{tags}","nearest":"{nearest}","commit_date":"{commit_date}","author":"{author}","scanned":"{scanned}","scanned_ms":{scanned_ms},"code":{code},"comments":{comments},"blank":{blank},"files":{files},"tests":{tests},"cov":{cov}}}"#,
9593            run_id = js_escape(&pt.run_id),
9594            commit = js_escape(commit),
9595            branch = js_escape(branch),
9596            tags = js_escape(tags),
9597            nearest = js_escape(nearest),
9598            commit_date = js_escape(&commit_date),
9599            author = js_escape(&author),
9600            scanned = js_escape(&scanned),
9601            code = pt.code_lines,
9602            comments = pt.comment_lines,
9603            blank = pt.blank_lines,
9604            files = pt.files_analyzed,
9605            tests = pt.test_count,
9606        ));
9607    }
9608    format!("[{}]", parts.join(","))
9609}
9610
9611/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
9612fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9613    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9614    for row in &multi.file_matrix {
9615        let lang = row.language.as_deref().unwrap_or("");
9616        let codes: Vec<String> = row
9617            .code_per_scan
9618            .iter()
9619            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9620            .collect();
9621        let deltas: Vec<String> = row
9622            .code_delta_per_scan
9623            .iter()
9624            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9625            .collect();
9626        parts.push(format!(
9627            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9628            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9629            status = row.overall_status,
9630            codes = codes.join(","),
9631            deltas = deltas.join(","),
9632            total = row.total_code_delta,
9633        ));
9634    }
9635    format!("[{}]", parts.join(","))
9636}
9637
9638/// Build the column header cells for the file-matrix table.
9639fn build_mc_file_col_headers(n: usize) -> String {
9640    use std::fmt::Write as _;
9641    let mut out = String::new();
9642    for i in 0..n {
9643        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9644        if i < n - 1 {
9645            write!(
9646                out,
9647                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
9648                i + 2
9649            )
9650            .unwrap();
9651        }
9652    }
9653    out
9654}
9655
9656/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
9657fn build_mc_scope_bar(
9658    has_submodule_data: bool,
9659    sub_names: &[String],
9660    runs_csv: &str,
9661    active_sub: Option<&str>,
9662    super_scope_active: bool,
9663) -> String {
9664    use std::fmt::Write as _;
9665    if !has_submodule_data {
9666        return String::new();
9667    }
9668    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9669    let full_active = active_sub.is_none() && !super_scope_active;
9670    let mut bar = format!(
9671        r#"<div class="submod-scope-bar">
9672  <span class="submod-scope-label">
9673    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
9674    Scope:
9675  </span>
9676  <div class="submod-scope-divider"></div>
9677  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9678  <a class="submod-scope-btn{super_cls}" href="{base_url}&amp;scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
9679        full_cls = if full_active { " active" } else { "" },
9680        super_cls = if super_scope_active { " active" } else { "" },
9681    );
9682    for s in sub_names {
9683        let is_active = active_sub == Some(s.as_str());
9684        write!(
9685            bar,
9686            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9687            cls = if is_active { " active" } else { "" },
9688            name_enc = html_escape(s),
9689            name_esc = html_escape(s),
9690        )
9691        .unwrap();
9692    }
9693    bar.push_str("\n</div>");
9694    bar
9695}
9696
9697/// Build the scope-description label shown in the page subtitle.
9698fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9699    active_sub.map_or_else(
9700        || {
9701            if super_scope_active {
9702                "Super-repo only &mdash; ".to_string()
9703            } else {
9704                String::new()
9705            }
9706        },
9707        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
9708    )
9709}
9710
9711#[allow(clippy::too_many_lines)]
9712#[allow(clippy::too_many_arguments)]
9713fn multi_compare_page(
9714    multi: &MultiScanComparison,
9715    project_label: &str,
9716    version: &str,
9717    csp_nonce: &str,
9718    has_submodule_data: bool,
9719    sub_names: &[String],
9720    runs_csv: &str,
9721    super_scope_active: bool,
9722    active_sub: Option<&str>,
9723    entries: &[RegistryEntry],
9724) -> String {
9725    let n = multi.points.len();
9726    let is_many = n > 4;
9727    let mc_strip_class = if is_many {
9728        "mc-strip mc-strip-grid"
9729    } else {
9730        "mc-strip"
9731    };
9732
9733    // ── Scan strip cards ──────────────────────────────────────────────────────
9734    let scan_strip = build_mc_scan_strip(
9735        multi,
9736        entries,
9737        n,
9738        is_many,
9739        active_sub,
9740        super_scope_active,
9741        project_label,
9742    );
9743
9744    // ── Summary metrics table ─────────────────────────────────────────────────
9745    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9746
9747    // ── Chart data and table helpers ──────────────────────────────────────────
9748    let points_json = build_mc_points_json(multi, entries);
9749    let file_matrix_json = build_mc_file_matrix_json(multi);
9750
9751    // Counts for filter tabs
9752    let files_modified = multi
9753        .file_matrix
9754        .iter()
9755        .filter(|f| f.overall_status == "modified")
9756        .count();
9757    let files_added = multi
9758        .file_matrix
9759        .iter()
9760        .filter(|f| f.overall_status == "added")
9761        .count();
9762    let files_removed = multi
9763        .file_matrix
9764        .iter()
9765        .filter(|f| f.overall_status == "removed")
9766        .count();
9767    let files_unchanged = multi
9768        .file_matrix
9769        .iter()
9770        .filter(|f| f.overall_status == "unchanged")
9771        .count();
9772    let total_files = multi.file_matrix.len();
9773
9774    let file_col_headers = build_mc_file_col_headers(n);
9775    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9776    let scope_bar_html = build_mc_scope_bar(
9777        has_submodule_data,
9778        sub_names,
9779        runs_csv,
9780        active_sub,
9781        super_scope_active,
9782    );
9783    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9784    let toast_assets = sloc_toast_assets(csp_nonce);
9785
9786    format!(
9787        r#"<!doctype html>
9788<html lang="en">
9789<head>
9790  <meta charset="utf-8">
9791  <meta name="viewport" content="width=device-width, initial-scale=1">
9792  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9793  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9794  <style nonce="{csp_nonce}">
9795    :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
9796    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9797    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9798    body.dark-theme{{--bg:#1a120b;--surface:#241a12;--surface-2:#2d2117;--line:#3d2e22;--line-strong:#54402f;--text:#f0e6dc;--muted:#b09080;--muted-2:#8a6e5f;--pos-bg:#163a23;--neg-bg:#3d1c1c;}}
9799    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9800    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9801    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9802    .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
9803    @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));}}}}
9804    .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);}}
9805    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}}
9806    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9807    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9808    @media(max-width:1150px){{.nav-right{{gap:4px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 8px;font-size:11px;min-height:34px;}}.brand-subtitle{{display:none;}}}}
9809    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9810    .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));}}
9811    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9812    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9813    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9814    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9815    .nav-pill,.theme-toggle{{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;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
9816    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9817    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9818    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9819    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9820    .nav-dropdown{{position:relative;display:inline-flex;}}
9821    .nav-dropdown-btn{{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;white-space:nowrap;text-decoration:none;cursor:pointer;transition:background .15s ease,transform .15s ease;}}
9822    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9823    .nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity .13s,visibility 0s .13s;}}
9824    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9825    .nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}
9826    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9827    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9828    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9829    body:not(.dark-theme) .icon-sun{{display:none;}}
9830    body.dark-theme .icon-moon{{display:none;}}
9831    .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;}}
9832    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9833    .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);}}
9834    .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;}}
9835    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9836    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9837    .settings-modal-body{{padding:14px 16px 16px;}}
9838    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9839    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9840    .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;}}
9841    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9842    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9843    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9844    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9845    .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;}}
9846    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9847    .btn-back{{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s;white-space:nowrap;margin-bottom:16px;}}
9848    .btn-back:hover{{background:var(--line);}}
9849    .mc-title{{font-size:28px;font-weight:900;letter-spacing:-.03em;margin:0 0 6px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
9850    body.dark-theme .mc-title{{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
9851    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9852    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9853    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9854    .mc-strip.mc-strip-grid{{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;overflow:visible;padding:8px 4px 6px;}}
9855    .mc-hero{{background:linear-gradient(180deg,rgba(255,255,255,0.18),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px 24px;margin-bottom:18px;}}
9856    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9857    .mc-card{{background:var(--surface);border:1.5px solid var(--oxide);border-radius:14px;padding:16px 18px;flex:1 1 0;min-width:0;min-height:160px;display:flex;flex-direction:column;justify-content:flex-start;transition:box-shadow .15s ease,transform .12s ease;overflow:visible;position:relative;}}
9858    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9859    body.dark-theme .mc-card{{background:var(--surface-2);}}
9860    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9861    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9862    .mc-card-project{{font-size:12px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}}
9863    .mc-card-commit{{display:block;font-family:ui-monospace,monospace;font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:14px;word-break:break-all;}}
9864    .mc-card-commit:hover{{color:var(--oxide);}}
9865    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9866    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9867    .mc-row-label{{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}}
9868    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9869    .mc-card-branch{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);font-weight:700;display:inline-block;}}
9870    .mc-tag{{font-size:10px;background:rgba(211,122,76,0.12);border:1px solid rgba(211,122,76,0.28);border-radius:4px;padding:1px 6px;color:var(--oxide);font-weight:700;margin-right:3px;display:inline-block;}}
9871    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9872    .mc-scope-tag{{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}}
9873    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
9874    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
9875    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
9876    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
9877    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
9878    .mc-card-nearest-tip{{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:500;line-height:1.5;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.28);pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.10);}}
9879    .mc-card-nearest-tip::after{{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:rgba(20,12,8,0.97);}}
9880    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
9881    .mc-card-code{{font-size:15px;font-weight:800;color:var(--text);margin-top:12px;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:nowrap;}}
9882    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
9883    .submod-scope-bar{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1.5px solid var(--line-strong);border-radius:12px;margin:0 0 16px;}}
9884    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
9885    .submod-scope-label{{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);flex-shrink:0;white-space:nowrap;}}
9886    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
9887    .submod-scope-btn{{padding:5px 13px;border-radius:7px;border:1.5px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;transition:background .12s,border-color .12s,color .12s;}}
9888    .submod-scope-btn:hover{{background:var(--line);}}
9889    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9890    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
9891    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px;margin-bottom:18px;position:relative;}}
9892    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
9893    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
9894    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
9895    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
9896    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
9897    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
9898    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
9899    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
9900    .metrics-table .pos{{color:var(--pos);}}
9901    .metrics-table .neg{{color:var(--neg);}}
9902    .metrics-table .zero{{color:var(--muted);}}
9903    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
9904    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9905    .chart-metric-btn{{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
9906    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9907    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
9908    .chart-wrap{{width:100%;overflow-x:auto;}}
9909    #mc-chart{{display:block;width:100%;}}
9910    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
9911    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
9912    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
9913    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
9914    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
9915    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
9916    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
9917    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
9918    .ic-card-h2-row .ic-card-h2{{margin:0;}}
9919    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
9920    .ic-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
9921    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
9922    .ic-svg-modal-ov{{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
9923    .ic-svg-modal-ov.open{{display:flex;}}
9924    .ic-svg-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:900px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
9925    .ic-svg-modal-hdr{{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}}
9926    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
9927    .ic-svg-modal-close{{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}}
9928    .ic-svg-modal-close:hover{{background:var(--line);}}
9929    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
9930    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
9931    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
9932    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
9933    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
9934    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
9935    #mc-ic-tt{{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);max-width:240px;white-space:nowrap;}}
9936    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
9937    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
9938    .tab-btn{{padding:6px 16px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s;}}
9939    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9940    .tab-btn:hover:not(.active){{background:var(--line);}}
9941    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
9942    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
9943    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
9944    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
9945    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
9946    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
9947    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
9948    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
9949    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
9950    .table-wrap{{width:100%;overflow-x:auto;}}
9951    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
9952    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
9953    #file-table th{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);text-align:right;}}
9954    #file-table th.left,#file-table td.left{{text-align:left;}}
9955    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
9956    .file-delta-col{{color:var(--muted);font-size:11px;}}
9957    .file-net-col{{font-weight:800;}}
9958    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
9959    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
9960    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
9961    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
9962    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
9963    .status-badge.modified{{background:#fff2d8;color:#926000;}}
9964    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
9965    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
9966    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
9967    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
9968    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
9969    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
9970    tr.row-added td{{background:rgba(26,143,71,0.04);}}
9971    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
9972    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
9973    tr.row-unchanged td{{color:var(--muted);}}
9974    tr.row-unchanged .status-badge{{opacity:.65;}}
9975    .file-path{{font-family:ui-monospace,monospace;font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}}
9976    .absent{{color:var(--muted);font-style:italic;}}
9977    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
9978    .pagination-info{{font-size:12px;color:var(--muted);}}
9979    .pagination-btns{{display:flex;gap:5px;}}
9980    .pg-btn{{min-width:32px;min-height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
9981    .pg-btn:hover:not(:disabled){{background:var(--line);}}
9982    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
9983    .pg-btn:disabled{{opacity:.35;cursor:default;}}
9984    select.per-page{{border:1px solid var(--line-strong);border-radius:7px;background:var(--surface-2);color:var(--text);padding:4px 9px;font-size:12px;cursor:pointer;}}
9985    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s;}}
9986    .export-btn:hover{{background:var(--line);}}
9987    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip{{display:block;}}.status-dot{{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26d768;box-shadow:0 0 0 3px rgba(38,215,104,0.18);flex-shrink:0;}}
9988    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
9989    .site-footer a{{color:var(--muted);}}
9990    body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-back,body.pdf-mode .chart-toolbar,body.pdf-mode .filter-tabs-row,body.pdf-mode .filter-tabs,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .submod-scope-bar,body.pdf-mode .settings-modal,body.pdf-mode .site-footer{{display:none!important;}}
9991    body.pdf-mode{{background:#fff!important;}}
9992    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
9993    .mc-modal-overlay{{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.52);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;}}
9994    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
9995    .mc-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.28);max-width:1000px;width:94%;max-height:86vh;overflow-y:auto;position:relative;}}
9996    .mc-modal-head{{background:var(--nav);color:#fff;padding:16px 20px;border-radius:14px 14px 0 0;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}}
9997    .mc-modal-title{{font-size:18px;font-weight:800;}}
9998    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
9999    .mc-modal-close{{background:rgba(255,255,255,0.18);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}}
10000    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10001    .mc-modal-body{{padding:18px 22px;}}
10002    .mc-modal-sec{{margin-bottom:20px;}}
10003    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10004    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10005    .mc-modal-stat{{flex:1 1 0;min-width:0;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:default;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;}}
10006    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10007    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10008    .mc-modal-stat-lbl{{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10009    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10010    .mc-modal-row:last-child{{border-bottom:none;}}
10011    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10012    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10013    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10014    .mc-modal-val a:hover{{text-decoration:underline;}}
10015    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10016    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10017    .mc-modal-stat[data-tip]{{cursor:help;}}
10018    #mc-stat-tt{{display:none;position:fixed;background:rgba(15,10,6,0.96);color:rgba(255,255,255,0.94);border-radius:8px;padding:9px 13px;font-size:12.5px;font-weight:500;line-height:1.5;pointer-events:none;z-index:9001;box-shadow:0 6px 22px rgba(0,0,0,0.34);max-width:300px;border:1px solid rgba(255,255,255,0.12);}}
10019    .mc-card{{cursor:pointer;}}
10020    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10021  </style>
10022</head>
10023<body>
10024  {loading_overlay}
10025  <div class="background-watermarks" aria-hidden="true">
10026    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10027    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10028    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10029    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10030    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10031    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10032  </div>
10033  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10034  <div class="top-nav">
10035    <div class="top-nav-inner">
10036      <a class="brand" href="/">
10037        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10038        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10039      </a>
10040      <div class="nav-right">
10041        <a class="nav-pill" href="/">Home</a>
10042        <div class="nav-dropdown">
10043          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10044          <div class="nav-dropdown-menu">
10045            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
10046          </div>
10047        </div>
10048        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10049        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10050        <div class="nav-dropdown">
10051          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10052          <div class="nav-dropdown-menu">
10053            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
10054          </div>
10055        </div>
10056        <div class="server-status-wrap" id="server-status-wrap">
10057          <div class="nav-pill server-online-pill" id="server-status-pill">
10058            <span class="status-dot" id="status-dot"></span>
10059            <span id="server-status-label">Server</span>
10060            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10061          </div>
10062          <div class="server-status-tip">
10063            OxideSLOC is running &mdash; accessible on your network.
10064            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10065          </div>
10066        </div>
10067        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10068          <svg viewBox="0 0 24 24" 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>
10069        </button>
10070        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10071          <svg class="icon-moon" viewBox="0 0 24 24"><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>
10072          <svg class="icon-sun" viewBox="0 0 24 24"><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>
10073        </button>
10074      </div>
10075    </div>
10076  </div>
10077
10078  <div class="page">
10079    <!-- Hero header -->
10080    <div class="mc-hero">
10081      <div class="mc-hero-header">
10082        <div>
10083          <div class="mc-title">Multi-Scan Timeline</div>
10084          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10085          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10086        </div>
10087        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10088          <a class="btn-back" href="/compare-scans"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg> Compare Scans</a>
10089          <div class="export-group" id="mc-top-export-group">
10090            <button type="button" class="export-btn" id="mc-top-export-html-btn" title="Export this page as a standalone HTML report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export HTML</button>
10091            <button type="button" class="export-btn" id="mc-top-export-pdf-btn" title="Export this page as a PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
10092          </div>
10093        </div>
10094      </div>
10095      {scope_bar_html}
10096      <!-- Scan strip -->
10097      <div class="{mc_strip_class}">{scan_strip}</div>
10098    </div>
10099
10100    <!-- Summary metrics table -->
10101    <div class="panel">
10102      <div class="panel-title">Metric Progression</div>
10103      <div class="table-wrap">
10104        <table class="metrics-table">
10105          <thead>{metrics_thead}</thead>
10106          <tbody>{metrics_tbody}</tbody>
10107        </table>
10108      </div>
10109    </div>
10110
10111    <!-- Scan Charts -->
10112    <div class="panel" id="mc-charts-panel">
10113      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10114      <div class="ic-grid">
10115        <!-- Timeline line chart — spans full width -->
10116        <div class="ic-card" style="grid-column:span 2">
10117          <div class="ic-card-h2-row">
10118            <span class="ic-card-h2">Timeline</span>
10119            <div class="chart-toolbar" style="margin:0">
10120              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10121              <button class="chart-metric-btn" data-metric="files">Files</button>
10122              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10123              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10124              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10125            </div>
10126          </div>
10127          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10128        </div>
10129        <!-- Code Metrics: Scan 1 vs Latest -->
10130        <div class="ic-card">
10131          <div class="ic-chart-hdr"><span class="ic-card-h2">Code Metrics &mdash; Scan 1 vs Latest</span><button class="ic-expand-btn" data-expand-src="mc-ic-c1" data-expand-title="Code Metrics — Scan 1 vs Latest">&#x2922; Full View</button></div>
10132          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded&nbsp;=&nbsp;scan&nbsp;1)</span></div>
10133          <div id="mc-ic-c1"></div>
10134        </div>
10135        <!-- Language Code Delta -->
10136        <div class="ic-card" id="mc-ic-lang-card">
10137          <div class="ic-chart-hdr"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="mc-ic-c3" data-expand-title="Language Code Delta">&#x2922; Full View</button></div>
10138          <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10139          <div id="mc-ic-c3"></div>
10140        </div>
10141        <!-- Delta by Metric -->
10142        <div class="ic-card">
10143          <div class="ic-chart-hdr"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="mc-ic-c2" data-expand-title="Delta by Metric">&#x2922; Full View</button></div>
10144          <div id="mc-ic-c2"></div>
10145        </div>
10146        <!-- File Change Distribution -->
10147        <div class="ic-card">
10148          <div class="ic-chart-hdr"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="mc-ic-c4" data-expand-title="File Change Distribution">&#x2922; Full View</button></div>
10149          <div id="mc-ic-c4"></div>
10150        </div>
10151      </div>
10152    </div>
10153
10154    <!-- File matrix table -->
10155    <div class="panel">
10156      <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
10157      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10158        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10159          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10160          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10161          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10162          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10163          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10164        </div>
10165        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10166          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10167          <div class="export-group">
10168          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10169          <button type="button" class="export-btn" id="export-csv-btn">
10170            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10171            CSV
10172          </button>
10173          <button type="button" class="export-btn" id="mc-file-xls-btn">
10174            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10175            Excel
10176          </button>
10177          </div>
10178        </div>
10179      </div>
10180      <div class="table-wrap">
10181        <table id="file-table">
10182          <thead>
10183            <tr>
10184              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10185              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10186              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10187              {file_col_headers}
10188              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10189            </tr>
10190          </thead>
10191          <tbody id="file-tbody"></tbody>
10192        </table>
10193      </div>
10194      <div class="pagination">
10195        <span class="pagination-info" id="pg-info"></span>
10196        <div class="pagination-btns" id="pg-btns"></div>
10197        <div style="display:flex;align-items:center;gap:6px;">
10198          <span style="font-size:12px;color:var(--muted)">Show</span>
10199          <select class="per-page" id="per-page-sel">
10200            <option value="25" selected>25 per page</option>
10201            <option value="50">50 per page</option>
10202            <option value="100">100 per page</option>
10203          </select>
10204        </div>
10205      </div>
10206    </div>
10207  </div>
10208
10209  <div id="mc-ic-tt"></div>
10210
10211  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10212    <div class="ic-svg-modal">
10213      <div class="ic-svg-modal-hdr">
10214        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10215        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10216      </div>
10217      <div id="ic-svg-modal-body"></div>
10218    </div>
10219  </div>
10220
10221  <footer class="site-footer">
10222    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10223    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10224    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10225    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10226    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10227  </footer>
10228
10229  <script nonce="{csp_nonce}">
10230  (function(){{
10231    // ── Dark theme ───────────────────────────────────────────────────────────
10232    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10233    var renderInlineCharts=null;
10234    var tt=document.getElementById('theme-toggle');
10235    if(tt)tt.addEventListener('click',function(){{
10236      var on=document.body.classList.toggle('dark-theme');
10237      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10238      renderChart(activeMetric);
10239      if(renderInlineCharts)renderInlineCharts();
10240    }});
10241
10242    // ── Code particles ───────────────────────────────────────────────────────
10243    var container=document.getElementById('code-particles');
10244    if(container){{
10245      var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
10246      for(var i=0;i<28;i++){{
10247        (function(idx){{
10248          var el=document.createElement('span');el.className='code-particle';
10249          el.textContent=snips[idx%snips.length];
10250          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10251          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10252          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10253          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10254          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10255          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10256          container.appendChild(el);
10257        }})(i);
10258      }}
10259    }}
10260
10261    // ── Watermarks ───────────────────────────────────────────────────────────
10262    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10263    if(wms.length){{
10264      var placed=[];
10265      function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
10266      function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
10267      var half=Math.floor(wms.length/2);
10268      wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
10269    }}
10270
10271    // ── Settings / colour scheme modal ───────────────────────────────────────
10272    (function(){{
10273      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'}}];
10274      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);}});}}
10275      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10276      function init(){{
10277        var btn=document.getElementById('settings-btn');if(!btn)return;
10278        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10279        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" 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>';
10280        document.body.appendChild(m);
10281        var g=document.getElementById('scheme-grid');
10282        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);}});
10283        var cl=document.getElementById('settings-close-btn');
10284        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');}});
10285        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10286        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10287      }}
10288      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10289    }})();
10290
10291    // ── Timezone support for scan timestamps ─────────────────────────────────
10292    (function(){{
10293      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';}};
10294      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'';}}}};
10295      window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('.mc-ts-local[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};
10296      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10297      window.applyTz(storedTz);
10298      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
10299      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10300    }})();
10301
10302    // ── Data ────────────────────────────────────────────────────────────────
10303    var POINTS={points_json};
10304    var FILES={file_matrix_json};
10305    var N={n};
10306
10307    // ── fmt helper ───────────────────────────────────────────────────────────
10308    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
10309    function fmtFull(n){{return Number(n).toLocaleString();}}
10310    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10311
10312    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10313    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10314    function mcShortRef(p,i){{var c=(p&&p.commit?String(p.commit):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);if(c)return c;var r=(p&&p.run_id?String(p.run_id):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);return r||('scan'+(i+1));}}
10315    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10316    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10317
10318    // ── Timeline chart ───────────────────────────────────────────────────────
10319    var activeMetric='code';
10320    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10321    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10322
10323    function renderChart(metric){{
10324      var svg=document.getElementById('mc-chart');if(!svg)return;
10325      var W=svg.getBoundingClientRect().width||800,H=280;
10326      svg.setAttribute('height',H);
10327      var pad={{l:62,r:20,t:32,b:72}};
10328      var dark=document.body.classList.contains('dark-theme');
10329      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10330      var valid=pts.filter(function(v){{return v!=null;}});
10331      if(!valid.length){{var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(metricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'None of the selected scans reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+escHtml(_nd_msg)+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+escHtml(_nd_sub)+'</text>';return;}}
10332      var minV=0,maxV=Math.max.apply(null,valid);
10333      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10334      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10335      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10336      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10337      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10338      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10339      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10340      var parts=[];
10341      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10342      for(var gi=0;gi<5;gi++){{var gy=pad.t+plotH/4*gi;parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');var gv=maxV-(maxV-minV)/4*gi;parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmt(gv)+'</text>');}}
10343      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10344      var lineD='';var firstPt=true;
10345      for(var i=0;i<N;i++){{if(pts[i]==null)continue;var cx=xOf(i),cy=yOf(pts[i]);areaD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);if(firstPt){{lineD='M '+cx.toFixed(1)+' '+cy.toFixed(1);firstPt=false;}}else{{lineD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);}}}}
10346      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10347      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10348      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10349      for(var i=0;i<N;i++){{
10350        if(pts[i]==null)continue;
10351        var cx=xOf(i),cy=yOf(pts[i]);
10352        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10353        var hasTag=p.tags&&p.tags.length>0;
10354        // Permanent Y-value label above the dot
10355        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtFull(pts[i])+'</text>');
10356        parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
10357        var xanchor=i===0?'start':i===N-1?'end':'middle';
10358        // X-axis label at 2× the original size (18 px)
10359        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(H-pad.b+22)+'" text-anchor="'+xanchor+'" font-size="18" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escHtml(lbl)+'</text>');
10360      }}
10361      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10362      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10363      svg.innerHTML=parts.join('');
10364      svg.addEventListener('click',function(e){{var c=e.target.closest('circle[data-run-id]');if(c)window.location='/runs/html/'+c.getAttribute('data-run-id');}});
10365      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10366      svg.onmousemove=function(e){{
10367        var rect=svg.getBoundingClientRect();
10368        var scaleX=W/rect.width;
10369        var mouseX=(e.clientX-rect.left)*scaleX;
10370        var nearest=-1,minDist=Infinity;
10371        for(var k=0;k<N;k++){{if(pts[k]==null)continue;var dx=Math.abs(xOf(k)-mouseX);if(dx<minDist){{minDist=dx;nearest=k;}}}}
10372        if(nearest<0)return;
10373        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10374        var xhair=svg.querySelector('.mc-xhair');
10375        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10376        xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
10377        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10378        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10379        tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
10380        var bx=rect.left+(nc/W*rect.width)+18;
10381        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10382        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10383      }};
10384      svg.onmouseleave=function(){{
10385        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10386        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10387      }};
10388    }}
10389
10390    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10391
10392    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10393      btn.addEventListener('click',function(){{
10394        activeMetric=this.dataset.metric;
10395        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10396        this.classList.add('active');
10397        renderChart(activeMetric);
10398      }});
10399    }});
10400    if(typeof ResizeObserver!=='undefined'){{
10401      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10402    }}
10403    renderChart(activeMetric);
10404
10405    // ── File matrix table ────────────────────────────────────────────────────
10406    var activeStatus='';
10407    var currentPage=1;
10408    var perPage=25;
10409    var mcSortCol=null,mcSortAsc=true;
10410
10411    function getFiltered(){{
10412      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10413      if(!mcSortCol)return data;
10414      var asc=mcSortAsc;
10415      return data.slice().sort(function(a,b){{
10416        var va,vb;
10417        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10418        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10419        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10420        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10421        else{{return 0;}}
10422        if(asc)return va<vb?-1:va>vb?1:0;
10423        return va<vb?1:va>vb?-1:0;
10424      }});
10425    }}
10426
10427    function renderFilePage(){{
10428      var filtered=getFiltered();
10429      var total=filtered.length;
10430      var totalPages=Math.max(1,Math.ceil(total/perPage));
10431      if(currentPage>totalPages)currentPage=totalPages;
10432      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10433      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10434      var rows=[];
10435      for(var i=start;i<end;i++){{
10436        var f=filtered[i];
10437        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10438        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10439        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10440        for(var j=0;j<N;j++){{
10441          var cv=f.c[j];
10442          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10443          if(j<N-1){{
10444            var dv=f.d[j+1];
10445            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10446              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10447          }}
10448        }}
10449        var tc=f.t;
10450        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10451        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10452      }}
10453      tbody.innerHTML=rows.join('');
10454
10455      var info=document.getElementById('pg-info');
10456      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10457      renderPgBtns(totalPages);
10458    }}
10459
10460    function renderPgBtns(totalPages){{
10461      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10462      var btns=[];
10463      function mkBtn(label,page,active,disabled){{
10464        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10465        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10466      }}
10467      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10468      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10469      if(s>1)btns.push(mkBtn('1',1,false,false));
10470      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10471      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10472      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10473      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10474      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10475      wrap.innerHTML=btns.join('');
10476      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10477        b.addEventListener('click',function(){{
10478          var pg=parseInt(this.dataset.pg,10);
10479          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10480        }});
10481      }});
10482    }}
10483
10484    // Tab filter
10485    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10486      btn.addEventListener('click',function(){{
10487        activeStatus=this.dataset.status||'';
10488        currentPage=1;
10489        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10490        this.classList.add('active');
10491        renderFilePage();
10492      }});
10493    }});
10494
10495    // Per-page selector
10496    var ppSel=document.getElementById('per-page-sel');
10497    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10498
10499    // ── Column header sort ───────────────────────────────────────────────────
10500    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10501      th.addEventListener('click',function(){{
10502        var col=th.dataset.sortCol;
10503        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10504        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10505          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10506        }});
10507        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10508        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10509        currentPage=1;renderFilePage();
10510      }});
10511    }});
10512
10513    // Reset button also clears sort
10514    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10515    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10516      mcSortCol=null;mcSortAsc=true;
10517      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10518        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10519      }});
10520      activeStatus='';currentPage=1;
10521      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10522      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10523      renderFilePage();
10524    }});
10525
10526    renderFilePage();
10527
10528    // ── CSV export ───────────────────────────────────────────────────────────
10529    var exportBtn=document.getElementById('export-csv-btn');
10530    if(exportBtn)exportBtn.addEventListener('click',function(){{
10531      var header=['File','Language','Status'];
10532      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10533      header.push('Net Delta');
10534      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10535      var filtered=getFiltered();
10536      filtered.forEach(function(f){{
10537        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10538        for(var j=0;j<N;j++){{
10539          cols.push(f.c[j]!=null?f.c[j]:'');
10540          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10541        }}
10542        cols.push(f.t);
10543        rows.push(cols.join(','));
10544      }});
10545      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10546      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10547      a.download=mcExportName('csv');a.click();
10548    }});
10549
10550    // ── File matrix extra export buttons ─────────────────────────────────────
10551    (function(){{
10552      var resetBtn=document.getElementById('mc-file-reset-btn');
10553      if(resetBtn)resetBtn.addEventListener('click',function(){{
10554        activeStatus='';currentPage=1;
10555        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10556        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10557        renderFilePage();
10558      }});
10559
10560      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10561      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10562      function mcMakeXlsx(fname){{
10563        var filtered=getFiltered();
10564        var enc=new TextEncoder();
10565        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;}}
10566        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;}}
10567        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10568        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10569        var ss=[],si={{}};
10570        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10571        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10572        function WS(){{
10573          var R=0,buf=[];
10574          function cl(c){{return String.fromCharCode(65+c);}}
10575          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10576          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10577          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10578          function xml(cw){{return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/>'+(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}}
10579          return{{sc:sc,nc:nc,row:row,xml:xml}};
10580        }}
10581        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10582        var proj=mcExportProj();
10583        // \u2500\u2500 Summary sheet \u2500\u2500
10584        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10585        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10586        r1(s1(0,proj,2));
10587        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10588        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
10589        r1('');
10590        r1(s1(0,'SCAN SUMMARY',8));
10591        r1(s1(0,'Scan',3)+s1(1,'Commit',3)+s1(2,'Branch',3)+s1(3,'Timestamp',3)+s1(4,'Code Lines',3)+s1(5,'Comment Lines',3)+s1(6,'Files',3)+s1(7,'Tests',3));
10592        POINTS.forEach(function(p,i){{
10593          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10594          r1(s1(0,'Scan '+(i+1))+s1(1,sha||'\u2014')+s1(2,p.branch||'\u2014')+s1(3,p.scanned||'')+n1(4,p.code,4)+n1(5,p.comments,4)+n1(6,p.files,4)+n1(7,p.tests,4));
10595        }});
10596        r1('');
10597        if(POINTS.length>1){{
10598          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10599          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10600          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10601          var nr=function(lbl,a,b){{var d=(+b)-(+a),ds=d>0?'+'+d:String(d);r1(s1(0,lbl)+n1(1,a,4)+n1(2,b,4)+s1(3,ds,dstyle(ds)));}};
10602          nr('Code Lines',pf.code,pl.code);
10603          nr('Comment Lines',pf.comments,pl.comments);
10604          nr('Files Analyzed',pf.files,pl.files);
10605          nr('Tests',pf.tests,pl.tests);
10606          r1('');
10607        }}
10608        var cMod=0,cAdd=0,cRem=0,cUnch=0;
10609        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10610        var totF=FILES.length||1;
10611        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10612        r1(s1(0,'FILE CHANGES',8));
10613        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10614        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10615        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10616        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10617        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10618        var lm={{}};
10619        FILES.forEach(function(f){{var l=f.l||'Unknown',d=+f.t||0;if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=d;}});
10620        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10621        if(langs.length){{
10622          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10623          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10624          langs.forEach(function(l){{var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);r1(s1(0,l)+n1(1,e.f,4)+s1(2,dv,dstyle(dv)));}});
10625        }}
10626        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10627        // \u2500\u2500 File Delta sheet \u2500\u2500
10628        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10629        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10630        for(var hi=0;hi<N;hi++){{hcells+=s2(hc++,'Scan '+(hi+1)+' Code',3);if(hi<N-1)hcells+=s2(hc++,'Delta \u2192 '+(hi+2),3);}}
10631        hcells+=s2(hc,'Net Delta',3);
10632        r2(hcells);
10633        filtered.forEach(function(f){{
10634          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10635          for(var j=0;j<N;j++){{cells+=n2(c++,f.c[j]!=null?f.c[j]:'',4);if(j<N-1){{var dv=mcSignDelta(f.d[j+1]);cells+=s2(c++,dv,dstyle(dv));}}}}
10636          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10637          r2(cells);
10638        }});
10639        var ncols=3+N+(N-1)+1;
10640        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10641        var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){{return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}}).join('')+'</sst>';
10642        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10643        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/worksheets/sheet2.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>',
10644          '_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>',
10645          '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/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
10646          'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="16384" windowHeight="8192"/></bookViews><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="File Delta" sheetId="2" r:id="rId2"/></sheets></workbook>',
10647          'xl/styles.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="8"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="14"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font><font><sz val="10"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF155724"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF721C24"/><name val="Calibri"/></font><font><sz val="11"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFD4EDDA"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF8D7DA"/></patternFill></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="9"><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="2" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
10648          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10649        var zparts=[],zcds=[],zoff=0,znf=0;
10650        ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'].forEach(function(name){{
10651          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10652          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]);
10653          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);
10654          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));
10655          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10656          zoff+=entry.length;znf++;
10657        }});
10658        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10659        var eocd=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
10660        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10661        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10662        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10663        out.set(new Uint8Array(eocd),pos);
10664        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10665        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10666      }}
10667
10668      var xlsBtn=document.getElementById('mc-file-xls-btn');
10669      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10670
10671      // File matrix HTML export — interactive: sort by column, filter by status
10672      function mcFileBuildHtml(){{
10673        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10674        var hdrs=['File','Language','Status'];
10675        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10676        hdrs.push('Net \u0394');
10677        var SI=2;
10678        var allRows=FILES.map(function(f){{var r=[f.p,f.l||'',f.s||''];for(var _i=0;_i<N;_i++){{r.push(f.c[_i]!=null?f.c[_i]:null);if(_i<N-1)r.push(f.d[_i+1]!=null?f.d[_i+1]:null);}}r.push(f.t);return r;}});
10679        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10680        var cnt={{all:allRows.length}};
10681        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10682        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10683        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10684          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10685          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10686          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10687          '.sub{{font-size:12px;color:#99aabb;}}'+
10688          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10689          '.wr{{padding:16px 20px;}}'+
10690          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10691          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10692          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10693          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10694          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10695          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10696          'thead tr{{background:#1a2035;}}'+
10697          'th{{padding:6px 10px;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;white-space:nowrap;cursor:pointer;user-select:none;}}'+
10698          'th:hover{{background:#2a3050;}}'+
10699          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10700          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10701          'tr:nth-child(even) td{{background:#faf7f4;}}'+
10702          'tr:hover td{{background:#f5f0ea;}}'+
10703          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10704          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10705        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10706        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10707          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10708          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10709          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10710          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10711        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10712          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
10713          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
10714          'var n=Number(v);if(ci>SI&&!isNaN(n)&&n!==0){{return n>0?"<span class=\\"ap\\">+"+n.toLocaleString()+"<\\/span>":"<span class=\\"an\\">"+n.toLocaleString()+"<\\/span>";}}'+
10715          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10716          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10717          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
10718          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10719          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10720          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10721          'document.getElementById("tb").innerHTML=data.map(function(r){{return"<tr>"+HDRS.map(function(h,ci){{return"<td>"+fc(r[ci],ci)+"<\\/td>";}}).join("")+"<\\/tr>";}}).join("")'+
10722          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10723          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10724          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10725          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10726          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10727          'sd=(sc===ci)?-sd:1;sc=ci;'+
10728          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10729          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10730          'render();';
10731        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10732          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10733          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
10734          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10735          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10736          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10737          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10738          '<script>'+inlineJs+'<\/script><\/body><\/html>';
10739      }}
10740
10741      var htmlBtn=document.getElementById('mc-file-html-btn');
10742      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10743        var h=mcFileBuildHtml();
10744        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10745        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10746        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10747      }});
10748
10749      var pdfBtn=document.getElementById('mc-file-pdf-btn');
10750      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10751        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10752      }});
10753    }})();
10754
10755    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10756    (function(){{
10757      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10758      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10759      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10760      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10761      function fmt2(n){{return Number(n).toLocaleString();}}
10762      function px(n){{return Math.round(n);}}
10763      var _tt=document.getElementById('mc-ic-tt');
10764      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10765      function addTT(el){{
10766        if(!el)return;
10767        el.addEventListener('mouseover',function(e){{
10768          var t=e.target.closest('[data-ttl]');
10769          if(t&&_tt){{
10770            var ttl=t.getAttribute('data-ttl');
10771            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10772            _tt.style.display='block';mvTT(e);
10773            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10774            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10775          }} else {{
10776            if(_tt)_tt.style.display='none';
10777            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10778          }}
10779        }});
10780        el.addEventListener('mouseleave',function(){{
10781          if(_tt)_tt.style.display='none';
10782          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10783        }});
10784        el.addEventListener('mousemove',function(e){{mvTT(e);}});
10785      }}
10786      function mvTT(e){{if(!_tt)return;var x=e.clientX+16,y=e.clientY-10,r=_tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_tt.style.left=x+'px';_tt.style.top=y+'px';}}
10787      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10788      function buildCharts(){{
10789        if(N<2)return;
10790        var cs=getComputedStyle(document.body);
10791        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10792        var textCol=cv('--text','#43342d');
10793        var mutedCol=cv('--muted','#7b675b');
10794        var gFill=cv('--muted-2','#a08777');
10795        var LGY=cv('--line','#e6d0bf');
10796        var axisCol=cv('--line-strong','#d8bfad');
10797        var surf2col=cv('--surface-2','#f4ede4');
10798        var surfCol=cv('--surface','#fff8f0');
10799        var p0=POINTS[0],pLast=POINTS[N-1];
10800        var dark=document.body.classList.contains('dark-theme');
10801        var FADE=dark?'#524238':'#e6d0bf';
10802        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10803        function niceMax(v){{var x=v||1;var p=Math.pow(10,Math.floor(Math.log10(x)));var n=x/p;var s=n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10;return s*p;}}
10804      var c1mets=[
10805        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10806        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10807        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10808      ];
10809      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10810      // Code Metrics chart — grows to fill the height its grid row settled to (the
10811      // Language Code Delta sibling usually drives that), so it never sits short at
10812      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10813      function drawC1(){{
10814        var C1W=620,C1H=200;
10815        var c1host=document.getElementById('mc-ic-c1');
10816        var c1card=c1host?c1host.closest('.ic-card'):null;
10817        if(c1host&&c1card&&c1host.clientWidth>0){{
10818          var avW=c1host.clientWidth;
10819          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10820          var wantH=availPx*C1W/avW;
10821          if(wantH>C1H)C1H=wantH;
10822        }}
10823        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10824        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10825        for(var gi=1;gi<=4;gi++){{
10826          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10827          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10828          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10829        }}
10830        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10831        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10832        c1mets.forEach(function(m,i){{
10833          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10834          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10835          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10836          c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
10837          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.b)+'</text>';
10838          c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
10839          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.c)+'</text>';
10840          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Scan 1</text>';
10841          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Latest</text>';
10842        }});
10843        c1+='</svg>';
10844        return c1;
10845      }}
10846      // Chart 2: Delta by Metric (net delta first scan to last)
10847      var mets=[
10848        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10849        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10850        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10851      ];
10852      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10853      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
10854      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10855      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10856      mets.forEach(function(m,i){{
10857        var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,vcol=(m.v===0?textCol:col),bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt2(m.v);
10858        c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
10859        c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
10860        if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
10861        else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
10862      }});
10863      c2+='</svg>';
10864      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10865      var lm={{}};
10866      FILES.forEach(function(f){{var l=f.l||'Unknown';if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=f.t;}});
10867      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10868      function drawC3(){{
10869        if(!langs.length)return'';
10870        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10871        var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
10872        var c3host=document.getElementById('mc-ic-c3');
10873        var c3card=document.getElementById('mc-ic-lang-card');
10874        var C3H=langs.length*30+24;
10875        if(c3host&&c3card&&c3host.clientWidth>0){{
10876          var avW=c3host.clientWidth;
10877          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
10878          var wantH=availPx*C3W/avW;
10879          if(wantH>C3H)C3H=wantH;
10880        }}
10881        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
10882        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10883        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10884        langs.forEach(function(l,i){{
10885          var e=lm[l],yc=topPad+band*(i+0.5),bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
10886          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
10887          c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+px(yc-barH/2)+'" width="'+px(bw)+'" height="'+px(barH)+'" fill="'+col+'" rx="3"/>';
10888          if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+px(yc+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
10889          else{{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+px(yc+4)+'" text-anchor="'+anc3+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
10890          c3+='<text x="'+(C3W-5)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
10891        }});
10892        c3+='</svg>';
10893        return c3;
10894      }}
10895      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
10896      var fm=0,fa=0,fr=0,fu=0;
10897      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
10898      var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:FADE}}].filter(function(s){{return s.v>0;}});
10899      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
10900      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
10901      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
10902      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
10903      if(segs.length===1){{
10904        c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
10905        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
10906        c4+='<text x="'+cx4+'" y="'+px(cy4-(Ro+Ri)/2+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+pctFill(segs[0].c)+'">100%</text>';
10907      }} else {{
10908        segs.forEach(function(s){{
10909          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
10910          var x1=cx4+Ro*Math.cos(ang4),y1=cy4+Ro*Math.sin(ang4),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
10911          var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang4),yi2=cy4+Ri*Math.sin(ang4);
10912          c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' 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="'+s.c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
10913          if(sw>0.32){{var midA=ang4+sw/2,rr=(Ro+Ri)/2,lx=cx4+rr*Math.cos(midA),ly=cy4+rr*Math.sin(midA);c4+='<text x="'+px(lx)+'" y="'+px(ly+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="700" fill="'+pctFill(s.c)+'">'+px(s.v/tot4*100)+'%</text>';}}
10914          ang4+=sw;
10915        }});
10916      }}
10917      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
10918      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
10919      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
10920      segs.forEach(function(s,i){{
10921        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
10922        c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
10923        c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
10924        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
10925      }});
10926      c4+='</svg>';
10927      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
10928      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
10929      // once at natural height to seed the row, then both are filled to the row the
10930      // grid settled to, so neither sits short at the top of an over-tall cell.
10931      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
10932      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
10933      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
10934      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
10935      var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
10936      if(e1)e1.innerHTML=drawC1();
10937      }}
10938      buildCharts();
10939      renderInlineCharts=buildCharts;
10940      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
10941      (function(){{
10942        var ov=document.getElementById('ic-svg-modal-ov');
10943        var body=document.getElementById('ic-svg-modal-body');
10944        var ttl=document.getElementById('ic-svg-modal-title');
10945        var closeBtn=document.getElementById('ic-svg-modal-close');
10946        if(!ov||!body)return;
10947        function close(){{ov.classList.remove('open');body.innerHTML='';}}
10948        function open(srcId,title){{
10949          var src=document.getElementById(srcId);if(!src)return;
10950          ttl.textContent=title||'';
10951          var card=src.closest('.ic-card');
10952          var legHtml='';
10953          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
10954          body.innerHTML=legHtml+src.innerHTML;
10955          var svg=body.querySelector('svg');
10956          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
10957          addTT(body);
10958          ov.classList.add('open');
10959        }}
10960        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
10961          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
10962        }});
10963        if(closeBtn)closeBtn.addEventListener('click',close);
10964        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
10965        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
10966      }})();
10967
10968      // HTML legend hover → highlight matching SVG bars within the SAME card only
10969      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
10970        var metric=leg.getAttribute('data-highlight');
10971        var parentCard=leg.closest('.ic-card');
10972        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
10973        if(!chartEl)return;
10974        leg.addEventListener('mouseenter',function(){{
10975          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
10976            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
10977              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
10978              x.style.opacity='1';
10979            }} else {{
10980              x.style.opacity='0.28';
10981            }}
10982          }});
10983        }});
10984        leg.addEventListener('mouseleave',function(){{
10985          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10986        }});
10987      }});
10988      // Author handles
10989      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
10990
10991      // ── Export helpers ────────────────────────────────────────────────────────
10992      // Fetch one image from the server and return a data-URI Promise
10993      function mcFetchUri(path){{
10994        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
10995          return new Promise(function(res){{
10996            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
10997          }});
10998        }}).catch(function(){{return '';}});
10999      }}
11000      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11001      function mcInlineImgs(html,cb){{
11002        var paths=[],seen={{}};
11003        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11004        if(!paths.length){{cb(html);return;}}
11005        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11006          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11007          .catch(function(){{cb(html);}});
11008      }}
11009      // Capture full-page HTML with all table rows visible
11010      function mcRawHtml(pdfMode){{
11011        if(pdfMode)document.body.classList.add('pdf-mode');
11012        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11013        var html=document.documentElement.outerHTML;
11014        perPage=s;currentPage=p;renderFilePage();
11015        if(pdfMode)document.body.classList.remove('pdf-mode');
11016        return html;
11017      }}
11018
11019      // HTML export (full page with inlined images)
11020      function mcDoHtml(btn,fname){{
11021        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11022        mcInlineImgs(mcRawHtml(false),function(html){{
11023          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11024          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11025          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11026          btn.disabled=false;btn.innerHTML=orig;
11027        }});
11028      }}
11029      // PDF export — comprehensive document-style report: full numbers, all sections
11030      function mcBuildPdfHtml(){{
11031        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11032        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11033        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11034        function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
11035        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11036        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11037        function ptRef(pt,i){{return pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,7):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));}}
11038        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11039        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11040        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11041        // Header/footer flow in document order (NOT position:fixed) — a fixed
11042        // header repeats every printed page in Chromium and overlaps the content
11043        // below it, swallowing the first rows of pages 2+ and clipping the cards
11044        // on page 1. The table <thead> repeats per page natively, so every row
11045        // stays visible.
11046        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11047          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11048          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11049          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11050          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11051          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11052          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11053          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11054          '.info-bar{{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11055          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11056          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11057          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11058          '.body{{padding:12px 18px 0;}}'+
11059          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11060          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11061          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11062          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11063          '.sec{{margin-bottom:10px;}}'+
11064          '.sh{{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11065          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11066          'th{{background:#1a2035;color:#fff;padding:4px 7px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11067          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11068          'tr:nth-child(even) td{{background:#faf8f6;}}';
11069        // ── Metric Progression ────────────────────────────────────────────────
11070        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11071        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11072        var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
11073        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11074        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11075        var progRows=POINTS.map(function(pt,i){{
11076          var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,8):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));
11077          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11078            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11079            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11080            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11081            '<td style="text-align:right">'+full(pt.files)+'</td>';
11082          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11083          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11084          return r+'</tr>';
11085        }}).join('');
11086        // ── Scan-to-scan changes ──────────────────────────────────────────────
11087        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11088          var prev=POINTS[i];
11089          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11090          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11091          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11092            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11093            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11094            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11095            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11096        }}).join(''):'';
11097        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11098        var fmSection='';
11099        if(FILES&&FILES.length){{
11100          // Hard cap on per-scan columns so the table never overflows the page width.
11101          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11102          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11103          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11104          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11105          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11106          var fmRows=topFiles.map(function(f){{
11107            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11108            var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'&mdash;')+'</td>';
11109            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11110            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11111            return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
11112          }}).join('');
11113          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11114          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11115            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11116        }}
11117        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11118          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11119          '<div class="pdf-header"><div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Multi-Scan Timeline</div><div class="ph-date">'+esc(now)+'</div></div><div class="info-bar"><div><div class="ib-name">{project_label}</div></div><div class="ib-right">{n} scans compared<br>'+commitsList+'</div></div></div>'+
11120
11121          '<div class="body">'+
11122          '<div class="sg">'+
11123          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11124            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11125          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11126            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11127          (codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
11128            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11129          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11130          '</div>'+
11131          '<div class="sec"><p class="sh">Metric Progression</p>'+
11132          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11133          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11134          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11135          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11136          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11137          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11138          fmSection+
11139          '</div>'+
11140          '<div class="pdf-footer"><div class="ftr"><span>oxide-sloc v{version} | AGPL-3.0-or-later</span><span>Multi-Scan Timeline Report</span><span>{project_label} &middot; {n} scans</span></div></div>'+
11141          '</body></html>';
11142      }}
11143      function mcDoPdf(btn){{
11144        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11145      }}
11146
11147      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11148      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11149      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11150      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11151      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11152      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11153      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11154      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11155      if(location.protocol==='file:'){{
11156        [mcHtmlBtn,mcTopHtmlBtn,document.getElementById('mc-file-html-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='Already viewing an exported HTML file';b.textContent='Export HTML';}}}} );
11157        [mcPdfBtn,mcTopPdfBtn,document.getElementById('mc-file-pdf-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='PDF export requires a running server';b.textContent='Export PDF';}}}} );
11158      }}
11159    }})();
11160    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11161    (function(){{
11162      function $(id){{return document.getElementById(id);}}
11163      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11164      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11165      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11166      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11167      function openModal(idx){{
11168        var ov=$('mc-modal-overlay');if(!ov)return;
11169        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11170        if(idx<0||idx>=N)return;
11171        var pt=POINTS[idx];
11172        titleEl.textContent='Scan '+(idx+1);
11173        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11174        subEl.textContent=lbl;
11175        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11176          '<div class="mc-modal-stat" data-tip="Physical lines of source code that are neither blank nor comment-only. This is the primary SLOC metric used to size the codebase."><div class="mc-modal-stat-val">'+full(pt.code)+'</div><div class="mc-modal-stat-lbl">Code Lines</div></div>'+
11177          '<div class="mc-modal-stat" data-tip="Lines made up of code comments (single-line or block). Documentation within the source that is not executed."><div class="mc-modal-stat-val">'+full(pt.comments)+'</div><div class="mc-modal-stat-lbl">Comments</div></div>'+
11178          '<div class="mc-modal-stat" data-tip="Empty lines or lines containing only whitespace. Counted separately from code and comment lines."><div class="mc-modal-stat-val">'+full(pt.blank)+'</div><div class="mc-modal-stat-lbl">Blank Lines</div></div>'+
11179          '<div class="mc-modal-stat" data-tip="Total number of source files analyzed in this scan across every supported language."><div class="mc-modal-stat-val">'+full(pt.files)+'</div><div class="mc-modal-stat-lbl">Files</div></div>'+
11180          (pt.tests!=null&&Number(pt.tests)>0?'<div class="mc-modal-stat" data-tip="Number of unit-test definitions detected across the scanned files."><div class="mc-modal-stat-val">'+full(pt.tests)+'</div><div class="mc-modal-stat-lbl">Tests</div></div>':'')+
11181          (pt.cov!=null?'<div class="mc-modal-stat" data-tip="Percentage of code lines covered by tests for this scan, shown when coverage results were captured."><div class="mc-modal-stat-val">'+Number(pt.cov).toFixed(1)+'%</div><div class="mc-modal-stat-lbl">Coverage</div></div>':'')+
11182          '</div></div>';
11183        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11184          (pt.commit?'<div class="mc-modal-row"><span class="mc-modal-key">Commit</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.commit)+'</a></span></div>':'')+
11185          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11186          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11187          (pt.nearest?'<div class="mc-modal-row"><span class="mc-modal-key">Nearest tag</span><span class="mc-modal-val">'+esc(pt.nearest)+'</span></div>':'')+
11188          (pt.commit_date?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit on</span><span class="mc-modal-val">'+esc(pt.commit_date)+'</span></div>':'')+
11189          (pt.author?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit by</span><span class="mc-modal-val">'+esc(pt.author)+'</span></div>':'')+
11190          (pt.scanned?'<div class="mc-modal-row"><span class="mc-modal-key">Scanned on</span><span class="mc-modal-val">'+esc(pt.scanned)+'</span></div>':'')+
11191          '<div class="mc-modal-row"><span class="mc-modal-key">Run ID</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.run_id)+'</a></span></div>'+
11192          '</div>';
11193        var dHtml='';
11194        if(idx>0){{
11195          var prev=POINTS[idx-1];
11196          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11197          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11198            '<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
11199            '<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
11200            '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
11201            '</div></div>';
11202        }}
11203        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11204        ov.classList.add('open');document.body.style.overflow='hidden';
11205      }}
11206      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11207      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11208      document.addEventListener('click',function(e){{
11209        if(!e.target||!e.target.closest)return;
11210        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11211        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11212        var card=e.target.closest('.mc-card');
11213        if(!card)return;
11214        if(e.target.closest('a'))return;
11215        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11216        var i=cards.indexOf(card);
11217        if(i>=0)openModal(i);
11218      }});
11219      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11220      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11221      var statTip=null;
11222      document.addEventListener('mousemove',function(e){{
11223        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11224        if(!box){{if(statTip)statTip.style.display='none';return;}}
11225        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11226        var tip=box.getAttribute('data-tip')||'';
11227        if(statTip.textContent!==tip)statTip.textContent=tip;
11228        statTip.style.display='block';
11229        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11230        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11231        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11232        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11233      }});
11234      (function tagCards(){{var cs=document.querySelectorAll('.mc-card');for(var k=0;k<cs.length;k++)cs[k].setAttribute('title','Click to view full scan details');}})();
11235    }})();
11236  }})();
11237  </script>
11238  <script nonce="{csp_nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
11239  if(location.protocol==='file:'){{if(lbl)lbl.textContent='Offline';if(dot){{dot.style.background='#888';dot.style.boxShadow='none';}}if(pingEl)pingEl.textContent='';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}}
11240  if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
11241  <!-- Scan card detail modal -->
11242  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11243    <div class="mc-modal" id="mc-modal">
11244      <div class="mc-modal-head">
11245        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11246        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11247      </div>
11248      <div class="mc-modal-body" id="mc-modal-body"></div>
11249    </div>
11250  </div>
11251  {toast_assets}
11252</body>
11253</html>"#,
11254        project_label = html_escape(project_label),
11255        n = n,
11256        scan_strip = scan_strip,
11257        mc_strip_class = mc_strip_class,
11258        metrics_thead = metrics_thead,
11259        metrics_tbody = metrics_tbody,
11260        file_col_headers = file_col_headers,
11261        total_files = total_files,
11262        files_modified = files_modified,
11263        files_added = files_added,
11264        files_removed = files_removed,
11265        files_unchanged = files_unchanged,
11266        points_json = points_json,
11267        file_matrix_json = file_matrix_json,
11268        nav_compare_active = nav_compare_active,
11269        version = version,
11270        csp_nonce = csp_nonce,
11271        scope_bar_html = scope_bar_html,
11272        scope_label = scope_label,
11273        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11274    )
11275}
11276
11277// ── Trend report page ─────────────────────────────────────────────────────────
11278// Protected. Interactive time-series chart page that loads scan history via
11279// /api/metrics/history and renders a vanilla-SVG line chart.
11280//
11281// GET /trend-reports
11282
11283#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11284async fn trend_report_handler(
11285    State(state): State<AppState>,
11286    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11287) -> Response {
11288    auto_scan_watched_dirs(&state).await;
11289
11290    let watched_dirs_list: Vec<String> = {
11291        let wd = state.watched_dirs.lock().await;
11292        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11293    };
11294
11295    // Collect distinct project roots for the root selector dropdown.
11296    let roots: Vec<String> = {
11297        let reg = state.registry.lock().await;
11298        let mut seen = std::collections::BTreeSet::new();
11299        reg.entries
11300            .iter()
11301            .flat_map(|e| e.input_roots.iter().cloned())
11302            .filter(|r| seen.insert(r.clone()))
11303            .collect()
11304    };
11305
11306    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11307    let nonce = &csp_nonce;
11308    let version = env!("CARGO_PKG_VERSION");
11309    let toast_assets = sloc_toast_assets(nonce);
11310
11311    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11312    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11313    // of interactive controls — folder watching is managed by the host administrator.
11314    let watched_dirs_html: String = if state.server_mode {
11315        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
11316    } else {
11317        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11318            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11319                .to_string()
11320        } else {
11321            watched_dirs_list
11322                .iter()
11323                .fold(String::new(), |mut s, d| {
11324                    use std::fmt::Write as _;
11325                    let escaped =
11326                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11327                    write!(
11328                        s,
11329                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
11330                    ).expect("write to String is infallible");
11331                    s
11332                })
11333        };
11334        format!(
11335            r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
11336        )
11337    };
11338
11339    let html = format!(
11340        r##"<!doctype html>
11341<html lang="en">
11342<head>
11343  <meta charset="utf-8" />
11344  <meta name="viewport" content="width=device-width, initial-scale=1" />
11345  <title>OxideSLOC | Trend Reports</title>
11346  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11347  <style nonce="{nonce}">
11348    :root {{
11349      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11350      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11351      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11352      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11353      --info-bg:#eef3ff; --info-text:#4467d8;
11354    }}
11355    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11356    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
11357    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11358    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11359    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.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;}}
11360    @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));}}}}
11361    .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);}}
11362    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11363    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .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));}}
11364    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11365    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
11366    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11367    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11368    @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
11369    .nav-pill,.theme-toggle{{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;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
11370    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11371    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11372    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11373    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11374    .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
11375    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
11376    .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
11377    .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;}}
11378    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11379    .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);}}
11380    .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;}}
11381    .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
11382    .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
11383    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11384    .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;}}
11385    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
11386    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
11387    .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;}}
11388    .tz-select:focus{{border-color:var(--oxide);}}
11389    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11390    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11391    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11392    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11393    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11394    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11395    .trend-title-block{{flex:1;min-width:0;}}
11396    .controls-centered{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
11397    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11398    .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;}}
11399    .chart-select:focus{{border-color:var(--accent);}}
11400    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11401    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11402    .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);}}
11403    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11404    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11405    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11406    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;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 14px rgba(0,0,0,0.2);}}
11407    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11408    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11409    .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;}}
11410    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11411    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11412    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11413    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11414    .tr-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;white-space:nowrap;}}
11415    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11416    .tr-chart-full-modal{{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;}}
11417    .tr-chart-full-inner{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1600px;width:100%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
11418    .chart-hint-inline{{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);font-weight:600;white-space:nowrap;margin-top:8px;}}
11419    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11420    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11421    .chart-section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
11422    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11423    .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;user-select:none;}}
11424    .data-table td{{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
11425    .data-table tr:last-child td{{border-bottom:none;}}
11426    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11427    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11428    .table-wrap{{width:100%;overflow-x:auto;}}
11429    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11430    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11431    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11432    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11433    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11434    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11435    .filter-input{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:text;min-width:180px;}}
11436    .filter-select{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}}
11437    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11438    .pagination-info{{font-size:13px;color:var(--muted);}}
11439    .pagination-btns{{display:flex;gap:6px;}}
11440    .pg-btn{{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}}
11441    .pg-btn:hover{{background:var(--line);}} .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}} .pg-btn:disabled{{opacity:.35;cursor:default;pointer-events:none;}}
11442    #scan-history-table col:nth-child(1){{width:155px;}}
11443    #scan-history-table col:nth-child(2){{width:240px;}}
11444    #scan-history-table col:nth-child(3){{width:82px;}}
11445    #scan-history-table col:nth-child(4){{width:82px;}}
11446    #scan-history-table col:nth-child(5){{width:90px;}}
11447    #scan-history-table col:nth-child(6){{width:90px;}}
11448    #scan-history-table col:nth-child(7){{width:88px;}}
11449    #scan-history-table col:nth-child(8){{width:150px;}}
11450    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11451    .tag-chip{{display:inline-flex;padding:2px 8px;border-radius:999px;background:var(--info-bg);color:var(--info-text);font-size:11px;font-weight:700;margin-right:4px;}}
11452    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
11453    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11454    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11455    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11456    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11457    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11458    .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
11459    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11460    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11461    .watched-chip-rm:hover{{color:var(--oxide);}}
11462    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11463    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11464    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11465    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11466    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11467    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11468    a.run-link:hover{{text-decoration:underline;}}
11469    .run-id-chip{{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}}
11470    .git-chip{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent-2);}}
11471    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11472    .metric-num{{font-weight:700;color:var(--text);}}
11473    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11474    .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}}
11475    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11476    .btn.primary:hover{{opacity:.9;}}
11477    .rpt-btn{{min-width:58px;justify-content:center;}}
11478    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11479    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11480    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11481    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11482    .submod-details summary::-webkit-details-marker{{display:none;}}
11483    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11484    .submod-view-btn{{display:inline-flex;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.22);color:var(--accent-2);text-decoration:none;white-space:nowrap;}}
11485    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11486    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11487    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11488    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
11489    .export-btn:hover{{background:var(--line);}}
11490    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11491    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11492    .site-footer a{{color:var(--muted);}}
11493    .loading-state{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:52px 24px;gap:14px;color:var(--muted);font-size:13px;font-weight:600;}}
11494    .loading-spinner{{width:30px;height:30px;border:3px solid var(--line);border-top-color:var(--oxide);border-radius:50%;animation:spin-load 0.75s linear infinite;}}
11495    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11496    /* Modal system (Retention Policy / Clean-up) */
11497    .tr-modal-backdrop{{display:none;position:fixed;inset:0;z-index:9000;background:rgba(40,24,12,0.34);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);align-items:center;justify-content:center;padding:24px;animation:tr-fade .16s ease;}}
11498    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11499    .tr-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 28px 70px rgba(40,24,12,0.32),0 4px 14px rgba(40,24,12,0.16);width:100%;max-height:92vh;overflow-y:auto;animation:tr-pop .18s cubic-bezier(.2,.9,.3,1.2);}}
11500    .tr-modal{{background:rgba(255,255,255,0.90);}}
11501    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11502    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11503    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11504    .tr-modal-icon{{flex:none;width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 4px 12px rgba(184,80,40,0.32);}}
11505    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11506    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11507    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11508    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11509    .tr-modal-body{{padding:22px 30px;}}
11510    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11511    .tr-btn{{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:10px;font-size:13.5px;font-weight:800;cursor:pointer;border:1px solid transparent;transition:transform .12s ease,box-shadow .12s ease,background .12s ease,opacity .12s ease;font-family:inherit;line-height:1;}}
11512    .tr-btn:hover{{transform:translateY(-1px);}}
11513    .tr-btn:active{{transform:translateY(0);}}
11514    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11515    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11516    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11517    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11518    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11519    .tr-btn-secondary:hover{{background:var(--line);}}
11520    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11521    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11522  </style>
11523</head>
11524<body>
11525  <div class="background-watermarks" aria-hidden="true">
11526    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11527    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11528    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11529    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11530    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11531    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11532  </div>
11533  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11534  <div class="top-nav">
11535    <div class="top-nav-inner">
11536      <a class="brand" href="/">
11537        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11538        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11539      </a>
11540      <div class="nav-right">
11541        <a class="nav-pill" href="/">Home</a>
11542        <div class="nav-dropdown">
11543          <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11544          <div class="nav-dropdown-menu">
11545            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
11546          </div>
11547        </div>
11548        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11549        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11550        <div class="nav-dropdown">
11551          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11552          <div class="nav-dropdown-menu">
11553            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
11554          </div>
11555        </div>
11556        <div class="server-status-wrap" id="server-status-wrap">
11557          <div class="nav-pill server-online-pill" id="server-status-pill">
11558            <span class="status-dot" id="status-dot"></span>
11559            <span id="server-status-label">Server</span>
11560            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11561          </div>
11562          <div class="server-status-tip">
11563            OxideSLOC is running — accessible on your network.
11564            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11565          </div>
11566        </div>
11567        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11568          <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>
11569        </button>
11570        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11571          <svg class="icon-moon" viewBox="0 0 24 24"><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>
11572          <svg class="icon-sun" viewBox="0 0 24 24"><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>
11573        </button>
11574      </div>
11575    </div>
11576  </div>
11577
11578  <div class="page">
11579    {watched_dirs_html}
11580    <div class="summary-strip" id="trend-stats"></div>
11581    <div class="panel">
11582      <div class="trend-header">
11583        <div class="trend-title-block">
11584          <h1>Trend Reports</h1>
11585          <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root,<br>choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
11586          <span class="chart-hint-inline">
11587            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
11588            Click a dot or row to view its full report &nbsp;·&nbsp; <span class="dot" style="background:#C45C10;"></span>&thinsp;regular scan &nbsp;<span class="dot" style="background:#4472C4;"></span>&thinsp;tagged / release scan
11589          </span>
11590        </div>
11591        <div class="chart-actions">
11592          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11593            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11594            Retention Policy
11595          </button>
11596          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11597            <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
11598            Clean up old runs
11599          </button>
11600          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11601            <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
11602            Export Excel
11603          </button>
11604          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11605            <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
11606            Export PNG
11607          </button>
11608          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11609            <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
11610            Export PDF
11611          </button>
11612        </div>
11613      </div>
11614
11615      <div class="controls-centered">
11616        <label>Project Root:
11617          <select class="chart-select" id="root-sel">
11618            <option value="">All projects</option>
11619          </select>
11620        </label>
11621        <label>Y Metric:
11622          <select class="chart-select" id="y-sel">
11623            <option value="code_lines">Code Lines</option>
11624            <option value="comment_lines">Comment Lines</option>
11625            <option value="blank_lines">Blank Lines</option>
11626            <option value="physical_lines">Physical Lines</option>
11627            <option value="files_analyzed">Files Analyzed</option>
11628          </select>
11629        </label>
11630        <label>X Axis:
11631          <select class="chart-select" id="x-sel">
11632            <option value="time">By Time</option>
11633            <option value="commit" selected>By Commit</option>
11634            <option value="release">By Release</option>
11635            <option value="tag">Tagged Commits</option>
11636          </select>
11637        </label>
11638        <label id="submodule-label" style="display:none;">Submodule:
11639          <select class="chart-select" id="sub-sel">
11640            <option value="">All (project total)</option>
11641          </select>
11642        </label>
11643        <label>Chart Size:
11644          <select class="chart-select" id="scale-sel">
11645            <option value="0.75">Compact</option>
11646            <option value="1.2" selected>Normal</option>
11647            <option value="1.38">Large</option>
11648          </select>
11649        </label>
11650        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
11651      </div>
11652
11653      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
11654      <div id="data-table-wrap" style="overflow-x:auto;"></div>
11655    </div>
11656  </div>
11657
11658  <script nonce="{nonce}">
11659    (function() {{
11660      // Theme persistence
11661      var b = document.body;
11662      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11663      var tgl = document.getElementById('theme-toggle');
11664      if (tgl) tgl.addEventListener('click', function() {{
11665        var d = b.classList.toggle('dark-theme');
11666        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11667      }});
11668
11669      // Watermark randomizer
11670      (function() {{
11671        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11672        if (!wms.length) return;
11673        var placed = [];
11674        function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
11675        function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
11676        var half=Math.floor(wms.length/2);
11677        wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
11678      }})();
11679
11680      // Code particles
11681      (function() {{
11682        var container = document.getElementById('code-particles');
11683        if (!container) return;
11684        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'];
11685        for (var i = 0; i < 38; i++) {{
11686          (function(idx) {{
11687            var el = document.createElement('span');
11688            el.className = 'code-particle';
11689            el.textContent = snippets[idx % snippets.length];
11690            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11691            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11692            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11693            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
11694            container.appendChild(el);
11695          }})(i);
11696        }}
11697      }})();
11698
11699      // Watched folder picker
11700      (function() {{
11701        var btn = document.getElementById('add-watched-btn');
11702        if (!btn) return;
11703        btn.addEventListener('click', function() {{
11704          fetch('/pick-directory?kind=reports')
11705            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11706            .then(function(data) {{
11707              if (!data.cancelled && data.selected_path) {{
11708                var form = document.createElement('form');
11709                form.method = 'POST';
11710                form.action = '/watched-dirs/add';
11711                var ri = document.createElement('input');
11712                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11713                var fi = document.createElement('input');
11714                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11715                form.appendChild(ri); form.appendChild(fi);
11716                document.body.appendChild(form);
11717                form.submit();
11718              }}
11719            }})
11720            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11721        }});
11722      }})();
11723
11724      // Settings / color-scheme modal
11725      (function() {{
11726        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'}}];
11727        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);}});}}
11728        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11729        var btn=document.getElementById('settings-btn');if(!btn)return;
11730        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11731        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>';
11732        document.body.appendChild(m);
11733        var g=document.getElementById('scheme-grid');
11734        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);}});
11735        var cl=document.getElementById('settings-close');
11736        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);
11737        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');}});
11738        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11739        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11740      }})();
11741    }})();
11742
11743    var ROOTS = {roots_json};
11744    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11745    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11746    var allData = [];
11747
11748    // Populate root selector
11749    var rootSel = document.getElementById('root-sel');
11750    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11751
11752    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
11753    function fmtFull(n){{return Number(n).toLocaleString();}}
11754    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
11755
11756    // Tooltip
11757    var tt = document.createElement('div');
11758    tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:100000;max-width:280px;color:var(--text);';
11759    document.body.appendChild(tt);
11760    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11761    function moveTT(e){{var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+'px';tt.style.top=y+'px';}}
11762    function hideTT(){{tt.style.display='none';}}
11763    window.addEventListener('blur',function(){{hideTT();}});
11764    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11765
11766    function statExact(compact, full){{
11767      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11768    }}
11769    function statVal(n){{
11770      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11771    }}
11772
11773    function updateStats(data){{
11774      var statsEl=document.getElementById('trend-stats');
11775      if(!statsEl)return;
11776      if(!data||!data.length){{statsEl.innerHTML='';return;}}
11777      var yKey=document.getElementById('y-sel').value;
11778      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11779      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11780      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11781      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11782      var absDelta=Math.abs(delta);
11783      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11784      var deltaExact=statExact(deltaCompact,deltaFull);
11785      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11786      statsEl.innerHTML=
11787        '<div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">'+data.length+'</div><div class="stat-chip-label">Total Scans</div></div>'+
11788        '<div class="stat-chip"><div class="stat-chip-tip">The most recent recorded value for the selected metric</div><div class="stat-chip-val">'+statVal(lastVal)+'</div><div class="stat-chip-label">Latest '+(Y_LABELS[yKey]||yKey)+'</div></div>'+
11789        '<div class="stat-chip"><div class="stat-chip-tip">Change in the selected metric from the earliest to the latest scan</div><div class="stat-chip-val '+cls+'">'+sign+deltaCompact+deltaExact+'</div><div class="stat-chip-label">Net Change</div></div>'+
11790        '<div class="stat-chip"><div class="stat-chip-tip">Number of distinct project roots tracked across all scans</div><div class="stat-chip-val">'+Object.keys(projs).length+'</div><div class="stat-chip-label">Projects</div></div>';
11791    }}
11792
11793    var subSel = document.getElementById('sub-sel');
11794    var subLabel = document.getElementById('submodule-label');
11795
11796    function populateSubmodules(root){{
11797      if(!subSel||!subLabel)return;
11798      while(subSel.options.length>1)subSel.remove(1);
11799      subSel.value='';
11800      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11801      fetch(url)
11802        .then(function(r){{return r.json();}})
11803        .then(function(subs){{
11804          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11805          subs.forEach(function(s){{
11806            var o=document.createElement('option');
11807            o.value=s.name;
11808            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11809            subSel.appendChild(o);
11810          }});
11811          subLabel.style.display='';
11812        }})
11813        .catch(function(){{subLabel.style.display='none';}});
11814    }}
11815
11816    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11817
11818    function loadAndRender(){{
11819      var root = rootSel.value;
11820      var sub = subSel ? subSel.value : '';
11821      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11822      document.getElementById('data-table-wrap').innerHTML='';
11823      var url = '/api/metrics/history?limit=100'
11824        + (root ? '&root='+encodeURIComponent(root) : '')
11825        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
11826      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11827        allData = data;
11828        render(data);
11829        updateStats(data);
11830      }}).catch(function(){{
11831        document.getElementById('chart-wrap').innerHTML='<div class="empty-state">Failed to load scan history. Make sure the server is running and has recorded at least one scan.</div>';
11832      }});
11833    }}
11834
11835    function render(data){{
11836      var yKey = document.getElementById('y-sel').value;
11837      var xMode = document.getElementById('x-sel').value;
11838
11839      // Filter for tag/release mode
11840      var pts = data;
11841      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11842
11843      // Sort oldest-first for the line chart
11844      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11845
11846      var wrap = document.getElementById('chart-wrap');
11847      if(!pts.length){{
11848        var emptyMsg = (xMode === 'tag')
11849          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11850          : 'No scan data found for the selected filters.';
11851        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11852        renderTable([]);
11853        return;
11854      }}
11855
11856      var scaleEl=document.getElementById('scale-sel');
11857      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
11858      renderTrendInto(wrap, pts, yKey, xMode, sc);
11859      renderTable(pts, yKey);
11860    }}
11861
11862    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
11863    // Shared by the inline chart and the Full View modal so both render identically.
11864    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
11865      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
11866      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
11867      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
11868      var W=Math.max(600,availW),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
11869      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
11870
11871      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11872
11873      var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
11874      svg+='<defs><linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#C45C10" stop-opacity="0.18"/><stop offset="100%" stop-color="#C45C10" stop-opacity="0"/></linearGradient></defs>';
11875
11876      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
11877
11878      // Grid + Y axis ticks
11879      for(var ti=0;ti<=5;ti++){{
11880        var gy=PT+CH-Math.round(ti/5*CH);
11881        var gv=Math.round(ti/5*maxY);
11882        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
11883        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
11884      }}
11885
11886      // X axis labels (every N-th point to avoid crowding)
11887      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
11888      pts.forEach(function(d,i){{
11889        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11890        if(i%labelEvery===0||i===pts.length-1){{
11891          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):(xMode==='release'?(d.nearest_tag||d.tags&&d.tags[0]||d.timestamp.substring(0,10)):(d.tags&&d.tags[0]?d.tags[0]:d.timestamp.substring(0,10)));
11892          svg+='<text x="'+x+'" y="'+(PT+CH+fsS*2)+'" text-anchor="middle" transform="rotate(30,'+x+','+(PT+CH+fsS*2)+')" font-family="'+FONT+'" font-size="'+fsS+'" fill="#7b675b">'+esc(lbl)+'</text>';
11893        }}
11894      }});
11895
11896      // Axis label
11897      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
11898      svg+='<text x="'+(PL+CW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+xAxisLabel+'</text>';
11899      svg+='<text x="'+Math.round(14*sc)+'" y="'+(PT+CH/2)+'" text-anchor="middle" transform="rotate(-90,'+Math.round(14*sc)+','+(PT+CH/2)+')" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+(Y_LABELS[yKey]||yKey)+'</text>';
11900
11901      // Area fill + line path
11902      var pathD='';
11903      pts.forEach(function(d,i){{
11904        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11905        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11906        pathD+=(i===0?'M':'L')+x+','+y;
11907      }});
11908      if(pts.length>1){{
11909        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
11910        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
11911      }}
11912      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
11913
11914      // Data points (clickable) + permanent value labels
11915      var showLabels = pts.length <= 40;
11916      var labelEveryN = pts.length > 20 ? 2 : 1;
11917      pts.forEach(function(d,i){{
11918        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
11919        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
11920        var hasTags=d.tags&&d.tags.length>0;
11921        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
11922        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
11923        svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
11924        if(showLabels && i%labelEveryN===0){{
11925          var lx=x, ly=y-r-5;
11926          svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmtFull(Number(d[yKey]))+'</text>';
11927        }}
11928      }});
11929
11930      svg+='</svg>';
11931      wrap.innerHTML=svg;
11932
11933      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
11934      function lineYAt(mx){{
11935        var n=pts.length;
11936        if(n===0)return PT+CH;
11937        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
11938        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
11939        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
11940        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
11941        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
11942        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
11943        return y0+t*(y1-y0);
11944      }}
11945
11946      // SVG-level mousemove: show the value tooltip only when the pointer is over the
11947      // gradient fill (inside the chart and at/below the line) — never in the empty
11948      // space above the line. Cursor follows the same rule.
11949      (function(){{
11950        var svgEl=wrap.querySelector('svg');
11951        if(!svgEl)return;
11952        svgEl.addEventListener('mousemove',function(e){{
11953          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
11954          var rect=svgEl.getBoundingClientRect();
11955          var scaleX=W/Math.max(rect.width,1);
11956          var scaleY=H/Math.max(rect.height,1);
11957          var mouseX=(e.clientX-rect.left)*scaleX;
11958          var mouseY=(e.clientY-rect.top)*scaleY;
11959          var ly=lineYAt(mouseX);
11960          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
11961          svgEl.style.cursor='pointer';
11962          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
11963          var d=pts[idx];
11964          var val=Number(d[yKey]);
11965          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
11966          showTT(e,
11967            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
11968            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
11969            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
11970          );
11971        }});
11972        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
11973      }})();
11974
11975      // Attach point tooltips
11976      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
11977        c.addEventListener('mouseover',function(e){{
11978          var d=pts[parseInt(this.dataset.idx)];
11979          var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
11980          var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
11981          showTT(e,
11982            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
11983            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
11984            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
11985            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
11986          );
11987          this.setAttribute('r','8');
11988        }});
11989        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
11990        c.addEventListener('mousemove',moveTT);
11991        c.addEventListener('click',function(){{
11992          var d=pts[parseInt(this.dataset.idx)];
11993          if(d.html_url) window.open(d.html_url,'_blank');
11994        }});
11995      }});
11996    }}
11997
11998    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
11999    var shProjFilter='', shBranchFilter='';
12000
12001    function fmtPST(isoStr){{
12002      if(!isoStr)return'';
12003      var d=new Date(isoStr);
12004      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12005      if(window.fmtTz){{var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}return window.fmtTz(d.getTime(),tz);}}
12006      function p(n){{return n<10?'0'+n:String(n);}}
12007      function nthWeekdaySun(year,month,n){{var count=0,day=1;while(true){{var t=new Date(Date.UTC(year,month,day));if(t.getUTCDay()===0&&++count===n)return t;day++;}}}}
12008      var yr=d.getUTCFullYear();
12009      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12010      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12011      var isDST=d>=dstStart&&d<dstEnd;
12012      var off=isDST?-7*3600*1000:-8*3600*1000;
12013      var lbl=isDST?'PDT':'PST';
12014      var loc=new Date(d.getTime()+off);
12015      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12016    }}
12017
12018    function getShRows(){{
12019      var proj=shProjFilter.toLowerCase().trim();
12020      var branch=shBranchFilter;
12021      return shData.filter(function(d){{
12022        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12023        if(branch&&(d.branch||'')!==branch)return false;
12024        return true;
12025      }});
12026    }}
12027
12028    function renderShPage(){{
12029      var filtered=getShRows();
12030      if(shSortCol){{
12031        filtered.sort(function(a,b){{
12032          var va,vb;
12033          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12034          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12035          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12036          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12037          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12038          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12039        }});
12040      }}
12041      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12042      shPage=Math.min(shPage,totalPages);
12043      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12044      var visible=filtered.slice(start,end);
12045      var tbody=document.getElementById('sh-tbody');
12046      if(!tbody)return;
12047      tbody.innerHTML=visible.map(function(d){{
12048        var tsHtml=esc(fmtPST(d.timestamp));
12049        var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">&#8212;</span>';
12050        var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12051        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12052        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12053        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12054        var reportCell='';
12055        if(d.html_url){{
12056          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12057          if(d.has_pdf){{var pdfUrl=d.html_url.replace(/\/html$/,'/pdf');reportCell+='<a class="btn primary rpt-btn" href="'+esc(pdfUrl)+'" target="_blank" rel="noopener">PDF</a>';}}
12058          reportCell+='</div>';
12059        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12060        if(d.submodule_links&&d.submodule_links.length){{
12061          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12062          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12063          reportCell+='</div></details>';
12064        }}
12065        return '<tr>'
12066          +'<td>'+tsHtml+'</td>'
12067          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12068          +'<td>'+runIdHtml+'</td>'
12069          +'<td>'+commitHtml+'</td>'
12070          +'<td>'+branchHtml+'</td>'
12071          +'<td>'+tags+'</td>'
12072          +'<td class="num">'+metricHtml+'</td>'
12073          +'<td class="report-cell">'+reportCell+'</td>'
12074          +'</tr>';
12075      }}).join('');
12076      var pgRange=document.getElementById('sh-pg-range');
12077      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12078      var pgInfo=document.getElementById('sh-pg-info');
12079      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12080      var pgBtns=document.getElementById('sh-pg-btns');
12081      if(pgBtns){{
12082        pgBtns.innerHTML='';
12083        function mkPgBtn(lbl,pg,active,disabled){{
12084          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12085          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12086          return b;
12087        }}
12088        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12089        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12090        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12091        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12092      }}
12093    }}
12094
12095    function wireTableBehavior(){{
12096      var pf=document.getElementById('sh-proj-filter');
12097      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12098      var bf=document.getElementById('sh-branch-filter');
12099      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12100      var rb=document.getElementById('sh-reset-btn');
12101      if(rb)rb.addEventListener('click',function(){{
12102        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12103        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12104        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12105        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12106        renderShPage();
12107      }});
12108      var pps=document.getElementById('sh-per-page');
12109      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12110      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12111      ths.forEach(function(th){{
12112        th.addEventListener('click',function(e){{
12113          if(e.target.classList.contains('col-resize-handle'))return;
12114          var col=th.dataset.col;
12115          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12116          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12117          th.classList.add('sort-'+shSortOrder);
12118          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12119          shPage=1;renderShPage();
12120        }});
12121      }});
12122      var table=document.getElementById('scan-history-table');
12123      if(!table)return;
12124      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12125      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12126      allThs.forEach(function(th,i){{
12127        var handle=th.querySelector('.col-resize-handle');
12128        if(!handle||!cols[i])return;
12129        var startX,startW;
12130        handle.addEventListener('mousedown',function(e){{
12131          e.stopPropagation();e.preventDefault();
12132          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12133          handle.classList.add('dragging');
12134          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12135          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12136          document.addEventListener('mousemove',onMove);
12137          document.addEventListener('mouseup',onUp);
12138        }});
12139      }});
12140    }}
12141
12142    function renderTable(pts, yKey){{
12143      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12144      var wrap=document.getElementById('data-table-wrap');
12145      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12146      var yLabel=Y_LABELS[yKey]||yKey||'';
12147      shData=pts.slice().reverse();
12148      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12149      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12150      var branches={{}};
12151      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12152      var branchOpts='<option value="">All branches</option>';
12153      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12154      wrap.innerHTML=
12155        '<div class="chart-section-header">SCAN HISTORY</div>'+
12156        '<div class="filter-row">'+
12157          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12158          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12159          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12160        '</div>'+
12161        '<div class="table-wrap">'+
12162        '<table id="scan-history-table" class="data-table">'+
12163        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12164        '<thead><tr id="sh-thead">'+
12165        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12166        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12167        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12168        '<th>Commit<div class="col-resize-handle"></div></th>'+
12169        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12170        '<th>Tags<div class="col-resize-handle"></div></th>'+
12171        '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12172        '<th>Report<div class="col-resize-handle"></div></th>'+
12173        '</tr></thead>'+
12174        '<tbody id="sh-tbody"></tbody>'+
12175        '</table>'+
12176        '</div>'+
12177        '<div class="pagination">'+
12178          '<span class="pagination-info" id="sh-pg-info"></span>'+
12179          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12180          '<div style="display:flex;align-items:center;gap:8px;">'+
12181            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12182            '<select class="filter-select" id="sh-per-page">'+
12183              '<option value="10">10 per page</option>'+
12184              '<option value="25" selected>25 per page</option>'+
12185              '<option value="50">50 per page</option>'+
12186              '<option value="100">100 per page</option>'+
12187            '</select>'+
12188            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12189          '</div>'+
12190        '</div>';
12191      wireTableBehavior();
12192      renderShPage();
12193    }}
12194
12195    function exportXLSX(){{
12196      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12197      var xbtn=document.getElementById('export-xlsx-btn');
12198      var xorig=xbtn?xbtn.innerHTML:'';
12199      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12200      var root=rootSel.value;
12201      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12202      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12203        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12204        buildAndDownloadXLSX(cm);
12205      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12206    }}
12207
12208    function buildAndDownloadXLSX(churnMap){{
12209      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12210      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12211      // (sorted is newest-first), so a given project/commit appears at most once.
12212      var seenPC={{}},dedup=[];
12213      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12214      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12215      var s1R=dedup.map(function(d){{
12216        var c=churnMap[d.run_id]||{{}};
12217        return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0];
12218      }});
12219      var pm={{}};
12220      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12221      var s2H=['Project','Scan Count','First Scan','Latest Scan','Latest Code Lines','Latest Comment Lines','Latest Blank Lines','Latest Physical Lines','Latest Files','Min Code Lines','Max Code Lines','Avg Code Lines'];
12222      var s2R=Object.keys(pm).map(function(p){{
12223        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12224        var lat=sc[sc.length-1],fst=sc[0];
12225        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12226        var mn=Math.min.apply(null,codes),mx=Math.max.apply(null,codes),av=Math.round(codes.reduce(function(a,b){{return a+b;}},0)/codes.length);
12227        return[p,sc.length,fst.timestamp.substring(0,16).replace('T',' '),lat.timestamp.substring(0,16).replace('T',' '),+(lat.code_lines)||0,+(lat.comment_lines)||0,+(lat.blank_lines)||0,+(lat.physical_lines)||0,+(lat.files_analyzed)||0,mn,mx,av];
12228      }});
12229      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12230      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12231      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12232      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12233    }}
12234
12235    function buildXLSX(sheets,chartRows,chartRows2){{
12236      function s2b(s){{return new TextEncoder().encode(s);}}
12237      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12238      function col2l(n){{var s='';while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}}return s;}}
12239      function crc32(d){{
12240        if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
12241        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12242      }}
12243      function buildSheet(hdr,rows,drawRid,withCtrl){{
12244        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12245        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12246        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12247        x+='<row r="1">';
12248        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12249        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12250        x+='</row>';
12251        rows.forEach(function(row,ri){{
12252          var rn=ri+2;
12253          x+='<row r="'+rn+'">';
12254          row.forEach(function(cell,ci){{
12255            var addr=col2l(ci+1)+rn;
12256            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12257            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12258          }});
12259          if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12260          x+='</row>';
12261        }});
12262        x+='</sheetData>';
12263        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12264        return x+'</worksheet>';
12265      }}
12266      function buildChartXML(rows){{
12267        var sn="'Scan History'";
12268        var nr=rows.length,er=nr+1;
12269        var sd=[{{name:'Code Lines',col:'F',di:5,clr:'C45C10'}},{{name:'Comment Lines',col:'G',di:6,clr:'4472C4'}},{{name:'Blank Lines',col:'H',di:7,clr:'70AD47'}},{{name:'Physical Lines',col:'I',di:8,clr:'7030A0'}}];
12270        var catCol='C',catIdx=2;
12271        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12272        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12273        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12274        x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Scan History \u2014 all metrics over time</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12275        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12276        sd.forEach(function(s,i){{
12277          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12278          x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12279          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12280          x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12281          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12282          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12283          x+='</c:strCache></c:strRef></c:cat>';
12284          x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12285          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12286          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12287        }});
12288        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12289        x+='<c:catAx><c:axId val="1"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="2"/></c:catAx>';
12290        x+='<c:valAx><c:axId val="2"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="1"/><c:crossBetween val="between"/></c:valAx>';
12291        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12292        return x;
12293      }}
12294      function buildChartXML2(rows){{
12295        var sn="'By Project'";
12296        var nr=rows.length,er=nr+1;
12297        var sd=[{{name:'Latest Code Lines',col:'E',di:4,clr:'C45C10'}},{{name:'Latest Comment Lines',col:'F',di:5,clr:'4472C4'}},{{name:'Latest Blank Lines',col:'G',di:6,clr:'70AD47'}},{{name:'Latest Physical Lines',col:'H',di:7,clr:'7030A0'}}];
12298        var catCol='A',catIdx=0;
12299        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12300        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12301        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12302        x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Latest metrics by project</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12303        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12304        sd.forEach(function(s,i){{
12305          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12306          x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12307          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12308          x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12309          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12310          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12311          x+='</c:strCache></c:strRef></c:cat>';
12312          x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12313          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12314          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12315        }});
12316        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12317        x+='<c:catAx><c:axId val="3"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="4"/></c:catAx>';
12318        x+='<c:valAx><c:axId val="4"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="3"/><c:crossBetween val="between"/></c:valAx>';
12319        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12320        return x;
12321      }}
12322      function buildChartXML3(rows){{
12323        var sn="'Scan History'";
12324        var nr=rows.length,er=nr+1;
12325        var catCol='C',catIdx=2;
12326        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12327        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12328        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12329        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12330        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12331        x+="<c:tx><c:strRef><c:f>'Focus Chart'!$B$1</c:f><c:strCache><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>";
12332        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12333        x+='<c:marker><c:symbol val="circle"/><c:size val="6"/><c:spPr><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr></c:marker>';
12334        x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="t"/></c:dLbls>';
12335        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12336        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12337        x+='</c:strCache></c:strRef></c:cat>';
12338        x+='<c:val><c:numRef><c:f>'+sn+'!$Q$2:$Q$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12339        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12340        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12341        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12342        x+='<c:catAx><c:axId val="5"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="6"/></c:catAx>';
12343        x+='<c:valAx><c:axId val="6"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="5"/><c:crossBetween val="between"/></c:valAx>';
12344        x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Single-Metric Focus</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12345        return x;
12346      }}
12347      function buildFocusSheet(drawRid){{
12348        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12349        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12350        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12351        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12352        x+='<sheetData><row r="1">';
12353        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12354        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12355        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12356        x+='</row></sheetData>';
12357        x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified"</formula1></dataValidation></dataValidations>';
12358        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12359        return x+'</worksheet>';
12360      }}
12361      var hasChart=!!(chartRows&&chartRows.length);
12362      var nr=hasChart?chartRows.length:0;
12363      var hasChart2=!!(chartRows2&&chartRows2.length);
12364      var nr2=hasChart2?chartRows2.length:0;
12365      var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><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="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
12366      var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/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"/>';
12367      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12368      if(hasChart){{ct+='<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/charts/chart3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/drawings/drawing3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12369      if(hasChart2){{ct+='<Override PartName="/xl/charts/chart2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12370      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12371      var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
12372      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12373      sheets.forEach(function(s,i){{wbr+='<Relationship Id="rId'+(i+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}});
12374      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12375      var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>';
12376      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12377      wbx+='</sheets></workbook>';
12378      var files=[
12379        {{name:'[Content_Types].xml',data:s2b(ct)}},
12380        {{name:'_rels/.rels',data:s2b(dotrels)}},
12381        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12382        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12383        {{name:'xl/styles.xml',data:s2b(styl)}}
12384      ];
12385      // Chart embedded directly in Scan History (sheet1); By Project is plain
12386      sheets.forEach(function(s,i){{
12387        var sx;
12388        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12389        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12390        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12391      }});
12392      if(hasChart){{
12393        var fromRow=nr+4,toRow=nr+34;
12394        files.push({{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>')}});
12395        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12396        drx+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12397        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12398        drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12399        drx+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12400        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12401        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12402        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12403        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12404        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12405        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12406        files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/></Relationships>')}});
12407        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12408        files.push({{name:'xl/worksheets/_rels/sheet3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing3.xml"/></Relationships>')}});
12409        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12410        drx3+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12411        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12412        drx3+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12413        drx3+='<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>31</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12414        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12415        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12416        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12417        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12418        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12419        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12420        files.push({{name:'xl/drawings/_rels/drawing3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
12421        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12422      }}
12423      if(hasChart2){{
12424        var fromRow2=nr2+4,toRow2=nr2+36;
12425        files.push({{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing2.xml"/></Relationships>')}});
12426        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12427        drx2+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12428        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12429        drx2+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12430        drx2+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12431        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12432        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12433        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12434        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12435        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12436        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12437        files.push({{name:'xl/drawings/_rels/drawing2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart2.xml"/></Relationships>')}});
12438        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12439      }}
12440      var parts=[],offsets=[],total=0;
12441      files.forEach(function(f){{
12442        offsets.push(total);
12443        var nb=s2b(f.name),crc=crc32(f.data);
12444        var h=new DataView(new ArrayBuffer(30+nb.length));
12445        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12446        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12447        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12448        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12449        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12450        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12451        total+=30+nb.length+f.data.length;
12452      }});
12453      var cdStart=total;
12454      files.forEach(function(f,fi){{
12455        var nb=s2b(f.name),crc=crc32(f.data);
12456        var cd=new DataView(new ArrayBuffer(46+nb.length));
12457        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12458        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12459        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12460        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12461        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12462        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12463        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12464      }});
12465      var cdSz=total-cdStart;
12466      var eocd=new DataView(new ArrayBuffer(22));
12467      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12468      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12469      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12470      parts.push(new Uint8Array(eocd.buffer));
12471      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12472      var out=new Uint8Array(sz);var off=0;
12473      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12474      return out.buffer;
12475    }}
12476
12477    function trendTitleParts(){{
12478      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12479      var subSelEl=document.getElementById('sub-sel');
12480      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12481      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12482      var proj=(document.getElementById('root-sel').value)||'All projects';
12483      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12484      var cnt=(allData&&allData.length)||0;
12485      var now=new Date();
12486      function p2(n){{return(n<10?'0':'')+n;}}
12487      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12488      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12489    }}
12490
12491    function exportPNG(){{
12492      var svgEl=document.querySelector('#chart-wrap svg');
12493      if(!svgEl){{alert('No chart to export yet.');return;}}
12494      var svgStr=new XMLSerializer().serializeToString(svgEl);
12495      var vb=svgEl.viewBox.baseVal,scale=2;
12496      var headerH=84,footerH=36;
12497      var lw=(vb.width||900),lh=(vb.height||380);
12498      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12499      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12500      var url=URL.createObjectURL(blob);
12501      var img=new Image();
12502      var tp=trendTitleParts();
12503      img.onload=function(){{
12504        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12505        var ctx=canvas.getContext('2d');
12506        var cs=getComputedStyle(document.body);
12507        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12508        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12509        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12510        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12511        ctx.scale(scale,scale);
12512        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12513        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12514        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12515        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12516        ctx.strokeStyle=oxide;ctx.globalAlpha=0.55;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,74);ctx.lineTo(lw-24,74);ctx.stroke();ctx.globalAlpha=1;
12517        ctx.drawImage(img,0,headerH);
12518        var fy=headerH+lh;
12519        ctx.strokeStyle=oxide;ctx.globalAlpha=0.4;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(24,fy+9);ctx.lineTo(lw-24,fy+9);ctx.stroke();ctx.globalAlpha=1;
12520        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12521        ctx.fillText('\u00a9 2026 OxideSLOC  \u00b7  oxide-sloc v{version}  \u00b7  AGPL-3.0-or-later  \u00b7  github.com/oxide-sloc/oxide-sloc',lw/2,fy+27);
12522        ctx.textAlign='left';
12523        URL.revokeObjectURL(url);
12524        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12525      }};
12526      img.src=url;
12527    }}
12528
12529    function exportPDF(){{
12530      var svgEl=document.querySelector('#chart-wrap svg');
12531      if(!svgEl){{alert('No chart to export yet.');return;}}
12532      var tp=trendTitleParts();
12533      var svgStr=new XMLSerializer().serializeToString(svgEl);
12534      var statsEl=document.getElementById('trend-stats');
12535      var statsHtml=statsEl?statsEl.innerHTML:'';
12536      var yK=document.getElementById('y-sel').value;
12537      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12538      var yL=yLabels[yK]||yK;
12539      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12540      var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
12541      rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
12542      tableHtml+='</tbody></table>';
12543      var css='<style>'
12544        +'*{{box-sizing:border-box;}}'
12545        +'html,body{{margin:0;padding:0;}}'
12546        // Masthead/footer flow in document order — a position:fixed header repeats
12547        // on every printed page in Chromium and hides the rows beneath it on pages
12548        // 2+. The trend table's <thead> repeats per page natively instead.
12549        +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
12550        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12551        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12552        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12553        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12554        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12555        +'.rep-body{{padding:22px 34px 0;}}'
12556        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12557        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12558        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12559        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12560        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12561        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12562        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12563        +'.stat-chip-tip{{display:none!important;}}'
12564        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12565        +'.stat-chip-label{{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#7b675b;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12566        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12567        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12568        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12569        +'.rep-chart svg{{max-width:100%;height:auto;}}'
12570        +'.chart-section-header{{background:#191c26;color:#fff;padding:7px 13px;border-radius:4px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin:18px 0 10px;}}'
12571        +'.filter-row{{display:none!important;}}'
12572        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12573        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12574        +'th{{background:#f0e9e0;font-weight:800;}}'
12575        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12576        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12577        +'.rep-foot{{margin-top:22px;background:#191c26;color:rgba(255,255,255,0.72);padding:9px 34px;font-size:11px;font-weight:600;text-align:center;line-height:1.5;}}'
12578        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12579        +'</style>';
12580      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12581        +'<div class="rep-masthead"><div class="rep-mast-left"><span class="rep-mast-brand">oxide-sloc</span><span class="rep-mast-sub">Code Metrics Report \u00b7 Trend</span></div><div class="rep-mast-ts">Generated '+tp.date+'</div></div>'
12582        +'<div class="rep-body">'
12583        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12584        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12585        +'<div class="summary-strip">'+statsHtml+'</div>'
12586        +'<div class="rep-chart">'+svgStr+'</div>'
12587        +tableHtml
12588        +'</div>'
12589        +'<div class="rep-foot"><div>\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 local code metrics workbench \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc</div><div class="rep-foot-gen">Generated '+tp.date+'</div></div>'
12590        +'</body></html>';
12591      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12592    }}
12593
12594    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12595      var el=document.getElementById(id);
12596      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12597    }});
12598    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12599    // tracks the container like the responsive Chart.js charts do.
12600    var _rsT=null;
12601    window.addEventListener('resize',function(){{
12602      if(_rsT)clearTimeout(_rsT);
12603      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12604    }});
12605    rootSel.addEventListener('change',function(){{
12606      populateSubmodules(rootSel.value);
12607      loadAndRender();
12608    }});
12609    if(subSel)subSel.addEventListener('change',loadAndRender);
12610
12611    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12612    (function(){{
12613      var fvBtn=document.getElementById('tr-chart-fv-btn');
12614      if(!fvBtn)return;
12615      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12616      fvBtn.addEventListener('click',function(){{
12617        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12618        var yKey=document.getElementById('y-sel').value;
12619        var xMode=document.getElementById('x-sel').value;
12620        var pts=allData;
12621        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12622        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12623        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12624        var tp=trendTitleParts();
12625        var ov=document.createElement('div');
12626        ov.className='tr-chart-full-modal';
12627        ov.innerHTML='<div class="tr-chart-full-inner">'
12628          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12629          +'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'
12630          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12631          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12632          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12633        document.body.appendChild(ov);
12634        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12635        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12636        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12637        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12638        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12639      }});
12640    }})();
12641
12642    var xlsxBtn=document.getElementById('export-xlsx-btn');
12643    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12644    var pngBtn=document.getElementById('export-png-btn');
12645    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12646    var pdfBtn=document.getElementById('export-pdf-btn');
12647    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12648
12649    // ── Clean-up modal ───────────────────────────────────────────────────────
12650    (function(){{
12651      var triggerBtn=document.getElementById('cleanup-runs-btn');
12652      if(!triggerBtn)return;
12653      var modal=document.createElement('div');
12654      modal.className='tr-modal-backdrop';
12655      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12656        +'<div class="tr-modal-head">'
12657        +'<div class="tr-modal-icon danger"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg></div>'
12658        +'<div><h2 class="tr-modal-title">Clean up old runs</h2><p class="tr-modal-sub">One-shot deletion of older scan artifacts</p></div>'
12659        +'</div>'
12660        +'<div class="tr-modal-body">'
12661        +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
12662        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12663        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12664        +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
12665        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12666        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12667        +'</div>'
12668        +'<div class="tr-modal-foot">'
12669        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12670        +'<button class="tr-btn tr-btn-danger" id="cleanup-confirm-btn" type="button"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete old runs</button>'
12671        +'</div></div>';
12672      document.body.appendChild(modal);
12673      triggerBtn.addEventListener('click',function(){{
12674        document.getElementById('cleanup-status').style.display='none';
12675        modal.style.display='flex';
12676      }});
12677      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12678      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12679      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12680        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12681        var confirmBtn=this;
12682        confirmBtn.disabled=true;
12683        var status=document.getElementById('cleanup-status');
12684        status.style.display='block';
12685        status.style.background='#dbeafe';status.style.color='#1e40af';
12686        status.textContent='Deleting\u2026';
12687        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12688        .then(function(resp){{
12689          return resp.json().then(function(d){{
12690            if(resp.ok){{
12691              status.style.background='#dcfce7';status.style.color='#166534';
12692              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12693              setTimeout(function(){{window.location.reload();}},1500);
12694            }}else{{
12695              status.style.background='#fee2e2';status.style.color='#991b1b';
12696              status.textContent='Error: '+(d.error||'Unexpected error');
12697              confirmBtn.disabled=false;
12698            }}
12699          }});
12700        }})
12701        .catch(function(e){{
12702          status.style.background='#fee2e2';status.style.color='#991b1b';
12703          status.textContent='Network error: '+String(e);
12704          confirmBtn.disabled=false;
12705        }});
12706      }});
12707    }})();
12708
12709    // ── Retention policy panel ────────────────────────────────────────────────
12710    (function(){{
12711      var triggerBtn=document.getElementById('retention-policy-btn');
12712      if(!triggerBtn)return;
12713      var modal=document.createElement('div');
12714      modal.className='tr-modal-backdrop';
12715      modal.style.zIndex='9001';
12716      modal.innerHTML=''
12717        +'<div class="tr-modal" style="max-width:640px;">'
12718        +'<div class="tr-modal-head">'
12719        +'<div class="tr-modal-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg></div>'
12720        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12721        +'</div>'
12722        +'<div class="tr-modal-body">'
12723        +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
12724        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12725        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12726        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12727        +'</div>'
12728        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12729        +'<div>'
12730        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
12731        +'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
12732        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12733        +'</div>'
12734        +'<div>'
12735        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
12736        +'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
12737        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12738        +'</div>'
12739        +'</div>'
12740        +'<div style="margin-bottom:20px;">'
12741        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
12742        +'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
12743        +'<option value="1">Every hour</option>'
12744        +'<option value="6">Every 6 hours</option>'
12745        +'<option value="12">Every 12 hours</option>'
12746        +'<option value="24" selected>Every 24 hours</option>'
12747        +'<option value="48">Every 2 days</option>'
12748        +'<option value="72">Every 3 days</option>'
12749        +'<option value="168">Every week</option>'
12750        +'</select>'
12751        +'</div>'
12752        +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
12753        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12754        +'</div>'
12755        +'<div class="tr-modal-foot">'
12756        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12757        +'<button class="tr-btn tr-btn-secondary" id="rp-run-now-btn" type="button"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run Now</button>'
12758        +'<button class="tr-btn tr-btn-primary" id="rp-save-btn" type="button"><svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save Policy</button>'
12759        +'</div>'
12760        +'</div>';
12761      document.body.appendChild(modal);
12762
12763      function rpShowStatus(msg,ok){{
12764        var s=document.getElementById('rp-status');
12765        s.style.display='block';
12766        s.style.background=ok?'#dcfce7':'#fee2e2';
12767        s.style.color=ok?'#166534':'#991b1b';
12768        s.textContent=msg;
12769      }}
12770      function fmtAgo(iso){{
12771        if(!iso)return'Never';
12772        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12773        if(diff<60)return diff+'s ago';
12774        if(diff<3600)return Math.floor(diff/60)+'m ago';
12775        if(diff<86400)return Math.floor(diff/3600)+'h ago';
12776        return Math.floor(diff/86400)+'d ago';
12777      }}
12778      function loadPolicy(){{
12779        fetch('/api/cleanup-policy')
12780          .then(function(r){{return r.json();}})
12781          .then(function(d){{
12782            var p=d.policy;
12783            document.getElementById('rp-enabled').checked=p?p.enabled:false;
12784            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12785            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12786            var sel=document.getElementById('rp-interval');
12787            if(p){{var iv=String(p.interval_hours||24);for(var i=0;i<sel.options.length;i++){{if(sel.options[i].value===iv){{sel.selectedIndex=i;break;}}}}}}
12788            var lr=document.getElementById('rp-last-run');
12789            if(d.last_run_at){{
12790              lr.textContent='Last run: '+fmtAgo(d.last_run_at)+(d.last_run_deleted!=null?' \u00b7 deleted '+d.last_run_deleted+' run'+(d.last_run_deleted===1?'':'s'):'');
12791            }}else{{
12792              lr.textContent='Auto-cleanup has not run yet.';
12793            }}
12794          }})
12795          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12796      }}
12797
12798      triggerBtn.addEventListener('click',function(){{
12799        document.getElementById('rp-status').style.display='none';
12800        loadPolicy();
12801        modal.style.display='flex';
12802      }});
12803      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12804      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12805
12806      document.getElementById('rp-save-btn').addEventListener('click',function(){{
12807        var enabled=document.getElementById('rp-enabled').checked;
12808        var ageVal=document.getElementById('rp-max-age').value.trim();
12809        var countVal=document.getElementById('rp-max-count').value.trim();
12810        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12811        if(enabled&&!ageVal&&!countVal){{
12812          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12813          return;
12814        }}
12815        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12816        var saveBtn=document.getElementById('rp-save-btn');
12817        saveBtn.disabled=true;
12818        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12819          .then(function(r){{
12820            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12821            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12822          }})
12823          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12824          .finally(function(){{saveBtn.disabled=false;}});
12825      }});
12826
12827      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12828        var btn=this;
12829        var orig=btn.innerHTML;
12830        btn.disabled=true;
12831        btn.textContent='Running\u2026';
12832        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12833          .then(function(r){{return r.json();}})
12834          .then(function(d){{
12835            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12836            loadPolicy();
12837          }})
12838          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12839          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12840      }});
12841    }})();
12842
12843    populateSubmodules(rootSel.value);
12844    loadAndRender();
12845
12846    (function randomizeWatermarks() {{
12847      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12848      if (!wms.length) return;
12849      var placed = [];
12850      function tooClose(top, left) {{
12851        for (var i = 0; i < placed.length; i++) {{
12852          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12853          if (dt < 16 && dl < 12) return true;
12854        }}
12855        return false;
12856      }}
12857      function pick(leftBand) {{
12858        for (var attempt = 0; attempt < 50; attempt++) {{
12859          var top = Math.random() * 88 + 2;
12860          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12861          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
12862        }}
12863        var top = Math.random() * 88 + 2;
12864        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12865        placed.push([top, left]); return [top, left];
12866      }}
12867      var half = Math.floor(wms.length / 2);
12868      wms.forEach(function (img, i) {{
12869        var pos = pick(i < half);
12870        var size = Math.floor(Math.random() * 100 + 120);
12871        var rot = (Math.random() * 360).toFixed(1);
12872        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12873        img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
12874      }});
12875    }})();
12876    (function spawnCodeParticles() {{
12877      var container = document.getElementById('code-particles');
12878      if (!container) return;
12879      var snippets = [
12880        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12881        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12882        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12883        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12884        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
12885      ];
12886      var count = 38;
12887      for (var i = 0; i < count; i++) {{
12888        (function(idx) {{
12889          var el = document.createElement('span');
12890          el.className = 'code-particle';
12891          el.textContent = snippets[idx % snippets.length];
12892          var left = Math.random() * 94 + 2;
12893          var top = Math.random() * 88 + 6;
12894          var dur = (Math.random() * 10 + 9).toFixed(1);
12895          var delay = (Math.random() * 18).toFixed(1);
12896          var rot = (Math.random() * 26 - 13).toFixed(1);
12897          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12898          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
12899          container.appendChild(el);
12900        }})(i);
12901      }}
12902    }})();
12903  </script>
12904  <footer class="site-footer">
12905    local code analysis - metrics, history and reports
12906    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
12907    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12908    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12909    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12910    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12911  </footer>
12912  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
12913  {toast_assets}
12914</body>
12915</html>"##,
12916    );
12917
12918    Html(html).into_response()
12919}
12920
12921fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12922    use std::collections::HashMap;
12923    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
12924        return vec![];
12925    }
12926    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
12927    for rec in per_file_records {
12928        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
12929            let e = totals.entry(lang.display_name().to_string()).or_default();
12930            e.0 += u64::from(cov.lines_found);
12931            e.1 += u64::from(cov.lines_hit);
12932        }
12933    }
12934    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
12935    let mut pairs: Vec<(String, f64)> = totals
12936        .into_iter()
12937        .filter(|(_, (found, _))| *found > 0)
12938        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
12939        .collect();
12940    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
12941    pairs
12942        .iter()
12943        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
12944        .collect()
12945}
12946
12947fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
12948    let mut high = 0u64;
12949    let mut mid = 0u64;
12950    let mut low = 0u64;
12951    for rec in per_file_records {
12952        if let Some(cov) = &rec.coverage {
12953            if cov.lines_found == 0 {
12954                continue;
12955            }
12956            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
12957            if pct >= 80.0 {
12958                high += 1;
12959            } else if pct >= 50.0 {
12960                mid += 1;
12961            } else {
12962                low += 1;
12963            }
12964        }
12965    }
12966    (high, mid, low)
12967}
12968
12969fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
12970    let mut arr: Vec<serde_json::Value> = per_file_records
12971        .iter()
12972        .filter_map(|rec| {
12973            rec.coverage.as_ref().map(|cov| {
12974                let line_pct = if cov.lines_found > 0 {
12975                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
12976                        / 10.0
12977                } else {
12978                    0.0
12979                };
12980                let fn_pct = if cov.functions_found > 0 {
12981                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
12982                        .round()
12983                        / 10.0
12984                } else {
12985                    -1.0
12986                };
12987                serde_json::json!({
12988                    "rel": rec.relative_path,
12989                    "lang": rec.language.map_or("?", |l| l.display_name()),
12990                    "line_pct": line_pct,
12991                    "fn_pct": fn_pct,
12992                    "lhit": cov.lines_hit,
12993                    "lfound": cov.lines_found,
12994                    "fhit": cov.functions_hit,
12995                    "ffound": cov.functions_found,
12996                })
12997            })
12998        })
12999        .collect();
13000    arr.sort_by(|a, b| {
13001        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13002        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13003        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13004    });
13005    arr
13006}
13007
13008#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13009fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13010    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13011        .totals_by_language
13012        .iter()
13013        .filter(|l| l.test_count > 0)
13014        .collect();
13015    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13016    let lang_tests: Vec<serde_json::Value> = langs
13017        .iter()
13018        .map(|l| {
13019            let d = if l.code_lines > 0 {
13020                l.test_count as f64 / l.code_lines as f64 * 1000.0
13021            } else {
13022                0.0
13023            };
13024            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13025                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13026                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13027        })
13028        .collect();
13029    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13030    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13031    let t = &run.summary_totals;
13032    let total_tests = t.test_count;
13033    let density = if t.code_lines > 0 {
13034        total_tests as f64 / t.code_lines as f64 * 1000.0
13035    } else {
13036        0.0
13037    };
13038    let most_tested = langs.first().map_or_else(
13039        || "\u{2014}".to_string(),
13040        |l| l.language.display_name().to_string(),
13041    );
13042    let test_files: u64 = run
13043        .per_file_records
13044        .iter()
13045        .filter(|f| f.raw_line_categories.test_count > 0)
13046        .count() as u64;
13047    let cov_line = if t.coverage_lines_found > 0 {
13048        format!(
13049            "{:.1}",
13050            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13051        )
13052    } else {
13053        "0".to_string()
13054    };
13055    let cov_fn = if t.coverage_functions_found > 0 {
13056        format!(
13057            "{:.1}",
13058            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13059        )
13060    } else {
13061        "0".to_string()
13062    };
13063    let cov_branch = if t.coverage_branches_found > 0 {
13064        format!(
13065            "{:.1}",
13066            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13067        )
13068    } else {
13069        "0".to_string()
13070    };
13071    let has_cov = !cov_arr.is_empty();
13072    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13073    serde_json::json!({
13074        "totals": {
13075            "test_count": total_tests,
13076            "assertions": t.test_assertion_count,
13077            "suites": t.test_suite_count,
13078            "test_files": test_files,
13079            "total_files": t.files_analyzed,
13080            "density_str": format!("{density:.1}"),
13081            "most_tested": most_tested,
13082            "langs_with_tests": langs.len(),
13083            "cov_line": cov_line,
13084            "cov_fn": cov_fn,
13085            "cov_branch": cov_branch,
13086        },
13087        "lang_tests": lang_tests,
13088        "cov": cov_arr,
13089        "cov_tiers": {"high": high, "mid": mid, "low": low},
13090        "file_cov": file_cov_arr,
13091        "has_coverage": has_cov,
13092        "submodules": {},
13093    })
13094}
13095
13096#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13097fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13098    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13099        .language_summaries
13100        .iter()
13101        .filter(|l| l.test_count > 0)
13102        .collect();
13103    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13104    let lang_tests: Vec<serde_json::Value> = langs
13105        .iter()
13106        .map(|l| {
13107            let d = if l.code_lines > 0 {
13108                l.test_count as f64 / l.code_lines as f64 * 1000.0
13109            } else {
13110                0.0
13111            };
13112            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13113                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13114                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13115        })
13116        .collect();
13117    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13118    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13119    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13120    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13121    let density = if sub.code_lines > 0 {
13122        total_tests as f64 / sub.code_lines as f64 * 1000.0
13123    } else {
13124        0.0
13125    };
13126    let most_tested = langs.first().map_or_else(
13127        || "\u{2014}".to_string(),
13128        |l| l.language.display_name().to_string(),
13129    );
13130    serde_json::json!({
13131        "totals": {
13132            "test_count": total_tests,
13133            "assertions": total_assertions,
13134            "suites": total_suites,
13135            "test_files": test_files_approx,
13136            "total_files": sub.files_analyzed,
13137            "density_str": format!("{density:.1}"),
13138            "most_tested": most_tested,
13139            "langs_with_tests": langs.len(),
13140            "cov_line": "0",
13141            "cov_fn": "0",
13142            "cov_branch": "0",
13143        },
13144        "lang_tests": lang_tests,
13145        "cov": [],
13146        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13147        "has_coverage": false,
13148    })
13149}
13150
13151fn compute_cov_json_str(run: &AnalysisRun) -> String {
13152    use std::collections::HashMap;
13153    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13154    for rec in &run.per_file_records {
13155        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13156            let e = totals.entry(lang.display_name().to_string()).or_default();
13157            e.0 += u64::from(cov.lines_found);
13158            e.1 += u64::from(cov.lines_hit);
13159        }
13160    }
13161    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13162    let mut pairs: Vec<(String, f64)> = totals
13163        .into_iter()
13164        .filter(|(_, (found, _))| *found > 0)
13165        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13166        .collect();
13167    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13168    let parts: Vec<String> = pairs
13169        .iter()
13170        .map(|(lang, pct)| {
13171            let name = lang.replace('"', "\\\"");
13172            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13173        })
13174        .collect();
13175    format!("[{}]", parts.join(","))
13176}
13177
13178fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13179    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13180    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13181}
13182
13183fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13184    let mut entry = build_test_scope_entry(run);
13185    if !run.submodule_summaries.is_empty() {
13186        let subs: serde_json::Map<String, serde_json::Value> = run
13187            .submodule_summaries
13188            .iter()
13189            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13190            .collect();
13191        entry["submodules"] = serde_json::Value::Object(subs);
13192    }
13193    entry
13194}
13195
13196fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13197    let name = l.language.display_name().replace('"', "\\\"");
13198    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13199    let density = if l.code_lines > 0 {
13200        l.test_count as f64 / l.code_lines as f64 * 1000.0
13201    } else {
13202        0.0
13203    };
13204    format!(
13205        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13206        name = name,
13207        t = l.test_count,
13208        a = l.test_assertion_count,
13209        s = l.test_suite_count,
13210        c = l.code_lines,
13211        d = density,
13212        f = l.files,
13213    )
13214}
13215
13216fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13217    let Some(r) = run else {
13218        return "[]".to_string();
13219    };
13220    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13221        .totals_by_language
13222        .iter()
13223        .filter(|l| l.test_count > 0)
13224        .collect();
13225    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13226    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13227    format!("[{}]", parts.join(","))
13228}
13229
13230/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13231async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13232    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13233    scope_map.insert(
13234        "__all__".to_string(),
13235        latest_run.map_or_else(
13236            || {
13237                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13238                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13239                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13240                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13241                    "has_coverage":false,"submodules":{}})
13242            },
13243            build_test_scope_entry,
13244        ),
13245    );
13246    let all_roots: Vec<String> = {
13247        let reg = state.registry.lock().await;
13248        let mut seen = std::collections::BTreeSet::new();
13249        reg.entries
13250            .iter()
13251            .flat_map(|e| e.input_roots.iter().cloned())
13252            .filter(|r| seen.insert(r.clone()))
13253            .collect()
13254    };
13255    for root in &all_roots {
13256        let json_path = {
13257            let reg = state.registry.lock().await;
13258            reg.entries
13259                .iter()
13260                .find(|e| e.input_roots.iter().any(|r| r == root))
13261                .and_then(|e| e.json_path.clone())
13262        };
13263        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13264            let json_str = tokio::fs::read_to_string(&p).await.ok();
13265            json_str
13266                .as_deref()
13267                .and_then(|s| serde_json::from_str(s).ok())
13268        } else {
13269            None
13270        };
13271        if let Some(ref run) = run_for_root {
13272            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13273        }
13274    }
13275    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13276}
13277
13278// GET /test-metrics
13279#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13280#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13281async fn test_metrics_handler(
13282    State(state): State<AppState>,
13283    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13284) -> Response {
13285    auto_scan_watched_dirs(&state).await;
13286    let watched_dirs_list: Vec<String> = {
13287        let wd = state.watched_dirs.lock().await;
13288        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13289    };
13290    let latest_run: Option<AnalysisRun> = {
13291        let json_path = {
13292            let reg = state.registry.lock().await;
13293            reg.entries.first().and_then(|e| e.json_path.clone())
13294        };
13295        if let Some(p) = json_path {
13296            let json_str = tokio::fs::read_to_string(&p).await.ok();
13297            json_str
13298                .as_deref()
13299                .and_then(|s| serde_json::from_str(s).ok())
13300        } else {
13301            None
13302        }
13303    };
13304
13305    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13306    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13307
13308    // Build coverage chart JSON (per-language avg line coverage %).
13309    let cov_json: String = latest_run
13310        .as_ref()
13311        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13312        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13313
13314    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13315    let _cov_tier_json: String = latest_run
13316        .as_ref()
13317        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13318        .map_or_else(
13319            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13320            compute_cov_tier_json_str,
13321        );
13322
13323    let total_tests: u64 = latest_run
13324        .as_ref()
13325        .map_or(0, |r| r.summary_totals.test_count);
13326    let total_assertions: u64 = latest_run
13327        .as_ref()
13328        .map_or(0, |r| r.summary_totals.test_assertion_count);
13329    let total_suites: u64 = latest_run
13330        .as_ref()
13331        .map_or(0, |r| r.summary_totals.test_suite_count);
13332    let total_code: u64 = latest_run
13333        .as_ref()
13334        .map_or(0, |r| r.summary_totals.code_lines);
13335    let workspace_density: f64 = if total_code > 0 {
13336        total_tests as f64 / total_code as f64 * 1000.0
13337    } else {
13338        0.0
13339    };
13340    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13341        r.totals_by_language
13342            .iter()
13343            .filter(|l| l.test_count > 0)
13344            .count()
13345    });
13346    let most_tested: String = latest_run
13347        .as_ref()
13348        .and_then(|r| {
13349            r.totals_by_language
13350                .iter()
13351                .filter(|l| l.test_count > 0)
13352                .max_by_key(|l| l.test_count)
13353        })
13354        .map_or_else(
13355            || "\u{2014}".to_string(),
13356            |l| l.language.display_name().to_string(),
13357        );
13358    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13359        r.per_file_records
13360            .iter()
13361            .filter(|f| f.raw_line_categories.test_count > 0)
13362            .count() as u64
13363    });
13364    let total_files_analyzed: u64 = latest_run
13365        .as_ref()
13366        .map_or(0, |r| r.summary_totals.files_analyzed);
13367    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13368
13369    // Aggregated coverage percentages from summary_totals
13370    let cov_line_pct_str: String = latest_run
13371        .as_ref()
13372        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13373        .map_or_else(
13374            || "0".to_string(),
13375            |r| {
13376                format!(
13377                    "{:.1}",
13378                    r.summary_totals.coverage_lines_hit as f64
13379                        / r.summary_totals.coverage_lines_found as f64
13380                        * 100.0
13381                )
13382            },
13383        );
13384    let cov_fn_pct_str: String = latest_run
13385        .as_ref()
13386        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13387        .map_or_else(
13388            || "0".to_string(),
13389            |r| {
13390                format!(
13391                    "{:.1}",
13392                    r.summary_totals.coverage_functions_hit as f64
13393                        / r.summary_totals.coverage_functions_found as f64
13394                        * 100.0
13395                )
13396            },
13397        );
13398    let cov_branch_pct_str: String = latest_run
13399        .as_ref()
13400        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13401        .map_or_else(
13402            || "0".to_string(),
13403            |r| {
13404                format!(
13405                    "{:.1}",
13406                    r.summary_totals.coverage_branches_hit as f64
13407                        / r.summary_totals.coverage_branches_found as f64
13408                        * 100.0
13409                )
13410            },
13411        );
13412
13413    let cov_no_data_notice = if has_coverage {
13414        String::new()
13415    } else {
13416        String::from(
13417            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13418<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
13419<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13420  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13421  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
13422  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13423  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
13424  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13425  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
13426  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13427  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
13428  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13429  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
13430</div>
13431<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13432</div>"#,
13433        )
13434    };
13435
13436    let workspace_density_str = format!("{workspace_density:.1}");
13437    let nonce = &csp_nonce;
13438    let toast_assets = sloc_toast_assets(nonce);
13439    let version = env!("CARGO_PKG_VERSION");
13440
13441    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13442    // of interactive controls — folder watching is managed by the host administrator.
13443    let watched_dirs_html: String = if state.server_mode {
13444        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode \u2014 watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13445    } else {
13446        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13447            r#"<span class="watched-none">No folders watched \u2014 click Choose to add one</span>"#
13448                .to_string()
13449        } else {
13450            watched_dirs_list
13451                .iter()
13452                .fold(String::new(), |mut s, d| {
13453                    use std::fmt::Write as _;
13454                    let escaped =
13455                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13456                    write!(
13457                        s,
13458                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
13459                    ).expect("write to String is infallible");
13460                    s
13461                })
13462        };
13463        format!(
13464            r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
13465        )
13466    };
13467
13468    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13469    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13470
13471    let html = format!(
13472        r#"<!doctype html>
13473<html lang="en">
13474<head>
13475  <meta charset="utf-8" />
13476  <meta name="viewport" content="width=device-width, initial-scale=1" />
13477  <title>OxideSLOC | Test Metrics</title>
13478  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13479  <style nonce="{nonce}">
13480    :root {{
13481      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13482      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13483      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13484      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13485      --info-bg:#eef3ff; --info-text:#4467d8;
13486    }}
13487    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13488    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
13489    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13490    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13491    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.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;}}
13492    @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));}}}}
13493    .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);}}
13494    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13495    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .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));}}
13496    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13497    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
13498    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13499    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13500    @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
13501    .nav-pill,.theme-toggle{{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;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
13502    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13503    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13504    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13505    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13506    .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
13507    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
13508    .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
13509    .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;}}
13510    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13511    .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);}}
13512    .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;}}
13513    .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13514    .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13515    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13516    .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;}}
13517    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13518    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13519    .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;}}
13520    .tz-select:focus{{border-color:var(--oxide);}}
13521    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13522    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13523    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13524    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13525    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13526    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13527    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13528    .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);}}
13529    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13530    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13531    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13532    .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;}}
13533    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;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;}}
13534    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13535    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13536    .section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
13537    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13538    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13539    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13540    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13541    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13542    .chart-canvas-wrap{{position:relative;height:280px;}}
13543    .chart-no-data{{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;border:1px dashed var(--line-strong);border-radius:10px;color:var(--muted);font-size:13px;gap:10px;}}
13544    .chart-no-data svg{{opacity:0.35;}}
13545    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13546    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13547    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13548    .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;}}
13549    .data-table td{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
13550    .data-table tr:last-child td{{border-bottom:none;}}
13551    .data-table tbody tr:hover td{{background:var(--surface-2);}}
13552    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13553    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13554    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13555    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13556    .cov-gauge-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);min-width:0;}}
13557    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13558    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13559    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13560    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13561    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13562    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13563    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13564    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13565    .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;}}
13566    .chart-select:focus{{border-color:var(--accent);}}
13567    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13568    .trend-canvas-wrap{{position:relative;height:260px;}}
13569    .trend-controls-bar{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
13570    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13571    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13572    .site-footer a{{color:var(--muted);}}
13573    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13574    .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .13s;}}
13575    .btn:hover{{background:var(--surface-2);}}
13576    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
13577    .export-btn:hover{{background:var(--line);}}
13578    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13579    .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
13580    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13581    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13582    .scope-sel{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;max-width:500px;}}
13583    .scope-sel:focus{{border-color:var(--accent);}}
13584    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13585    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
13586    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13587    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13588    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13589    .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
13590    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13591    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13592    .watched-chip-rm:hover{{color:var(--oxide);}}
13593    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13594    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13595    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13596    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13597    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13598    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13599    .cov-tab{{padding:4px 12px;border-radius:20px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}}
13600    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13601    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13602    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13603    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13604    .cov-file-search{{flex:1;min-width:160px;max-width:340px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;outline:none;}}
13605    .cov-file-search:focus{{border-color:var(--accent);}}
13606    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13607    .cov-file-path{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--text);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13608    body.dark-theme .cov-file-search{{background:var(--surface);}}
13609    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13610    .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;flex-shrink:0;white-space:nowrap;}}
13611    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13612    .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;}}
13613    .chart-modal{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1200px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
13614    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13615    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13616    .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;}}
13617    .chart-modal-close:hover{{opacity:.7;}}
13618    body.dark-theme .chart-modal{{background:var(--surface);}}
13619  </style>
13620</head>
13621<body>
13622  <div class="background-watermarks" aria-hidden="true">
13623    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13624    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13625    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13626    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13627    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13628    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13629  </div>
13630  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13631  <div class="top-nav">
13632    <div class="top-nav-inner">
13633      <a class="brand" href="/">
13634        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13635        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13636      </a>
13637      <div class="nav-right">
13638        <a class="nav-pill" href="/">Home</a>
13639        <div class="nav-dropdown">
13640          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
13641          <div class="nav-dropdown-menu">
13642            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
13643          </div>
13644        </div>
13645        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13646        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13647        <div class="nav-dropdown">
13648          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
13649          <div class="nav-dropdown-menu">
13650            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
13651          </div>
13652        </div>
13653        <div class="server-status-wrap" id="server-status-wrap">
13654          <div class="nav-pill server-online-pill" id="server-status-pill">
13655            <span class="status-dot" id="status-dot"></span>
13656            <span id="server-status-label">Server</span>
13657            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13658          </div>
13659          <div class="server-status-tip">
13660            OxideSLOC is running — accessible on your network.
13661            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13662          </div>
13663        </div>
13664        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13665          <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>
13666        </button>
13667        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13668          <svg class="icon-moon" viewBox="0 0 24 24"><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>
13669          <svg class="icon-sun" viewBox="0 0 24 24"><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>
13670        </button>
13671      </div>
13672    </div>
13673  </div>
13674
13675  <div class="page">
13676    {watched_dirs_html}
13677    <div class="scope-bar">
13678      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
13679      <span class="scope-label">Scope</span>
13680      <div class="scope-sel-wrap">
13681        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13682        <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
13683          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
13684          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13685        </div>
13686      </div>
13687    </div>
13688    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13689      <div class="stat-chip"><div class="stat-chip-val" id="chip-total">{total_tests}</div><div class="stat-chip-label">Test Functions</div><div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div><div class="stat-chip-exact" id="chip-total-exact"></div></div>
13690      <div class="stat-chip"><div class="stat-chip-val" id="chip-assertions">{total_assertions}</div><div class="stat-chip-label">Assertions</div><div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div><div class="stat-chip-exact" id="chip-assertions-exact"></div></div>
13691      <div class="stat-chip"><div class="stat-chip-val" id="chip-suites">{total_suites}</div><div class="stat-chip-label">Test Suites</div><div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div></div>
13692      <div class="stat-chip"><div class="stat-chip-val" id="chip-test-files">{test_files_count} / {total_files_analyzed}</div><div class="stat-chip-label">Test Files</div><div class="stat-chip-tip">Files containing at least one test definition out of total analyzed files</div><div class="stat-chip-exact" id="chip-test-files-exact"></div></div>
13693    </div>
13694    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13695      <div class="stat-chip"><div class="stat-chip-val" id="chip-density">{workspace_density_str}</div><div class="stat-chip-label">Tests per 1K SLOC</div><div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div></div>
13696      <div class="stat-chip"><div class="stat-chip-val" id="chip-most">{most_tested}</div><div class="stat-chip-label">Most Tested Language</div><div class="stat-chip-tip">Language with the highest absolute test function count</div></div>
13697      <div class="stat-chip"><div class="stat-chip-val" id="chip-langs">{langs_with_tests}</div><div class="stat-chip-label">Languages with Tests</div><div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div></div>
13698      <div class="stat-chip"><div class="stat-chip-val" id="chip-cov-pct">{cov_line_pct_str}%</div><div class="stat-chip-label">Line Coverage</div><div class="stat-chip-tip">Overall line coverage across all LCOV-instrumented files (empty if no LCOV data)</div></div>
13699    </div>
13700
13701    <div class="panel" id="viz-panel">
13702      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13703        <span>Visualizations</span>
13704        <div style="display:flex;gap:8px;flex-wrap:wrap;">
13705          <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download test metrics as Excel workbook (.xlsx)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export Excel</button>
13706          <button type="button" class="export-btn" id="tm-export-png-btn" title="Save charts as PNG image"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Export PNG</button>
13707          <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export printable PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> Export PDF</button>
13708        </div>
13709      </div>
13710
13711      <div class="chart-box" style="margin-bottom:18px;">
13712        <div class="chart-box-header">
13713          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13714          <div style="display:flex;gap:8px;align-items:center;">
13715            <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">&#8652; Multi-Timeline</button>
13716            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13717          </div>
13718        </div>
13719        <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
13720        <div class="trend-controls-bar">
13721          <label>Y Metric:
13722            <select class="chart-select" id="tm-trend-y">
13723              <option value="test_count" selected>Test Definitions</option>
13724              <option value="code_lines">Code Lines</option>
13725            </select>
13726          </label>
13727          <label>X Axis:
13728            <select class="chart-select" id="tm-trend-x">
13729              <option value="commit" selected>By Commit</option>
13730              <option value="time">By Time</option>
13731            </select>
13732          </label>
13733          <label id="tm-sub-label" style="display:none;">Submodule:
13734            <select class="chart-select" id="tm-trend-sub">
13735              <option value="">All (project total)</option>
13736            </select>
13737          </label>
13738          <label>Chart Size:
13739            <select class="chart-select" id="tm-trend-size">
13740              <option value="200">Compact</option>
13741              <option value="260" selected>Normal</option>
13742              <option value="360">Large</option>
13743            </select>
13744          </label>
13745        </div>
13746        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13747        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13748      </div>
13749
13750      <div class="chart-row">
13751        <div class="chart-box">
13752          <div class="chart-box-header">
13753            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13754            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13755          </div>
13756          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13757          <div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
13758        </div>
13759        <div class="chart-box">
13760          <div class="chart-box-header">
13761            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13762            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13763          </div>
13764          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13765          <div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
13766        </div>
13767      </div>
13768
13769      <div class="chart-row">
13770        <div class="chart-box">
13771          <div class="chart-box-header">
13772            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13773            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13774          </div>
13775          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13776          <div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
13777        </div>
13778        <div class="chart-box" id="suites-chart-box">
13779          <div class="chart-box-header">
13780            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13781            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13782          </div>
13783          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13784          <div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
13785        </div>
13786      </div>
13787
13788      <div class="chart-row">
13789        <div class="chart-box">
13790          <div class="chart-box-title">Test Files Breakdown</div>
13791          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13792          <div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
13793        </div>
13794        <div class="chart-box">
13795          <div class="chart-box-title">Test Composition</div>
13796          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13797          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13798          <div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
13799        </div>
13800      </div>
13801    </div>
13802
13803    <div class="panel">
13804      <h1>Test Metrics</h1>
13805      <p class="muted">Lexical test definition counts across your codebase — how many test functions, test cases, and test decorators were detected per language, and how dense the test coverage is relative to production code.</p>
13806
13807      <div class="section-header">Language Breakdown</div>
13808      {cov_no_data_notice}
13809      <div style="overflow-x:auto;">
13810        <table class="data-table" id="lang-table">
13811          <thead><tr>
13812            <th>Language</th>
13813            <th class="num">Test Fns</th>
13814            <th class="num">Assertions</th>
13815            <th class="num">Suites</th>
13816            <th class="num">Code Lines</th>
13817            <th class="num">Files</th>
13818            <th class="num">Density / 1K</th>
13819            <th>Relative Density</th>
13820          </tr></thead>
13821          <tbody id="lang-tbody"></tbody>
13822        </table>
13823      </div>
13824    </div>
13825
13826    <div class="panel" id="cov-panel" style="display:none;">
13827      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
13828      <div class="cov-gauge-row" id="cov-gauges">
13829        <div class="cov-gauge-card">
13830          <div class="cov-gauge-label">Line Coverage</div>
13831          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
13832          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
13833          <div class="cov-gauge-sub">Lines hit / instrumented</div>
13834        </div>
13835        <div class="cov-gauge-card">
13836          <div class="cov-gauge-label">Function Coverage</div>
13837          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
13838          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
13839          <div class="cov-gauge-sub">Functions hit / found</div>
13840        </div>
13841        <div class="cov-gauge-card">
13842          <div class="cov-gauge-label">Branch Coverage</div>
13843          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
13844          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
13845          <div class="cov-gauge-sub">Branches hit / found</div>
13846        </div>
13847      </div>
13848      <div class="chart-row">
13849        <div class="chart-box">
13850          <div class="chart-box-title">Line Coverage % by Language</div>
13851          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
13852        </div>
13853        <div class="chart-box">
13854          <div class="chart-box-title">Coverage Tier Distribution</div>
13855          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
13856        </div>
13857      </div>
13858
13859      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
13860      <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
13861      <div class="cov-file-toolbar">
13862        <div class="cov-filter-tabs" id="cov-filter-tabs">
13863          <button class="cov-tab active" data-tier="all">All</button>
13864          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
13865          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
13866          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
13867          <button class="cov-tab" data-tier="high">High (≥80%)</button>
13868        </div>
13869        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename\u2026">
13870      </div>
13871      <div style="overflow-x:auto;">
13872        <table class="data-table" id="cov-file-table">
13873          <thead><tr>
13874            <th>File</th>
13875            <th>Lang</th>
13876            <th class="num">Line %</th>
13877            <th class="num">Lines Hit / Found</th>
13878            <th class="num">Fn %</th>
13879            <th class="num">Fns Hit / Found</th>
13880          </tr></thead>
13881          <tbody id="cov-file-tbody"></tbody>
13882        </table>
13883      </div>
13884      <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
13885      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
13886    </div>
13887
13888  </div>
13889
13890  <footer class="site-footer">
13891    local code analysis - metrics, history and reports
13892    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
13893    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13894    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13895    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13896    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13897  </footer>
13898
13899  <script nonce="{nonce}">
13900  (function() {{
13901    // Theme
13902    var b = document.body;
13903    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
13904    var tgl = document.getElementById('theme-toggle');
13905    if (tgl) tgl.addEventListener('click', function() {{
13906      var d = b.classList.toggle('dark-theme');
13907      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
13908    }});
13909
13910    // Watermarks
13911    (function() {{
13912      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13913      if (!wms.length) return;
13914      var placed = [];
13915      function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
13916      function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
13917      var half=Math.floor(wms.length/2);
13918      wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
13919    }})();
13920
13921    // Code particles
13922    (function() {{
13923      var container = document.getElementById('code-particles');
13924      if (!container) return;
13925      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
13926      for (var i = 0; i < 36; i++) {{
13927        (function(idx) {{
13928          var el = document.createElement('span');
13929          el.className = 'code-particle';
13930          el.textContent = snippets[idx % snippets.length];
13931          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
13932          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
13933          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
13934          el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
13935          container.appendChild(el);
13936        }})(i);
13937      }}
13938    }})();
13939
13940    // Settings modal
13941    (function() {{
13942      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'}}];
13943      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);}});}}
13944      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
13945      var btn=document.getElementById('settings-btn');if(!btn)return;
13946      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13947      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>';
13948      document.body.appendChild(m);
13949      var g=document.getElementById('scheme-grid');
13950      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);}});
13951      var cl=document.getElementById('settings-close');
13952      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');}});
13953      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13954      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13955    }})();
13956
13957    // Watched folder picker
13958    (function() {{
13959      var btn = document.getElementById('add-watched-btn');
13960      if (!btn) return;
13961      btn.addEventListener('click', function() {{
13962        fetch('/pick-directory?kind=reports')
13963          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
13964          .then(function(data) {{
13965            if (!data.cancelled && data.selected_path) {{
13966              var form = document.createElement('form');
13967              form.method = 'POST';
13968              form.action = '/watched-dirs/add';
13969              var ri = document.createElement('input');
13970              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
13971              var fi = document.createElement('input');
13972              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
13973              form.appendChild(ri); form.appendChild(fi);
13974              document.body.appendChild(form);
13975              form.submit();
13976            }}
13977          }})
13978          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
13979      }});
13980    }})();
13981  }})();
13982  </script>
13983
13984  <script src="/static/chart.js" nonce="{nonce}"></script>
13985  <script nonce="{nonce}">
13986  (function() {{
13987    var SCOPE_DATA = {scope_data_json};
13988    var currentRoot = '__all__';
13989    var currentSub  = '';
13990    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
13991    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
13992    var ALL_CHARTS = [];
13993    var currentLangTests = [];
13994    var currentTrendPts = [];
13995
13996    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
13997    function fmtFull(n){{return Number(n).toLocaleString();}}
13998    function isDark(){{return document.body.classList.contains('dark-theme');}}
13999    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14000    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14001    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14002
14003    function makeDlPlugin(fmtFn, anchor) {{
14004      return {{
14005        afterDatasetsDraw: function(chart) {{
14006          var ctx = chart.ctx;
14007          var tc = txtClr();
14008          chart.data.datasets.forEach(function(ds, di) {{
14009            var meta = chart.getDatasetMeta(di);
14010            meta.data.forEach(function(el, idx) {{
14011              var label = fmtFn(ds.data[idx], di, idx);
14012              if (label == null || label === '') return;
14013              ctx.save();
14014              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14015              ctx.fillStyle = tc;
14016              if (anchor === 'top') {{
14017                ctx.textAlign = 'center';
14018                ctx.textBaseline = 'bottom';
14019                ctx.fillText(String(label), el.x, el.y - 5);
14020              }} else {{
14021                ctx.textAlign = 'left';
14022                ctx.textBaseline = 'middle';
14023                ctx.fillText(String(label), el.x + 5, el.y);
14024              }}
14025              ctx.restore();
14026            }});
14027          }});
14028        }}
14029      }};
14030    }}
14031
14032    // Cursor: pointer over chart data, default over empty chart area.
14033    function chartCursor(e, els) {{
14034      var t = e.native && e.native.target;
14035      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14036    }}
14037    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14038
14039    // Plugin: draws % labels inside each doughnut slice.
14040    var donutPctPlugin = {{
14041      afterDatasetsDraw: function(chart) {{
14042        var ctx = chart.ctx;
14043        chart.data.datasets.forEach(function(ds, di) {{
14044          var meta = chart.getDatasetMeta(di);
14045          if (meta.hidden) return;
14046          var total = 0;
14047          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14048          if (!total) return;
14049          meta.data.forEach(function(arc, i) {{
14050            if (arc.hidden) return;
14051            var val = ds.data[i] || 0;
14052            var pct = val / total * 100;
14053            if (pct < 3) return;
14054            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14055            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14056            var tx = arc.x + midR * Math.cos(midAngle);
14057            var ty = arc.y + midR * Math.sin(midAngle);
14058            ctx.save();
14059            ctx.textAlign = 'center';
14060            ctx.textBaseline = 'middle';
14061            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14062            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14063            ctx.shadowBlur = 3;
14064            ctx.fillStyle = '#fff';
14065            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14066            ctx.restore();
14067          }});
14068        }});
14069      }}
14070    }};
14071
14072    function makeTmOverlay(title, subtitle, h) {{
14073      var overlay = document.createElement('div');
14074      overlay.className = 'chart-modal-overlay';
14075      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14076      var ch = Math.min(h || 560, maxH);
14077      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14078      overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">&times;</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
14079      document.body.appendChild(overlay);
14080      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14081      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14082      return document.getElementById('tm-modal-canvas');
14083    }}
14084
14085    function getDataset() {{
14086      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14087      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14088      return r;
14089    }}
14090    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14091
14092    function showNoData(id, show) {{
14093      var el = document.getElementById(id);
14094      if (!el) return;
14095      var wrap = el.previousElementSibling;
14096      el.style.display = show ? '' : 'none';
14097      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14098    }}
14099
14100    function renderTestCharts(D) {{
14101      currentLangTests = D || [];
14102      testsChart = destroyChart(testsChart);
14103      densityChart = destroyChart(densityChart);
14104      if (!D || !D.length) {{
14105        showNoData('no-data-tests', true);
14106        showNoData('no-data-density', true);
14107        return;
14108      }}
14109      showNoData('no-data-tests', false);
14110      showNoData('no-data-density', false);
14111      var top15 = D.slice(0, 15);
14112      var canvas1 = document.getElementById('canvas-tests');
14113      if (canvas1) {{
14114        testsChart = new Chart(canvas1, {{
14115          type: 'bar',
14116          data: {{
14117            labels: top15.map(function(d){{ return d.lang; }}),
14118            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14119          }},
14120          options: {{
14121            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14122            layout: {{ padding: {{ right: 64 }} }},
14123            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14124            scales: {{
14125              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14126              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14127            }}
14128          }},
14129          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14130        }});
14131        ALL_CHARTS.push(testsChart);
14132      }}
14133      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14134      var canvas2 = document.getElementById('canvas-density');
14135      if (canvas2) {{
14136        densityChart = new Chart(canvas2, {{
14137          type: 'bar',
14138          data: {{
14139            labels: topD.map(function(d){{ return d.lang; }}),
14140            datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14141          }},
14142          options: {{
14143            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14144            layout: {{ padding: {{ right: 64 }} }},
14145            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14146            scales: {{
14147              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14148              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14149            }}
14150          }},
14151          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14152        }});
14153        ALL_CHARTS.push(densityChart);
14154      }}
14155    }}
14156
14157    function renderAssertionsChart(D) {{
14158      assertionsChart = destroyChart(assertionsChart);
14159      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14160      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14161      var canvas = document.getElementById('canvas-assertions');
14162      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14163      showNoData('no-data-assertions', false);
14164      assertionsChart = new Chart(canvas, {{
14165        type: 'bar',
14166        data: {{
14167          labels: top15.map(function(d){{ return d.lang; }}),
14168          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14169        }},
14170        options: {{
14171          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14172          layout: {{ padding: {{ right: 64 }} }},
14173          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14174          scales: {{
14175            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14176            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14177          }}
14178        }},
14179        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14180      }});
14181      ALL_CHARTS.push(assertionsChart);
14182    }}
14183
14184    function renderSuitesChart(D) {{
14185      suitesChart = destroyChart(suitesChart);
14186      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14187      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14188      var canvas = document.getElementById('canvas-suites');
14189      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14190      showNoData('no-data-suites', false);
14191      suitesChart = new Chart(canvas, {{
14192        type: 'bar',
14193        data: {{
14194          labels: top15.map(function(d){{ return d.lang; }}),
14195          datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14196        }},
14197        options: {{
14198          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14199          layout: {{ padding: {{ right: 64 }} }},
14200          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14201          scales: {{
14202            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14203            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14204          }}
14205        }},
14206        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14207      }});
14208      ALL_CHARTS.push(suitesChart);
14209    }}
14210
14211    function renderFilesChart(totals) {{
14212      filesChart = destroyChart(filesChart);
14213      var canvas = document.getElementById('canvas-files');
14214      if (!canvas) return;
14215      var testF = totals.test_files || 0;
14216      var totalF = totals.total_files || 0;
14217      var nonTest = Math.max(0, totalF - testF);
14218      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14219      showNoData('no-data-files', false);
14220      var dark = isDark();
14221      filesChart = new Chart(canvas, {{
14222        type: 'doughnut',
14223        data: {{
14224          labels: ['Test Files', 'Non-Test Files'],
14225          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14226        }},
14227        options: {{
14228          responsive: true, maintainAspectRatio: false, cutout: '62%',
14229          onHover: chartCursor,
14230          plugins: {{
14231            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14232              generateLabels: function(chart) {{
14233                var ds = chart.data.datasets[0];
14234                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14235                return chart.data.labels.map(function(lbl, i) {{
14236                  var val = ds.data[i] || 0;
14237                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14238                  return {{
14239                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14240                    fillStyle: ds.backgroundColor[i],
14241                    strokeStyle: ds.borderColor,
14242                    lineWidth: ds.borderWidth,
14243                    hidden: false,
14244                    index: i,
14245                    datasetIndex: 0
14246                  }};
14247                }});
14248              }}
14249            }},
14250              onHover: function(e, item, leg) {{
14251                var ch = leg.chart;
14252                var t = e.native && e.native.target;
14253                if (t) t.style.cursor = 'pointer';
14254                ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14255                ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14256                ch.update();
14257              }},
14258              onLeave: function(e, item, leg) {{
14259                var ch = leg.chart;
14260                var t = e.native && e.native.target;
14261                if (t) t.style.cursor = 'default';
14262                ch.setActiveElements([]);
14263                ch.tooltip.setActiveElements([], {{}});
14264                ch.update('none');
14265              }}
14266            }},
14267            tooltip: {{ callbacks: {{ label: function(ctx) {{
14268              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14269              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14270            }} }} }}
14271          }}
14272        }},
14273        plugins: [donutPctPlugin]
14274      }});
14275      ALL_CHARTS.push(filesChart);
14276    }}
14277
14278    function renderCompositionChart(totals) {{
14279      compositionChart = destroyChart(compositionChart);
14280      var canvas = document.getElementById('canvas-composition');
14281      if (!canvas) return;
14282      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14283      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14284      showNoData('no-data-composition', false);
14285      compositionChart = new Chart(canvas, {{
14286        type: 'bar',
14287        data: {{
14288          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14289          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14290        }},
14291        options: {{
14292          responsive: true, maintainAspectRatio: false,
14293          layout: {{ padding: {{ top: 22 }} }},
14294          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14295          scales: {{
14296            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14297            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14298          }}
14299        }},
14300        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14301      }});
14302      ALL_CHARTS.push(compositionChart);
14303    }}
14304
14305    function renderCovCharts(covD, tiers) {{
14306      covChart = destroyChart(covChart);
14307      tierChart = destroyChart(tierChart);
14308      var covCanvas = document.getElementById('canvas-cov');
14309      if (covCanvas && covD && covD.length) {{
14310        covChart = new Chart(covCanvas, {{
14311          type: 'bar',
14312          data: {{
14313            labels: covD.map(function(d){{ return d.lang; }}),
14314            datasets: [{{ label: 'Line Coverage %', data: covD.map(function(d){{ return d.pct; }}), backgroundColor: covD.map(function(d){{ return d.pct >= 80 ? '#2A6846' : d.pct >= 50 ? '#D4A017' : '#B23030'; }}), borderRadius: 4 }}]
14315          }},
14316          options: {{
14317            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14318            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14319            scales: {{
14320              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14321              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14322            }}
14323          }}
14324        }});
14325        ALL_CHARTS.push(covChart);
14326      }}
14327      var tierCanvas = document.getElementById('canvas-cov-tiers');
14328      if (tierCanvas && tiers) {{
14329        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14330        tierChart = new Chart(tierCanvas, {{
14331          type: 'doughnut',
14332          data: {{
14333            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14334            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14335          }},
14336          options: {{
14337            responsive: true, maintainAspectRatio: false, cutout: '62%',
14338            onHover: chartCursor,
14339            plugins: {{
14340              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14341                onHover: function(e, item, leg) {{
14342                  var ch = leg.chart;
14343                  var t = e.native && e.native.target;
14344                  if (t) t.style.cursor = 'pointer';
14345                  ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14346                  ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14347                  ch.update();
14348                }},
14349                onLeave: function(e, item, leg) {{
14350                  var ch = leg.chart;
14351                  var t = e.native && e.native.target;
14352                  if (t) t.style.cursor = 'default';
14353                  ch.setActiveElements([]);
14354                  ch.tooltip.setActiveElements([], {{}});
14355                  ch.update('none');
14356                }}
14357              }},
14358              tooltip: {{ callbacks: {{ label: function(ctx) {{
14359                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14360                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14361              }} }} }}
14362            }}
14363          }},
14364          plugins: [donutPctPlugin]
14365        }});
14366        ALL_CHARTS.push(tierChart);
14367      }}
14368    }}
14369
14370    function buildLangTable(D) {{
14371      var tbody = document.getElementById('lang-tbody');
14372      if (!tbody) return;
14373      if (!D || !D.length) {{
14374        tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
14375        return;
14376      }}
14377      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14378      tbody.innerHTML = D.map(function(d) {{
14379        var barW = Math.round(d.density / maxDensity * 120);
14380        return '<tr>' +
14381          '<td><strong>' + d.lang + '</strong></td>' +
14382          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14383          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14384          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14385          '<td class="num">' + fmtFull(d.code) + '</td>' +
14386          '<td class="num">' + fmtFull(d.files) + '</td>' +
14387          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14388          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14389          '</tr>';
14390      }}).join('');
14391    }}
14392
14393    var covFileData = [];
14394    var covFileTier = 'all';
14395    var covFileSearch = '';
14396
14397    function pctBadge(pct) {{
14398      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14399      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14400      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14401    }}
14402
14403    function buildCovFileTable() {{
14404      var tbody = document.getElementById('cov-file-tbody');
14405      var empty = document.getElementById('cov-file-empty');
14406      var count = document.getElementById('cov-file-count');
14407      if (!tbody) return;
14408      var srch = covFileSearch.toLowerCase();
14409      var filtered = covFileData.filter(function(f) {{
14410        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14411        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14412        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14413        if (covFileTier === 'high' && f.line_pct < 80) return false;
14414        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14415        return true;
14416      }});
14417      if (!filtered.length) {{
14418        tbody.innerHTML = '';
14419        if (empty) empty.style.display = '';
14420        if (count) count.textContent = '';
14421        return;
14422      }}
14423      if (empty) empty.style.display = 'none';
14424      var shown = Math.min(filtered.length, 500);
14425      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14426      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14427        var fnCol = f.fn_pct < 0
14428          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14429          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14430        return '<tr>' +
14431          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
14432          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14433          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14434          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14435          fnCol +
14436          '</tr>';
14437      }}).join('');
14438    }}
14439
14440    (function() {{
14441      var tabs = document.getElementById('cov-filter-tabs');
14442      if (tabs) {{
14443        tabs.addEventListener('click', function(e) {{
14444          var btn = e.target.closest('.cov-tab');
14445          if (!btn) return;
14446          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14447          btn.classList.add('active');
14448          covFileTier = btn.getAttribute('data-tier');
14449          buildCovFileTable();
14450        }});
14451      }}
14452      var srch = document.getElementById('cov-file-search');
14453      if (srch) {{
14454        srch.addEventListener('input', function() {{
14455          covFileSearch = this.value;
14456          buildCovFileTable();
14457        }});
14458      }}
14459    }})();
14460
14461    function updateCovGauges(t) {{
14462      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14463      var el;
14464      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14465      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14466      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14467      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14468      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14469      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14470    }}
14471
14472    function applyScope() {{
14473      var d = getDataset();
14474      var t = d.totals;
14475      var el;
14476      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14477      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14478      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14479      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14480      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14481      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14482      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14483      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14484      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14485      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14486      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14487      renderTestCharts(d.lang_tests);
14488      renderAssertionsChart(d.lang_tests);
14489      renderSuitesChart(d.lang_tests);
14490      renderFilesChart(t);
14491      renderCompositionChart(t);
14492      buildLangTable(d.lang_tests);
14493      var covPanel = document.getElementById('cov-panel');
14494      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14495      if (d.has_coverage) {{
14496        renderCovCharts(d.cov, d.cov_tiers);
14497        updateCovGauges(t);
14498        covFileData = d.file_cov || [];
14499        covFileTier = 'all';
14500        covFileSearch = '';
14501        var tabs = document.getElementById('cov-filter-tabs');
14502        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14503        var srch = document.getElementById('cov-file-search');
14504        if (srch) srch.value = '';
14505        buildCovFileTable();
14506      }}
14507      loadTrend();
14508    }}
14509
14510    // Populate scope-root-sel from SCOPE_DATA keys
14511    (function() {{
14512      var sel = document.getElementById('scope-root-sel');
14513      if (!sel) return;
14514      Object.keys(SCOPE_DATA).forEach(function(k) {{
14515        if (k === '__all__') return;
14516        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14517      }});
14518    }})();
14519
14520    document.getElementById('scope-root-sel').addEventListener('change', function() {{
14521      currentRoot = this.value;
14522      currentSub = '';
14523      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14524      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14525      var subWrap = document.getElementById('scope-sub-wrap');
14526      var subSel  = document.getElementById('scope-sub-sel');
14527      subSel.innerHTML = '<option value="">Entire project</option>';
14528      if (subNames.length) {{
14529        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14530        subWrap.style.display = 'flex';
14531      }} else {{
14532        subWrap.style.display = 'none';
14533      }}
14534      applyScope();
14535    }});
14536
14537    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14538      currentSub = this.value;
14539      applyScope();
14540    }});
14541
14542    var allTrendData = [];
14543
14544    var TM_Y_META = {{
14545      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14546      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
14547    }};
14548
14549    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14550    function hexRgb(hex) {{
14551      var h = String(hex).replace('#', '');
14552      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14553      var n = parseInt(h, 16);
14554      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14555    }}
14556    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14557    // tint at the top to transparent at the bottom (no flat solid block).
14558    function tmTrendGradient(ctx2, chartArea, color) {{
14559      var rgb = hexRgb(color);
14560      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14561      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
14562      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14563      g.addColorStop(1,   'rgba(' + rgb + ',0)');
14564      return g;
14565    }}
14566
14567    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14568    // so linear interpolation between adjacent points matches the drawn line).
14569    function tmLineYAt(chart, px) {{
14570      var meta = chart.getDatasetMeta(0);
14571      if (!meta || !meta.data || !meta.data.length) return null;
14572      var d = meta.data;
14573      if (px <= d[0].x) return d[0].y;
14574      for (var i = 1; i < d.length; i++) {{
14575        if (px <= d[i].x) {{
14576          var span = d[i].x - d[i - 1].x;
14577          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14578          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14579        }}
14580      }}
14581      return d[d.length - 1].y;
14582    }}
14583
14584    // Plugin: only show the tooltip / finger cursor when the pointer is over the
14585    // gradient fill (inside the plot and at/below the line) — never in the empty
14586    // space above the line. Outside the fill we retype the event as 'mouseout' so
14587    // the core interaction dismisses any active tooltip on its own.
14588    var tmFillGuard = {{
14589      id: 'tmFillGuard',
14590      beforeEvent: function(chart, args) {{
14591        var e = args.event;
14592        if (!e || e.type !== 'mousemove') return;
14593        var ca = chart.chartArea;
14594        if (!ca) return;
14595        var inFill = false;
14596        if (e.x >= ca.left && e.x <= ca.right) {{
14597          var ly = tmLineYAt(chart, e.x);
14598          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14599        }}
14600        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14601        if (!inFill) {{ e.type = 'mouseout'; }}
14602      }}
14603    }};
14604
14605    // Single source of truth for the test-metrics trend chart config so the inline
14606    // chart and the Full View modal render identically (straight segments, gradient
14607    // fill, white-ringed points, gradient-only interactivity).
14608    function buildTmTrendConfig(pts, ctrl, meta) {{
14609      return {{
14610        type: 'line',
14611        data: {{
14612          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14613          datasets: [{{
14614            label: meta.label,
14615            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14616            borderColor: meta.color,
14617            borderWidth: 2.5,
14618            backgroundColor: function(context) {{
14619              var ca = context.chart.chartArea;
14620              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14621              return tmTrendGradient(context.chart.ctx, ca, meta.color);
14622            }},
14623            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14624            pointBorderColor: '#fff',
14625            pointBorderWidth: 2,
14626            pointRadius: 6,
14627            pointHoverRadius: 9,
14628            pointHoverBorderWidth: 2.5,
14629            fill: true, tension: 0
14630          }}]
14631        }},
14632        options: {{
14633          responsive: true, maintainAspectRatio: false,
14634          layout: {{ padding: {{ top: 22 }} }},
14635          interaction: {{ mode: 'index', intersect: false }},
14636          plugins: {{
14637            legend: {{ display: false }},
14638            tooltip: {{
14639              mode: 'index', intersect: false,
14640              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14641            }}
14642          }},
14643          scales: {{
14644            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14645            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14646          }}
14647        }},
14648        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14649      }};
14650    }}
14651
14652    function getTrendControls() {{
14653      var ySel    = document.getElementById('tm-trend-y');
14654      var xSel    = document.getElementById('tm-trend-x');
14655      var sizeSel = document.getElementById('tm-trend-size');
14656      var subSel  = document.getElementById('tm-trend-sub');
14657      return {{
14658        yKey:    ySel    ? ySel.value    : 'test_count',
14659        xMode:   xSel    ? xSel.value    : 'commit',
14660        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
14661        submod:  subSel  ? subSel.value  : ''
14662      }};
14663    }}
14664
14665    function makeTrendLabel(d, xMode) {{
14666      if (xMode === 'commit') {{
14667        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14668      }}
14669      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14670    }}
14671
14672    function buildTrend(data) {{
14673      allTrendData = data || [];
14674      renderTrend();
14675    }}
14676
14677    function renderTrend() {{
14678      var data = allTrendData;
14679      var ctrl = getTrendControls();
14680      var trendCanvas = document.getElementById('canvas-trend');
14681      var trendWrap   = document.getElementById('trend-canvas-wrap');
14682      var trendEmpty  = document.getElementById('trend-empty');
14683
14684      // Apply chart size
14685      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14686
14687      // Filter by submodule if selected (entries from project_label match)
14688      var pts = data.slice().reverse();
14689      if (ctrl.submod) {{
14690        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14691      }}
14692
14693      currentTrendPts = pts;
14694
14695      if (!pts.length) {{
14696        if (trendCanvas) trendCanvas.style.display = 'none';
14697        if (trendEmpty) trendEmpty.style.display = '';
14698        return;
14699      }}
14700      if (trendCanvas) trendCanvas.style.display = '';
14701      if (trendEmpty) trendEmpty.style.display = 'none';
14702
14703      trendChart = destroyChart(trendChart);
14704      if (!trendCanvas) return;
14705
14706      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14707
14708      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14709      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14710      ALL_CHARTS.push(trendChart);
14711
14712      // Populate submodule selector from unique project_labels
14713      var subSel = document.getElementById('tm-trend-sub');
14714      var subLabel = document.getElementById('tm-sub-label');
14715      if (subSel && data.length) {{
14716        var projects = [];
14717        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14718        if (projects.length > 1) {{
14719          var curVal = subSel.value;
14720          subSel.innerHTML = '<option value="">All (project total)</option>';
14721          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14722          if (subLabel) subLabel.style.display = '';
14723        }} else {{
14724          if (subLabel) subLabel.style.display = 'none';
14725        }}
14726      }}
14727    }}
14728
14729    // ── Full View expand buttons ──────────────────────────────────────────────
14730    (function() {{
14731      var btn = document.getElementById('tests-expand-btn');
14732      if (!btn) return;
14733      btn.addEventListener('click', function() {{
14734        var D = currentLangTests;
14735        if (!D || !D.length) return;
14736        var top15 = D.slice(0, 15);
14737        var h = Math.max(320, top15.length * 36 + 80);
14738        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14739        if (!canvas) return;
14740        new Chart(canvas, {{
14741          type: 'bar',
14742          data: {{
14743            labels: top15.map(function(d){{ return d.lang; }}),
14744            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14745          }},
14746          options: {{
14747            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14748            layout: {{ padding: {{ right: 72 }} }},
14749            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14750            scales: {{
14751              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14752              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14753            }}
14754          }},
14755          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14756        }});
14757      }});
14758    }})();
14759
14760    (function() {{
14761      var btn = document.getElementById('density-expand-btn');
14762      if (!btn) return;
14763      btn.addEventListener('click', function() {{
14764        var D = currentLangTests;
14765        if (!D || !D.length) return;
14766        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14767        var h = Math.max(320, topD.length * 36 + 80);
14768        var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14769        if (!canvas) return;
14770        new Chart(canvas, {{
14771          type: 'bar',
14772          data: {{
14773            labels: topD.map(function(d){{ return d.lang; }}),
14774            datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14775          }},
14776          options: {{
14777            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14778            layout: {{ padding: {{ right: 72 }} }},
14779            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14780            scales: {{
14781              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14782              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14783            }}
14784          }},
14785          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14786        }});
14787      }});
14788    }})();
14789
14790    (function() {{
14791      var btn = document.getElementById('trend-expand-btn');
14792      if (!btn) return;
14793      btn.addEventListener('click', function() {{
14794        var pts = currentTrendPts;
14795        if (!pts || !pts.length) return;
14796        var ctrl = getTrendControls();
14797        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14798        var title = meta.label + ' Trend \u2014 Full View';
14799        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14800        if (!canvas) return;
14801        // Reuse the exact inline-chart config so Full View matches the default view
14802        // (straight segments + gradient-only interactivity), just larger.
14803        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14804      }});
14805    }})();
14806
14807    (function() {{
14808      var btn = document.getElementById('assertions-expand-btn');
14809      if (!btn) return;
14810      btn.addEventListener('click', function() {{
14811        var D = currentLangTests;
14812        if (!D || !D.length) return;
14813        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14814        if (!top15.length) return;
14815        var h = Math.max(320, top15.length * 36 + 80);
14816        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14817        if (!canvas) return;
14818        new Chart(canvas, {{
14819          type: 'bar',
14820          data: {{
14821            labels: top15.map(function(d){{ return d.lang; }}),
14822            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14823          }},
14824          options: {{
14825            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14826            layout: {{ padding: {{ right: 72 }} }},
14827            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14828            scales: {{
14829              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14830              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14831            }}
14832          }},
14833          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14834        }});
14835      }});
14836    }})();
14837
14838    (function() {{
14839      var btn = document.getElementById('suites-expand-btn');
14840      if (!btn) return;
14841      btn.addEventListener('click', function() {{
14842        var D = currentLangTests;
14843        if (!D || !D.length) return;
14844        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14845        if (!top15.length) return;
14846        var h = Math.max(320, top15.length * 36 + 80);
14847        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
14848        if (!canvas) return;
14849        new Chart(canvas, {{
14850          type: 'bar',
14851          data: {{
14852            labels: top15.map(function(d){{ return d.lang; }}),
14853            datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14854          }},
14855          options: {{
14856            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14857            layout: {{ padding: {{ right: 72 }} }},
14858            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14859            scales: {{
14860              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14861              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14862            }}
14863          }},
14864          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14865        }});
14866      }});
14867    }})();
14868
14869    // Wire trend control selectors — re-render without re-fetching
14870    (function() {{
14871      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
14872        var el = document.getElementById(id);
14873        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
14874      }});
14875    }})();
14876
14877    function loadTrend() {{
14878      var url = '/api/metrics/history?limit=100';
14879      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
14880      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
14881        buildTrend(data);
14882        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
14883        var btn = document.getElementById('multi-compare-trend-btn');
14884        if (btn) {{
14885          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
14886          if (ids.length >= 2) {{
14887            btn.style.display = '';
14888            btn.onclick = function() {{
14889              // Reverse so oldest first (API returns newest first).
14890              var sorted = ids.slice().reverse();
14891              if (sorted.length === 2) {{
14892                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
14893              }} else {{
14894                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
14895              }}
14896            }};
14897          }} else {{
14898            btn.style.display = 'none';
14899          }}
14900        }}
14901      }}).catch(function(){{
14902        var trendEmpty = document.getElementById('trend-empty');
14903        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
14904      }});
14905    }}
14906
14907    // Re-render charts on theme toggle
14908    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
14909      setTimeout(function() {{
14910        ALL_CHARTS.forEach(function(c) {{
14911          if (c && c.options && c.options.scales) {{
14912            Object.values(c.options.scales).forEach(function(ax) {{
14913              if (ax.grid) ax.grid.color = clr();
14914              if (ax.ticks) ax.ticks.color = txtClr();
14915            }});
14916            c.update();
14917          }}
14918        }});
14919      }}, 80);
14920    }});
14921
14922    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
14923    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14924    function tmExportMeta() {{
14925      var sel = document.getElementById('scope-sel');
14926      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
14927      if (!proj || proj === '__all__') proj = 'All projects';
14928      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
14929      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
14930      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
14931      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
14932      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
14933    }}
14934
14935    function exportTmXLSX() {{
14936      var D = currentLangTests;
14937      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
14938      var t = tmExportMeta();
14939      function s2b(s) {{ return new TextEncoder().encode(s); }}
14940      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
14941      function col2l(n) {{ var s=''; while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}} return s; }}
14942      function crc32(d) {{
14943        if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
14944        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
14945      }}
14946      // Store all cells as strings so Excel left-aligns uniformly.
14947      function cs(addr, val, bold) {{
14948        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
14949      }}
14950      // Build an Excel Table XML definition for a given sheet range and columns.
14951      function makeTableXml(tblId, name, ref, cols) {{
14952        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
14953        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14954        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
14955        x+='<autoFilter ref="'+ref+'"/>';
14956        x+='<tableColumns count="'+cols.length+'">';
14957        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
14958        x+='</tableColumns>';
14959        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
14960        return x+'</table>';
14961      }}
14962      // Worksheet XML with optional Excel Table part reference.
14963      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
14964        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
14965        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
14966        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
14967        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
14968        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
14969        rows.forEach(function(row,ri){{var rn=ri+2;x+='<row r="'+rn+'">';row.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,false);}});x+='</row>';}});
14970        if(totRow){{var rn=rows.length+2;x+='<row r="'+rn+'">';totRow.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,true);}});x+='</row>';}}
14971        x+='</sheetData>';
14972        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
14973        return x+'</worksheet>';
14974      }}
14975
14976      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
14977      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
14978      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
14979      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
14980      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
14981      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
14982
14983      // Sheet 1: Summary
14984      var sumHdr=['Metric','Value'];
14985      var sumRows=[
14986        ['Project / Scope', t.proj],
14987        ['Export Date', t.full],
14988        ['Test Functions', Number(totTests).toLocaleString()],
14989        ['Assertions', Number(totAssert).toLocaleString()],
14990        ['Test Suites', Number(totSuites).toLocaleString()],
14991        ['Languages with Tests', String(D.length)],
14992        ['Total Code Lines', Number(totCode).toLocaleString()],
14993        ['Average Density (per 1K)', String(avgDensity)],
14994      ];
14995      var sumRef='A1:B'+(sumRows.length+1);
14996      var sumTblXml=makeTableXml(1,'Summary',sumRef,sumHdr);
14997      var sumSheetXml=buildSheet(sumHdr,sumRows,null,[28,22],'rId1');
14998
14999      // Sheet 2: Language Breakdown
15000      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15001      var langRows=D.map(function(d){{return[d.lang,Number(d.tests).toLocaleString(),Number(d.assertions||0).toLocaleString(),Number(d.suites||0).toLocaleString(),Number(d.code).toLocaleString(),Number(d.files).toLocaleString(),Number(d.density).toFixed(2)];}});
15002      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15003      // Table covers header + data only (TOTAL row excluded from table, sits just below)
15004      var langDataRows=langRows.length;
15005      var langTblRef='A1:G'+(langDataRows+1);
15006      var langTblXml=makeTableXml(2,'LangBreakdown',langTblRef,langHdr);
15007      var langSheetXml=buildSheet(langHdr,langRows,totRow,[22,15,15,15,15,12,15],'rId1');
15008
15009      var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><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="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
15010      var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/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/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/tables/table2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15011      var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
15012      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
15013      var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="Language Breakdown" sheetId="2" r:id="rId2"/></sheets></workbook>';
15014      var sh1rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table1.xml"/></Relationships>';
15015      var sh2rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table2.xml"/></Relationships>';
15016      var files=[
15017        {{name:'[Content_Types].xml',data:s2b(ct)}},
15018        {{name:'_rels/.rels',data:s2b(dotrels)}},
15019        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15020        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15021        {{name:'xl/styles.xml',data:s2b(styl)}},
15022        {{name:'xl/worksheets/sheet1.xml',data:s2b(sumSheetXml)}},
15023        {{name:'xl/worksheets/sheet2.xml',data:s2b(langSheetXml)}},
15024        {{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b(sh1rels)}},
15025        {{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b(sh2rels)}},
15026        {{name:'xl/tables/table1.xml',data:s2b(sumTblXml)}},
15027        {{name:'xl/tables/table2.xml',data:s2b(langTblXml)}},
15028      ];
15029      var parts=[],offsets=[],total=0;
15030      files.forEach(function(f){{offsets.push(total);var nb=s2b(f.name),crc=crc32(f.data);var h=new DataView(new ArrayBuffer(30+nb.length));h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);h.setUint16(26,nb.length,true);h.setUint16(28,0,true);for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);parts.push(new Uint8Array(h.buffer));parts.push(f.data);total+=30+nb.length+f.data.length;}});
15031      var cdStart=total;files.forEach(function(f,fi){{var nb=s2b(f.name),crc=crc32(f.data);var cd=new DataView(new ArrayBuffer(46+nb.length));cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;}});
15032      var cdSz=total-cdStart;var eocd=new DataView(new ArrayBuffer(22));eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);parts.push(new Uint8Array(eocd.buffer));
15033      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);var out=new Uint8Array(sz);var off=0;parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15034      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15035      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15036      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15037      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15038    }}
15039
15040    function exportTmPNG() {{
15041      // Map canvas IDs to display titles
15042      var CHART_TITLES = {{
15043        'canvas-trend':       'TEST COUNT TREND',
15044        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15045        'canvas-density':     'TEST DENSITY (PER 1,000 CODE LINES)',
15046        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15047        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15048        'canvas-files':       'TEST FILES BREAKDOWN',
15049        'canvas-composition': 'TEST COMPOSITION'
15050      }};
15051      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15052      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15053      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15054      var t=tmExportMeta();
15055      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15056      var trendCanvas=document.getElementById('canvas-trend');
15057      var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15058      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15059      var TOTAL_W=COLW*2+GAP;
15060      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15061      TREND_H=Math.min(Math.max(200,TREND_H),340);
15062      // Per-row chart heights (2-col grid)
15063      var gridRows=Math.ceil(gridCanvases.length/2);
15064      var rowHeights=[];
15065      for(var ri=0;ri<gridRows;ri++){{
15066        var rh=240;
15067        for(var ci=0;ci<2;ci++){{
15068          var cv=gridCanvases[ri*2+ci];
15069          if(cv&&cv.width>0){{
15070            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15071            rh=Math.max(rh,Math.min(420,nat));
15072          }}
15073        }}
15074        rowHeights.push(rh);
15075      }}
15076      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15077      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15078      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15079      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15080      var ctx=out.getContext('2d');
15081      var cs2=getComputedStyle(document.body);
15082      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15083      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15084      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15085
15086      // Background
15087      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15088
15089      // Orange header block
15090      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15091      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15092      ctx.fillText('Test Metrics — '+t.proj,22,42);
15093      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15094      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15095      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15096
15097      // Helper: draw a section title label
15098      function drawTitle(label, x, y, w) {{
15099        ctx.save();
15100        ctx.fillStyle=oxide;
15101        ctx.font='700 11px '+TM_FONT;
15102        ctx.textBaseline='middle';
15103        ctx.textAlign='left';
15104        ctx.letterSpacing='0.07em';
15105        ctx.fillText(label, x+2, y+TITLE_H/2);
15106        // Underline
15107        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15108        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15109        ctx.globalAlpha=1;
15110        ctx.restore();
15111      }}
15112
15113      var yOff=HEADER_H;
15114
15115      // Trend chart (full width)
15116      if(hasTrend){{
15117        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15118        yOff+=TITLE_H;
15119        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15120        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15121        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15122        ctx.drawImage(surf,0,yOff);
15123        yOff+=TREND_H+ROW_PAD;
15124      }}
15125
15126      // Grid charts (2-col), each cell gets title + chart
15127      for(var gi=0;gi<gridRows;gi++){{
15128        var rh2=rowHeights[gi];
15129        // Draw row titles and charts
15130        for(var gci=0;gci<2;gci++){{
15131          var idx2=gi*2+gci;
15132          if(idx2>=gridCanvases.length)continue;
15133          var gcv=gridCanvases[idx2];
15134          var gx=gci*(COLW+GAP);
15135          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15136        }}
15137        yOff+=TITLE_H;
15138        for(var gci2=0;gci2<2;gci2++){{
15139          var idx3=gi*2+gci2;
15140          if(idx3>=gridCanvases.length)continue;
15141          var gcv2=gridCanvases[idx3];
15142          var gx2=gci2*(COLW+GAP);
15143          var natW=gcv2.width,natH=gcv2.height;
15144          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15145          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15146          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15147          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15148          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15149          ctx.drawImage(surf2,gx2,yOff);
15150        }}
15151        yOff+=rh2+ROW_PAD;
15152      }}
15153
15154      // Dark footer
15155      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15156      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15157      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15158
15159      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15160      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15161    }}
15162
15163    function exportTmPDF() {{
15164      var D=currentLangTests;
15165      var t=tmExportMeta();
15166      var strips=document.querySelectorAll('.summary-strip');
15167      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15168      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15169      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15170      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15171      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15172      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15173      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15174      var rows='';
15175      (D||[]).forEach(function(d){{
15176        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15177          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15178          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15179          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15180          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15181          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15182          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15183      }});
15184      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15185        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15186        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15187        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15188        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15189        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15190        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15191      var tableHtml='<table><thead><tr><th>Language</th><th class="n">Test Fns</th><th class="n">Assertions</th><th class="n">Suites</th><th class="n">Code Lines</th><th class="n">Files</th><th class="n">Density/1K</th></tr></thead><tbody>'+rows+totRow+'</tbody></table>';
15192      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15193        +'html,body{{height:100%;margin:0;}}'
15194        +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;display:flex;flex-direction:column;min-height:100vh;}}'
15195        +'.rep-header{{background:#C45C10;color:#fff;padding:18px 32px 16px;display:flex;justify-content:space-between;align-items:flex-start;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15196        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15197        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15198        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15199        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15200        +'.rep-body{{padding:20px 32px;flex:1;}}'
15201        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15202        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15203        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15204        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15205        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15206        +'.section-hdr{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:#C45C10;margin:16px 0 8px;border-bottom:2px solid #C45C10;padding-bottom:4px;}}'
15207        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15208        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15209        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15210        +'.n{{text-align:right;}}'
15211        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15212        +'.rep-footer{{background:#43342d;color:rgba(255,255,255,0.75);padding:10px 32px;font-size:10px;text-align:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15213        +'</style>';
15214      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15215        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15216        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15217        +'<div class="rep-body">'+statsHtml
15218        +'<div class="section-hdr">Language Breakdown</div>'
15219        +tableHtml+'</div>'
15220        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15221        +'</body></html>';
15222      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15223      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:document.getElementById('tm-export-pdf-btn')}});
15224    }}
15225
15226    (function() {{
15227      var xBtn=document.getElementById('tm-export-xlsx-btn');
15228      var pngBtn=document.getElementById('tm-export-png-btn');
15229      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15230      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15231      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15232      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15233    }})();
15234
15235    applyScope();
15236  }})();
15237  </script>
15238  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
15239  {toast_assets}
15240</body>
15241</html>"#,
15242    );
15243    (
15244        [(axum::http::header::CACHE_CONTROL, "no-store")],
15245        Html(html),
15246    )
15247        .into_response()
15248}
15249
15250// ── Embeddable widget ─────────────────────────────────────────────────────────
15251// Protected. Returns a self-contained HTML page suitable for iframing inside
15252// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15253//
15254// GET /embed/summary?run_id=<uuid>&theme=dark
15255
15256#[derive(Deserialize)]
15257struct EmbedQuery {
15258    run_id: Option<String>,
15259    theme: Option<String>,
15260}
15261
15262async fn embed_handler(
15263    State(state): State<AppState>,
15264    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15265    Query(query): Query<EmbedQuery>,
15266) -> Response {
15267    let entry = {
15268        let reg = state.registry.lock().await;
15269        query.run_id.as_ref().map_or_else(
15270            || reg.entries.first().cloned(),
15271            |id| reg.find_by_run_id(id).cloned(),
15272        )
15273    };
15274
15275    let Some(entry) = entry else {
15276        return Html(
15277            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15278                .to_string(),
15279        )
15280        .into_response();
15281    };
15282
15283    let dark = query.theme.as_deref() == Some("dark");
15284    let languages: Vec<(String, u64, u64)> = entry
15285        .json_path
15286        .as_ref()
15287        .and_then(|p| read_json(p).ok())
15288        .map(|run| {
15289            run.totals_by_language
15290                .iter()
15291                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15292                .collect()
15293        })
15294        .unwrap_or_default();
15295
15296    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15297}
15298
15299fn render_embed_widget(
15300    entry: &RegistryEntry,
15301    languages: &[(String, u64, u64)],
15302    dark: bool,
15303    csp_nonce: &str,
15304) -> String {
15305    let s = &entry.summary;
15306    let total = s.code_lines + s.comment_lines + s.blank_lines;
15307    let code_pct = s
15308        .code_lines
15309        .checked_mul(100)
15310        .and_then(|n| n.checked_div(total))
15311        .unwrap_or(0);
15312
15313    let (bg, fg, surface, muted, border) = if dark {
15314        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15315    } else {
15316        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15317    };
15318
15319    let mut lang_rows = String::new();
15320    for (name, files, code) in languages {
15321        write!(
15322            lang_rows,
15323            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15324            escape_html(name),
15325            format_number(*files),
15326            format_number(*code),
15327        )
15328        .ok();
15329    }
15330
15331    let lang_table = if lang_rows.is_empty() {
15332        String::new()
15333    } else {
15334        format!(
15335            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15336        )
15337    };
15338
15339    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15340    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15341    let project_esc = escape_html(&entry.project_label);
15342    let code_lines = format_number(s.code_lines);
15343    let comment_lines = format_number(s.comment_lines);
15344    let files = format_number(s.files_analyzed);
15345    let code_raw = s.code_lines;
15346    let comment_raw = s.comment_lines;
15347    let blank_raw = s.blank_lines;
15348
15349    format!(
15350        r#"<!doctype html>
15351<html lang="en">
15352<head>
15353  <meta charset="utf-8">
15354  <meta name="viewport" content="width=device-width,initial-scale=1">
15355  <title>OxideSLOC &mdash; {project_esc}</title>
15356  <script src="/static/chart.js"></script>
15357  <style nonce="{csp_nonce}">
15358    *{{box-sizing:border-box;margin:0;padding:0}}
15359    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15360    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15361    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15362    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15363    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15364    .card .v{{font-size:18px;font-weight:700}}
15365    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15366    .row{{display:flex;gap:12px;align-items:flex-start}}
15367    .pie{{width:120px;height:120px;flex-shrink:0}}
15368    .lt{{border-collapse:collapse;width:100%;flex:1}}
15369    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15370    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15371    .n{{text-align:right}}
15372    .footer{{margin-top:10px;color:{muted};font-size:10px}}
15373  </style>
15374</head>
15375<body>
15376  <h2>{project_esc}</h2>
15377  <div class="sub">{timestamp} &middot; run {run_short}</div>
15378  <div class="cards">
15379    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15380    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15381    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15382    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15383  </div>
15384  <div class="row">
15385    <canvas class="pie" id="c"></canvas>
15386    {lang_table}
15387  </div>
15388  <div class="footer">oxide-sloc</div>
15389  <script nonce="{csp_nonce}">
15390    new Chart(document.getElementById('c'),{{
15391      type:'doughnut',
15392      data:{{
15393        labels:['Code','Comments','Blank'],
15394        datasets:[{{
15395          data:[{code_raw},{comment_raw},{blank_raw}],
15396          backgroundColor:['#4a78ee','#b35428','#aaa'],
15397          borderWidth:0
15398        }}]
15399      }},
15400      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15401    }});
15402  </script>
15403</body>
15404</html>"#
15405    )
15406}
15407
15408/// Returns a process-wide mutex unique to `dir`, so that two requests writing
15409/// artifacts into the *same* output directory (e.g. re-ingesting an identical
15410/// `run_id`) serialize instead of corrupting each other's files. Directories that
15411/// differ never contend, so legitimate parallel analyses keep their throughput.
15412fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15413    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15414        OnceLock::new();
15415    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15416    let mut guard = map
15417        .lock()
15418        .unwrap_or_else(std::sync::PoisonError::into_inner);
15419    guard
15420        .entry(dir.to_path_buf())
15421        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15422        .clone()
15423}
15424
15425#[allow(clippy::too_many_lines)]
15426fn persist_run_artifacts(
15427    run: &sloc_core::AnalysisRun,
15428    report_html: &str,
15429    run_dir: &Path,
15430    report_title: &str,
15431    file_stem: &str,
15432    result_context: RunResultContext,
15433) -> Result<(RunArtifacts, PendingPdf)> {
15434    // Serialize concurrent writers targeting this same output directory so their
15435    // file writes cannot interleave and corrupt one another.
15436    let dir_lock = output_dir_lock(run_dir);
15437    let _dir_guard = dir_lock
15438        .lock()
15439        .unwrap_or_else(std::sync::PoisonError::into_inner);
15440
15441    // Root dir + organised subdirectories.
15442    let html_dir = run_dir.join("html");
15443    let pdf_dir = run_dir.join("pdf");
15444    let excel_dir = run_dir.join("excel");
15445    let json_dir = run_dir.join("json");
15446    let submodules_dir = run_dir.join("submodules");
15447    for dir in &[
15448        run_dir,
15449        &html_dir,
15450        &pdf_dir,
15451        &excel_dir,
15452        &json_dir,
15453        &submodules_dir,
15454    ] {
15455        fs::create_dir_all(dir)
15456            .with_context(|| format!("failed to create directory {}", dir.display()))?;
15457    }
15458
15459    // HTML report in html/.
15460    let html_path = {
15461        let path = html_dir.join(format!("report_{file_stem}.html"));
15462        fs::write(&path, report_html)
15463            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15464        Some(path)
15465    };
15466
15467    // JSON result in json/.
15468    let json_path = {
15469        let path = json_dir.join(format!("result_{file_stem}.json"));
15470        let json = serde_json::to_string_pretty(run)
15471            .context("failed to serialize analysis run to JSON")?;
15472        fs::write(&path, json)
15473            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15474        Some(path)
15475    };
15476
15477    // PDF in pdf/.
15478    let (pdf_path, pending_pdf) = {
15479        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15480        match write_pdf_from_run(run, &pdf_dest) {
15481            Ok(()) => {
15482                eprintln!(
15483                    "[oxide-sloc][pdf] native PDF written to {}",
15484                    pdf_dest.display()
15485                );
15486                (Some(pdf_dest), None)
15487            }
15488            Err(native_err) => {
15489                eprintln!(
15490                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15491                );
15492                let source_html_path = html_path
15493                    .as_ref()
15494                    .expect("html_path always Some here")
15495                    .clone();
15496                let pending = Some((source_html_path, pdf_dest.clone(), false));
15497                (Some(pdf_dest), pending)
15498            }
15499        }
15500    };
15501
15502    // CSV and XLSX in excel/.
15503    let csv_path = {
15504        let path = excel_dir.join(format!("report_{file_stem}.csv"));
15505        if let Err(e) = sloc_report::write_csv(run, &path) {
15506            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15507            None
15508        } else {
15509            Some(path)
15510        }
15511    };
15512
15513    let xlsx_path = {
15514        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15515        if let Err(e) = sloc_report::write_xlsx(run, &path) {
15516            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15517            None
15518        } else {
15519            Some(path)
15520        }
15521    };
15522
15523    // Scan config in json/.
15524    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15525
15526    // Eagerly generate sub-reports before index.html so relative links work.
15527    if run.effective_configuration.discovery.submodule_breakdown {
15528        let run_id = &run.tool.run_id;
15529        for s in &run.submodule_summaries {
15530            build_submodule_row(s, run, run_id, run_dir);
15531        }
15532    }
15533
15534    // index.html at root — offline static export of the result-page dashboard.
15535    generate_offline_index(
15536        run,
15537        run_dir,
15538        file_stem,
15539        html_path.as_deref(),
15540        pdf_path.as_deref(),
15541        json_path.as_deref(),
15542        scan_config_path.as_deref(),
15543        &result_context,
15544    );
15545
15546    Ok((
15547        RunArtifacts {
15548            output_dir: run_dir.to_path_buf(),
15549            html_path,
15550            pdf_path,
15551            json_path,
15552            csv_path,
15553            xlsx_path,
15554            scan_config_path,
15555            report_title: report_title.to_string(),
15556            result_context,
15557        },
15558        pending_pdf,
15559    ))
15560}
15561
15562/// Render a static offline result-page dashboard and write it as `index.html` at
15563/// the root of the run output directory so business users can open it from disk.
15564#[allow(clippy::too_many_arguments)]
15565#[allow(clippy::too_many_lines)]
15566#[allow(clippy::similar_names)]
15567fn generate_offline_index(
15568    run: &sloc_core::AnalysisRun,
15569    run_dir: &Path,
15570    file_stem: &str,
15571    html_path: Option<&Path>,
15572    pdf_path: Option<&Path>,
15573    json_path: Option<&Path>,
15574    scan_config_path: Option<&Path>,
15575    result_context: &RunResultContext,
15576) {
15577    let prev_entry = &result_context.prev_entry;
15578    let prev_scan_count = result_context.prev_scan_count;
15579    let project_path = &result_context.project_path;
15580
15581    let scan_delta = prev_entry.as_ref().and_then(|prev| {
15582        prev.json_path
15583            .as_ref()
15584            .and_then(|p| read_json(p).ok())
15585            .map(|prev_run| compute_delta(&prev_run, run))
15586    });
15587
15588    let files_analyzed = run.per_file_records.len() as u64;
15589    let files_skipped = run.skipped_file_records.len() as u64;
15590    let totals = sum_lang_totals(run);
15591
15592    let DeltaFields {
15593        prev_fa_str,
15594        prev_fs_str,
15595        prev_pl_str,
15596        prev_cl_str,
15597        prev_cml_str,
15598        prev_bl_str,
15599        delta_fa_str,
15600        delta_fa_class,
15601        delta_fs_str,
15602        delta_fs_class,
15603        delta_pl_str,
15604        delta_pl_class,
15605        delta_cl_str,
15606        delta_cl_class,
15607        delta_cml_str,
15608        delta_cml_class,
15609        delta_bl_str,
15610        delta_bl_class,
15611        delta_lines_added,
15612        delta_lines_removed,
15613        delta_lines_net_str,
15614        delta_lines_net_class,
15615    } = compute_delta_fields(
15616        prev_entry.as_ref(),
15617        &totals,
15618        files_analyzed,
15619        files_skipped,
15620        scan_delta.as_ref(),
15621    );
15622
15623    let git_commit_url = git_commit_url_for(run);
15624    let git_branch_url = git_branch_url_for(run);
15625    let scan_performed_by = scan_performed_by(run);
15626
15627    // Convert absolute path to relative from run_dir (for file:// navigation).
15628    let make_rel = |p: Option<&Path>| -> Option<String> {
15629        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15630            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15631    };
15632
15633    let run_id = &run.tool.run_id;
15634
15635    // Submodule rows with relative paths into submodules/.
15636    let submodule_rows: Vec<SubmoduleRow> = run
15637        .submodule_summaries
15638        .iter()
15639        .map(|s| {
15640            let safe = sanitize_project_label(&s.name);
15641            let key = format!("sub_{safe}");
15642            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15643            SubmoduleRow {
15644                name: s.name.clone(),
15645                relative_path: s.relative_path.clone(),
15646                files_analyzed: s.files_analyzed,
15647                code_lines: s.code_lines,
15648                comment_lines: s.comment_lines,
15649                blank_lines: s.blank_lines,
15650                total_physical_lines: s.total_physical_lines,
15651                html_url: if sub_path.exists() {
15652                    Some(format!("submodules/{key}.html"))
15653                } else {
15654                    None
15655                },
15656            }
15657        })
15658        .collect();
15659
15660    let lang_chart_json = build_lang_chart_json(run);
15661
15662    let scan_config_rel =
15663        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15664
15665    let template = ResultTemplate {
15666        version: env!("CARGO_PKG_VERSION"),
15667        report_title: run.effective_configuration.reporting.report_title.clone(),
15668        project_path: project_path.clone(),
15669        output_dir: display_path(run_dir),
15670        run_id: run_id.clone(),
15671        run_id_short: run_id
15672            .split('-')
15673            .next_back()
15674            .unwrap_or(run_id)
15675            .chars()
15676            .take(7)
15677            .collect(),
15678        files_analyzed,
15679        files_skipped,
15680        physical_lines: totals.physical_lines,
15681        code_lines: totals.code_lines,
15682        comment_lines: totals.comment_lines,
15683        blank_lines: totals.blank_lines,
15684        mixed_lines: totals.mixed_lines,
15685        functions: totals.functions,
15686        classes: totals.classes,
15687        variables: totals.variables,
15688        imports: totals.imports,
15689        html_url: make_rel(html_path),
15690        pdf_url: make_rel(pdf_path),
15691        json_url: make_rel(json_path),
15692        html_download_url: make_rel(html_path),
15693        pdf_download_url: make_rel(pdf_path),
15694        json_download_url: make_rel(json_path),
15695        html_path: html_path.map(display_path),
15696        json_path: json_path.map(display_path),
15697        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15698        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15699        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15700        prev_fa_str,
15701        prev_fs_str,
15702        prev_pl_str,
15703        prev_cl_str,
15704        prev_cml_str,
15705        prev_bl_str,
15706        delta_fa_str,
15707        delta_fa_class,
15708        delta_fs_str,
15709        delta_fs_class,
15710        delta_pl_str,
15711        delta_pl_class,
15712        delta_cl_str,
15713        delta_cl_class,
15714        delta_cml_str,
15715        delta_cml_class,
15716        delta_bl_str,
15717        delta_bl_class,
15718        delta_lines_added,
15719        delta_lines_removed,
15720        delta_lines_net_str,
15721        delta_lines_net_class,
15722        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15723        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15724        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15725        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15726        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15727        git_branch: run.git_branch.clone(),
15728        git_branch_url,
15729        git_commit: run.git_commit_short.clone(),
15730        git_commit_long: run.git_commit_long.clone(),
15731        git_author: run.git_commit_author.clone(),
15732        git_commit_url,
15733        scan_performed_by,
15734        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
15735        os_display: format!(
15736            "{} / {}",
15737            run.environment.operating_system, run.environment.architecture
15738        ),
15739        test_count: run.summary_totals.test_count,
15740        test_assertion_count: run.summary_totals.test_assertion_count,
15741        current_scan_number: prev_scan_count + 1,
15742        prev_scan_count,
15743        submodule_rows,
15744        pdf_generating: false,
15745        scan_config_url: scan_config_rel,
15746        lang_chart_json,
15747        scatter_chart_json: build_scatter_chart_json(run),
15748        semantic_chart_json: build_semantic_chart_json(run),
15749        submodule_chart_json: build_submodule_chart_json(run),
15750        has_submodule_data: !run.submodule_summaries.is_empty(),
15751        has_semantic_data: run
15752            .totals_by_language
15753            .iter()
15754            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
15755        csp_nonce: String::new(),
15756        confluence_configured: false,
15757        server_mode: false,
15758        report_header_footer: run
15759            .effective_configuration
15760            .reporting
15761            .report_header_footer
15762            .clone(),
15763        is_offline: true,
15764        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
15765        lsloc: run.summary_totals.lsloc,
15766        uloc: run.uloc,
15767        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
15768        duplicate_group_count: run.duplicate_groups.len(),
15769        has_cocomo: run.cocomo.is_some(),
15770        cocomo_effort_str: run
15771            .cocomo
15772            .as_ref()
15773            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
15774        cocomo_duration_str: run
15775            .cocomo
15776            .as_ref()
15777            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
15778        cocomo_staff_str: run
15779            .cocomo
15780            .as_ref()
15781            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
15782        cocomo_ksloc_str: run
15783            .cocomo
15784            .as_ref()
15785            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
15786        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
15787            || "Organic".to_string(),
15788            |c| cocomo_mode_label(c.mode).to_string(),
15789        ),
15790        cocomo_mode_tooltip: run
15791            .cocomo
15792            .as_ref()
15793            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
15794        complexity_alert: 0,
15795        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
15796        cov_line_pct: cov_pct_str(
15797            run.summary_totals.coverage_lines_hit,
15798            run.summary_totals.coverage_lines_found,
15799        ),
15800        cov_fn_pct: cov_pct_str(
15801            run.summary_totals.coverage_functions_hit,
15802            run.summary_totals.coverage_functions_found,
15803        ),
15804        cov_branch_pct: cov_pct_str(
15805            run.summary_totals.coverage_branches_hit,
15806            run.summary_totals.coverage_branches_found,
15807        ),
15808        cov_lines_summary: cov_lines_summary_str(
15809            run.summary_totals.coverage_lines_hit,
15810            run.summary_totals.coverage_lines_found,
15811        ),
15812    };
15813
15814    if let Ok(html) = template.render() {
15815        // Inline the brand + watermark logos as data URIs: a file:// page has no
15816        // server to resolve the /images/logo/* routes, so without this the top-left
15817        // logo and the repeated "Oxide" background watermark render as broken images.
15818        let html = inline_offline_logos(&html);
15819        let index_path = run_dir.join("index.html");
15820        if let Err(e) = fs::write(&index_path, html) {
15821            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
15822        }
15823    }
15824}
15825
15826/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
15827/// offline `index.html` displays the brand logo and background watermark when
15828/// opened directly from disk (file://), where the `/images/...` routes do not exist.
15829fn inline_offline_logos(html: &str) -> String {
15830    use base64::Engine;
15831    let text_uri = format!(
15832        "data:image/png;base64,{}",
15833        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
15834    );
15835    let small_uri = format!(
15836        "data:image/png;base64,{}",
15837        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
15838    );
15839    html.replace("/images/logo/logo-text.png", &text_uri)
15840        .replace("/images/logo/small-logo.png", &small_uri)
15841}
15842
15843/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
15844/// then root (old flat layout), for backwards compatibility.
15845fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
15846    // New layout: json/scan-config_*.json
15847    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
15848        return Some(found);
15849    }
15850    // Old flat layout: scan-config.json or scan-config_*.json at root
15851    find_scan_config_in_dir_flat(dir)
15852}
15853
15854fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
15855    let exact = dir.join("scan-config.json");
15856    if exact.exists() {
15857        return Some(exact);
15858    }
15859    fs::read_dir(dir).ok().and_then(|entries| {
15860        entries
15861            .filter_map(std::result::Result::ok)
15862            .find(|e| {
15863                let name = e.file_name();
15864                let name = name.to_string_lossy();
15865                name.starts_with("scan-config") && name.ends_with(".json")
15866            })
15867            .map(|e| e.path())
15868    })
15869}
15870
15871// ── Config export / import ────────────────────────────────────────────────────
15872
15873/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
15874/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
15875#[derive(Deserialize)]
15876struct ExportPdfRequest {
15877    html: String,
15878    #[serde(default)]
15879    filename: Option<String>,
15880}
15881
15882async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
15883    let html_content = body.html;
15884    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
15885    if html_content.is_empty() {
15886        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
15887    }
15888    // Write HTML to a temp file, run headless Chrome PDF export, read result.
15889    let tmp_dir = std::env::temp_dir();
15890    let html_path = tmp_dir.join(format!(
15891        "sloc-export-{}.html",
15892        uuid::Uuid::new_v4().simple()
15893    ));
15894    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
15895    if let Err(e) = std::fs::write(&html_path, &html_content) {
15896        return (
15897            StatusCode::INTERNAL_SERVER_ERROR,
15898            format!("Failed to write temp HTML: {e}"),
15899        )
15900            .into_response();
15901    }
15902    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
15903    let _ = std::fs::remove_file(&html_path);
15904    if let Err(e) = pdf_result {
15905        let _ = std::fs::remove_file(&pdf_path);
15906        return (
15907            StatusCode::INTERNAL_SERVER_ERROR,
15908            format!("PDF generation failed: {e}"),
15909        )
15910            .into_response();
15911    }
15912    let pdf_bytes = match std::fs::read(&pdf_path) {
15913        Ok(b) => b,
15914        Err(e) => {
15915            let _ = std::fs::remove_file(&pdf_path);
15916            return (
15917                StatusCode::INTERNAL_SERVER_ERROR,
15918                format!("Failed to read PDF: {e}"),
15919            )
15920                .into_response();
15921        }
15922    };
15923    let _ = std::fs::remove_file(&pdf_path);
15924    let safe_name: String = filename
15925        .chars()
15926        .map(|c| {
15927            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
15928                c
15929            } else {
15930                '_'
15931            }
15932        })
15933        .collect();
15934    let disposition = format!("attachment; filename=\"{safe_name}\"");
15935    (
15936        [
15937            (header::CONTENT_TYPE, "application/pdf".to_string()),
15938            (header::CONTENT_DISPOSITION, disposition),
15939        ],
15940        pdf_bytes,
15941    )
15942        .into_response()
15943}
15944
15945async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
15946    let toml_str = match toml::to_string_pretty(&state.base_config) {
15947        Ok(s) => s,
15948        Err(e) => {
15949            return (
15950                StatusCode::INTERNAL_SERVER_ERROR,
15951                format!("serialization error: {e}"),
15952            )
15953                .into_response();
15954        }
15955    };
15956    (
15957        [
15958            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
15959            (
15960                header::CONTENT_DISPOSITION,
15961                "attachment; filename=\".oxide-sloc.toml\"",
15962            ),
15963        ],
15964        toml_str,
15965    )
15966        .into_response()
15967}
15968
15969#[derive(Serialize)]
15970struct OkResponse {
15971    ok: bool,
15972}
15973
15974#[derive(Serialize)]
15975struct SaveProfileResponse {
15976    ok: bool,
15977    id: String,
15978}
15979
15980#[derive(Serialize)]
15981struct ProfileListResponse {
15982    profiles: Vec<ScanProfile>,
15983}
15984
15985#[derive(Serialize)]
15986struct ImportConfigResponse {
15987    ok: bool,
15988    config: sloc_config::AppConfig,
15989}
15990
15991#[derive(Deserialize)]
15992struct ImportConfigBody {
15993    toml: String,
15994}
15995
15996async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
15997    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
15998        Ok(config) => {
15999            if let Err(e) = config.validate() {
16000                return error::unprocessable_entity(&e.to_string());
16001            }
16002            Json(ImportConfigResponse { ok: true, config }).into_response()
16003        }
16004        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16005    }
16006}
16007
16008// ── Scan profiles API ─────────────────────────────────────────────────────────
16009
16010async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16011    let store = state.scan_profiles.lock().await;
16012    Json(ProfileListResponse {
16013        profiles: store.profiles.clone(),
16014    })
16015}
16016
16017#[derive(Deserialize)]
16018struct SaveScanProfileBody {
16019    name: String,
16020    params: serde_json::Value,
16021}
16022
16023async fn api_save_scan_profile(
16024    State(state): State<AppState>,
16025    Json(body): Json<SaveScanProfileBody>,
16026) -> impl IntoResponse {
16027    if body.name.trim().is_empty() {
16028        return error::bad_request("name must not be empty");
16029    }
16030
16031    let id = uuid::Uuid::new_v4().to_string();
16032    let profile = ScanProfile {
16033        id: id.clone(),
16034        name: body.name.trim().to_string(),
16035        created_at: chrono::Utc::now().to_rfc3339(),
16036        params: body.params,
16037    };
16038
16039    let mut store = state.scan_profiles.lock().await;
16040    store.profiles.push(profile);
16041    if let Err(e) = store.save(&state.scan_profiles_path) {
16042        tracing::warn!("failed to persist scan profiles: {e}");
16043    }
16044    drop(store);
16045
16046    (
16047        StatusCode::CREATED,
16048        Json(SaveProfileResponse { ok: true, id }),
16049    )
16050        .into_response()
16051}
16052
16053async fn api_delete_scan_profile(
16054    State(state): State<AppState>,
16055    AxumPath(id): AxumPath<String>,
16056) -> impl IntoResponse {
16057    let mut store = state.scan_profiles.lock().await;
16058    let before = store.profiles.len();
16059    store.profiles.retain(|p| p.id != id);
16060    if store.profiles.len() == before {
16061        drop(store);
16062        return error::not_found("profile not found");
16063    }
16064    if let Err(e) = store.save(&state.scan_profiles_path) {
16065        tracing::warn!("failed to persist scan profiles: {e}");
16066    }
16067    drop(store);
16068    Json(OkResponse { ok: true }).into_response()
16069}
16070
16071fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16072    let value = raw.unwrap_or("out/web").trim();
16073    let path = if value.is_empty() {
16074        PathBuf::from("out/web")
16075    } else {
16076        PathBuf::from(value)
16077    };
16078
16079    if path.is_absolute() {
16080        path
16081    } else {
16082        workspace_root().join(path)
16083    }
16084}
16085
16086/// Derive the directory that holds remote-repo clones from the output root.
16087fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16088    std::env::var("SLOC_GIT_CLONES_DIR")
16089        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16090}
16091
16092/// Build a deterministic filesystem path for a cloned remote repository.
16093/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16094pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16095    let safe: String = repo_url
16096        .chars()
16097        .map(|c| {
16098            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16099                c
16100            } else {
16101                '_'
16102            }
16103        })
16104        .take(80)
16105        .collect();
16106    clones_dir.join(safe)
16107}
16108
16109/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16110/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16111pub(crate) fn scan_path_to_artifacts(
16112    scan_path: &Path,
16113    base_config: &AppConfig,
16114    label: &str,
16115) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16116    let mut config = base_config.clone();
16117    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16118    label.clone_into(&mut config.reporting.report_title);
16119    let run = analyze(&config, "git", None, None)?;
16120    let html = render_html(&run)?;
16121    let run_id = run.tool.run_id.clone();
16122    let project_label = sanitize_project_label(label);
16123    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16124    let file_stem = {
16125        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16126        if commit.is_empty() {
16127            project_label
16128        } else {
16129            format!("{project_label}_{commit}")
16130        }
16131    };
16132    let (artifacts, _pending_pdf) = persist_run_artifacts(
16133        &run,
16134        &html,
16135        &output_dir,
16136        label,
16137        &file_stem,
16138        RunResultContext::default(),
16139    )?;
16140    Ok((run_id, artifacts, run))
16141}
16142
16143/// Re-spawn background poll tasks for any polling schedules saved to disk.
16144async fn restart_poll_schedules(state: &AppState) {
16145    let store = state.schedules.lock().await;
16146    let poll_schedules: Vec<_> = store
16147        .schedules
16148        .iter()
16149        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16150        .cloned()
16151        .collect();
16152    drop(store);
16153    for schedule in poll_schedules {
16154        let interval = schedule.interval_secs.unwrap_or(300);
16155        let st = state.clone();
16156        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16157    }
16158}
16159
16160/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16161/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16162/// header (no HMAC over the body), so the token is exposed in cleartext unless
16163/// the transport is encrypted. This is only an advisory — TLS may be terminated
16164/// by an upstream reverse proxy, in which case the warning can be ignored.
16165async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16166    if state.tls_enabled {
16167        return;
16168    }
16169    let store = state.schedules.lock().await;
16170    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16171        s.kind == sloc_git::ScanScheduleKind::Webhook
16172            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16173    });
16174    drop(store);
16175    if has_gitlab_webhook {
16176        tracing::warn!(
16177            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16178             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16179             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16180             proxy so the token is not exposed in cleartext."
16181        );
16182    }
16183}
16184
16185fn split_patterns(raw: Option<&str>) -> Vec<String> {
16186    raw.unwrap_or("")
16187        .lines()
16188        .flat_map(|line| line.split(','))
16189        .map(str::trim)
16190        .filter(|part| !part.is_empty())
16191        .map(ToOwned::to_owned)
16192        .collect()
16193}
16194
16195#[must_use]
16196pub fn build_sub_run(
16197    parent: &AnalysisRun,
16198    sub: &sloc_core::SubmoduleSummary,
16199    parent_path: &str,
16200) -> AnalysisRun {
16201    let sub_files: Vec<_> = parent
16202        .per_file_records
16203        .iter()
16204        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16205        .cloned()
16206        .collect();
16207    let mut config = parent.effective_configuration.clone();
16208    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16209
16210    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16211    let mut functions = 0u64;
16212    let mut classes = 0u64;
16213    let mut variables = 0u64;
16214    let mut imports = 0u64;
16215    let mut test_count = 0u64;
16216    let mut test_assertion_count = 0u64;
16217    let mut test_suite_count = 0u64;
16218    let mut mixed_lines_separate = 0u64;
16219    let mut coverage_lines_found = 0u64;
16220    let mut coverage_lines_hit = 0u64;
16221    let mut coverage_functions_found = 0u64;
16222    let mut coverage_functions_hit = 0u64;
16223    let mut coverage_branches_found = 0u64;
16224    let mut coverage_branches_hit = 0u64;
16225    for r in &sub_files {
16226        functions += r.raw_line_categories.functions;
16227        classes += r.raw_line_categories.classes;
16228        variables += r.raw_line_categories.variables;
16229        imports += r.raw_line_categories.imports;
16230        test_count += r.raw_line_categories.test_count;
16231        test_assertion_count += r.raw_line_categories.test_assertion_count;
16232        test_suite_count += r.raw_line_categories.test_suite_count;
16233        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16234        if let Some(cov) = &r.coverage {
16235            coverage_lines_found += u64::from(cov.lines_found);
16236            coverage_lines_hit += u64::from(cov.lines_hit);
16237            coverage_functions_found += u64::from(cov.functions_found);
16238            coverage_functions_hit += u64::from(cov.functions_hit);
16239            coverage_branches_found += u64::from(cov.branches_found);
16240            coverage_branches_hit += u64::from(cov.branches_hit);
16241        }
16242    }
16243
16244    AnalysisRun {
16245        tool: parent.tool.clone(),
16246        environment: parent.environment.clone(),
16247        effective_configuration: config,
16248        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16249        summary_totals: SummaryTotals {
16250            files_considered: sub.files_analyzed,
16251            files_analyzed: sub.files_analyzed,
16252            files_skipped: 0,
16253            total_physical_lines: sub.total_physical_lines,
16254            code_lines: sub.code_lines,
16255            comment_lines: sub.comment_lines,
16256            blank_lines: sub.blank_lines,
16257            mixed_lines_separate,
16258            functions,
16259            classes,
16260            variables,
16261            imports,
16262            test_count,
16263            test_assertion_count,
16264            test_suite_count,
16265            coverage_lines_found,
16266            coverage_lines_hit,
16267            coverage_functions_found,
16268            coverage_functions_hit,
16269            coverage_branches_found,
16270            coverage_branches_hit,
16271            cyclomatic_complexity: 0,
16272            lsloc: None,
16273        },
16274        totals_by_language: sub.language_summaries.clone(),
16275        per_file_records: sub_files,
16276        skipped_file_records: vec![],
16277        warnings: vec![],
16278        submodule_summaries: vec![],
16279        git_commit_short: sub.git_commit_short.clone(),
16280        git_commit_long: sub.git_commit_long.clone(),
16281        git_branch: sub.git_branch.clone(),
16282        git_commit_author: sub.git_commit_author.clone(),
16283        git_commit_date: sub.git_commit_date.clone(),
16284        git_tags: None,
16285        git_nearest_tag: None,
16286        git_remote_url: sub.git_remote_url.clone(),
16287        style_summary: None,
16288        cocomo: None,
16289        uloc: 0,
16290        dryness_pct: None,
16291        duplicate_groups: vec![],
16292        duplicates_excluded: 0,
16293    }
16294}
16295
16296#[must_use]
16297pub fn sanitize_project_label(raw: &str) -> String {
16298    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16299    // where `Path` treats '\' as a literal character, not a separator.
16300    let candidate = raw
16301        .split(['/', '\\'])
16302        .rfind(|s| !s.is_empty())
16303        .unwrap_or("project");
16304
16305    let mut value = String::with_capacity(candidate.len());
16306    for ch in candidate.chars() {
16307        if ch.is_ascii_alphanumeric() {
16308            value.push(ch.to_ascii_lowercase());
16309        } else {
16310            value.push('-');
16311        }
16312    }
16313
16314    let compact = value.trim_matches('-').to_string();
16315    if compact.is_empty() {
16316        "project".to_string()
16317    } else {
16318        compact
16319    }
16320}
16321
16322/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16323/// comparisons with non-canonicalized stored paths work correctly.
16324fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16325    let s = path.to_string_lossy();
16326    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16327        return PathBuf::from(format!(r"\\{rest}"));
16328    }
16329    if let Some(rest) = s.strip_prefix(r"\\?\") {
16330        return PathBuf::from(rest);
16331    }
16332    path
16333}
16334
16335/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16336/// commit page URL for the most common hosting platforms.
16337fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16338    let base = if let Some(rest) = remote.strip_prefix("git@") {
16339        let (host, path) = rest.split_once(':')?;
16340        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16341    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16342        remote
16343            .trim_end_matches('/')
16344            .trim_end_matches(".git")
16345            .to_owned()
16346    } else {
16347        return None;
16348    };
16349    let base = base.trim_end_matches('/');
16350    // GitLab uses /-/commit/; everything else uses /commit/
16351    if base.contains("gitlab.com") || base.contains("gitlab.") {
16352        Some(format!("{base}/-/commit/{sha}"))
16353    } else if base.contains("bitbucket.org") {
16354        Some(format!("{base}/commits/{sha}"))
16355    } else {
16356        Some(format!("{base}/commit/{sha}"))
16357    }
16358}
16359
16360/// Convert a git remote URL (https or git@) + branch name into a browser-openable
16361/// branch page URL for the most common hosting platforms.
16362fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16363    let base = if let Some(rest) = remote.strip_prefix("git@") {
16364        let (host, path) = rest.split_once(':')?;
16365        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16366    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16367        remote
16368            .trim_end_matches('/')
16369            .trim_end_matches(".git")
16370            .to_owned()
16371    } else {
16372        return None;
16373    };
16374    let base = base.trim_end_matches('/');
16375    if base.contains("gitlab.com") || base.contains("gitlab.") {
16376        Some(format!("{base}/-/tree/{branch}"))
16377    } else {
16378        Some(format!("{base}/tree/{branch}"))
16379    }
16380}
16381
16382fn display_path(path: &Path) -> String {
16383    let s = path.to_string_lossy();
16384    // Strip Windows extended-length prefix for display only; the underlying
16385    // PathBuf remains unchanged so file operations are unaffected.
16386    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
16387    // \\?\C:\path           →  C:\path          (local drive)
16388    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16389        return format!(r"\\{rest}");
16390    }
16391    if let Some(rest) = s.strip_prefix(r"\\?\") {
16392        return rest.to_owned();
16393    }
16394    s.into_owned()
16395}
16396
16397fn sanitize_path_str(s: &str) -> String {
16398    // Forward-slash variants of the Windows extended-length prefix that appear
16399    // when paths stored as plain strings have been processed through some path
16400    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
16401    if let Some(rest) = s.strip_prefix("//?/UNC/") {
16402        return format!("//{rest}");
16403    }
16404    if let Some(rest) = s.strip_prefix("//?/") {
16405        return rest.to_owned();
16406    }
16407    display_path(Path::new(s))
16408}
16409
16410fn workspace_root() -> PathBuf {
16411    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
16412    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16413        let p = PathBuf::from(root);
16414        if p.is_dir() {
16415            return p;
16416        }
16417    }
16418
16419    // Current working directory — works for `cargo run` from the project root
16420    // and for scripts/run.sh which cds there first.
16421    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16422}
16423
16424/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
16425fn make_git_label(repo: &str, ref_name: &str) -> String {
16426    if repo.is_empty() || ref_name.is_empty() {
16427        return String::new();
16428    }
16429    let base = repo
16430        .trim_end_matches('/')
16431        .trim_end_matches(".git")
16432        .rsplit('/')
16433        .next()
16434        .unwrap_or("repo");
16435    let ref_safe: String = ref_name
16436        .chars()
16437        .map(|c| {
16438            if c.is_alphanumeric() || c == '-' || c == '.' {
16439                c
16440            } else {
16441                '_'
16442            }
16443        })
16444        .collect();
16445    format!("{base}_at_{ref_safe}_sloc")
16446}
16447
16448/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
16449fn desktop_dir() -> PathBuf {
16450    if let Ok(profile) = std::env::var("USERPROFILE") {
16451        let p = PathBuf::from(profile).join("Desktop");
16452        if p.exists() {
16453            return p;
16454        }
16455    }
16456    if let Ok(home) = std::env::var("HOME") {
16457        let p = PathBuf::from(home).join("Desktop");
16458        if p.exists() {
16459            return p;
16460        }
16461    }
16462    workspace_root().join("out").join("web")
16463}
16464
16465fn resolve_input_path(raw: &str) -> PathBuf {
16466    let trimmed = raw.trim();
16467    if trimmed.is_empty() {
16468        return workspace_root().join("samples").join("basic");
16469    }
16470
16471    let candidate = PathBuf::from(trimmed);
16472    let resolved = if candidate.is_absolute() {
16473        candidate
16474    } else {
16475        let rooted = workspace_root().join(&candidate);
16476        if rooted.exists() {
16477            rooted
16478        } else {
16479            workspace_root().join(candidate)
16480        }
16481    };
16482
16483    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
16484    // strip that prefix so stored paths and the displayed "Project path" are clean.
16485    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16486    PathBuf::from(display_path(&canonical))
16487}
16488
16489fn dir_size_bytes(path: &Path) -> u64 {
16490    let mut total = 0u64;
16491    if let Ok(rd) = fs::read_dir(path) {
16492        for entry in rd.filter_map(Result::ok) {
16493            let p = entry.path();
16494            if p.is_file() {
16495                if let Ok(meta) = p.metadata() {
16496                    total += meta.len();
16497                }
16498            } else if p.is_dir() {
16499                total += dir_size_bytes(&p);
16500            }
16501        }
16502    }
16503    total
16504}
16505
16506#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
16507fn format_dir_size(bytes: u64) -> String {
16508    if bytes >= 1_073_741_824 {
16509        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16510    } else if bytes >= 1_048_576 {
16511        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16512    } else if bytes >= 1_024 {
16513        format!("{:.0} KB", bytes as f64 / 1_024.0)
16514    } else {
16515        format!("{bytes} B")
16516    }
16517}
16518
16519fn render_submodule_chips(
16520    root: &Path,
16521    submodules: &[(String, std::path::PathBuf)],
16522    out: &mut String,
16523) {
16524    use std::fmt::Write as _;
16525    let count = submodules.len();
16526    out.push_str(r#"<div class="submodule-preview-strip">"#);
16527    write!(
16528        out,
16529        r#"<div class="submodule-preview-label"><svg viewBox="0 0 24 24" aria-hidden="true"><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"/><circle cx="6" cy="6" r="3"/></svg><strong>{count}</strong>&nbsp;git&nbsp;submodule{}&nbsp;detected</div>"#,
16530        if count == 1 { "" } else { "s" }
16531    )
16532    .ok();
16533    out.push_str(r#"<div class="submodule-preview-chips">"#);
16534    for (sub_name, sub_rel_path) in submodules {
16535        let sub_abs = root.join(sub_rel_path);
16536        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16537        let mut sub_stats = PreviewStats::default();
16538        let mut sub_rows: Vec<PreviewRow> = Vec::new();
16539        let mut sub_langs: Vec<&'static str> = Vec::new();
16540        let mut sub_budget = PreviewBudget {
16541            shown: 0,
16542            max_entries: 2000,
16543            max_depth: 9,
16544        };
16545        let mut sub_next_id = 1usize;
16546        let _ = collect_preview_rows(
16547            &sub_abs,
16548            &sub_abs,
16549            0,
16550            None,
16551            &mut sub_next_id,
16552            &mut sub_budget,
16553            &mut sub_stats,
16554            &mut sub_rows,
16555            &mut sub_langs,
16556            &[],
16557            &[],
16558        );
16559        let stats_json = format!(
16560            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16561            sub_stats.directories,
16562            sub_stats.files,
16563            sub_stats.supported,
16564            sub_stats.skipped,
16565            sub_stats.unsupported
16566        );
16567        write!(
16568            out,
16569            r#"<button type="button" class="submodule-preview-chip" data-sub-name="{}" data-sub-path="{}" data-size="{}" data-sub-stats="{}">{}<span class="submodule-chip-tooltip">Size: {}</span></button>"#,
16570            escape_html(sub_name),
16571            escape_html(&sub_rel_path.to_string_lossy()),
16572            escape_html(&sub_size),
16573            escape_html(&stats_json),
16574            escape_html(sub_name),
16575            escape_html(&sub_size),
16576        )
16577        .ok();
16578    }
16579    out.push_str(
16580        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
16581    );
16582    out.push_str(r"</div>");
16583}
16584
16585fn render_language_pills_row(languages: &[&str], out: &mut String) {
16586    use std::fmt::Write as _;
16587    if languages.is_empty() {
16588        out.push_str(
16589            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16590        );
16591        return;
16592    }
16593    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16594    for language in languages {
16595        if let Some(icon) = language_icon_file(language) {
16596            write!(out, r#"<button type="button" class="language-pill has-icon detected-language-chip" data-language-filter="{}"><img src="/images/icons/{}" alt="{} icon" /><span>{}</span></button>"#, escape_html(&language.to_ascii_lowercase()), icon, escape_html(language), escape_html(language)).ok();
16597        } else if let Some(svg) = language_inline_svg(language) {
16598            write!(out, r#"<button type="button" class="language-pill has-icon detected-language-chip" data-language-filter="{}">{}<span>{}</span></button>"#, escape_html(&language.to_ascii_lowercase()), svg, escape_html(language)).ok();
16599        } else {
16600            write!(
16601                out,
16602                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16603                escape_html(&language.to_ascii_lowercase()),
16604                escape_html(language)
16605            )
16606            .ok();
16607        }
16608    }
16609}
16610
16611#[allow(clippy::too_many_lines)]
16612fn build_preview_html(
16613    root: &Path,
16614    include_patterns: &[String],
16615    exclude_patterns: &[String],
16616) -> Result<String> {
16617    if !root.exists() {
16618        return Ok(format!(
16619            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16620            escape_html(&display_path(root))
16621        ));
16622    }
16623
16624    let _selected = display_path(root);
16625    let mut stats = PreviewStats::default();
16626    let mut rows = Vec::new();
16627    let mut languages = Vec::new();
16628    let mut budget = PreviewBudget {
16629        shown: 0,
16630        max_entries: 600,
16631        max_depth: 9,
16632    };
16633    let mut next_row_id = 1usize;
16634
16635    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16636        || root.to_string_lossy().into_owned(),
16637        std::string::ToString::to_string,
16638    );
16639    let root_modified = root
16640        .metadata()
16641        .ok()
16642        .and_then(|meta| meta.modified().ok())
16643        .map_or_else(|| "-".to_string(), format_system_time);
16644
16645    rows.push(PreviewRow {
16646        row_id: 0,
16647        parent_row_id: None,
16648        depth: 0,
16649        name: format!("{root_name}/"),
16650        kind: PreviewKind::Dir,
16651        is_dir: true,
16652        language: None,
16653        modified: root_modified,
16654        type_label: "Directory".to_string(),
16655    });
16656    collect_preview_rows(
16657        root,
16658        root,
16659        0,
16660        Some(0),
16661        &mut next_row_id,
16662        &mut budget,
16663        &mut stats,
16664        &mut rows,
16665        &mut languages,
16666        include_patterns,
16667        exclude_patterns,
16668    )?;
16669
16670    let root_size = format_dir_size(dir_size_bytes(root));
16671
16672    let mut out = String::new();
16673    write!(
16674        out,
16675        r#"<div class="explorer-wrap" data-project-size="{}">"#,
16676        escape_html(&root_size)
16677    )
16678    .ok();
16679    out.push_str(r#"<div class="explorer-toolbar compact">"#);
16680    out.push_str(r#"<div class="explorer-title-group">"#);
16681    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
16682    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
16683    out.push_str(r"</div></div>");
16684
16685    out.push_str(r#"<div class="scope-stats">"#);
16686    write!(out, r#"<button type="button" class="scope-stat-button" data-filter="dir" data-tooltip="Total directories in the project scope. Click to filter the explorer to directories only."><span class="scope-stat-label">Directories</span><span class="scope-stat-value">{}</span></button>"#, stats.directories).ok();
16687    write!(out, r#"<button type="button" class="scope-stat-button" data-filter="file" data-tooltip="Total files found in the project scope. Click to show only files in the explorer."><span class="scope-stat-label">Files</span><span class="scope-stat-value">{}</span></button>"#, stats.files).ok();
16688    write!(out, r#"<button type="button" class="scope-stat-button supported" data-filter="supported" data-tooltip="Files with a supported language analyzer — counted in SLOC totals. Click to filter to supported files."><span class="scope-stat-label">Supported files</span><span class="scope-stat-value">{}</span></button>"#, stats.supported).ok();
16689    write!(out, r#"<button type="button" class="scope-stat-button skipped" data-filter="skipped" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection. Click to see skipped files."><span class="scope-stat-label">Skipped by policy</span><span class="scope-stat-value">{}</span></button>"#, stats.skipped).ok();
16690    write!(out, r#"<button type="button" class="scope-stat-button unsupported" data-filter="unsupported" data-tooltip="Files outside the supported language set — listed but not counted. Click to filter to unsupported files."><span class="scope-stat-label">Unsupported files</span><span class="scope-stat-value">{}</span></button>"#, stats.unsupported).ok();
16691    out.push_str(r#"<button type="button" class="scope-stat-button reset" data-filter="reset-view" data-tooltip="Clear all filters and return to the full project view."><span class="scope-stat-label">Reset view</span><span class="scope-stat-value">All</span></button>"#);
16692    out.push_str(r"</div>");
16693
16694    let submodules = sloc_core::detect_submodules(root);
16695    if !submodules.is_empty() {
16696        render_submodule_chips(root, &submodules, &mut out);
16697    }
16698
16699    out.push_str(r#"<div class="scope-info-row">"#);
16700    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
16701    render_language_pills_row(&languages, &mut out);
16702    out.push_str(r"</div></div>");
16703    out.push_str(r#"<div class="preview-note stronger">This preview is generated before the run starts. It shows what is currently supported, what default policies skip, and which files are outside the enabled analyzer set for this build.</div>"#);
16704    out.push_str(r"</div>");
16705
16706    out.push_str(r#"<div class="file-explorer-shell">"#);
16707    out.push_str(r#"<div class="file-explorer-controls"><div class="file-explorer-actions"><button type="button" class="mini-button explorer-action" data-explorer-action="expand-all">Expand all</button><button type="button" class="mini-button explorer-action" data-explorer-action="collapse-all">Collapse all</button><button type="button" class="mini-button explorer-action" data-explorer-action="clear-filters">Reset view</button></div><div class="file-explorer-search-row"><select class="explorer-filter-select" id="explorer-filter-select"><option value="all">All rows</option><option value="dir">Directories only</option><option value="file">Files only</option><option value="supported">Supported only</option><option value="skipped">Skipped by policy</option><option value="unsupported">Unsupported only</option></select><input type="text" class="explorer-search" id="explorer-search" placeholder="Filter by file or folder name" /></div></div>"#);
16708    out.push_str(r#"<div class="file-explorer-header"><button type="button" class="tree-sort-button" data-sort-key="name" data-sort-order="none"><span>Name</span><span class="tree-sort-indicator">↕</span></button><button type="button" class="tree-sort-button" data-sort-key="date" data-sort-order="none"><span>Date</span><span class="tree-sort-indicator">↕</span></button><button type="button" class="tree-sort-button" data-sort-key="type" data-sort-order="none"><span>Type</span><span class="tree-sort-indicator">↕</span></button><button type="button" class="tree-sort-button" data-sort-key="status" data-sort-order="none"><span>Status</span><span class="tree-sort-indicator">↕</span></button></div>"#);
16709    out.push_str(r#"<div class="file-explorer-tree">"#);
16710    for row in rows {
16711        let status_label = row.kind.label();
16712        let lang_attr = row.language.unwrap_or("");
16713        let toggle_html = if row.is_dir {
16714            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">\u25be</button>"#
16715                .to_string()
16716        } else {
16717            r#"<span class="tree-bullet">•</span>"#.to_string()
16718        };
16719        write!(out, r#"<div class="tree-row kind-{} status-{}" data-kind="{}" data-status="{}" data-language="{}" data-row-id="{}" data-parent-id="{}" data-dir="{}" data-expanded="true" data-name-lower="{}" data-sort-name="{}" data-sort-date="{}" data-sort-type="{}" data-sort-status="{}"><div class="tree-name-cell" style="--depth:{}">{}<span class="tree-node {}">{}</span></div><div class="tree-date-cell">{}</div><div class="tree-type-cell">{}</div><div class="tree-status-cell"><span class="badge {}">{}</span></div></div>"#, if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), escape_html(lang_attr), row.row_id, row.parent_row_id.map(|id| id.to_string()).unwrap_or_default(), if row.is_dir { "true" } else { "false" }, escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.modified), escape_html(&row.type_label.to_ascii_lowercase()), escape_html(status_label), row.depth, toggle_html, if row.is_dir { "tree-node-dir" } else { row.kind.node_class() }, escape_html(&row.name), escape_html(&row.modified), escape_html(&row.type_label), row.kind.badge_class(), status_label).ok();
16720    }
16721    if budget.shown >= budget.max_entries {
16722        out.push_str(r#"<div class="tree-row more-row" data-kind="file" data-status="more" data-row-id="999999" data-parent-id="" data-dir="false" data-expanded="true" data-name-lower="preview truncated"><div class="tree-name-cell" style="--depth:0"><span class="tree-bullet">•</span><span class="tree-node tree-node-more">... preview truncated for readability ...</span></div><div class="tree-date-cell">-</div><div class="tree-type-cell">Preview note</div><div class="tree-status-cell"></div></div>"#);
16723    }
16724    out.push_str(r"</div></div></div>");
16725
16726    Ok(out)
16727}
16728
16729#[derive(Default)]
16730struct PreviewStats {
16731    directories: usize,
16732    files: usize,
16733    supported: usize,
16734    skipped: usize,
16735    unsupported: usize,
16736}
16737
16738struct PreviewRow {
16739    row_id: usize,
16740    parent_row_id: Option<usize>,
16741    depth: usize,
16742    name: String,
16743    kind: PreviewKind,
16744    is_dir: bool,
16745    language: Option<&'static str>,
16746    modified: String,
16747    type_label: String,
16748}
16749
16750#[derive(Copy, Clone)]
16751enum PreviewKind {
16752    Dir,
16753    Supported,
16754    Skipped,
16755    Unsupported,
16756}
16757
16758impl PreviewKind {
16759    const fn filter_key(self) -> &'static str {
16760        match self {
16761            Self::Dir => "dir",
16762            Self::Supported => "supported",
16763            Self::Skipped => "skipped",
16764            Self::Unsupported => "unsupported",
16765        }
16766    }
16767
16768    const fn label(self) -> &'static str {
16769        match self {
16770            Self::Dir => "dir",
16771            Self::Supported => "supported",
16772            Self::Skipped => "skipped by policy",
16773            Self::Unsupported => "unsupported",
16774        }
16775    }
16776
16777    const fn badge_class(self) -> &'static str {
16778        match self {
16779            Self::Dir => "badge badge-dir",
16780            Self::Supported => "badge badge-scan",
16781            Self::Skipped => "badge badge-skip",
16782            Self::Unsupported => "badge badge-unsupported",
16783        }
16784    }
16785
16786    const fn node_class(self) -> &'static str {
16787        match self {
16788            Self::Dir => "tree-node-dir",
16789            Self::Supported => "tree-node-supported",
16790            Self::Skipped => "tree-node-skipped",
16791            Self::Unsupported => "tree-node-unsupported",
16792        }
16793    }
16794}
16795
16796struct PreviewBudget {
16797    shown: usize,
16798    max_entries: usize,
16799    max_depth: usize,
16800}
16801
16802/// Handle a single directory entry inside `collect_preview_rows`.
16803/// Returns `true` when the entry was handled (caller should `continue`).
16804#[allow(clippy::too_many_arguments)]
16805fn handle_preview_dir_entry(
16806    root: &Path,
16807    path: &Path,
16808    name: &str,
16809    modified: String,
16810    depth: usize,
16811    parent_row_id: Option<usize>,
16812    row_id: usize,
16813    next_row_id: &mut usize,
16814    budget: &mut PreviewBudget,
16815    stats: &mut PreviewStats,
16816    rows: &mut Vec<PreviewRow>,
16817    languages: &mut Vec<&'static str>,
16818    include_patterns: &[String],
16819    exclude_patterns: &[String],
16820) -> Result<()> {
16821    let relative = preview_relative_path(root, path);
16822    if should_skip_preview_directory(&relative, exclude_patterns) {
16823        return Ok(());
16824    }
16825    stats.directories += 1;
16826    rows.push(PreviewRow {
16827        row_id,
16828        parent_row_id,
16829        depth: depth + 1,
16830        name: format!("{name}/"),
16831        kind: PreviewKind::Dir,
16832        is_dir: true,
16833        language: None,
16834        modified,
16835        type_label: "Directory".to_string(),
16836    });
16837    budget.shown += 1;
16838    if !matches!(name, ".git" | "node_modules" | "target") {
16839        collect_preview_rows(
16840            root,
16841            path,
16842            depth + 1,
16843            Some(row_id),
16844            next_row_id,
16845            budget,
16846            stats,
16847            rows,
16848            languages,
16849            include_patterns,
16850            exclude_patterns,
16851        )?;
16852    }
16853    Ok(())
16854}
16855
16856/// Handle a single file entry inside `collect_preview_rows`.
16857#[allow(clippy::too_many_arguments)]
16858fn handle_preview_file_entry(
16859    root: &Path,
16860    path: &Path,
16861    name: &str,
16862    modified: String,
16863    depth: usize,
16864    parent_row_id: Option<usize>,
16865    row_id: usize,
16866    budget: &mut PreviewBudget,
16867    stats: &mut PreviewStats,
16868    rows: &mut Vec<PreviewRow>,
16869    languages: &mut Vec<&'static str>,
16870    include_patterns: &[String],
16871    exclude_patterns: &[String],
16872) {
16873    let relative = preview_relative_path(root, path);
16874    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
16875        return;
16876    }
16877    stats.files += 1;
16878    let kind = classify_preview_file(name);
16879    match kind {
16880        PreviewKind::Supported => stats.supported += 1,
16881        PreviewKind::Skipped => stats.skipped += 1,
16882        PreviewKind::Unsupported => stats.unsupported += 1,
16883        PreviewKind::Dir => {}
16884    }
16885    let language = detect_language_name(name);
16886    if let Some(lang) = language {
16887        if !languages.contains(&lang) {
16888            languages.push(lang);
16889        }
16890    }
16891    rows.push(PreviewRow {
16892        row_id,
16893        parent_row_id,
16894        depth: depth + 1,
16895        name: name.to_owned(),
16896        kind,
16897        is_dir: false,
16898        language,
16899        modified,
16900        type_label: preview_type_label(name, language, kind),
16901    });
16902    budget.shown += 1;
16903}
16904
16905#[allow(clippy::too_many_arguments)]
16906#[allow(clippy::too_many_lines)]
16907fn collect_preview_rows(
16908    root: &Path,
16909    dir: &Path,
16910    depth: usize,
16911    parent_row_id: Option<usize>,
16912    next_row_id: &mut usize,
16913    budget: &mut PreviewBudget,
16914    stats: &mut PreviewStats,
16915    rows: &mut Vec<PreviewRow>,
16916    languages: &mut Vec<&'static str>,
16917    include_patterns: &[String],
16918    exclude_patterns: &[String],
16919) -> Result<()> {
16920    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
16921        return Ok(());
16922    }
16923
16924    let mut entries = fs::read_dir(dir)
16925        .with_context(|| format!("failed to read directory {}", dir.display()))?
16926        .filter_map(std::result::Result::ok)
16927        .collect::<Vec<_>>();
16928    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
16929
16930    for entry in entries {
16931        if budget.shown >= budget.max_entries {
16932            break;
16933        }
16934
16935        let path = entry.path();
16936        let name = entry.file_name().to_string_lossy().into_owned();
16937        let Ok(metadata) = entry.metadata() else {
16938            continue;
16939        };
16940        let row_id = *next_row_id;
16941        *next_row_id += 1;
16942        let modified = metadata
16943            .modified()
16944            .ok()
16945            .map_or_else(|| "-".to_string(), format_system_time);
16946
16947        if metadata.is_dir() {
16948            handle_preview_dir_entry(
16949                root,
16950                &path,
16951                &name,
16952                modified,
16953                depth,
16954                parent_row_id,
16955                row_id,
16956                next_row_id,
16957                budget,
16958                stats,
16959                rows,
16960                languages,
16961                include_patterns,
16962                exclude_patterns,
16963            )?;
16964            continue;
16965        }
16966
16967        if metadata.is_file() {
16968            handle_preview_file_entry(
16969                root,
16970                &path,
16971                &name,
16972                modified,
16973                depth,
16974                parent_row_id,
16975                row_id,
16976                budget,
16977                stats,
16978                rows,
16979                languages,
16980                include_patterns,
16981                exclude_patterns,
16982            );
16983        }
16984    }
16985
16986    Ok(())
16987}
16988
16989fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
16990    if let Some(language) = language {
16991        return format!("{language} source");
16992    }
16993    let lower = name.to_ascii_lowercase();
16994    let ext = Path::new(&lower)
16995        .extension()
16996        .and_then(|e| e.to_str())
16997        .unwrap_or("");
16998    match kind {
16999        PreviewKind::Skipped => {
17000            if lower.ends_with(".min.js") {
17001                "Minified asset".to_string()
17002            } else if [
17003                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17004            ]
17005            .contains(&ext)
17006            {
17007                "Binary or archive".to_string()
17008            } else {
17009                "Skipped file".to_string()
17010            }
17011        }
17012        PreviewKind::Unsupported => {
17013            if ext.is_empty() {
17014                "Unsupported file".to_string()
17015            } else {
17016                format!("{} file", ext.to_ascii_uppercase())
17017            }
17018        }
17019        PreviewKind::Supported => "Supported source".to_string(),
17020        PreviewKind::Dir => "Directory".to_string(),
17021    }
17022}
17023
17024fn format_system_time(time: SystemTime) -> String {
17025    #[allow(clippy::cast_possible_wrap)]
17026    let secs = match time.duration_since(UNIX_EPOCH) {
17027        Ok(duration) => duration.as_secs() as i64,
17028        Err(_) => return "-".to_string(),
17029    };
17030    let days = secs.div_euclid(86_400);
17031    let secs_of_day = secs.rem_euclid(86_400);
17032    let (year, month, day) = civil_from_days(days);
17033    let hour = secs_of_day / 3_600;
17034    let minute = (secs_of_day % 3_600) / 60;
17035    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17036}
17037
17038#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17039fn civil_from_days(days: i64) -> (i32, u32, u32) {
17040    let z = days + 719_468;
17041    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17042    let doe = z - era * 146_097;
17043    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17044    let y = yoe + era * 400;
17045    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17046    let mp = (5 * doy + 2) / 153;
17047    let d = doy - (153 * mp + 2) / 5 + 1;
17048    let m = mp + if mp < 10 { 3 } else { -9 };
17049    let year = y + i64::from(m <= 2);
17050    (year as i32, m as u32, d as u32)
17051}
17052
17053// The input is already lowercased via `to_ascii_lowercase()` before calling
17054// `ends_with`, so the comparisons are inherently case-insensitive.
17055#[allow(clippy::case_sensitive_file_extension_comparisons)]
17056fn detect_language_name(name: &str) -> Option<&'static str> {
17057    let lower = name.to_ascii_lowercase();
17058    if lower.ends_with(".c") || lower.ends_with(".h") {
17059        Some("C")
17060    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17061        .iter()
17062        .any(|s| lower.ends_with(s))
17063    {
17064        Some("C++")
17065    } else if lower.ends_with(".cs") {
17066        Some("C#")
17067    } else if lower.ends_with(".py") {
17068        Some("Python")
17069    } else if lower.ends_with(".sh") {
17070        Some("Shell")
17071    } else if [".ps1", ".psm1", ".psd1"]
17072        .iter()
17073        .any(|s| lower.ends_with(s))
17074    {
17075        Some("PowerShell")
17076    } else {
17077        None
17078    }
17079}
17080
17081fn language_icon_file(language: &str) -> Option<&'static str> {
17082    match language {
17083        "C" => Some("c.png"),
17084        "C++" => Some("cpp.png"),
17085        "C#" => Some("c-sharp.png"),
17086        "Python" => Some("python.png"),
17087        "Shell" => Some("shell.png"),
17088        "PowerShell" => Some("powershell.png"),
17089        "JavaScript" => Some("java-script.png"),
17090        "HTML" => Some("html-5.png"),
17091        "Java" => Some("java.png"),
17092        "Visual Basic" => Some("visual-basic.png"),
17093        "Assembly" => Some("asm.png"),
17094        "Go" => Some("go.png"),
17095        "R" => Some("r.png"),
17096        "XML" => Some("xml.png"),
17097        "Groovy" => Some("groovy.png"),
17098        "Dockerfile" => Some("docker.png"),
17099        "Makefile" => Some("makefile.svg"),
17100        "Perl" => Some("perl.svg"),
17101        _ => None,
17102    }
17103}
17104
17105// Inline SVG badges for languages that have no PNG icon in images/icons/.
17106// Using inline SVG keeps the web UI fully self-contained — no extra files
17107// needed on disk, no 404s on air-gapped deployments.
17108// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17109fn language_inline_svg(language: &str) -> Option<&'static str> {
17110    match language {
17111        "Rust" => Some(
17112            r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" rx="16" fill="#B7410E"/><text x="50" y="68" text-anchor="middle" font-family="sans-serif" font-weight="900" font-size="46" fill="#fff">Rs</text></svg>"##,
17113        ),
17114        "TypeScript" => Some(
17115            r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" rx="16" fill="#3178C6"/><text x="50" y="68" text-anchor="middle" font-family="sans-serif" font-weight="900" font-size="46" fill="#fff">TS</text></svg>"##,
17116        ),
17117        _ => None,
17118    }
17119}
17120
17121// The input is already lowercased via `to_ascii_lowercase()` before the
17122// `ends_with` calls, so these comparisons are inherently case-insensitive.
17123#[allow(clippy::case_sensitive_file_extension_comparisons)]
17124fn classify_preview_file(name: &str) -> PreviewKind {
17125    let lower = name.to_ascii_lowercase();
17126
17127    let scannable = [
17128        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17129        ".psm1", ".psd1",
17130    ]
17131    .iter()
17132    .any(|suffix| lower.ends_with(suffix));
17133
17134    if scannable {
17135        PreviewKind::Supported
17136    } else if lower.ends_with(".min.js")
17137        || lower.ends_with(".lock")
17138        || lower.ends_with(".png")
17139        || lower.ends_with(".jpg")
17140        || lower.ends_with(".jpeg")
17141        || lower.ends_with(".gif")
17142        || lower.ends_with(".zip")
17143        || lower.ends_with(".pdf")
17144        || lower.ends_with(".pyc")
17145        || lower.ends_with(".xz")
17146        || lower.ends_with(".tar")
17147        || lower.ends_with(".gz")
17148    {
17149        PreviewKind::Skipped
17150    } else {
17151        PreviewKind::Unsupported
17152    }
17153}
17154
17155fn preview_relative_path(root: &Path, path: &Path) -> String {
17156    path.strip_prefix(root)
17157        .ok()
17158        .unwrap_or(path)
17159        .to_string_lossy()
17160        .replace('\\', "/")
17161        .trim_matches('/')
17162        .to_string()
17163}
17164
17165fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17166    if relative.is_empty() {
17167        return false;
17168    }
17169
17170    exclude_patterns.iter().any(|pattern| {
17171        wildcard_match(pattern, relative)
17172            || wildcard_match(pattern, &format!("{relative}/"))
17173            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17174    })
17175}
17176
17177fn should_include_preview_file(
17178    relative: &str,
17179    include_patterns: &[String],
17180    exclude_patterns: &[String],
17181) -> bool {
17182    if relative.is_empty() {
17183        return true;
17184    }
17185
17186    let included = include_patterns.is_empty()
17187        || include_patterns
17188            .iter()
17189            .any(|pattern| wildcard_match(pattern, relative));
17190    let excluded = exclude_patterns
17191        .iter()
17192        .any(|pattern| wildcard_match(pattern, relative));
17193
17194    included && !excluded
17195}
17196
17197fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17198    let pattern = pattern.trim().replace('\\', "/");
17199    let candidate = candidate.trim().replace('\\', "/");
17200    let p = pattern.as_bytes();
17201    let c = candidate.as_bytes();
17202    let mut pi = 0usize;
17203    let mut ci = 0usize;
17204    let mut star: Option<usize> = None;
17205    let mut star_match = 0usize;
17206
17207    while ci < c.len() {
17208        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17209            pi += 1;
17210            ci += 1;
17211        } else if pi < p.len() && p[pi] == b'*' {
17212            while pi < p.len() && p[pi] == b'*' {
17213                pi += 1;
17214            }
17215            star = Some(pi);
17216            star_match = ci;
17217        } else if let Some(star_pi) = star {
17218            star_match += 1;
17219            ci = star_match;
17220            pi = star_pi;
17221        } else {
17222            return false;
17223        }
17224    }
17225
17226    while pi < p.len() && p[pi] == b'*' {
17227        pi += 1;
17228    }
17229
17230    pi == p.len()
17231}
17232
17233fn escape_html(value: &str) -> String {
17234    value
17235        .replace('&', "&amp;")
17236        .replace('<', "&lt;")
17237        .replace('>', "&gt;")
17238        .replace('"', "&quot;")
17239        .replace('\'', "&#39;")
17240}
17241
17242#[derive(Clone)]
17243struct SubmoduleRow {
17244    name: String,
17245    relative_path: String,
17246    files_analyzed: u64,
17247    code_lines: u64,
17248    comment_lines: u64,
17249    blank_lines: u64,
17250    total_physical_lines: u64,
17251    html_url: Option<String>,
17252}
17253
17254#[derive(Template)]
17255#[template(
17256    source = r##"
17257<!doctype html>
17258<html lang="en">
17259<head>
17260  <meta charset="utf-8">
17261  <title>OxideSLOC | tmp-sloc</title>
17262  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17263  <style nonce="{{ csp_nonce }}">
17264    :root {
17265      --bg: #efe9e2;
17266      --surface: #fcfaf7;
17267      --surface-2: #f7f0e8;
17268      --surface-3: #efe3d5;
17269      --line: #dfcfbf;
17270      --line-strong: #cfb29c;
17271      --text: #2f241c;
17272      --muted: #6f6257;
17273      --muted-2: #917f71;
17274      --nav: #b85d33;
17275      --nav-2: #7a371b;
17276      --accent: #2563eb;
17277      --accent-2: #1d4ed8;
17278      --oxide: #b85d33;
17279      --oxide-2: #8f4220;
17280      --success-bg: #eaf9ee;
17281      --success-text: #1c8746;
17282      --warn-bg: #fff2d8;
17283      --warn-text: #926000;
17284      --danger-bg: #fdeaea;
17285      --danger-text: #b33b3b;
17286      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17287      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17288      --radius: 14px;
17289    }
17290
17291    body.dark-theme {
17292      --bg: #1b1511;
17293      --surface: #261c17;
17294      --surface-2: #2d221d;
17295      --surface-3: #372922;
17296      --line: #524238;
17297      --line-strong: #6c5649;
17298      --text: #f5ece6;
17299      --muted: #c7b7aa;
17300      --muted-2: #aa9485;
17301      --nav: #b85d33;
17302      --nav-2: #7a371b;
17303      --accent: #6f9bff;
17304      --accent-2: #4a78ee;
17305      --oxide: #d37a4c;
17306      --oxide-2: #b35428;
17307      --success-bg: #163927;
17308      --success-text: #8fe2a8;
17309      --warn-bg: #3c2d11;
17310      --warn-text: #f3cb75;
17311      --danger-bg: #3d1f1f;
17312      --danger-text: #ff9f9f;
17313      --shadow: 0 14px 28px rgba(0,0,0,0.28);
17314      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17315    }
17316
17317    * { box-sizing: border-box; }
17318    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); }
17319    html { overflow-y: scroll; }
17320    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17321    .top-nav, .page, .loading { position: relative; z-index: 2; }
17322    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17323    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17324    .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); }
17325    .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: 18px; }
17326    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17327    .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)); }
17328    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17329    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17330    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17331    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17332    .nav-project-pill { width: 100%; max-width: 240px; display:none; align-items:center; justify-content:center; gap: 10px; 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.10); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
17333    .nav-project-pill.visible { display:inline-flex; }
17334    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17335    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17336    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17337    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17338    @media (max-width: 1150px) { .nav-status { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
17339    .nav-pill, .theme-toggle { 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: inset 0 1px 0 rgba(255,255,255,0.08); white-space: nowrap; text-decoration:none; transition:background .15s ease,transform .15s ease; }
17340    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17341    .nav-pill code { color: #fff; background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.10); padding: 3px 8px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17342    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17343    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17344    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17345    .theme-toggle .icon-sun { display:none; }
17346    body.dark-theme .theme-toggle .icon-sun { display:block; }
17347    body.dark-theme .theme-toggle .icon-moon { display:none; }
17348    .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;}
17349    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17350    .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);}
17351    .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;}
17352    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17353    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17354    .settings-modal-body{padding:14px 16px 16px;}
17355    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17356    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17357    .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;}
17358    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17359    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17360    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17361    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17362    .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;}
17363    .tz-select:focus{border-color:var(--oxide);}
17364    .status-dot { width: 8px; height: 8px; border-radius: 999px; background: #26d768; box-shadow: 0 0 0 4px rgba(38,215,104,0.14); flex:0 0 auto; }
17365    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
17366    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17367    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17368    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17369    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17370    .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); transition: transform .2s ease, box-shadow .2s ease; }
17371    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17372    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17373    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17374    .wb-stats-header { padding: 10px 24px 0; }
17375    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17376    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17377    .ws-stat { display:flex; flex-direction:column; justify-content:center; gap: 6px; flex:0 0 auto; min-width:110px; padding: 12px 18px; border-radius: 10px; background: rgba(184,93,51,0.06); border: 1px solid rgba(184,93,51,0.15); transition: transform .2s ease, box-shadow .2s ease; }
17378    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17379    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17380    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17381    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17382    .ws-badge { display:inline-flex; align-items:center; padding: 1px 8px; border-radius: 999px; background: rgba(184,93,51,0.10); border: 1px solid rgba(184,93,51,0.20); color: var(--oxide-2); font-size: 12px; font-weight: 800; position:relative; cursor:help; overflow: visible; }
17383    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17384    .ws-stat-analyzers { position: relative; }
17385    .ws-lang-tooltip { display:none; position:absolute; top:calc(100% + 6px); left:0; z-index:9999; background:var(--surface); border:1px solid var(--line-strong); border-radius:12px; box-shadow:0 10px 30px rgba(0,0,0,0.18); padding:14px 16px; pointer-events:none; min-width:400px; }
17386    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17387    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17388    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17389    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17390    .ws-lang-item { padding:3px 6px; border-radius:5px; background:rgba(184,93,51,0.08); border:1px solid rgba(184,93,51,0.14); color:var(--oxide-2); font-size:11px; font-weight:700; text-align:center; white-space:nowrap; }
17391    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17392    .ws-divider { display: none; }
17393    .ws-path-link { background:none; border:none; padding:0; font:inherit; font-size:13px; font-weight:700; color:var(--oxide-2); cursor:pointer; text-decoration:underline; text-decoration-style:dotted; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; max-width:100%; }
17394    .ws-path-link:hover { color:var(--oxide); }
17395    body.dark-theme .ws-path-link { color:var(--oxide); }
17396    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17397    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17398    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17399    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17400    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17401    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17402    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17403    .ws-mini-box-lg { flex:2 1 0; }
17404    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17405    .ws-mini-box-br { flex:1.5 1 0; }
17406    .scope-legend-row { display:flex; flex-direction:row; align-items:center; justify-content:flex-start; flex-wrap:nowrap; gap:0; padding:5px 10px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:12px; width:100%; min-width:0; border-left:3px solid var(--line-strong); white-space:nowrap; }
17407    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17408    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17409    #path.drag-over { background: rgba(37,99,235,0.05) !important; border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(37,99,235,0.15) !important; }
17410    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17411    .git-source-banner { display:flex; align-items:center; gap:10px; padding:10px 14px; background:linear-gradient(135deg,rgba(124,58,237,0.07),rgba(99,40,217,0.05)); border:1.5px solid rgba(124,58,237,0.22); border-radius:9px; margin-bottom:12px; font-size:13px; color:var(--text); flex-wrap:wrap; }
17412    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17413    .git-source-banner strong { font-weight:800; color:var(--text); }
17414    .git-source-banner code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; background:rgba(124,58,237,0.10); border:1px solid rgba(124,58,237,0.22); border-radius:5px; padding:1px 7px; color:#5b21b6; }
17415    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17416    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17417    .git-source-banner a:hover { text-decoration:underline; }
17418    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17419    .path-scope-sep { background:var(--line); margin:4px 14px; }
17420    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17421    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17422    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17423    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17424    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17425    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17426    .ws-mini-box { display:flex; flex-direction:column; gap: 6px; padding: 12px 14px; border-radius: 10px; background: rgba(184,93,51,0.06); border: 1px solid rgba(184,93,51,0.15); min-width: 0; flex: 1 1 0; transition: transform .2s ease, box-shadow .2s ease; }
17427    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17428    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17429    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17430    .wb-ftip { position:fixed; z-index:9000; background:var(--surface); border:1px solid var(--line-strong); border-radius:10px; box-shadow:0 8px 28px rgba(0,0,0,0.18); padding:10px 14px; font-size:12px; line-height:1.55; color:var(--text); max-width:300px; white-space:normal; pointer-events:none; display:none; text-align:left; }
17431    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17432    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17433    [data-wb-tip] { cursor:help; }
17434    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17435    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17436    .ws-action-link { display:inline-flex; align-items:center; justify-content:center; gap: 7px; padding: 12px 22px; border-radius: 10px; font-size: 13px; font-weight: 800; color: var(--oxide-2); text-decoration:none; border: 1px solid rgba(184,93,51,0.20); background: rgba(184,93,51,0.06); transition: background 0.15s ease, border-color 0.15s ease; white-space:nowrap; align-self:stretch; }
17437    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17438    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17439    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17440    .summary-card, .card, .step-nav, .explainer-card, .review-card, .workspace-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; }
17441    .summary-card:hover, .workspace-card:hover, .explainer-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
17442    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17443    .side-info-card { padding: 18px; }
17444    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17445    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17446    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17447    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17448    .summary-label, .section-kicker, .meta-label, .field-help-title { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
17449    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17450    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17451    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17452    .coverage-pill, .language-pill, .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; }
17453    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17454    .side-stack { display:grid; gap: 16px; align-items:start; align-self: start; position: sticky; top: 73px; max-height: calc(100vh - 90px); overflow-y: auto; width: 244px; max-width: 244px; scrollbar-width: none; }
17455    .side-stack::-webkit-scrollbar { display: none; }
17456    .step-nav { padding: 20px 16px; }
17457    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17458    .step-button { width:100%; display:flex; align-items:center; gap:10px; border:none; background:transparent; border-radius: 12px; padding: 11px 8px; color: var(--text); cursor:pointer; text-align:left; font-size:13px; font-weight:700; white-space:nowrap; transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; animation: stepEntrance 0.3s ease both; }
17459    .step-button:hover { background: var(--surface-2); }
17460    .step-button.active { background: rgba(37,99,235,0.09); box-shadow: inset 0 0 0 1px rgba(37,99,235,0.18); color: var(--accent-2); }
17461    .step-num { width:22px; height:22px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background: var(--surface-3); color: var(--text); font-size:12px; font-weight:800; flex:0 0 auto; }
17462    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17463    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17464    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17465    .step-nav-summary { margin:8px 4px 0; padding:10px 12px; border-radius:10px; background:rgba(184,93,51,0.05); border:1px solid rgba(184,93,51,0.14); }
17466    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17467    .step-nav-sum-row:last-child { border-bottom:none; }
17468    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17469    .step-nav-sum-val { font-size:12px; font-weight:700; color:var(--text); text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:120px; }
17470    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17471    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17472    .quick-scan-section { padding: 10px 4px 14px; }
17473    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17474    .quick-scan-btn { width:100%; display:flex; align-items:center; justify-content:center; gap:8px; padding:11px 14px; border-radius:14px; border:none; background:linear-gradient(135deg,#e07b3a,#b85028); color:#fff; font-size:14px; font-weight:800; cursor:pointer; box-shadow:0 6px 18px rgba(184,80,40,0.28); transition:transform 0.15s ease,box-shadow 0.15s ease; }
17475    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17476    .quick-scan-btn:active { transform:translateY(0); }
17477    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17478    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17479    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17480    @keyframes stepPulse { 0%,100%{box-shadow:0 0 0 0 rgba(37,99,235,0.2);} 60%{box-shadow:0 0 0 5px rgba(37,99,235,0.07);} }
17481    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17482    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17483    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17484    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17485    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17486    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17487    .step-button.done .step-check { opacity:1; }
17488    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17489    .sidebar-kbd-hint { margin:14px 4px 0; font-size:10px; color:var(--muted-2); line-height:1.55; text-align:center; display:flex; align-items:center; justify-content:center; gap:4px; }
17490    .sidebar-kbd-key { display:inline-flex; align-items:center; justify-content:center; padding:1px 5px; border-radius:4px; background:var(--surface-3); border:1px solid var(--line); font-size:9px; font-weight:700; color:var(--muted); font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; line-height:1; }
17491    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17492    .sidebar-scroll-btn { display:flex; align-items:center; justify-content:center; gap:5px; width:100%; padding:7px 10px; border-radius:9px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); font-size:11px; font-weight:700; text-decoration:none; cursor:pointer; transition:background 0.15s ease,border-color 0.15s ease,color 0.15s ease; }
17493    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17494    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17495    .card-header { padding: 22px 22px 18px; border-bottom:1px solid var(--line); background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); position: sticky; top: 57px; z-index: 20; border-radius: var(--radius) var(--radius) 0 0; }
17496    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17497    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17498    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17499    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17500    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17501    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17502    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17503    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17504    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17505    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17506    .card-body { padding: 22px; }
17507    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17508    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17509    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17510    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17511    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17512    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17513    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17514    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17515    .field { min-width:0; }
17516    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17517    input[type="text"], textarea, select { width:100%; min-width:0; border-radius: 10px; border:1px solid var(--line-strong); background: #fff; color: var(--text); font-size: 15px; padding: 12px 14px; transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background 0.15s ease; }
17518    body.dark-theme input[type="text"], body.dark-theme textarea, body.dark-theme select, body.dark-theme code, body.dark-theme .preview-code { background: #201813; color: var(--text); }
17519    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17520    input[type="text"]:focus, textarea:focus, select:focus { outline:none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,0.13); transform: translateY(-1px); }
17521    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17522    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17523    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17524    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17525    .path-history-badge { margin-top: 6px; padding: 4px 10px; border-radius: 6px; font-size: 12px; line-height: 1.4; display: inline-flex; align-items: center; gap: 4px; }
17526    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17527    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17528    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17529    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17530    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17531    .input-group.compact { grid-template-columns: 1fr auto auto; }
17532    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17533    .path-info-card { padding: 16px 18px; border-radius: 14px; border: 1px solid var(--line); background: linear-gradient(135deg, var(--surface-2), rgba(184,93,51,0.03)); }
17534    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17535    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17536    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17537    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17538    .path-info-val { font-size: 13px; font-weight: 800; color: var(--text); text-align:right; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:120px; }
17539    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17540    .mini-button, button.primary, button.secondary, .artifact-toggle { min-height: 42px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color: var(--text); padding: 0 14px; font-size: 14px; font-weight: 800; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; }
17541    .mini-button:hover, button.primary:hover, button.secondary:hover, .artifact-toggle:hover { transform: translateY(-1px); box-shadow: 0 10px 18px rgba(0,0,0,0.08); }
17542    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17543    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17544    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17545    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17546    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17547    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17548    @media (max-height: 1200px) { .workbench-strip { margin-bottom: 12px; } .wb-stats-header { padding: 8px 20px 0; } .ws-left { padding: 10px 16px 12px; } .ws-history-group { padding: 12px 20px; } }
17549    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17550    button.secondary { background: var(--surface); }
17551    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17552    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17553    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17554    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17555    .wizard-actions { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-top: 22px; padding-top: 18px; border-top:1px solid var(--line); }
17556    .section + .wizard-actions { border-top: none; padding-top: 0; }
17557    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17558    .default-path-overlay { position: fixed; inset: 0; z-index: 9000; background: rgba(0,0,0,0.52); display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; pointer-events: none; transition: opacity .18s ease; }
17559    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17560    .default-path-modal { background: var(--surface); border: 1px solid var(--line); border-radius: 20px; max-width: 682px; width: 100%; box-shadow: 0 30px 80px rgba(0,0,0,0.34); padding: 33px 37px 29px; transform: translateY(10px); transition: transform .18s ease; }
17561    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17562    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17563    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17564    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17565    .default-path-modal p code { background: rgba(0,0,0,0.06); padding: 1px 6px; border-radius: 5px; font-size: 11.5px; color: var(--text); }
17566    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17567    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17568    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17569    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17570    .field-help-grid.coupled-help { margin-top: 12px; }
17571    .field-help-grid.preset-grid { align-items: start; }
17572    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17573    .preset-inline-row .field { margin: 0; }
17574    .preset-inline-row .explainer-card { margin: 0; }
17575    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17576    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17577    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17578    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17579    .preset-kv-row > :last-child { flex:1; min-width:0; }
17580    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17581    .output-field-row .field { margin: 0; }
17582    .output-field-aside { padding: 16px 18px; border-radius: 14px; border: 1px solid var(--line); background: var(--surface-2); font-size: 14px; color: var(--muted); line-height: 1.6; }
17583    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17584    .step3-subtitle { margin-bottom: 10px; max-width: none; }
17585    .counting-intro { margin-bottom: 8px; max-width: none; }
17586    .ieee-note { margin-bottom: 22px; padding: 14px; border-radius: 12px; border: 1px solid var(--line); border-left: 4px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.08), transparent), var(--surface-2); font-size: 15px; line-height: 1.65; }
17587    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17588    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17589    .counting-top-grid .hint { margin-top: 14px; padding: 12px 14px; border-left: 4px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.06), transparent), var(--surface-2); border-radius: 10px; }
17590    .subsection-bar { margin: 24px 0 14px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--line); background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface-2); font-size: 12px; font-weight: 900; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17591    .section-spacer-top { margin-top: 28px; }
17592    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17593    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17594    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17595    .code-sample { margin-top: 10px; padding: 14px 16px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; font-size: 13px; color: var(--text); }
17596    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17597    .preset-summary-chip { display:inline-flex; align-items:center; min-height: 30px; padding: 0 12px; border-radius: 999px; border:1px solid var(--line); background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface-2); color: var(--text); font-size: 12px; font-weight: 800; }
17598    .preset-note { margin-top: 12px; padding: 12px 14px; border-radius: 12px; border:1px solid var(--line); background: linear-gradient(180deg, rgba(184,93,51,0.08), transparent), var(--surface-2); color: var(--muted); font-size: 13px; line-height: 1.6; }
17599    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17600    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17601    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17602    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17603    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17604    .include-scope-badge { display:flex; align-items:center; gap:7px; padding:7px 12px; border-radius:8px; font-size:12px; font-weight:700; margin-bottom:7px; transition:background .2s,color .2s,border-color .2s; }
17605    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17606    .include-scope-badge.scope-narrow { background:rgba(184,93,51,0.08); border:1px solid rgba(184,93,51,0.22); color:var(--nav,#b85d33); }
17607    body.dark-theme .include-scope-badge.scope-all { background:rgba(90,186,138,0.12); border-color:rgba(90,186,138,0.3); color:#5aba8a; }
17608    body.dark-theme .include-scope-badge.scope-narrow { background:rgba(210,130,70,0.12); border-color:rgba(210,130,70,0.3); color:#e0a060; }
17609    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17610    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17611    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17612    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17613    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17614    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17615    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17616    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17617    .advanced-rule-row { display:grid; grid-template-columns: 220px 220px minmax(0, 1fr); gap: 14px; align-items:center; padding: 16px; border:1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
17618    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17619    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17620    .docstring-example-inset { padding: 14px 16px 14px 32px; background: var(--surface-2); border-left: 3px solid var(--line-strong); border-radius: 0 0 10px 10px; margin-top: -1px; }
17621    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17622    .always-tracked-tip { display:flex; align-items:flex-start; gap: 14px; padding: 16px 18px; border-radius: 14px; border: 1px solid rgba(37,99,235,0.18); background: linear-gradient(135deg, rgba(37,99,235,0.05), rgba(37,99,235,0.02)); margin-top: 8px; width:100%; box-sizing:border-box; }
17623    .always-tracked-tip-icon { flex: 0 0 auto; width: 28px; height: 28px; border-radius: 50%; background: rgba(37,99,235,0.12); color: var(--accent-2); display:flex; align-items:center; justify-content:center; font-size: 14px; font-weight: 900; margin-top: 2px; }
17624    .always-tracked-tip-body { flex:1; min-width:0; }
17625    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17626    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17627    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17628    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17629    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17630    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17631    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17632    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17633    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17634    .advanced-rule-description strong { color: var(--text); }
17635    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17636    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17637    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17638    .review-link:hover { text-decoration: underline; }
17639    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17640    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17641    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17642    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17643    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17644    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17645    .review-card ul { padding-left: 18px; margin: 0; }
17646    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17647    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17648    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17649    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17650    .review-card { min-height: 0; }
17651    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17652    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17653    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17654    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17655    .lang-overflow-chip { position:relative; cursor:default; }
17656    .lang-overflow-tip { display:none; position:absolute; top:calc(100% + 6px); left:0; z-index:300; background:var(--surface); border:1px solid var(--line-strong); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,0.16); padding:10px 14px; min-width:160px; white-space:pre-line; font-size:12px; font-weight:600; color:var(--text); line-height:1.7; pointer-events:none; }
17657    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17658    .git-inline-row { align-items:start; }
17659    .mixed-line-card { display:flex; flex-direction:column; }
17660    .preset-inline-row .toggle-card { justify-content: center; }
17661        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17662    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17663    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17664    .explorer-title { font-size: 18px; font-weight: 850; }
17665    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17666    .explorer-subtitle.wide { max-width: none; }
17667    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17668    .better-spacing { align-items:flex-start; justify-content:flex-end; }
17669    .badge { display:inline-flex; align-items:center; min-height: 30px; padding: 0 12px; border-radius: 999px; font-size: 13px; font-weight: 800; border:1px solid transparent; }
17670    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17671    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17672    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17673    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17674    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
17675    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
17676    .scope-stat-button { appearance:none; text-align:left; border:1px solid var(--line); background: var(--surface); border-radius: 14px; padding: 14px 16px; cursor:pointer; transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease, background .15s ease; }
17677    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
17678    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
17679    .scope-stat-button.supported { background: var(--success-bg); }
17680    .scope-stat-button.skipped { background: var(--warn-bg); }
17681    .scope-stat-button.unsupported { background: var(--danger-bg); }
17682    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
17683    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
17684    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
17685    [data-tooltip] { position: relative; }
17686    [data-tooltip]::after { content: attr(data-tooltip); display: none; position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 7px 12px; border-radius: 8px; font-size: 12px; font-weight: 600; white-space: normal; width: max-content; min-width: 180px; max-width: 280px; text-align: center; line-height: 1.5; pointer-events: none; z-index: 400; box-shadow: 0 4px 14px rgba(0,0,0,0.22); }
17687    [data-tooltip]:hover::after { display: block; }
17688    .scope-stat-button[data-tooltip] { cursor: pointer; }
17689    .badge[data-tooltip] { cursor: help; }
17690    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
17691    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
17692    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
17693    .preview-note.stronger { background: linear-gradient(180deg, rgba(184,93,51,0.08), transparent), var(--surface-2); border-left: 4px solid var(--oxide); font-size: 15px; line-height: 1.65; }
17694    .preview-code, code { display:block; margin-top: 8px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line); background: #fff; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; overflow-wrap:anywhere; }
17695    code { display:inline-block; margin-top:0; padding:2px 7px; }
17696    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17697    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
17698    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
17699    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
17700    .language-pill.muted-pill { color: var(--muted); }
17701    button.language-pill { appearance:none; cursor:pointer; }
17702    .detected-language-chip.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,0.12); background: linear-gradient(180deg, rgba(37,99,235,0.10), transparent), var(--surface-2); }
17703    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
17704    .file-explorer-controls { display:flex; justify-content:space-between; gap: 12px; align-items:center; padding: 12px 14px; border-bottom:1px solid var(--line); background: linear-gradient(180deg, var(--surface-2), rgba(255,255,255,0.35)); flex-wrap: nowrap; }
17705    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
17706    .file-explorer-search-row { margin-left: auto; }
17707    .explorer-filter-select { min-width: 170px; width: 170px; }
17708    .explorer-search { min-width: 300px; width: 300px; }
17709    .file-explorer-header { display:grid; grid-template-columns: minmax(0, 1fr) 170px 160px 200px; gap: 12px; padding: 11px 14px; background: linear-gradient(180deg, var(--surface-2), transparent); border-bottom:1px solid var(--line); }
17710    .tree-sort-button { display:flex; align-items:center; justify-content:space-between; gap: 10px; width:100%; padding: 4px 8px; border:none; border-radius: 10px; background: transparent; color: var(--muted-2); font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; cursor:pointer; }
17711    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
17712    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
17713    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
17714    .file-explorer-tree { max-height: 640px; overflow:auto; }
17715    .tree-row { display:grid; grid-template-columns: minmax(0, 1fr) 170px 160px 200px; gap: 12px; align-items:center; padding: 0 14px; border-bottom:1px solid rgba(0,0,0,0.04); }
17716    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
17717    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
17718    .tree-row.hidden-by-filter { display:none !important; }
17719    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
17720    .tree-name-cell { display:flex; align-items:center; gap: 10px; padding-left: calc(var(--depth) * 22px + 8px); position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; min-width:0; }
17721    .tree-toggle { width: 22px; height: 22px; display:inline-flex; align-items:center; justify-content:center; border:none; background: var(--surface-2); color: var(--muted-2); cursor:pointer; font-size: 14px; line-height: 1; flex:0 0 22px; border-radius: 6px; border: 1px solid var(--line); font-weight: 900; }
17722    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
17723    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
17724    .tree-node { display:inline-flex; align-items:center; min-width:0; }
17725    .tree-node-dir { color: var(--text); font-weight: 800; }
17726    .tree-node-supported { color: var(--success-text); }
17727    .tree-node-skipped { color: var(--warn-text); }
17728    .tree-node-unsupported { color: var(--danger-text); }
17729    .tree-node-more { color: var(--muted-2); font-style: italic; }
17730    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
17731    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
17732    .tree-status-cell { display:flex; justify-content:flex-start; }
17733    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
17734    .preview-hint { color: var(--muted); background: var(--surface-2); border:1px solid var(--line); padding: 18px 20px; border-radius: 12px; font-size:14px; text-align:center; }
17735    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17736    .preview-spinner { width:18px; height:18px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 18px; }
17737    @keyframes prevSpin { to { transform:rotate(360deg); } }
17738    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
17739    .preview-gate-spinner { width:15px; height:15px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 15px; }
17740    .preview-gate-info { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; padding:0; border:none; background:transparent; color:var(--oxide); cursor:pointer; border-radius:50%; flex:0 0 18px; transition:transform .15s ease, color .15s ease; }
17741    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
17742    .preview-gate-info svg { width:16px; height:16px; }
17743    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
17744    @keyframes previewPanelFlash { 0%,100% { box-shadow:0 0 0 0 rgba(196,93,42,0); } 25% { box-shadow:0 0 0 4px rgba(196,93,42,0.45); } }
17745    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
17746    .preview-loading-text { flex:1; min-width:0; }
17747    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
17748    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
17749    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
17750    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
17751    .cov-scan-idle { display:none; }
17752    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
17753    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
17754    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
17755    .cov-scan-title { font-weight:600; font-size:12.5px; }
17756    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
17757    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
17758    .cov-scan-use { appearance:none; padding:3px 12px; border-radius:999px; border:1px solid currentColor; background:transparent; font-size:11.5px; font-weight:700; cursor:pointer; white-space:nowrap; }
17759    .cov-scan-use:hover { opacity:.75; }
17760    .cov-scan-cmd { font-family:monospace; font-size:11px; background:rgba(0,0,0,0.07); padding:2px 7px; border-radius:4px; word-break:break-all; }
17761    .cov-scan-tool { display:inline-block; font-size:10.5px; font-weight:700; padding:1px 7px; border-radius:999px; margin-left:4px; vertical-align:middle; }
17762    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
17763    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
17764    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
17765    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
17766    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
17767    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
17768    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
17769    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
17770    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
17771    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
17772    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
17773    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
17774    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
17775    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
17776    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
17777    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
17778    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
17779    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
17780    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
17781    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
17782    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
17783    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
17784    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
17785    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
17786    .loading { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; background: rgba(17,24,39,0.35); z-index: 100; backdrop-filter: blur(2px); }
17787    .loading.active { display:flex; }
17788    /* Lock page scroll while the analysis modal is open so the removed scrollbar
17789       gutter doesn't pull the centered card slightly left of true center. */
17790    body.modal-open { overflow: hidden; }
17791    .loading-card { position:relative; overflow:hidden; width: min(840px, calc(100vw - 40px)); border-radius: 20px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 24px 56px rgba(0,0,0,0.26); padding: 42px 48px; }
17792    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
17793    .loading-card::before { content:''; position:absolute; inset:0; z-index:0; pointer-events:none; border-radius:inherit; opacity:0; background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.22), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(37,99,235,0.16), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.14), transparent 60%); transition: opacity .4s ease; }
17794    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
17795    .loading-card > * { position:relative; z-index:1; }
17796    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
17797    body.dark-theme .loading-card::before { background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.26), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(111,155,255,0.18), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.18), transparent 60%); }
17798    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
17799    .progress-bar span { display:block; width:35%; height:100%; border-radius:999px; background: linear-gradient(90deg, transparent, var(--accent-2) 22%, var(--oxide,#d37a4c) 78%, transparent); will-change: transform; animation: pulseBar 1.5s linear infinite; }
17800    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
17801    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
17802    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
17803    .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:18px;display:flex;align-items:center;gap:10px; }
17804    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
17805    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
17806    .lc-metric-label { font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17807    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
17808    .lc-stage-desc { font-size:12px;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:9px 14px;margin-bottom:18px;line-height:1.5;transition:opacity .3s; }
17809    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
17810    .lc-step { display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;color:var(--muted);border:1.5px solid transparent;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;transition:all .25s; }
17811    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
17812    .lc-step.done { color:var(--muted);opacity:0.55; }
17813    .lc-step-num { width:18px;height:18px;border-radius:50%;background:rgba(150,140,130,0.2);color:var(--muted);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;flex:0 0 auto; }
17814    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
17815    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
17816    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
17817    .lc-warn { background:rgba(230,160,50,0.12);border:1px solid rgba(230,160,50,0.3);border-radius:8px;padding:10px 14px;font-size:12px;color:#8a6a10;margin-top:14px; }
17818    .lc-err { background:rgba(180,40,40,0.08);border:1px solid rgba(180,40,40,0.25);border-radius:8px;padding:12px 16px;margin-top:14px; }
17819    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
17820    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
17821    .lc-cancelled { background:rgba(100,100,100,0.08);border:1px solid rgba(100,100,100,0.22);border-radius:8px;padding:12px 16px;margin-top:14px; }
17822    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
17823    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
17824    .lc-outline-btn { display:inline-flex;align-items:center;padding:9px 20px;border-radius:999px;background:transparent;color:var(--nav,#b85d33);border:2px solid var(--nav,#b85d33);font-size:13px;font-weight:700;text-decoration:none;cursor:pointer; }
17825    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
17826    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
17827    .quick-excl-chip { display:inline-flex;align-items:center;padding:3px 10px;border-radius:999px;background:rgba(37,99,235,0.07);border:1px solid rgba(37,99,235,0.2);color:var(--accent-2);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,border-color .12s; }
17828    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
17829    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
17830    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
17831    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
17832    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
17833    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
17834    .lc-cancel-btn { display:inline-flex;align-items:center;gap:6px;margin-top:14px;padding:8px 18px;border-radius:999px;background:transparent;color:var(--muted);border:1.5px solid rgba(150,150,150,0.35);font-size:12px;font-weight:700;cursor:pointer;transition:color .15s,border-color .15s; }
17835    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
17836    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
17837    .hidden { display:none !important; }
17838    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17839    .site-footer a{color:var(--muted);}
17840    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
17841    @media (max-width: 980px) { .field-grid, .artifact-grid, .review-grid, .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split, .glob-guidance-grid { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; } .side-stack { width: auto; max-width: none; } .step-nav { position:static; } .top-nav-inner { grid-template-columns: 1fr; justify-items: stretch; } .nav-project-slot, .nav-status { justify-content:flex-start; } .input-group { grid-template-columns: 1fr 1fr; } .input-group.compact { grid-template-columns: 1fr 1fr; } .better-spacing { justify-content:flex-start; } .file-explorer-controls { flex-direction: column; align-items:flex-start; flex-wrap: wrap; } .file-explorer-search-row { margin-left: 0; flex-wrap: wrap; width: 100%; } .explorer-search { min-width: 0; width: 100%; } .file-explorer-header, .tree-row { grid-template-columns: minmax(0, 1fr) 110px 110px 140px; } .advanced-rule-row, .advanced-rule-row.static-note, .output-identity-grid, .counting-top-grid, .preset-inline-row { grid-template-columns: 1fr; } .wizard-progress { max-width: none; } .path-row-grid { grid-template-columns: 1fr; } .ws-left { flex-wrap: wrap; } .scan-pills-row { flex-wrap: wrap; } }
17842    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
17843    @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));}}
17844    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
17845    .submodule-preview-strip { display:flex; align-items:center; gap:14px; padding:12px 16px; border:1px solid rgba(37,99,235,0.2); border-radius:12px; background:linear-gradient(180deg,rgba(37,99,235,0.05),transparent),var(--surface-2); flex-wrap:wrap; }
17846    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
17847    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
17848    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
17849    .submodule-preview-chip { appearance:none; display:inline-flex; align-items:center; padding:3px 11px; border-radius:999px; font-size:12px; font-weight:700; background:rgba(37,99,235,0.09); border:1px solid rgba(37,99,235,0.22); color:var(--accent-2); cursor:pointer; position:relative; transition:background .15s ease, box-shadow .15s ease; }
17850    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
17851    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
17852    .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(7px); background:var(--text); color:var(--bg); padding:5px 10px; border-radius:7px; font-size:11px; font-weight:600; 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; }
17853    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
17854    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
17855    .submodule-base-repo-btn { appearance:none; display:inline-flex; align-items:center; gap:5px; padding:3px 11px; border-radius:999px; font-size:12px; font-weight:700; background:rgba(77,44,20,0.1); border:1px solid rgba(77,44,20,0.25); color:var(--text); cursor:pointer; transition:background .15s ease; }
17856    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
17857    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
17858    .info-icon-btn { appearance:none; display:inline-flex; align-items:center; gap:5px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:12px; font-weight:600; padding:2px 0; line-height:1.4; }
17859    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
17860    .info-icon-btn:hover { color:var(--text); }
17861    body.dark-theme .submodule-preview-strip { border-color:rgba(111,155,255,0.22); background:linear-gradient(180deg,rgba(37,99,235,0.09),transparent),var(--surface-2); }
17862    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
17863    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
17864    .toast-success{display:flex;align-items:center;gap:10px;background:#e8f5ed;border:1px solid #a3d9b1;border-radius:10px;padding:10px 16px;font-size:13px;color:#1a5c35;font-weight:600;}
17865    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
17866    .toast-error{display:flex;align-items:center;gap:10px;background:#fde8e8;border:1px solid #f5a3a3;border-radius:10px;padding:10px 16px;font-size:13px;color:#7a1a1a;font-weight:600;}
17867    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
17868    #offline-file-banner{display:none;position:sticky;top:0;z-index:9999;background:#fff8e1;border-bottom:2px solid #f0b429;padding:10px 20px;font-size:13px;font-weight:600;color:#7a5000;align-items:center;gap:12px;box-shadow:0 2px 10px rgba(0,0,0,0.12);}
17869    #offline-file-banner.show{display:flex;}
17870    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
17871    #offline-file-banner .ofb-text{flex:1;}
17872    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
17873    #offline-file-banner .ofb-code{background:rgba(0,0,0,0.08);padding:1px 5px;border-radius:4px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
17874    #offline-file-banner .ofb-dismiss{margin-left:auto;background:none;border:1px solid #d4950a;border-radius:6px;color:#7a5000;font-size:12px;font-weight:700;padding:3px 10px;cursor:pointer;white-space:nowrap;}
17875    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
17876    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
17877    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
17878    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
17879    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
17880    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
17881    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
17882  </style>
17883</head>
17884<body id="page-top">
17885  <div id="offline-file-banner" role="alert">
17886    <svg viewBox="0 0 24 24" 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>
17887    <span class="ofb-text">
17888      Charts, images, and navigation require the oxide-sloc server.
17889      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
17890      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
17891      The metric tables below are fully readable without the server.
17892    </span>
17893    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
17894  </div>
17895  <script nonce="{{ csp_nonce }}">(function(){if(location.protocol==='file:'){var b=document.getElementById('offline-file-banner');if(b)b.classList.add('show');var d=document.getElementById('ofb-dismiss-btn');if(d)d.addEventListener('click',function(){b.classList.remove('show');});}})();</script>
17896  <div class="background-watermarks" aria-hidden="true">
17897    <img src="/images/logo/logo-text.png" alt="" />
17898    <img src="/images/logo/logo-text.png" alt="" />
17899    <img src="/images/logo/logo-text.png" alt="" />
17900    <img src="/images/logo/logo-text.png" alt="" />
17901    <img src="/images/logo/logo-text.png" alt="" />
17902    <img src="/images/logo/logo-text.png" alt="" />
17903    <img src="/images/logo/logo-text.png" alt="" />
17904    <img src="/images/logo/logo-text.png" alt="" />
17905    <img src="/images/logo/logo-text.png" alt="" />
17906    <img src="/images/logo/logo-text.png" alt="" />
17907    <img src="/images/logo/logo-text.png" alt="" />
17908    <img src="/images/logo/logo-text.png" alt="" />
17909    <img src="/images/logo/logo-text.png" alt="" />
17910    <img src="/images/logo/logo-text.png" alt="" />
17911  </div>
17912  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17913  <div class="top-nav">
17914    <div class="top-nav-inner">
17915      <a class="brand" href="/">
17916        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
17917        <div class="brand-copy">
17918          <div class="brand-title">OxideSLOC</div>
17919          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
17920        </div>
17921      </a>
17922      <div class="nav-project-slot">
17923        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
17924          <span class="nav-project-label">Project</span>
17925          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
17926        </div>
17927      </div>
17928      <div class="nav-status">
17929        <a class="nav-pill" href="/">Home</a>
17930        <div class="nav-dropdown">
17931          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
17932          <div class="nav-dropdown-menu">
17933            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
17934          </div>
17935        </div>
17936        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17937        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17938        <div class="nav-dropdown">
17939          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
17940          <div class="nav-dropdown-menu">
17941            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
17942          </div>
17943        </div>
17944        <div class="server-status-wrap" id="server-status-wrap">
17945          <div class="nav-pill server-online-pill" id="server-status-pill">
17946            <span class="status-dot" id="status-dot"></span>
17947            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
17948            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
17949          </div>
17950          <div class="server-status-tip">
17951            {% if server_mode %}
17952            OxideSLOC is running in server mode — accessible on your LAN.
17953            {% else %}
17954            OxideSLOC is running locally — only accessible from this machine.
17955            {% endif %}
17956            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
17957          </div>
17958        </div>
17959        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17960          <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>
17961        </button>
17962        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
17963          <svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 1 0 9.8 9.8z"></path></svg>
17964          <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="M4.9 4.9l1.4 1.4"></path><path d="M17.7 17.7l1.4 1.4"></path><path d="M4.9 19.1l1.4-1.4"></path><path d="M17.7 6.3l1.4-1.4"></path></svg>
17965        </button>
17966      </div>
17967    </div>
17968  </div>
17969
17970  <div class="loading" id="loading">
17971    <div class="loading-card" id="loading-card">
17972      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
17973      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
17974      <div class="lc-path" id="lc-path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" style="flex:0 0 auto;opacity:0.45"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span id="lc-path-text"></span></div>
17975      <div class="lc-steps" id="lc-steps">
17976        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
17977        <div class="lc-step-arrow">›</div>
17978        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
17979        <div class="lc-step-arrow">›</div>
17980        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
17981        <div class="lc-step-arrow">›</div>
17982        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
17983      </div>
17984      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
17985      <div class="lc-metrics" id="lc-metrics">
17986        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
17987        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
17988        <div class="lc-metric hidden" id="lc-files-card"><div class="lc-metric-label">Files</div><div class="lc-metric-value" id="lc-files">0</div></div>
17989        <div class="lc-metric hidden" id="lc-speed-card"><div class="lc-metric-label">Files/sec</div><div class="lc-metric-value" id="lc-speed">—</div></div>
17990      </div>
17991      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
17992      <div class="lc-warn hidden" id="lc-warn">This is taking longer than usual. Large repositories can take several minutes — the analysis is still running.</div>
17993      <div class="lc-err hidden" id="lc-err"><strong>Analysis failed</strong><p id="lc-err-msg">An unexpected error occurred. Check that the path exists and is readable.</p></div>
17994      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
17995      <div class="lc-actions hidden" id="lc-actions">
17996        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
17997        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
17998      </div>
17999      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18000        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
18001        Cancel scan
18002      </button>
18003    </div>
18004  </div>
18005
18006  <div class="page">
18007    <div class="workbench-strip">
18008      <div class="workbench-box wb-stats">
18009        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18010          <span class="wb-stats-title">Analysis session</span>
18011        </div>
18012        <div class="ws-left">
18013          <div class="ws-stat ws-stat-analyzers">
18014            <span class="ws-label">Analyzers</span>
18015            <span class="ws-value">
18016              <span class="ws-badge">60 languages</span>
18017            </span>
18018            <div class="ws-lang-tooltip">
18019              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18020              <div class="ws-lang-tooltip-desc">Language detection engines loaded for this session. Each engine uses a lexical state machine to count code, comment, and blank lines.</div>
18021              <div class="ws-lang-grid">
18022                <span class="ws-lang-item">Assembly</span>
18023                <span class="ws-lang-item">C</span>
18024                <span class="ws-lang-item">C++</span>
18025                <span class="ws-lang-item">C#</span>
18026                <span class="ws-lang-item">Clojure</span>
18027                <span class="ws-lang-item">CSS</span>
18028                <span class="ws-lang-item">Dart</span>
18029                <span class="ws-lang-item">Dockerfile</span>
18030                <span class="ws-lang-item">Elixir</span>
18031                <span class="ws-lang-item">Erlang</span>
18032                <span class="ws-lang-item">F#</span>
18033                <span class="ws-lang-item">Go</span>
18034                <span class="ws-lang-item">Groovy</span>
18035                <span class="ws-lang-item">Haskell</span>
18036                <span class="ws-lang-item">HTML</span>
18037                <span class="ws-lang-item">Java</span>
18038                <span class="ws-lang-item">JavaScript</span>
18039                <span class="ws-lang-item">Julia</span>
18040                <span class="ws-lang-item">Kotlin</span>
18041                <span class="ws-lang-item">Lua</span>
18042                <span class="ws-lang-item">Makefile</span>
18043                <span class="ws-lang-item">Nim</span>
18044                <span class="ws-lang-item">Obj-C</span>
18045                <span class="ws-lang-item">OCaml</span>
18046                <span class="ws-lang-item">Perl</span>
18047                <span class="ws-lang-item">PHP</span>
18048                <span class="ws-lang-item">PowerShell</span>
18049                <span class="ws-lang-item">Python</span>
18050                <span class="ws-lang-item">R</span>
18051                <span class="ws-lang-item">Ruby</span>
18052                <span class="ws-lang-item">Rust</span>
18053                <span class="ws-lang-item">Scala</span>
18054                <span class="ws-lang-item">SCSS</span>
18055                <span class="ws-lang-item">Shell</span>
18056                <span class="ws-lang-item">SQL</span>
18057                <span class="ws-lang-item">Svelte</span>
18058                <span class="ws-lang-item">Swift</span>
18059                <span class="ws-lang-item">TypeScript</span>
18060                <span class="ws-lang-item">Vue</span>
18061                <span class="ws-lang-item">XML</span>
18062                <span class="ws-lang-item">Zig</span>
18063                <span class="ws-lang-item">Solidity</span>
18064                <span class="ws-lang-item">Protobuf</span>
18065                <span class="ws-lang-item">HCL</span>
18066                <span class="ws-lang-item">GraphQL</span>
18067                <span class="ws-lang-item">Ada</span>
18068                <span class="ws-lang-item">VHDL</span>
18069                <span class="ws-lang-item">Verilog</span>
18070                <span class="ws-lang-item">Tcl</span>
18071                <span class="ws-lang-item">Pascal</span>
18072                <span class="ws-lang-item">Visual Basic</span>
18073                <span class="ws-lang-item">Lisp</span>
18074                <span class="ws-lang-item">Fortran</span>
18075                <span class="ws-lang-item">Nix</span>
18076                <span class="ws-lang-item">Crystal</span>
18077                <span class="ws-lang-item">D</span>
18078                <span class="ws-lang-item">GLSL</span>
18079                <span class="ws-lang-item">CMake</span>
18080                <span class="ws-lang-item">Elm</span>
18081                <span class="ws-lang-item">Awk</span>
18082              </div>
18083            </div>
18084          </div>
18085          <div class="ws-divider"></div>
18086          <div class="ws-stat ws-stat-clamp" data-wb-tip="Directory path of the project currently selected or most recently analyzed."><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
18087          <div class="ws-divider"></div>
18088          <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts \u2014 JSON, HTML, and PDF reports \u2014 are written after each completed scan.">
18089            <span class="ws-label">Output</span>
18090            <span class="ws-value">
18091              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18092                <span id="ws-output-root">project/sloc</span>
18093              </button>
18094            </span>
18095          </div>
18096        </div>
18097      </div>
18098      <div class="workbench-box ws-history-group" data-wb-tip="Scan statistics aggregated across all runs completed for this project in the current server session.">
18099        <div class="ws-history-label">Scan history</div>
18100        <div class="ws-history-inner">
18101          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18102            <div class="ws-mini-label">Scans</div>
18103            <div class="ws-mini-value" id="ws-scan-count">—</div>
18104          </div>
18105          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18106            <div class="ws-mini-label">Last Scan</div>
18107            <div class="ws-mini-value" id="ws-last-scan">—</div>
18108          </div>
18109          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18110            <div class="ws-mini-label">Branch</div>
18111            <div class="ws-mini-value" id="ws-branch">—</div>
18112          </div>
18113        </div>
18114      </div>
18115    </div>
18116
18117    <div class="layout">
18118      <aside class="side-stack">
18119        <section class="step-nav">
18120        <h3>Guided scan setup</h3>
18121        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18122          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18123          Top of page
18124        </a>
18125        <button type="button" class="step-button active" style="margin-top:10px;" data-step-target="1"><span class="step-num">1</span><span>Select project</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18126        <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18127        <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18128        <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18129
18130        <div class="step-steps-divider"></div>
18131
18132        <div class="step-nav-info" id="step-nav-info">
18133          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18134          <div class="step-nav-info-desc" id="step-nav-info-desc">Choose a project folder, apply scope filters, and preview which files will be counted.</div>
18135        </div>
18136
18137        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18138          <div class="step-nav-sum-row"><span class="step-nav-sum-key">Path</span><span class="step-nav-sum-val" id="sum-path">—</span></div>
18139          <div class="step-nav-sum-row"><span class="step-nav-sum-key">Preset</span><span class="step-nav-sum-val" id="sum-preset">—</span></div>
18140          <div class="step-nav-sum-row"><span class="step-nav-sum-key">Output</span><span class="step-nav-sum-val" id="sum-output">—</span></div>
18141        </div>
18142
18143        <div class="quick-scan-divider"></div>
18144        <div class="quick-scan-section">
18145          <div class="quick-scan-label">No customization needed?</div>
18146          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18147            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
18148            Quick Scan
18149          </button>
18150          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18151        </div>
18152
18153        <div class="sidebar-kbd-hint"><span class="sidebar-kbd-key">←</span><span>Back</span><span style="margin:0 6px;">·</span><span class="sidebar-kbd-key">→</span><span>Next</span></div>
18154        <div class="sidebar-scroll-divider"></div>
18155        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18156          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18157          Skip to bottom
18158        </a>
18159        </section>
18160
18161      </aside>
18162
18163      <section class="card">
18164        <div class="card-header">
18165          <div class="card-title-row">
18166            <div>
18167              <h1 class="card-title">Guided scan configuration</h1>
18168              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18169            </div>
18170            <div class="wizard-progress" aria-label="Scan setup progress">
18171              <div class="wizard-progress-top">
18172                <span class="wizard-progress-label">Setup progress</span>
18173                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18174              </div>
18175              <div class="wizard-progress-track">
18176                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18177              </div>
18178            </div>
18179          </div>
18180        </div>
18181        <div class="card-body">
18182          <form method="post" action="/analyze" id="analyze-form">
18183            <div class="wizard-step active" data-step="1">
18184              <div class="section">
18185                <div class="section-kicker">Step 1</div>
18186                <h2>Select project and preview scope</h2>
18187                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18188                <div class="field">
18189                  <label for="path">Project path</label>
18190                  {% if !git_repo.is_empty() %}
18191                  <div class="git-source-banner">
18192                    <svg viewBox="0 0 24 24"><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"/><circle cx="6" cy="6" r="3"/></svg>
18193                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18194                    <a href="/git-browser">← Back to Git Browser</a>
18195                  </div>
18196                  {% endif %}
18197                  <div class="path-scope-grid">
18198                      {% if !git_repo.is_empty() %}
18199                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18200                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18201                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18202                      {% else %}
18203                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
18204                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18205                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18206                      {% endif %}
18207                    <div class="path-scope-sep"></div>
18208                    <div class="scope-legend-row">
18209                      <span class="scope-legend-label">Scope legend:</span>
18210                      <span class="scope-legend-badges">
18211                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer \u2014 counted in SLOC totals.">supported</span>
18212                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18213                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set \u2014 listed but not counted.">unsupported</span>
18214                      </span>
18215                    </div>
18216                  </div>
18217                  {% if git_repo.is_empty() %}
18218                  {% if server_mode %}
18219                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18220                    ℹ️ Files are compressed and streamed — no fixed size limit.
18221                  </div>
18222                  {% endif %}
18223                  <div class="path-info-row">
18224                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18225                      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
18226                      <span id="project-size-text">Project size: —</span>
18227                    </button>
18228                  </div>
18229                  {% else %}
18230                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18231                  {% endif %}
18232                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18233                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18234                </div>
18235
18236                <div class="scope-preview-divider" aria-hidden="true"></div>
18237
18238                <div id="preview-panel">
18239                  <div class="preview-error">Loading preview...</div>
18240                </div>
18241              </div>
18242
18243              <div class="section" style="margin-top:14px;">
18244                <div class="preset-inline-row git-inline-row">
18245                  <div class="toggle-card" style="margin:0;">
18246                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18247                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18248                    <label class="checkbox">
18249                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18250                      <div>
18251                        <span>Detect and separate git submodules</span>
18252                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18253                      </div>
18254                    </label>
18255                  </div>
18256                  <div class="explainer-card prominent" style="margin:0;">
18257                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18258                    <div class="advanced-rule-description"><strong>Purpose:</strong> Group each git submodule&#39;s files into its own section in the report so you can see per-submodule SLOC totals alongside overall figures.<br /><strong>Good default when:</strong> your repository contains nested sub-projects managed as git submodules.<br /><strong>Turn it off when:</strong> the repository has no submodules, or you only need aggregate totals across the whole tree.</div>
18259                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18260    path = libs/core
18261    url  = https://github.com/org/core.git
18262
18263[submodule "libs/ui"]
18264    path = libs/ui
18265    url  = https://github.com/org/ui.git</div>
18266                  </div>
18267                </div>
18268              </div>
18269
18270              <div class="section">
18271                <div class="field-grid">
18272                  <div class="field">
18273                    <div class="glob-label-row">
18274                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18275                      <div id="include-scope-badge" class="include-scope-badge scope-all" aria-live="polite" style="margin:0;padding:4px 10px;font-size:11px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> All files eligible &mdash; no include filter active</div>
18276                    </div>
18277                    <textarea id="include_globs" name="include_globs" class="glob-textarea" placeholder="Leave blank to scan everything&#10;&#10;Or narrow scope with patterns:&#10;src/**/*.py&#10;lib/**/*.js&#10;scripts/*.sh"></textarea>
18278                    <div class="hint"><strong>Leave blank to scan everything</strong> under the project path. Only add patterns here when you want to limit the scan to specific folders or file types. Patterns are line- or comma-separated and relative to the project path.</div>
18279                  </div>
18280                  <div class="field">
18281                    <div class="glob-label-row">
18282                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18283                    </div>
18284                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
18285                    <div id="quick-exclude-chips" class="quick-excl-row">
18286                      <span class="quick-excl-label">Quick add:</span>
18287                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18288                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18289                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18290                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18291                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18292                      <button type="button" class="quick-excl-chip quick-excl-chip-all" data-pattern="third_party/**&#10;vendor/**&#10;node_modules/**&#10;build/**&#10;target/**&#10;dist/**">⚡ Skip all deps</button>
18293                    </div>
18294                    <div class="hint">Use this to remove noisy areas from the scope such as dependency trees, generated output, build folders, snapshots, or minified assets.</div>
18295                  </div>
18296                </div>
18297                <div class="glob-guidance-grid">
18298                  <div class="glob-guidance-card">
18299                    <strong>How to read them</strong>
18300                    <p><code>*</code> matches within a name, <code>**</code> reaches across nested folders, and patterns are usually written relative to the selected project path.</p>
18301                  </div>
18302                  <div class="glob-guidance-card">
18303                    <strong>Common include examples</strong>
18304                    <p><strong>Empty (default)</strong> — scans everything. <code>src/**/*.rs</code> only Rust sources, <code>scripts/*</code> top-level scripts only, <code>tests/**</code> everything under tests.</p>
18305                  </div>
18306                  <div class="glob-guidance-card">
18307                    <strong>Common exclude examples</strong>
18308                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18309                  </div>
18310                </div>
18311              </div>
18312
18313              <div class="section" style="margin-top:14px;">
18314                <div class="preset-inline-row git-inline-row">
18315                  <div class="toggle-card" style="margin:0;">
18316                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18317                    <h4 style="margin:0 0 12px;font-size:16px;">Code Coverage file <span style="font-weight:400;color:var(--muted);font-size:13px;">(optional)</span></h4>
18318                    <div class="field" style="margin:0;">
18319                      <div class="input-group compact">
18320                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18321                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18322                      </div>
18323                      <div class="hint" style="margin-top:8px;">When provided, line, function, and branch coverage percentages are overlaid on each file in the report and shown on the Test Metrics page.</div>
18324                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18325                    </div>
18326                  </div>
18327                  <div class="explainer-card prominent" style="margin:0;">
18328                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18329                    <div class="advanced-rule-description"><strong>Purpose:</strong> Overlay line, function, and branch coverage on each file in the HTML report and populate the Test Metrics dashboard.<br /><strong>Good default when:</strong> your test suite emits a coverage report in one of the supported formats.<br /><strong>Leave blank when:</strong> you only need SLOC totals without coverage data.</div>
18330                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18331lcov --capture --directory . --output-file coverage/lcov.info
18332
18333# C / C++ — llvm-cov (LCOV)
18334llvm-profdata merge -sparse default.profraw -o default.profdata
18335llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18336
18337# C# — coverlet (Cobertura XML)
18338dotnet test --collect:"XPlat Code Coverage"
18339
18340# Python — pytest-cov (Cobertura XML)
18341pytest --cov --cov-report=xml
18342
18343# Python — coverage.py native JSON
18344coverage run -m pytest && coverage json   # writes coverage.json
18345
18346# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18347./gradlew jacocoTestReport</div>
18348                  </div>
18349                </div>
18350              </div>
18351
18352              <div class="wizard-actions">
18353                <div class="left"></div>
18354                <div class="right">
18355                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18356                    <span class="preview-gate-spinner" aria-hidden="true"></span>
18357                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
18358                    <button type="button" class="preview-gate-info" id="preview-gate-info" title="What is this? Jump up to the live scope preview" aria-label="Show what is being scanned — jump to the scope preview">
18359                      <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
18360                    </button>
18361                  </div>
18362                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18363                </div>
18364              </div>
18365            </div>
18366
18367            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18368              <div class="default-path-modal">
18369                <h3 id="default-path-title">
18370                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
18371                  Proceed with the default sample test?
18372                </h3>
18373                <p>The <strong>Project path</strong> is still set to the bundled sample <code>tests/fixtures/basic</code></p>
18374                <p>You haven&#39;t selected your own project yet.</p>
18375                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18376                <div class="default-path-actions">
18377                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18378                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18379                </div>
18380              </div>
18381            </div>
18382
18383            <div class="wizard-step" data-step="2">
18384              <div class="section">
18385                <div class="section-kicker">Step 2</div>
18386                <h2>Choose counting behavior</h2>
18387                <p class="card-subtitle counting-intro">These settings decide how mixed code-plus-comment lines and Python docstrings are classified. Pure comment lines, block comments, physical lines, and blank lines are still tracked by supported analyzers even when they do not share a line with executable code.</p>
18388<div class="subsection-bar">Primary line classification</div>
18389                <div class="preset-kv-row">
18390                  <div class="toggle-card mixed-line-card" style="margin:0;">
18391                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18392                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18393                    <select id="mixed_line_policy" name="mixed_line_policy">
18394                      <option value="code_only">Code only</option>
18395                      <option value="code_and_comment">Code and comment</option>
18396                      <option value="comment_only">Comment only</option>
18397                      <option value="separate_mixed_category">Separate mixed category</option>
18398                    </select>
18399                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18400                  </div>
18401                  <div class="explainer-card prominent" style="margin:0;">
18402                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18403                    <div class="explainer-body" id="mixed-policy-description"></div>
18404                    <div class="code-sample" id="mixed-policy-example"></div>
18405                  </div>
18406                </div>
18407              </div>
18408
18409              <div class="subsection-bar">Additional scan rules</div>
18410              <div class="scan-rules-grid">
18411                <div class="preset-inline-row">
18412                  <div class="toggle-card" style="margin:0;">
18413                    <div class="field-help-title">Generated files</div>
18414                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18415                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18416                  </div>
18417                  <div class="explainer-card prominent" style="margin:0;">
18418                    <div class="advanced-rule-description"><strong>Purpose:</strong> Keep generated code and assets out of SLOC totals so counts reflect authored source.<br /><strong>Good default when:</strong> you want implementation-only totals.<br /><strong>Turn it off when:</strong> you intentionally want generated SDKs, compiled templates, or codegen output included.</div>
18419                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18420# Files matching codegen patterns are excluded:
18421#   *.generated.cs  *.pb.go  *.g.dart</div>
18422                  </div>
18423                </div>
18424                <div class="preset-inline-row">
18425                  <div class="toggle-card" style="margin:0;">
18426                    <div class="field-help-title">Minified files</div>
18427                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18428                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18429                  </div>
18430                  <div class="explainer-card prominent" style="margin:0;">
18431                    <div class="advanced-rule-description"><strong>Purpose:</strong> Prevent compressed assets from distorting file and line counts.<br /><strong>Good default when:</strong> your repo includes built JavaScript or bundled web assets.<br /><strong>Turn it off when:</strong> minified files are the actual subject of the review.</div>
18432                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18433# Heuristic: very long lines + low whitespace ratio
18434#   jquery.min.js  bundle.min.css  → skipped</div>
18435                  </div>
18436                </div>
18437                <div class="preset-inline-row">
18438                  <div class="toggle-card" style="margin:0;">
18439                    <div class="field-help-title">Vendor directories</div>
18440                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18441                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18442                  </div>
18443                  <div class="explainer-card prominent" style="margin:0;">
18444                    <div class="advanced-rule-description"><strong>Purpose:</strong> Skip bundled third-party dependencies so totals reflect your first-party code.<br /><strong>Good default when:</strong> you only want authored source in the report.<br /><strong>Turn it off when:</strong> vendored code is part of what you need to measure.</div>
18445                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18446# Directories named vendor/ node_modules/ third_party/
18447#   → entire subtree is excluded from totals</div>
18448                  </div>
18449                </div>
18450                <div class="preset-inline-row">
18451                  <div class="toggle-card" style="margin:0;">
18452                    <div class="field-help-title">Lockfiles and manifests</div>
18453                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18454                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18455                  </div>
18456                  <div class="explainer-card prominent" style="margin:0;">
18457                    <div class="advanced-rule-description"><strong>Purpose:</strong> Decide whether package lockfiles and generated manifests belong in the scan scope.<br /><strong>Good default when:</strong> you want implementation-focused totals.<br /><strong>Turn it off when:</strong> your review needs to include dependency metadata or footprint accounting.</div>
18458                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
18459# Files like package-lock.json  Cargo.lock  yarn.lock
18460#   → skipped unless this is enabled</div>
18461                  </div>
18462                </div>
18463                <div class="preset-inline-row">
18464                  <div class="toggle-card" style="margin:0;">
18465                    <div class="field-help-title">Binary handling</div>
18466                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18467                    <select name="binary_file_behavior" id="binary_file_behavior"><option value="skip" selected>Skip binary files</option><option value="fail">Fail on binary files</option></select>
18468                  </div>
18469                  <div class="explainer-card prominent" style="margin:0;">
18470                    <div class="advanced-rule-description"><strong>Purpose:</strong> Control how the scan reacts when binaries are found inside the selected scope.<br /><strong>Good default when:</strong> your repo has images, fonts, or other assets alongside source.<br /><strong>Turn it off when:</strong> you want the run to fail-fast and force cleanup of binary assets in the path.</div>
18471                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
18472# Detected via long lines + low whitespace heuristic
18473#   .png  .exe  .so  → skipped silently</div>
18474                  </div>
18475                </div>
18476                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18477                  <div class="toggle-card" style="margin:0;">
18478                    <div class="field-help-title">Python docstrings</div>
18479                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18480                    <label class="checkbox">
18481                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18482                      <span>Count as comment-style lines</span>
18483                    </label>
18484                  </div>
18485                  <div class="explainer-card prominent" style="margin:0;">
18486                    <div class="advanced-rule-description" id="python-docstring-live-help">Enabled: docstrings contribute to comment-style totals. Disable to count only inline comments and explicit comment lines.</div>
18487                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18488                  </div>
18489                </div>
18490              </div>
18491              <div class="subsection-bar">IEEE 1045-1992 counting</div>
18492              <div class="scan-rules-grid">
18493                <div class="preset-inline-row">
18494                  <div class="toggle-card" style="margin:0;">
18495                    <div class="field-help-title">Continuation lines</div>
18496                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18497                    <select name="continuation_line_policy" id="continuation_line_policy">
18498                      <option value="each_physical_line" selected>Each physical line (default)</option>
18499                      <option value="collapse_to_logical">Collapse to logical line</option>
18500                    </select>
18501                  </div>
18502                  <div class="explainer-card prominent" style="margin:0;">
18503                    <div class="advanced-rule-description"><strong>Purpose:</strong> Controls how backslash-continued lines (C macros, shell, Makefile) are counted.<br /><strong>Each physical line</strong> — the IEEE 1045-1992 default; every line with content is counted separately.<br /><strong>Collapse to logical</strong> — a backslash-continued sequence counts as one logical line, matching logical-SLOC conventions.</div>
18504                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18505    ((a) &gt; (b) ? (a) : (b))
18506# each_physical_line → 2 SLOC
18507# collapse_to_logical → 1 SLOC</div>
18508                  </div>
18509                </div>
18510                <div class="preset-inline-row">
18511                  <div class="toggle-card" style="margin:0;">
18512                    <div class="field-help-title">Block-comment blanks</div>
18513                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18514                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18515                      <option value="count_as_comment" selected>Count as comment (default)</option>
18516                      <option value="count_as_blank">Count as blank</option>
18517                    </select>
18518                  </div>
18519                  <div class="explainer-card prominent" style="margin:0;">
18520                    <div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code style="font-size:12px;">/* … */</code> block comment are classified.<br /><strong>Count as comment</strong> — IEEE-aligned; blank lines are part of the comment body.<br /><strong>Count as blank</strong> — legacy behaviour; blank lines inside block comments are treated as ordinary blank lines.</div>
18521                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18522 * Summary line
18523 *              ← blank inside block comment
18524 * Detail line
18525 */
18526# count_as_comment → blank counts toward comments
18527# count_as_blank   → blank counts toward blanks</div>
18528                  </div>
18529                </div>
18530                <div class="preset-inline-row">
18531                  <div class="toggle-card" style="margin:0;">
18532                    <div class="field-help-title">Compiler directives</div>
18533                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18534                    <select name="count_compiler_directives" id="count_compiler_directives">
18535                      <option value="enabled" selected>Include in code SLOC (default)</option>
18536                      <option value="disabled">Exclude from code SLOC</option>
18537                    </select>
18538                  </div>
18539                  <div class="explainer-card prominent" style="margin:0;">
18540                    <div class="advanced-rule-description"><strong>Purpose:</strong> IEEE 1045-1992 §4.2 — controls whether preprocessor directives contribute to code SLOC. Applies to C, C++, and Objective-C.<br /><strong>Include</strong> — <code style="font-size:12px;">#include</code> / <code style="font-size:12px;">#define</code> lines count toward code SLOC (default).<br /><strong>Exclude</strong> — directives are tracked separately in raw counts but not added to effective code SLOC; useful when comparing with tools that strip the preprocessor layer.</div>
18541                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
18542#define BUF 256     ← compiler directive
18543int main() { … }   ← code
18544# enabled  → 3 code SLOC
18545# disabled → 1 code SLOC + 2 directive lines</div>
18546                  </div>
18547                </div>
18548              </div>
18549
18550              <div class="subsection-bar">Code Style Analysis</div>
18551              <div class="scan-rules-grid">
18552                <div class="preset-inline-row">
18553                  <div class="toggle-card" style="margin:0;">
18554                    <div class="field-help-title">Style analysis</div>
18555                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18556                    <select name="style_analysis_enabled" id="style_analysis_enabled">
18557                      <option value="enabled" selected>Enabled (default)</option>
18558                      <option value="disabled">Disabled — skip style scoring</option>
18559                    </select>
18560                  </div>
18561                  <div class="explainer-card prominent" style="margin:0;">
18562                    <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> \u2014 every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> \u2014 style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
18563                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
18564# style_analysis_enabled = false  (skip, faster scan)
18565# Disabling removes the Code Style section from the report.</div>
18566                  </div>
18567                </div>
18568                <div class="preset-inline-row">
18569                  <div class="toggle-card" style="margin:0;">
18570                    <div class="field-help-title">Column-width threshold</div>
18571                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18572                    <select name="style_col_threshold" id="style_col_threshold">
18573                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18574                      <option value="100">100 columns (Uber Go, Google Java)</option>
18575                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
18576                    </select>
18577                  </div>
18578                  <div class="explainer-card prominent" style="margin:0;">
18579                    <div class="advanced-rule-description"><strong>Purpose:</strong> Sets the column width used to compute the <em>N-col Compliant</em> summary chip in the Code Style Analysis section of the report.<br /><strong>A file is compliant</strong> when ≤&thinsp;5&thinsp;% of its lines exceed this limit.<br /><strong>Does not affect SLOC counts</strong> — only the style-adherence reporting. The style guide scores themselves are always computed across all three thresholds (80 / 100 / 120) regardless of this setting.</div>
18580                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
18581# style_col_threshold = 100 (Uber Go, Google Java)
18582# style_col_threshold = 120 (Uber Go max, Kotlin)
18583# Files where &lt;= 5% of lines exceed the limit
18584# are counted as "N-col compliant" in the report.</div>
18585                  </div>
18586                </div>
18587                <div class="preset-inline-row">
18588                  <div class="toggle-card" style="margin:0;">
18589                    <div class="field-help-title">Score alert threshold</div>
18590                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18591                    <select name="style_score_threshold" id="style_score_threshold">
18592                      <option value="0" selected>Off — no threshold (default)</option>
18593                      <option value="40">40% — flag poorly styled files</option>
18594                      <option value="50">50% — flag below-average files</option>
18595                      <option value="60">60% — flag below-good files</option>
18596                      <option value="70">70% — flag below-strong files</option>
18597                    </select>
18598                  </div>
18599                  <div class="explainer-card prominent" style="margin:0;">
18600                    <div class="advanced-rule-description"><strong>Purpose:</strong> Files whose dominant-guide adherence score falls below this percentage are highlighted with a red left-border in the per-file style table — making it easy to spot the lowest-conformance files at a glance.<br /><strong>Off</strong> — all files shown without any alert (default).<br /><strong>Any other value</strong> — a red indicator flags each file scoring below the threshold.</div>
18601                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
18602# style_score_threshold = 50  (flag files &lt; 50%)
18603# Low-scoring files get a red left-border in the
18604# per-file style breakdown table.</div>
18605                  </div>
18606                </div>
18607              </div>
18608
18609              <div class="always-tracked-tip">
18610                <div class="always-tracked-tip-icon">ℹ</div>
18611                <div class="always-tracked-tip-body">
18612                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
18613                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
18614                  <div class="advanced-rule-description">Pure comment lines, multi-line comment blocks, blank lines, and total physical lines are always included by every supported analyzer. The settings on this page only affect lines that live on the boundary between code and comments — for example <code style="font-size:12px;">x = 1  # counter</code>, which contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
18615                </div>
18616              </div>
18617
18618              <div class="subsection-bar">Advanced Metrics</div>
18619              <div class="scan-rules-grid">
18620                <div class="preset-inline-row">
18621                  <div class="toggle-card" style="margin:0;">
18622                    <div class="field-help-title">COCOMO mode</div>
18623                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18624                    <select name="cocomo_mode" id="cocomo_mode">
18625                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18626                      <option value="semi_detached">Semi-detached — mixed constraints</option>
18627                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
18628                    </select>
18629                  </div>
18630                  <div class="explainer-card prominent" style="margin:0;">
18631                    <div class="advanced-rule-description"><strong>Purpose:</strong> Selects the COCOMO I Basic mode used to estimate development effort, schedule, and team size from code SLOC.<br /><strong>Organic</strong> — small teams with good experience on similar problems (most software projects).<br /><strong>Semi-detached</strong> — mixed experience; some novel aspects; medium-sized projects.<br /><strong>Embedded</strong> — tight hardware, OS, or real-time constraints; high innovation; large projects.</div>
18632                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
18633# Semi-detached: Effort = 3.0 × KSLOC^1.12
18634# Embedded:     Effort = 3.6 × KSLOC^1.20
18635# All modes: Schedule = 2.5 × Effort^d</div>
18636                  </div>
18637                </div>
18638                <div class="preset-inline-row">
18639                  <div class="toggle-card" style="margin:0;">
18640                    <div class="field-help-title">Complexity alert</div>
18641                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18642                    <input type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
18643                  </div>
18644                  <div class="explainer-card prominent" style="margin:0;">
18645                    <div class="advanced-rule-description"><strong>Purpose:</strong> When set, files whose total cyclomatic complexity score exceeds this threshold are highlighted in the results page with an accent border.<br /><strong>Complexity score</strong> counts branch decision keywords (if, for, while, ||, &amp;&amp;, …) across all code lines — a fast lexical approximation of McCabe complexity.<br /><strong>Common thresholds:</strong> 50 for a simple project, 100–200 for medium, 300+ for large repos.</div>
18646                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18647# 50  = flag any file with &gt; 50 branch points
18648# 100 = flag any file with &gt; 100 branch points
18649# Files above the threshold are highlighted
18650# in the result page metric strip.</div>
18651                  </div>
18652                </div>
18653                <div class="preset-inline-row">
18654                  <div class="toggle-card" style="margin:0;">
18655                    <div class="field-help-title">Git hotspots</div>
18656                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18657                    <input type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
18658                  </div>
18659                  <div class="explainer-card prominent" style="margin:0;">
18660                    <div class="advanced-rule-description"><strong>Purpose:</strong> <strong>On by default (90 days).</strong> oxide-sloc runs a single <code>git log</code> pass over the last N days and ranks files by <strong>code&nbsp;lines&nbsp;&times;&nbsp;recent&nbsp;commits</strong> in a Git Hotspots table — large files that change often are the strongest refactoring candidates.<br /><strong>Requires</strong> the scanned path to be a git repository. This is distinct from the scan-to-scan churn rate shown on the Compare page.</div>
18661                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
18662# 30  = last month of activity
18663# 365 = last year
18664# 0   = disable the hotspots table
18665# Adds Commits + Last-changed columns to CSV.</div>
18666                  </div>
18667                </div>
18668                <div class="preset-inline-row">
18669                  <div class="toggle-card" style="margin:0;">
18670                    <div class="field-help-title">Duplicate handling</div>
18671                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
18672                    <select name="exclude_duplicates" id="exclude_duplicates">
18673                      <option value="disabled" selected>Detect and report only (default)</option>
18674                      <option value="enabled">Detect and exclude from SLOC totals</option>
18675                    </select>
18676                  </div>
18677                  <div class="explainer-card prominent" style="margin:0;">
18678                    <div class="advanced-rule-description"><strong>Purpose:</strong> Detects files with identical content (bit-for-bit copies) that would otherwise inflate SLOC counts.<br /><strong>Detect and report only</strong> — duplicates are counted normally in totals; a "Duplicate groups" chip in the result page shows how many groups exist (default).<br /><strong>Detect and exclude</strong> — only one file per identical-content group contributes to code/comment/blank line totals; the rest are silently excluded.</div>
18679                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
18680# detect only   → all 3 counted in SLOC
18681# exclude dupes → 1 counted, 2 excluded
18682# Duplicate groups chip always shows the count.</div>
18683                  </div>
18684                </div>
18685                <div class="always-tracked-tip" style="margin:8px 0 0;">
18686                  <div class="always-tracked-tip-icon">ℹ</div>
18687                  <div class="always-tracked-tip-body">
18688                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
18689                    <div class="always-tracked-metrics-row">
18690                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
18691                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
18692                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
18693                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
18694                    </div>
18695                    <div class="hint" style="margin-top:8px;">All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
18696                  </div>
18697                </div>
18698              </div>
18699
18700              <div class="wizard-actions">
18701                <div class="left">
18702                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
18703                </div>
18704                <div class="right">
18705                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
18706                </div>
18707              </div>
18708            </div>
18709
18710            <div class="wizard-step" data-step="3">
18711              <div class="section">
18712                <div class="section-kicker">Step 3</div>
18713                <h2>Output and report identity</h2>
18714                <p class="card-subtitle step3-subtitle" style="white-space:nowrap;">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
18715                <div class="preset-kv-row">
18716                  <div class="toggle-card" style="margin:0;">
18717                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
18718                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
18719                    <select id="scan_preset">
18720                      <option value="balanced">Balanced local scan</option>
18721                      <option value="code_focused">Code focused</option>
18722                      <option value="comment_audit">Comment audit</option>
18723                      <option value="deep_review">Deep review</option>
18724                    </select>
18725                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
18726                  </div>
18727                  <div class="explainer-card">
18728                    <div class="field-help-title">Selected scan preset</div>
18729                    <div class="explainer-body" id="scan-preset-description"></div>
18730                    <div class="preset-summary-row" id="scan-preset-summary"></div>
18731                    <div class="code-sample" id="scan-preset-example"></div>
18732                    <div class="preset-note" id="scan-preset-note"></div>
18733                  </div>
18734                </div>
18735                <hr class="step3-separator" />
18736                <div class="preset-kv-row">
18737                  <div class="toggle-card" style="margin:0;">
18738                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
18739                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
18740                    <select id="artifact_preset">
18741                      <option value="review">Review bundle</option>
18742                      <option value="full">Full bundle</option>
18743                      <option value="html_only">HTML only</option>
18744                      <option value="machine">Machine bundle</option>
18745                    </select>
18746                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
18747                  </div>
18748                  <div class="explainer-card">
18749                    <div class="field-help-title">Selected artifact preset</div>
18750                    <div class="explainer-body" id="artifact-preset-description"></div>
18751                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
18752                    <div class="code-sample" id="artifact-preset-example"></div>
18753                  </div>
18754                </div>
18755              </div>
18756
18757              <div class="section section-spacer-top">
18758                <div class="output-field-row">
18759                  <div class="field">
18760                    <label for="output_dir">Output directory</label>
18761                    {% if server_mode %}
18762                    <div class="input-group compact">
18763                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly style="cursor:default;opacity:0.68;background:var(--surface-2);" />
18764                    </div>
18765                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
18766                    {% else %}
18767                    <div class="input-group compact">
18768                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
18769                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
18770                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
18771                    </div>
18772                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
18773                    {% endif %}
18774                  </div>
18775                  <div class="output-field-aside">
18776                    <strong>Where reports land</strong>
18777                    Each run creates a timestamped subfolder here containing the selected artifacts. If the path does not exist it will be created automatically. This path is separate from the project being scanned and does not affect what files are analyzed.
18778                  </div>
18779                </div>
18780              </div>
18781
18782              <div class="section section-spacer-top">
18783                <div class="output-field-row">
18784                  <div class="field">
18785                    <label for="report_title">Report title</label>
18786                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
18787                    <div class="hint">Appears in HTML and PDF output headers.</div>
18788                  </div>
18789                  <div class="output-field-aside">
18790                    <strong>Shown in exported artifacts</strong>
18791                    This title is embedded in the HTML and PDF reports and stays visible in the tool header while you configure the run. It defaults to the last folder name of the selected project path.
18792                  </div>
18793                </div>
18794              </div>
18795
18796              <div class="section section-spacer-top">
18797                <div class="output-field-row">
18798                  <div class="field">
18799                    <label for="report_header_footer">Report header / footer</label>
18800                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
18801                    <div class="hint" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Printed on every HTML/PDF page — company name, project ID, or scanner tag.</div>
18802                  </div>
18803                  <div class="output-field-aside">
18804                    <strong>Page-level identification</strong>
18805                    This text appears as a thin banner at the top and bottom of every report page. Leave blank to omit. Useful for labeling reports with an organization name, engagement ID, or classification level.
18806                  </div>
18807                </div>
18808              </div>
18809
18810              <div class="wizard-actions">
18811                <div class="left">
18812                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
18813                </div>
18814                <div class="right">
18815                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
18816                </div>
18817              </div>
18818            </div>
18819
18820            <div class="wizard-step" data-step="4">
18821              <div class="section">
18822                <div class="section-kicker">Step 4</div>
18823                <h2>Review selections and run</h2>
18824                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
18825                <div class="review-grid">
18826                  <div class="review-card highlight">
18827                    <div class="review-card-head"><h4>What will be scanned</h4><button type="button" class="review-link jump-step" data-step-target="1">Edit step 1</button></div>
18828                    <ul id="review-scan-summary"></ul>
18829                  </div>
18830                  <div class="review-card highlight">
18831                    <div class="review-card-head"><h4>How it will be counted</h4><button type="button" class="review-link jump-step" data-step-target="2">Edit step 2</button></div>
18832                    <ul id="review-count-summary"></ul>
18833                  </div>
18834                  <div class="review-card">
18835                    <div class="review-card-head"><h4>Output &amp; artifacts</h4><button type="button" class="review-link jump-step" data-step-target="3">Edit step 3</button></div>
18836                    <ul id="review-artifact-summary"></ul>
18837                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
18838                  </div>
18839                  <div class="review-card">
18840                    <div class="review-card-head"><h4>Scope preview snapshot</h4><button type="button" class="review-link jump-step" data-step-target="1">Review scope</button></div>
18841                    <ul id="review-preview-summary"></ul>
18842                  </div>
18843                </div>
18844              </div>
18845
18846              <div class="wizard-actions">
18847                <div class="left">
18848                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
18849                </div>
18850                <div class="right">
18851                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
18852                </div>
18853              </div>
18854            </div>
18855            {% if server_mode %}
18856            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
18857            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
18858            {% endif %}
18859          </form>
18860        </div>
18861      </section>
18862    </div>
18863  </div>
18864
18865  <script nonce="{{ csp_nonce }}">
18866    (function () {
18867      function startScanPhase() {
18868        var phaseEl = document.getElementById("scan-phase");
18869        if (!phaseEl) return;
18870        var phases = [
18871          "Discovering files...",
18872          "Decoding file encodings...",
18873          "Detecting languages...",
18874          "Analyzing source lines...",
18875          "Applying counting policies...",
18876          "Aggregating results...",
18877          "Rendering report..."
18878        ];
18879        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
18880        var i = 0;
18881        function next() {
18882          phaseEl.style.opacity = "0";
18883          setTimeout(function () {
18884            phaseEl.textContent = phases[i];
18885            phaseEl.style.opacity = "0.85";
18886            var delay = durations[i] || 1800;
18887            i++;
18888            if (i < phases.length) { setTimeout(next, delay); }
18889          }, 200);
18890        }
18891        next();
18892      }
18893
18894      var form = document.getElementById("analyze-form");
18895      var loading = document.getElementById("loading");
18896      var submitButton = document.getElementById("submit-button");
18897      var pathInput = document.getElementById("path");
18898      var GIT_MODE = !!(pathInput && pathInput.readOnly);
18899      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
18900      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
18901      var outputDirInput = document.getElementById("output_dir");
18902      var reportTitleInput = document.getElementById("report_title");
18903      var previewPanel = document.getElementById("preview-panel");
18904      var refreshButton = document.getElementById("refresh-preview");
18905      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
18906      var useSamplePath = document.getElementById("use-sample-path");
18907      var useDefaultOutput = document.getElementById("use-default-output");
18908      var browsePath = document.getElementById("browse-path");
18909      var browseOutputDir = document.getElementById("browse-output-dir");
18910      var browseCoverage = document.getElementById("browse-coverage");
18911      var coverageInput = document.getElementById("coverage_file");
18912      var covScanStatus = document.getElementById("cov-scan-status");
18913      var coverageSuggestTimer = null;
18914      var covAutoFilled = false;
18915      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
18916
18917      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
18918      (function() {
18919        var ids = ["path", "output_dir"];
18920        ids.forEach(function(id) {
18921          var el = document.getElementById(id);
18922          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
18923        });
18924      }());
18925      function fmtBytes(b) {
18926        b = Number(b) || 0;
18927        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
18928        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
18929        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
18930        return b + ' B';
18931      }
18932      var themeToggle = document.getElementById("theme-toggle");
18933
18934      function showBannerToast(msg, isError, opts) {
18935        opts = opts || {};
18936        var t = document.createElement('div');
18937        t.className = isError ? 'toast-error' : 'toast-success';
18938        var topPos = opts.top ? '80px' : null;
18939        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
18940          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
18941          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
18942          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
18943        if (opts.icon) {
18944          var inner = document.createElement('span');
18945          inner.innerHTML = opts.icon + ' ';
18946          t.appendChild(inner);
18947        }
18948        t.appendChild(document.createTextNode(msg));
18949        document.body.appendChild(t);
18950        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
18951      }
18952      var mixedLinePolicy = document.getElementById("mixed_line_policy");
18953      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
18954      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
18955      var scanPreset = document.getElementById("scan_preset");
18956      var artifactPreset = document.getElementById("artifact_preset");
18957      var includeGlobsInput = document.getElementById("include_globs");
18958      var excludeGlobsInput = document.getElementById("exclude_globs");
18959
18960      // Include globs scope badge — updates reactively as the user types.
18961      (function() {
18962        var badge = document.getElementById("include-scope-badge");
18963        if (!badge || !includeGlobsInput) return;
18964        var iconCheck = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> ';
18965        var iconFilter = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> ';
18966        function update() {
18967          var val = includeGlobsInput.value.trim();
18968          if (!val) {
18969            badge.className = "include-scope-badge scope-all";
18970            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
18971          } else {
18972            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
18973            badge.className = "include-scope-badge scope-narrow";
18974            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
18975          }
18976        }
18977        includeGlobsInput.addEventListener("input", update);
18978        update();
18979      }());
18980
18981      // Quick-exclude chips — append pattern to exclude_globs textarea.
18982      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
18983        chip.addEventListener("click", function() {
18984          var pattern = chip.getAttribute("data-pattern") || "";
18985          if (!pattern || !excludeGlobsInput) return;
18986          var current = excludeGlobsInput.value.trim();
18987          // For the "skip all" chip, replace any existing dep patterns cleanly.
18988          var patterns = pattern.split("\n");
18989          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
18990          var added = false;
18991          patterns.forEach(function(p) {
18992            p = p.trim();
18993            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
18994          });
18995          if (added) {
18996            excludeGlobsInput.value = lines.join("\n");
18997            excludeGlobsInput.dispatchEvent(new Event("input"));
18998          }
18999          chip.classList.add("active");
19000        });
19001      });
19002
19003      var liveReportTitle = document.getElementById("live-report-title");
19004      var navProjectPill = document.getElementById("nav-project-pill");
19005      var navProjectTitle = document.getElementById("nav-project-title");
19006      var reportTitlePreview = null;
19007      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19008      var wizardProgressValue = document.getElementById("wizard-progress-value");
19009      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19010      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19011      var reportTitleTouched = false;
19012      var currentStep = 1;
19013      var previewTimer = null;
19014      var _previewGen = 0;
19015      // True while the scope preview (local) / project upload (server mode) is in
19016      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19017      // user can't advance past a project whose scope/upload isn't ready yet.
19018      var previewLoading = false;
19019      function setPreviewLoading(loading) {
19020        previewLoading = !!loading;
19021        var nextBtn = document.getElementById("step1-next");
19022        var gate = document.getElementById("preview-gate-status");
19023        if (nextBtn) {
19024          nextBtn.classList.toggle("is-blocked", previewLoading);
19025          nextBtn.setAttribute("aria-disabled", previewLoading ? "true" : "false");
19026        }
19027        if (gate) {
19028          var txt = gate.querySelector(".preview-gate-text");
19029          if (txt) txt.textContent = SERVER_MODE
19030            ? "Uploading & scanning project…"
19031            : "Scanning project scope…";
19032          gate.style.display = previewLoading ? "flex" : "none";
19033        }
19034      }
19035      // Info button on the gate: scroll up to the live scope preview so the user
19036      // can see exactly what is being scanned (elapsed time + rotating status).
19037      var previewGateInfo = document.getElementById("preview-gate-info");
19038      if (previewGateInfo) {
19039        previewGateInfo.addEventListener("click", function () {
19040          var target = document.getElementById("preview-panel");
19041          if (!target) return;
19042          target.scrollIntoView({ behavior: "smooth", block: "center" });
19043          target.classList.add("preview-panel-flash");
19044          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19045        });
19046      }
19047      var quickScanBtn = document.getElementById("quick-scan-btn");
19048
19049      function dismissAnalysisModal() {
19050        if (loading) loading.classList.remove("active");
19051        document.body.classList.remove("modal-open");
19052        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19053          var el = document.getElementById(id);
19054          if (el) el.classList.add("hidden");
19055        });
19056        var cancelBtn = document.getElementById("lc-cancel-btn");
19057        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19058        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19059        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19060        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19061        for (var ri=1;ri<=4;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
19062        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19063        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19064        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19065        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19066        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19067        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19068      }
19069
19070      var lcDismissBtn = document.getElementById("lc-dismiss");
19071      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19072
19073      // When the browser restores this page from bfcache (Back button after navigating to results),
19074      // the loading overlay would still be showing its active state. Dismiss it immediately.
19075      window.addEventListener("pageshow", function(e) {
19076        if (e.persisted) { dismissAnalysisModal(); }
19077      });
19078
19079      function startAsyncAnalysis(formData) {
19080        var gitRepo = (formData.get("git_repo") || "").toString();
19081        var gitRef  = (formData.get("git_ref")  || "").toString();
19082        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19083        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19084
19085        var pathEl = document.getElementById("lc-path-text");
19086        if (pathEl) pathEl.textContent = displayPath;
19087
19088        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19089          var el = document.getElementById(id);
19090          if (el) el.classList.add("hidden");
19091        });
19092        var cancelBtn = document.getElementById("lc-cancel-btn");
19093        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19094        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19095        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19096        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19097        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19098        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19099        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19100        for (var si=1;si<=4;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
19101        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19102
19103        if (loading) loading.classList.add("active");
19104        document.body.classList.add("modal-open");
19105
19106        var startTime = Date.now();
19107        var elapsedTimer = setInterval(function() {
19108          var s = Math.floor((Date.now() - startTime) / 1000);
19109          var el = document.getElementById("lc-elapsed");
19110          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19111        }, 1000);
19112
19113        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19114
19115        function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
19116
19117        var PHASE_DESC = {
19118          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19119          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19120          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19121          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19122          'Done': 'Analysis complete \u2014 loading your results\u2026',
19123          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19124        };
19125        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19126        function lcSetPhase(txt) {
19127          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19128          var desc = document.getElementById("lc-stage-desc");
19129          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19130          var step = PHASE_STEP[txt] || 1;
19131          for (var i=1;i<=4;i++){var s=document.getElementById("lc-step-"+i);if(!s)continue;s.classList.remove("active","done");if(i<step)s.classList.add("done");else if(i===step)s.classList.add("active");}
19132        }
19133
19134        function lcShowCancelled() {
19135          clearInterval(elapsedTimer);
19136          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19137          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19138          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19139          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19140          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19141          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19142          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19143          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19144          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19145          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19146        }
19147
19148        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19149        if (lcCancelBtn) {
19150          lcCancelBtn.onclick = function() {
19151            if (!activeWaitId) { dismissAnalysisModal(); return; }
19152            lcCancelBtn.disabled = true;
19153            lcCancelBtn.textContent = "Cancelling\u2026";
19154            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19155              .then(function() { lcShowCancelled(); })
19156              .catch(function() { lcShowCancelled(); });
19157          };
19158        }
19159
19160        function lcShowError(msg) {
19161          clearInterval(elapsedTimer);
19162          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19163          lcSetPhase("Failed");
19164          var msgEl = document.getElementById("lc-err-msg");
19165          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19166          var errEl = document.getElementById("lc-err");
19167          var actEl = document.getElementById("lc-actions");
19168          if (errEl) errEl.classList.remove("hidden");
19169          if (actEl) actEl.classList.remove("hidden");
19170          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19171          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19172        }
19173
19174        function lcPoll(waitId) {
19175          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19176            .then(function(r) {
19177              if (!r.ok) throw new Error("HTTP " + r.status);
19178              return r.json();
19179            })
19180            .then(function(data) {
19181              pollRetries = 0;
19182              if (data.state === "complete") {
19183                clearInterval(elapsedTimer);
19184                lcSetPhase("Done");
19185                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19186              } else if (data.state === "failed") {
19187                lcShowError(data.message);
19188              } else if (data.state === "cancelled") {
19189                lcShowCancelled();
19190              } else {
19191                var s = Math.floor((Date.now() - startTime) / 1000);
19192                if (s > 90 && !warnShown) {
19193                  warnShown = true;
19194                  var w = document.getElementById("lc-warn");
19195                  if (w) w.classList.remove("hidden");
19196                }
19197                lcSetPhase(data.phase || "Running");
19198                var fd = data.files_done || 0, ft = data.files_total || 0;
19199                if (ft > 0) {
19200                  var card = document.getElementById("lc-files-card");
19201                  if (card) card.classList.remove("hidden");
19202                  var el = document.getElementById("lc-files");
19203                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19204                  var now = Date.now();
19205                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19206                  if (fdelta > 0 && tdelta > 0.4) {
19207                    var fps = Math.round(fdelta / tdelta);
19208                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19209                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19210                  }
19211                  lastFd = fd; lastFdTime = now;
19212                }
19213                setTimeout(function() { lcPoll(waitId); }, 1500);
19214              }
19215            })
19216            .catch(function() {
19217              pollRetries++;
19218              if (pollRetries >= 5) {
19219                lcShowError("Lost connection to server. Reload to check status.");
19220              } else {
19221                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19222              }
19223            });
19224        }
19225
19226        var params = new URLSearchParams(formData);
19227        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19228          .then(function(r) {
19229            var waitId = r.headers.get("x-wait-id");
19230            if (!waitId) { window.location.href = "/scan"; return; }
19231            activeWaitId = waitId;
19232            setTimeout(function() { lcPoll(waitId); }, 1500);
19233          })
19234          .catch(function(err) {
19235            lcShowError("Could not reach server: " + (err.message || err));
19236          });
19237      }
19238
19239      if (quickScanBtn) {
19240        quickScanBtn.addEventListener("click", function () {
19241          var pathVal = pathInput ? pathInput.value.trim() : "";
19242          if (!pathVal) {
19243            alert("Please enter or browse to a project path first.");
19244            return;
19245          }
19246          quickScanBtn.disabled = true;
19247          quickScanBtn.textContent = "Scanning...";
19248          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19249          startAsyncAnalysis(new FormData(form));
19250        });
19251      }
19252
19253      var mixedPolicyInfo = {
19254        code_only: {
19255          description: "Treat a line that contains both executable code and an inline comment as a code line only. This is the simplest and most common default when you want line counts to emphasize executable logic.",
19256          example: 'Example line:\n\nx = 1  # initialize counter\n\nResult:\n- counts as code\n- does not add to comment totals\n- useful for compact implementation-focused reports'
19257        },
19258        code_and_comment: {
19259          description: "Count mixed lines in both buckets. This is useful when you want the report to reflect that a single line contributes executable logic and reviewer-facing commentary at the same time.",
19260          example: 'Example line:\n\nx = 1  # initialize counter\n\nResult:\n- counts as code\n- also counts as comment\n- useful when documentation density matters'
19261        },
19262        comment_only: {
19263          description: "Treat mixed lines as comment lines only. This is unusual, but can be useful when auditing how much annotation or commentary exists inline, especially in heavily documented scripts.",
19264          example: 'Example line:\n\nx = 1  # initialize counter\n\nResult:\n- does not add to code totals\n- counts as comment\n- useful for specialized comment-centric audits'
19265        },
19266        separate_mixed_category: {
19267          description: "Place mixed lines into their own bucket so they are not hidden inside pure code or pure comment totals. This gives you the most explicit view of how much code and commentary are co-located on one line.",
19268          example: 'Example line:\n\nx = 1  # initialize counter\n\nResult:\n- goes into a separate mixed-line bucket\n- keeps pure code and pure comment counts cleaner\n- useful for deeper review and comparison'
19269        }
19270      };
19271
19272      var scanPresetInfo = {
19273        balanced: {
19274          description: "Balanced local scan is the default starting point for most repositories. It keeps scope guards enabled, counts mixed lines conservatively, and gives you a practical everyday review setup.",
19275          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19276          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19277          note: "Best when you want a stable local overview before making deeper adjustments.",
19278          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19279        },
19280        code_focused: {
19281          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19282          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19283          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19284          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19285          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19286        },
19287        comment_audit: {
19288          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19289          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19290          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19291          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19292          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19293        },
19294        deep_review: {
19295          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19296          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19297          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19298          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19299          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19300        }
19301      };
19302
19303      var artifactPresetInfo = {
19304        review: {
19305          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19306          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19307          example: "Ideal for a quick local review before sharing results."
19308        },
19309        full: {
19310          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19311          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19312          example: "Use when producing a deliverable or storing a snapshot for future comparison."
19313        },
19314        html_only: {
19315          description: "Standalone HTML report only. No PDF generation, no data files.",
19316          chips: ["HTML only"],
19317          example: "Fastest option when you only need to open the report in a browser."
19318        },
19319        machine: {
19320          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19321          chips: ["JSON", "CSV", "no HTML", "no PDF"],
19322          example: "Use in CI to capture metrics without generating visual reports."
19323        }
19324      };
19325
19326      function applyArtifactPreset() {
19327        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19328        if (!info) return;
19329        var descEl = document.getElementById("artifact-preset-description");
19330        var exampleEl = document.getElementById("artifact-preset-example");
19331        if (descEl) descEl.textContent = info.description;
19332        if (exampleEl) exampleEl.textContent = info.example;
19333        renderPresetChips("artifact-preset-summary", info.chips);
19334      }
19335
19336      function applyTheme(theme) {
19337        if (theme === "dark") document.body.classList.add("dark-theme");
19338        else document.body.classList.remove("dark-theme");
19339      }
19340
19341      function loadSavedTheme() {
19342        var saved = null;
19343        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19344        applyTheme(saved === "dark" ? "dark" : "light");
19345      }
19346
19347      function updateScrollProgress() {
19348        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19349        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19350        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19351        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19352        var step = Math.min(Math.max(currentStep, 1), 4);
19353        var base = stepBase[step];
19354        var end  = stepEnd[step];
19355
19356        var scrollFrac = 0;
19357        var activePanel = document.querySelector(".wizard-step.active");
19358        if (activePanel) {
19359          var scrollTop = window.scrollY || window.pageYOffset || 0;
19360          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19361          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19362          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19363          var scrolled = scrollTop + viewH - panelTop;
19364          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19365        }
19366
19367        var percent = Math.round(base + (end - base) * scrollFrac);
19368        percent = Math.min(end, Math.max(base, percent));
19369        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19370        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19371      }
19372
19373      function updateWizardProgress() {
19374        updateScrollProgress();
19375      }
19376
19377      var stepDescriptions = [
19378        "Choose a project folder, apply scope filters, and preview which files will be counted.",
19379        "Configure how mixed code-plus-comment lines and docstrings are classified.",
19380        "Pick your output formats, scan preset, and where reports are saved.",
19381        "Review all settings and launch the analysis."
19382      ];
19383
19384      function updateStepNav(step) {
19385        var infoLabel = document.getElementById("step-nav-info-label");
19386        var infoDesc  = document.getElementById("step-nav-info-desc");
19387        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19388        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
19389      }
19390
19391      function updateSidebarSummary() {
19392        var sumPath    = document.getElementById("sum-path");
19393        var sumPreset  = document.getElementById("sum-preset");
19394        var sumOutput  = document.getElementById("sum-output");
19395        var sidebarSummary = document.getElementById("sidebar-summary");
19396        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19397        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
19398        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19399        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
19400        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19401        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19402        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19403      }
19404
19405      function setStep(step, pushHistory) {
19406        currentStep = step;
19407        stepPanels.forEach(function (panel) {
19408          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19409        });
19410        stepButtons.forEach(function (button) {
19411          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19412        });
19413        var layoutEl = document.querySelector(".layout");
19414        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19415        updateWizardProgress();
19416        updateStepNav(step);
19417        stepButtons.forEach(function(btn) {
19418          var t = Number(btn.getAttribute("data-step-target"));
19419          btn.classList.toggle("done", t < step);
19420        });
19421        updateSidebarSummary();
19422
19423        if (pushHistory !== false) {
19424          try {
19425            history.pushState({ wizardStep: step }, "", "#step" + step);
19426          } catch (e) {}
19427        }
19428
19429        window.scrollTo({ top: 0, behavior: "instant" });
19430      }
19431
19432      window.addEventListener("popstate", function (e) {
19433        if (e.state && e.state.wizardStep) {
19434          setStep(e.state.wizardStep, false);
19435        } else {
19436          var hashMatch = location.hash.match(/^#step([1-4])$/);
19437          if (hashMatch) setStep(Number(hashMatch[1]), false);
19438        }
19439      });
19440
19441      function inferTitleFromPath(value) {
19442        if (!value) return "project";
19443        var cleaned = value.replace(/[\/\\]+$/, "");
19444        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19445        return parts.length ? parts[parts.length - 1] : value;
19446      }
19447
19448      function updateReportTitleFromPath() {
19449        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19450        if (!reportTitleTouched) {
19451          reportTitleInput.value = inferred;
19452        }
19453        var title = reportTitleInput.value || inferred;
19454        if (liveReportTitle) liveReportTitle.textContent = title;
19455        if (reportTitlePreview) reportTitlePreview.textContent = title;
19456        document.title = "OxideSLOC | " + title;
19457
19458        var projectPath = (pathInput.value || "").trim();
19459        if (navProjectPill && navProjectTitle) {
19460          if (projectPath.length > 0) {
19461            navProjectTitle.textContent = inferred;
19462            navProjectPill.classList.add("visible");
19463          } else {
19464            navProjectTitle.textContent = "";
19465            navProjectPill.classList.remove("visible");
19466          }
19467        }
19468      }
19469
19470      function updateMixedPolicyUI() {
19471        var key = mixedLinePolicy.value || "code_only";
19472        var info = mixedPolicyInfo[key];
19473        document.getElementById("mixed-policy-description").textContent = info.description;
19474        document.getElementById("mixed-policy-example").textContent = info.example;
19475      }
19476
19477      function updatePythonDocstringUI() {
19478        var checked = !!pythonDocstrings.checked;
19479        document.getElementById("python-docstring-example").textContent = checked
19480          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
19481          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
19482        document.getElementById("python-docstring-live-help").textContent = checked
19483          ? "Enabled: docstrings contribute to comment-style totals."
19484          : "Disabled: docstrings are not counted as comment content.";
19485      }
19486
19487      function renderPresetChips(targetId, chips) {
19488        var target = document.getElementById(targetId);
19489        if (!target) return;
19490        target.innerHTML = (chips || []).map(function (chip) {
19491          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19492        }).join('');
19493      }
19494
19495      function updatePresetDescriptions() {
19496        var scanInfo = scanPresetInfo[scanPreset.value];
19497        if (!scanInfo) return;
19498        document.getElementById("scan-preset-description").textContent = scanInfo.description;
19499        document.getElementById("scan-preset-example").textContent = scanInfo.example;
19500        document.getElementById("scan-preset-note").textContent = scanInfo.note;
19501        renderPresetChips("scan-preset-summary", scanInfo.chips);
19502      }
19503
19504      function applyScanPreset() {
19505        var info = scanPresetInfo[scanPreset.value];
19506        if (!info || !info.apply) return;
19507        mixedLinePolicy.value = info.apply.mixed;
19508        pythonDocstrings.checked = !!info.apply.docstrings;
19509        document.getElementById("generated_file_detection").value = info.apply.generated;
19510        document.getElementById("minified_file_detection").value = info.apply.minified;
19511        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19512        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19513        document.getElementById("binary_file_behavior").value = info.apply.binary;
19514        updateMixedPolicyUI();
19515        updatePythonDocstringUI();
19516      }
19517
19518      function updateReview() {
19519        var scanSummary = document.getElementById("review-scan-summary");
19520        var countSummary = document.getElementById("review-count-summary");
19521        var artifactSummary = document.getElementById("review-artifact-summary");
19522        var outputSummary = document.getElementById("review-output-summary");
19523        var previewSummary = document.getElementById("review-preview-summary");
19524        var readinessSummary = document.getElementById("review-readiness-summary");
19525        var includeText = document.getElementById("include_globs").value.trim();
19526        var excludeText = document.getElementById("exclude_globs").value.trim();
19527        var sidePathPreview = document.getElementById("side-path-preview");
19528        var sideOutputPreview = document.getElementById("side-output-preview");
19529        var sideTitlePreview = document.getElementById("side-title-preview");
19530
19531        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19532        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19533        if (sideTitlePreview) {
19534          var rt = document.getElementById("report_title");
19535          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19536        }
19537
19538        scanSummary.innerHTML = ""
19539          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19540          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19541          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19542
19543        countSummary.innerHTML = ""
19544          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19545          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19546          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19547          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19548          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19549          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19550          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19551          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19552
19553        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19554
19555        outputSummary.innerHTML = ""
19556          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19557          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19558
19559        if (previewSummary) {
19560          if (GIT_MODE) {
19561            previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
19562          } else {
19563          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19564          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19565          var statMap = {};
19566          statButtons.forEach(function (button) {
19567            var valueNode = button.querySelector('.scope-stat-value');
19568            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19569          });
19570          previewSummary.innerHTML = ''
19571            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19572            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19573            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19574            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19575            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19576            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19577
19578          if (readinessSummary) {
19579            readinessSummary.innerHTML = ''
19580              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19581              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19582              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19583          }
19584          } // end else (non-GIT_MODE)
19585        }
19586      }
19587
19588      function escapeHtml(value) {
19589        return String(value)
19590          .replace(/&/g, "&amp;")
19591          .replace(/</g, "&lt;")
19592          .replace(/>/g, "&gt;")
19593          .replace(/"/g, "&quot;")
19594          .replace(/'/g, "&#39;");
19595      }
19596
19597      function isPythonVisible() {
19598        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19599      }
19600
19601      function syncPythonVisibility() {
19602        var html = previewPanel.textContent || "";
19603        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19604        pythonWraps.forEach(function (node) {
19605          node.classList.toggle("hidden", !hasPython);
19606        });
19607      }
19608
19609      function attachPreviewInteractions() {
19610        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19611        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19612        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19613        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19614        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19615        var searchInput = previewPanel.querySelector("#explorer-search");
19616        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19617        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19618        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19619        var activeFilter = "all";
19620        var activeLanguage = "";
19621        var searchTerm = "";
19622        var currentSortKey = null;
19623        var currentSortOrder = "asc";
19624        var childRows = {};
19625
19626        rows.forEach(function (row) {
19627          var parentId = row.getAttribute("data-parent-id") || "";
19628          var rowId = row.getAttribute("data-row-id") || "";
19629          if (!childRows[parentId]) childRows[parentId] = [];
19630          childRows[parentId].push(rowId);
19631        });
19632
19633        function rowById(id) {
19634          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
19635        }
19636
19637        function hasCollapsedAncestor(row) {
19638          var parentId = row.getAttribute("data-parent-id");
19639          while (parentId) {
19640            var parent = rowById(parentId);
19641            if (!parent) break;
19642            if (parent.getAttribute("data-expanded") === "false") return true;
19643            parentId = parent.getAttribute("data-parent-id");
19644          }
19645          return false;
19646        }
19647
19648        function updateToggleGlyph(row) {
19649          var toggle = row.querySelector(".tree-toggle");
19650          if (!toggle) return;
19651          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
19652        }
19653
19654        function rowSortValue(row, key) {
19655          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
19656        }
19657
19658        function updateSortButtons() {
19659          sortButtons.forEach(function (button) {
19660            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
19661            var indicator = button.querySelector(".tree-sort-indicator");
19662            button.classList.toggle("active", isActive);
19663            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
19664            if (indicator) {
19665              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
19666            }
19667          });
19668        }
19669
19670        function sortSiblingRows() {
19671          if (!treeContainer) {
19672            updateSortButtons();
19673            return;
19674          }
19675
19676          var rowMap = {};
19677          var childrenMap = {};
19678          rows.forEach(function (row) {
19679            var rowId = row.getAttribute("data-row-id");
19680            var parentId = row.getAttribute("data-parent-id") || "";
19681            rowMap[rowId] = row;
19682            if (!childrenMap[parentId]) childrenMap[parentId] = [];
19683            childrenMap[parentId].push(rowId);
19684          });
19685
19686          Object.keys(childrenMap).forEach(function (parentId) {
19687            if (!parentId) return;
19688            childrenMap[parentId].sort(function (a, b) {
19689              var rowA = rowMap[a];
19690              var rowB = rowMap[b];
19691              if (!currentSortKey) {
19692                return Number(a) - Number(b);
19693              }
19694              var valueA = rowSortValue(rowA, currentSortKey);
19695              var valueB = rowSortValue(rowB, currentSortKey);
19696              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
19697              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
19698              var fallbackA = rowSortValue(rowA, "name");
19699              var fallbackB = rowSortValue(rowB, "name");
19700              if (fallbackA < fallbackB) return -1;
19701              if (fallbackA > fallbackB) return 1;
19702              return Number(a) - Number(b);
19703            });
19704          });
19705
19706          var orderedIds = [];
19707          function pushChildren(parentId) {
19708            (childrenMap[parentId] || []).forEach(function (childId) {
19709              orderedIds.push(childId);
19710              pushChildren(childId);
19711            });
19712          }
19713
19714          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
19715            orderedIds.push(topId);
19716            pushChildren(topId);
19717          });
19718
19719          orderedIds.forEach(function (id) {
19720            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
19721          });
19722          updateSortButtons();
19723        }
19724
19725        function updateLanguageButtons() {
19726          languageButtons.forEach(function (button) {
19727            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
19728            var isActive = languageValue === activeLanguage;
19729            button.classList.toggle("active", isActive);
19730          });
19731        }
19732
19733        function rowSelfMatches(row) {
19734          var kind = row.getAttribute("data-kind");
19735          var status = row.getAttribute("data-status");
19736          var language = (row.getAttribute("data-language") || "").toLowerCase();
19737          var name = row.getAttribute("data-name-lower") || "";
19738          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
19739          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
19740          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
19741          var passesLanguage = !activeLanguage || language === activeLanguage;
19742          return passesFilter && passesSearch && passesLanguage;
19743        }
19744
19745        function hasMatchingDescendant(rowId) {
19746          return (childRows[rowId] || []).some(function (childId) {
19747            var childRow = rowById(childId);
19748            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
19749          });
19750        }
19751
19752        function rowMatches(row) {
19753          if (rowSelfMatches(row)) return true;
19754          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
19755        }
19756
19757        function resetViewState() {
19758          activeFilter = "all";
19759          activeLanguage = "";
19760          searchTerm = "";
19761          currentSortKey = null;
19762          currentSortOrder = "asc";
19763          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19764          if (searchInput) searchInput.value = "";
19765          if (filterSelect) filterSelect.value = "all";
19766          updateLanguageButtons();
19767        }
19768
19769        function applyVisibility() {
19770          rows.forEach(function (row) {
19771            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
19772            row.classList.toggle("hidden-by-filter", !visible);
19773            row.style.display = visible ? "grid" : "none";
19774          });
19775          buttons.forEach(function (button) {
19776            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
19777          });
19778          if (filterSelect) filterSelect.value = activeFilter;
19779        }
19780
19781        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
19782        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
19783        var originalStats = {};
19784        buttons.forEach(function (btn) {
19785          var f = btn.getAttribute('data-filter');
19786          var v = btn.querySelector('.scope-stat-value');
19787          if (f && v) originalStats[f] = v.textContent;
19788        });
19789
19790        function applySubmoduleStats(statsJson) {
19791          try {
19792            var s = JSON.parse(statsJson);
19793            buttons.forEach(function (btn) {
19794              var f = btn.getAttribute('data-filter');
19795              var v = btn.querySelector('.scope-stat-value');
19796              if (!v) return;
19797              if (f === 'dir') v.textContent = s.dirs;
19798              else if (f === 'file') v.textContent = s.files;
19799              else if (f === 'supported') v.textContent = s.supported;
19800              else if (f === 'skipped') v.textContent = s.skipped;
19801              else if (f === 'unsupported') v.textContent = s.unsupported;
19802            });
19803          } catch (e) {}
19804        }
19805
19806        function restoreBaseRepoStats() {
19807          buttons.forEach(function (btn) {
19808            var f = btn.getAttribute('data-filter');
19809            var v = btn.querySelector('.scope-stat-value');
19810            if (v && originalStats[f]) v.textContent = originalStats[f];
19811          });
19812          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19813          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
19814        }
19815
19816        submoduleChips.forEach(function (chip) {
19817          chip.addEventListener('click', function () {
19818            var statsJson = chip.getAttribute('data-sub-stats');
19819            if (!statsJson) return;
19820            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
19821            chip.classList.add('active');
19822            applySubmoduleStats(statsJson);
19823            if (baseRepoBtn) baseRepoBtn.style.display = '';
19824          });
19825        });
19826
19827        if (baseRepoBtn) {
19828          baseRepoBtn.addEventListener('click', function () {
19829            restoreBaseRepoStats();
19830            resetViewState();
19831            sortSiblingRows();
19832            applyVisibility();
19833          });
19834        }
19835
19836        buttons.forEach(function (button) {
19837          button.addEventListener("click", function () {
19838            var filterValue = button.getAttribute("data-filter") || "all";
19839            if (filterValue === "reset-view") {
19840              restoreBaseRepoStats();
19841              resetViewState();
19842              sortSiblingRows();
19843              applyVisibility();
19844              return;
19845            }
19846            activeFilter = filterValue;
19847            applyVisibility();
19848          });
19849        });
19850
19851        rows.forEach(function (row) {
19852          updateToggleGlyph(row);
19853          var toggle = row.querySelector(".tree-toggle");
19854          if (toggle) {
19855            toggle.addEventListener("click", function () {
19856              var expanded = row.getAttribute("data-expanded") !== "false";
19857              row.setAttribute("data-expanded", expanded ? "false" : "true");
19858              updateToggleGlyph(row);
19859              applyVisibility();
19860            });
19861          }
19862        });
19863
19864        actionButtons.forEach(function (button) {
19865          button.addEventListener("click", function () {
19866            var action = button.getAttribute("data-explorer-action");
19867            if (action === "expand-all") {
19868              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
19869            } else if (action === "collapse-all") {
19870              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
19871            } else if (action === "clear-filters") {
19872              resetViewState();
19873            }
19874            sortSiblingRows();
19875            applyVisibility();
19876          });
19877        });
19878
19879        if (filterSelect) {
19880          filterSelect.addEventListener("change", function () {
19881            activeFilter = filterSelect.value || "all";
19882            applyVisibility();
19883          });
19884        }
19885
19886        languageButtons.forEach(function (button) {
19887          button.addEventListener("click", function () {
19888            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
19889            updateLanguageButtons();
19890            applyVisibility();
19891          });
19892        });
19893
19894        sortButtons.forEach(function (button) {
19895          button.addEventListener("click", function () {
19896            var sortKey = button.getAttribute("data-sort-key");
19897            if (currentSortKey === sortKey) {
19898              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
19899            } else {
19900              currentSortKey = sortKey;
19901              currentSortOrder = "asc";
19902            }
19903            sortSiblingRows();
19904            applyVisibility();
19905          });
19906        });
19907
19908        if (searchInput) {
19909          searchInput.addEventListener("input", function () {
19910            searchTerm = searchInput.value.trim().toLowerCase();
19911            applyVisibility();
19912          });
19913        }
19914
19915        updateLanguageButtons();
19916        sortSiblingRows();
19917        applyVisibility();
19918      }
19919
19920      function loadPreview() {
19921        if (!previewPanel || !pathInput) return;
19922        if (GIT_MODE) {
19923          previewPanel.innerHTML = '<div class="preview-error" style="color:var(--muted);font-style:italic;">Preview is not available for remote git refs. The scan will check out the source at runtime.</div>';
19924          setPreviewLoading(false);
19925          return;
19926        }
19927        var path = pathInput.value.trim();
19928        var zeroWarn = document.getElementById('zero-files-warning');
19929        if (!path) {
19930          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
19931          if (zeroWarn) zeroWarn.style.display = 'none';
19932          setPreviewLoading(false);
19933          return;
19934        }
19935        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
19936        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
19937        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
19938        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
19939        var myGen = ++_previewGen;
19940        var _prevMsgs = [
19941          'Scanning directory structure\u2026',
19942          'Detecting file types\u2026',
19943          'Applying include / exclude filters\u2026',
19944          'Estimating file counts\u2026',
19945          'Building scope preview\u2026',
19946          'Almost there\u2026'
19947        ];
19948        var _prevMsgIdx = 0;
19949        var _prevStart = Date.now();
19950        previewPanel.innerHTML =
19951          '<div class="preview-loading">' +
19952          '<div class="preview-spinner"></div>' +
19953          '<div class="preview-loading-text">' +
19954          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
19955          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
19956          '</div></div>';
19957        var _sizeTextEl = document.getElementById('project-size-text');
19958        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
19959        window._previewInterval = setInterval(function() {
19960          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
19961          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
19962          var ml = document.getElementById('plm');
19963          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
19964        }, 1500);
19965        window._previewElapsedTimer = setInterval(function() {
19966          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
19967          var el = document.getElementById('ple');
19968          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
19969        }, 1000);
19970        setPreviewLoading(true);
19971        var previewUrl = "/preview?path=" + encodeURIComponent(path)
19972          + "&include_globs=" + encodeURIComponent(includeValue)
19973          + "&exclude_globs=" + encodeURIComponent(excludeValue);
19974        fetch(previewUrl)
19975          .then(function (response) { return response.text(); })
19976          .then(function (html) {
19977            if (myGen !== _previewGen) return;
19978            clearInterval(window._previewInterval); window._previewInterval = null;
19979            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
19980            setPreviewLoading(false);
19981            previewPanel.innerHTML = html;
19982            attachPreviewInteractions();
19983            syncPythonVisibility();
19984            updateReview();
19985            setTimeout(collapseLanguagePills, 50);
19986            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
19987            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
19988            var sizeText = document.getElementById('project-size-text');
19989            var sizeBtn = document.getElementById('project-size-btn');
19990            // In server mode with upload sizes available, keep the compressed/original pair.
19991            if (SERVER_MODE && window._lastUploadSizes) {
19992              var us = window._lastUploadSizes;
19993              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
19994                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
19995              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
19996                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
19997            } else if (sizeText && projectSize) {
19998              sizeText.textContent = 'Project size: ' + projectSize;
19999              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20000            } else if (sizeText) {
20001              sizeText.textContent = 'Project size: \u2014';
20002            }
20003            if (zeroWarn) {
20004              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20005              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20006              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20007              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20008              if (supportedCount === 0 && fileCount > 0) {
20009                zeroWarn.textContent = '\u26a0 Warning: No supported source files detected\u2014this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
20010                zeroWarn.style.display = '';
20011              } else {
20012                zeroWarn.style.display = 'none';
20013              }
20014            }
20015          })
20016          .catch(function (err) {
20017            if (myGen !== _previewGen) return;
20018            clearInterval(window._previewInterval); window._previewInterval = null;
20019            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20020            setPreviewLoading(false);
20021            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20022          });
20023      }
20024
20025      function pickDirectory(targetInput, kind) {
20026        if (!targetInput) {
20027          showBannerToast("Directory picker: input element not found.", true);
20028          return;
20029        }
20030        if (SERVER_MODE) {
20031          if (kind === 'output') {
20032            showBannerToast(
20033              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20034              false,
20035              { top: true, icon: '\u{1F4C1}' }
20036            );
20037            return;
20038          }
20039          var inputEl = kind === 'coverage'
20040            ? document.getElementById('cov-upload-input')
20041            : document.getElementById('dir-upload-input');
20042          if (!inputEl) return;
20043          inputEl.onchange = function () {
20044            var files = inputEl.files;
20045            if (!files || files.length === 0) return;
20046            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20047            if (browseBtn) browseBtn.disabled = true;
20048
20049            function fileToBase64(file) {
20050              return new Promise(function (resolve, reject) {
20051                var reader = new FileReader();
20052                reader.onload = function () {
20053                  var b64 = reader.result.split(',')[1];
20054                  resolve(b64);
20055                };
20056                reader.onerror = reject;
20057                reader.readAsDataURL(file);
20058              });
20059            }
20060
20061            if (kind === 'coverage') {
20062              var f = files[0];
20063              if (previewPanel && targetInput === pathInput)
20064                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20065              fileToBase64(f).then(function (b64) {
20066                return fetch('/api/upload-file', {
20067                  method: 'POST',
20068                  headers: { 'Content-Type': 'application/json' },
20069                  body: JSON.stringify({ filename: f.name, content: b64 })
20070                }).then(function (r) { return r.json(); });
20071              })
20072                .then(function (d) {
20073                  if (d && d.tmp_path) {
20074                    if (coverageInput) coverageInput.value = d.tmp_path;
20075                    setCovStatus('idle');
20076                  } else if (d && d.error) { showBannerToast(d.error, true); }
20077                })
20078                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20079                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20080            } else {
20081              // ── Filter to source-code files only ─────────────────────────
20082              // Binary, generated, and dependency files (node_modules, .git,
20083              // build artifacts) are skipped so they are never uploaded.
20084              var CODE_EXTS = new Set([
20085                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20086                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20087                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20088                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20089                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20090                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20091                'tf','hcl','proto','thrift','avsc','graphql','gql'
20092              ]);
20093              var codeFiles = [];
20094              for (var i = 0; i < files.length; i++) {
20095                var f = files[i];
20096                var name = f.name;
20097                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20098                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20099                  codeFiles.push(f); continue;
20100                }
20101                var dot = name.lastIndexOf('.');
20102                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20103              }
20104              // Collect specific .git metadata files for server-side git detection.
20105              // These have no source extension so they are excluded by the loop above,
20106              // but the server needs them to read branch/commit/author without running git.
20107              var gitMetaFiles = [];
20108              for (var i = 0; i < files.length; i++) {
20109                var f = files[i];
20110                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20111                var gitIdx = rp.indexOf('/.git/');
20112                if (gitIdx < 0) continue;
20113                var gitRel = rp.slice(gitIdx + 1);
20114                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20115                    gitRel === '.git/logs/HEAD' ||
20116                    gitRel.startsWith('.git/refs/heads/') ||
20117                    gitRel.startsWith('.git/refs/tags/')) {
20118                  gitMetaFiles.push(f);
20119                }
20120              }
20121              var uploadFiles = codeFiles.concat(gitMetaFiles);
20122              var total = files.length;
20123              var kept = codeFiles.length;
20124              if (kept === 0) {
20125                if (previewPanel && targetInput === pathInput)
20126                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20127                if (browseBtn) browseBtn.disabled = false;
20128                inputEl.value = '';
20129                return;
20130              }
20131
20132              // ── Helper: apply upload result to UI ────────────────────────
20133              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20134              function applyUploadResult(tmpPath, sizes) {
20135                targetInput.value = tmpPath;
20136                scrollInputToEnd(targetInput);
20137                if (sizes && SERVER_MODE) {
20138                  window._lastUploadSizes = sizes;
20139                  // Immediately show both sizes before preview loads.
20140                  var sizeText = document.getElementById('project-size-text');
20141                  var sizeBtn = document.getElementById('project-size-btn');
20142                  if (sizeText) {
20143                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20144                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20145                  }
20146                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20147                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20148                }
20149                if (targetInput === pathInput) {
20150                  updateReportTitleFromPath();
20151                  autoSetOutputDir(tmpPath);
20152                  fetchProjectHistory(tmpPath);
20153                  loadPreview();
20154                  suggestCoverageFile(tmpPath);
20155                }
20156                updateReview();
20157                if (browseBtn) browseBtn.disabled = false;
20158                inputEl.value = '';
20159              }
20160
20161              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20162              if (typeof CompressionStream !== 'undefined') {
20163                if (previewPanel && targetInput === pathInput)
20164                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20165
20166                // Build a minimal POSIX ustar tar header for a single file entry.
20167                function buildUstarHeader(filePath, fileSize) {
20168                  var BLOCK = 512;
20169                  var hdr = new Uint8Array(BLOCK);
20170                  var enc = new TextEncoder();
20171                  function wStr(off, len, s) {
20172                    var b = enc.encode(s);
20173                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20174                  }
20175                  function wOct(off, len, val) {
20176                    var s = val.toString(8);
20177                    while (s.length < len - 1) s = '0' + s;
20178                    wStr(off, len, s + '\0');
20179                  }
20180                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20181                  var name = filePath, prefix = '';
20182                  if (filePath.length > 99) {
20183                    var split = filePath.lastIndexOf('/', 154);
20184                    if (split > 0 && filePath.length - split - 1 <= 99) {
20185                      prefix = filePath.substring(0, split);
20186                      name   = filePath.substring(split + 1);
20187                    } else { name = filePath.substring(0, 99); }
20188                  }
20189                  wStr(0,   100, name);          // name
20190                  wOct(100,   8, 0o000644);      // mode
20191                  wOct(108,   8, 0);             // uid
20192                  wOct(116,   8, 0);             // gid
20193                  wOct(124,  12, fileSize);      // size
20194                  wOct(136,  12, 0);             // mtime (epoch)
20195                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20196                  hdr[156] = 48;                 // type flag '0' = regular file
20197                  wStr(157, 100, '');            // linkname
20198                  wStr(257,   6, 'ustar');       // magic
20199                  wStr(263,   2, '00');          // version
20200                  wStr(265,  32, '');            // uname
20201                  wStr(297,  32, '');            // gname
20202                  wOct(329,   8, 0);             // devmajor
20203                  wOct(337,   8, 0);             // devminor
20204                  wStr(345, 155, prefix);        // prefix
20205                  // Compute checksum (sum of all bytes, placeholder = 32).
20206                  var chk = 0;
20207                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20208                  var cs = chk.toString(8);
20209                  while (cs.length < 6) cs = '0' + cs;
20210                  wStr(148, 8, cs + '\0 ');
20211                  return hdr;
20212                }
20213
20214                // Build tar.gz one file at a time, piping through CompressionStream.
20215                // RAM usage = compressed output buffer + one file at a time.
20216                (async function () {
20217                  try {
20218                    var BLOCK = 512;
20219                    var cs     = new CompressionStream('gzip');
20220                    var writer = cs.writable.getWriter();
20221                    var chunks = [];
20222                    var reader = cs.readable.getReader();
20223                    var collecting = (async function () {
20224                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20225                    })();
20226
20227                    for (var i = 0; i < uploadFiles.length; i++) {
20228                      var file = uploadFiles[i];
20229                      var path = file.webkitRelativePath || file.name;
20230                      var buf  = await file.arrayBuffer();
20231                      var data = new Uint8Array(buf);
20232                      // Header block
20233                      await writer.write(buildUstarHeader(path, data.length));
20234                      // Data padded to 512-byte boundary
20235                      if (data.length > 0) {
20236                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20237                        var block  = new Uint8Array(padded);
20238                        block.set(data);
20239                        await writer.write(block);
20240                      }
20241                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20242                        if (previewPanel && targetInput === pathInput)
20243                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20244                      }
20245                    }
20246                    // End-of-archive: two 512-byte zero blocks
20247                    await writer.write(new Uint8Array(BLOCK * 2));
20248                    await writer.close();
20249                    await collecting;
20250
20251                    var blob = new Blob(chunks, { type: 'application/gzip' });
20252                    var sizeMB = (blob.size / 1048576).toFixed(1);
20253                    if (previewPanel && targetInput === pathInput)
20254                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20255
20256                    var resp = await fetch('/api/upload-tarball', {
20257                      method: 'POST',
20258                      headers: { 'Content-Type': 'application/gzip' },
20259                      body: blob
20260                    });
20261                    var d = await resp.json();
20262                    if (d && d.tmp_path) {
20263                      applyUploadResult(d.tmp_path, {
20264                        compressed_bytes: d.compressed_bytes || 0,
20265                        original_bytes: d.original_bytes || 0
20266                      });
20267                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20268                  } catch (e) {
20269                    showBannerToast('Upload failed: ' + String(e), true);
20270                    if (browseBtn) browseBtn.disabled = false;
20271                    inputEl.value = '';
20272                  }
20273                })();
20274
20275              } else {
20276                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20277                // Used only on browsers that lack CompressionStream (pre-2023).
20278                var BATCH = 200;
20279                var batches = [];
20280                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20281                var totalBatches = batches.length;
20282                if (previewPanel && targetInput === pathInput)
20283                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20284
20285                function sendBatch(idx, currentUploadId, lastTmpPath) {
20286                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20287                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
20288                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20289                  Promise.all(batches[idx].map(function (file) {
20290                    return fileToBase64(file).then(function (b64) {
20291                      return { path: file.webkitRelativePath || file.name, content: b64 };
20292                    });
20293                  })).then(function (fileList) {
20294                    var body = { files: fileList };
20295                    if (currentUploadId) body.upload_id = currentUploadId;
20296                    return fetch('/api/upload-directory', {
20297                      method: 'POST', headers: { 'Content-Type': 'application/json' },
20298                      body: JSON.stringify(body)
20299                    }).then(function (r) { return r.json(); });
20300                  }).then(function (d) {
20301                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20302                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20303                  }).catch(function (e) {
20304                    showBannerToast('Upload failed: ' + String(e), true);
20305                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20306                  });
20307                }
20308                sendBatch(0, null, '');
20309              }
20310            }
20311          };
20312          inputEl.click();
20313          return;
20314        }
20315
20316        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20317        if (browseButton) browseButton.disabled = true;
20318
20319        if (previewPanel && targetInput === pathInput) {
20320          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20321        }
20322
20323        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
20324          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20325          .then(function (data) {
20326            if (data && data.selected_path) {
20327              targetInput.value = data.selected_path;
20328              scrollInputToEnd(targetInput);
20329
20330              if (targetInput === pathInput) {
20331                updateReportTitleFromPath();
20332                autoSetOutputDir(data.selected_path);
20333                fetchProjectHistory(data.selected_path);
20334                loadPreview();
20335                suggestCoverageFile(data.selected_path);
20336              }
20337
20338              updateReview();
20339            } else if (targetInput === pathInput) {
20340              loadPreview();
20341            }
20342          })
20343          .catch(function () {
20344            window.alert("Directory picker request failed.");
20345            if (previewPanel && targetInput === pathInput) {
20346              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20347            }
20348          })
20349          .finally(function () {
20350            if (browseButton) browseButton.disabled = false;
20351          });
20352      }
20353
20354      if (themeToggle) {
20355        themeToggle.addEventListener("click", function () {
20356          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20357          applyTheme(nextTheme);
20358          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20359        });
20360      }
20361
20362      stepButtons.forEach(function (button) {
20363        button.addEventListener("click", function () {
20364          var target = Number(button.getAttribute("data-step-target"));
20365          // Block jumping forward off step 1 while the preview / upload is running.
20366          if (previewLoading && currentStep === 1 && target > 1) return;
20367          setStep(target);
20368        });
20369      });
20370
20371      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20372        button.addEventListener("click", function () {
20373          var target = Number(button.getAttribute("data-step-target")) || 1;
20374          if (previewLoading && currentStep === 1 && target > 1) return;
20375          setStep(target);
20376        });
20377      });
20378
20379      // True when the project path is untouched from the bundled sample default.
20380      function isDefaultSamplePath() {
20381        return !GIT_MODE && pathInput && pathInput.value.trim() === "tests/fixtures/basic";
20382      }
20383
20384      var defaultPathOverlay = document.getElementById("default-path-overlay");
20385      function closeDefaultPathModal() {
20386        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20387      }
20388      function openDefaultPathModal() {
20389        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20390      }
20391
20392      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20393        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20394        // that borrow the .next-step style class but carry no data-next target).
20395        if (!button.hasAttribute("data-next")) return;
20396        button.addEventListener("click", function () {
20397          // Guard step 1 → 2: block while the scope preview / upload is still running.
20398          if (button.getAttribute("data-next") === "2" && previewLoading) return;
20399          // Guard step 1 → 2: warn when the project path is still the sample default.
20400          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20401            openDefaultPathModal();
20402            return;
20403          }
20404          updateReview();
20405          setStep(Number(button.getAttribute("data-next")));
20406        });
20407      });
20408
20409      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20410        if (!button.hasAttribute("data-prev")) return;
20411        button.addEventListener("click", function () {
20412          setStep(Number(button.getAttribute("data-prev")));
20413        });
20414      });
20415
20416      // Default-sample-path confirmation modal wiring.
20417      var defaultPathProceed = document.getElementById("default-path-proceed");
20418      if (defaultPathProceed) {
20419        defaultPathProceed.addEventListener("click", function () {
20420          closeDefaultPathModal();
20421          updateReview();
20422          setStep(2);
20423        });
20424      }
20425      var defaultPathCancel = document.getElementById("default-path-cancel");
20426      if (defaultPathCancel) {
20427        defaultPathCancel.addEventListener("click", function () {
20428          closeDefaultPathModal();
20429          if (pathInput) { pathInput.focus(); pathInput.select(); }
20430        });
20431      }
20432      if (defaultPathOverlay) {
20433        defaultPathOverlay.addEventListener("click", function (e) {
20434          if (e.target === defaultPathOverlay) closeDefaultPathModal();
20435        });
20436      }
20437      document.addEventListener("keydown", function (e) {
20438        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20439          closeDefaultPathModal();
20440        }
20441      });
20442
20443      document.addEventListener("keydown", function (e) {
20444        var tag = (document.activeElement || {}).tagName || "";
20445        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20446        if (e.altKey || e.ctrlKey || e.metaKey) return;
20447        if (e.key === "ArrowRight" && currentStep < 4) {
20448          if (currentStep === 1 && previewLoading) return;
20449          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20450          updateReview(); setStep(currentStep + 1);
20451        }
20452        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20453      });
20454
20455      if (useSamplePath) {
20456        useSamplePath.addEventListener("click", function () {
20457          pathInput.value = "tests/fixtures/basic";
20458          updateReportTitleFromPath();
20459          autoSetOutputDir("tests/fixtures/basic");
20460          loadPreview();
20461          suggestCoverageFile("tests/fixtures/basic");
20462        });
20463      }
20464
20465      if (useDefaultOutput) {
20466        useDefaultOutput.addEventListener("click", function () {
20467          delete outputDirInput.dataset.userEdited;
20468          autoSetOutputDir(pathInput ? pathInput.value : "");
20469          updateReview();
20470        });
20471      }
20472
20473      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20474      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20475
20476      // ── Drag-and-drop directory upload (server mode only) ─────────────────
20477      // Dropping a folder onto the path field bypasses Chrome's
20478      // "Upload X files to this site?" confirmation dialog.
20479      async function readDirRecursively(dirEntry, basePath) {
20480        var reader = dirEntry.createReader();
20481        var all = [];
20482        for (;;) {
20483          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20484          if (!batch.length) break;
20485          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20486        }
20487        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20488        var out = [];
20489        for (var i = 0; i < all.length; i++) {
20490          var sub = all[i];
20491          if (sub.isFile) {
20492            var f = await new Promise(function(res) { sub.file(res); });
20493            out.push({ file: f, path: basePath + '/' + sub.name });
20494          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20495            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20496            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20497          }
20498        }
20499        return out;
20500      }
20501
20502      function setupPathDropZone() {
20503        if (!SERVER_MODE || !pathInput) return;
20504        var CODE_EXTS = new Set([
20505          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20506          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20507          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20508          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20509          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20510          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20511        ]);
20512        pathInput.addEventListener('dragover', function(e) {
20513          e.preventDefault();
20514          pathInput.classList.add('drag-over');
20515        });
20516        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20517        pathInput.addEventListener('drop', function(e) {
20518          e.preventDefault();
20519          pathInput.classList.remove('drag-over');
20520          var items = e.dataTransfer.items;
20521          if (!items || !items.length) return;
20522          var dirEntry = null;
20523          for (var i = 0; i < items.length; i++) {
20524            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20525            if (entry && entry.isDirectory) { dirEntry = entry; break; }
20526          }
20527          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20528          var btn = browsePath;
20529          if (btn) btn.disabled = true;
20530          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20531
20532          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20533            var total = allEntries.length;
20534            var codeEntries = allEntries.filter(function(e) {
20535              var n = e.file.name;
20536              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20537              var dot = n.lastIndexOf('.');
20538              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20539            });
20540            var kept = codeEntries.length;
20541            if (kept === 0) {
20542              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20543              if (btn) btn.disabled = false; return;
20544            }
20545
20546            function finish(tmpPath, sizes) {
20547              pathInput.value = tmpPath;
20548              scrollInputToEnd(pathInput);
20549              if (sizes) {
20550                window._lastUploadSizes = sizes;
20551                var sizeText = document.getElementById('project-size-text');
20552                var sizeBtn = document.getElementById('project-size-btn');
20553                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20554                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20555                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20556                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20557              }
20558              updateReportTitleFromPath();
20559              autoSetOutputDir(tmpPath);
20560              fetchProjectHistory(tmpPath);
20561              loadPreview();
20562              suggestCoverageFile(tmpPath);
20563              updateReview();
20564              if (btn) btn.disabled = false;
20565            }
20566
20567            if (typeof CompressionStream === 'undefined') {
20568              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20569              if (btn) btn.disabled = false; return;
20570            }
20571
20572            try {
20573              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20574              var BLOCK = 512;
20575              var cs = new CompressionStream('gzip');
20576              var wtr = cs.writable.getWriter();
20577              var chunks = [];
20578              var rdr = cs.readable.getReader();
20579              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20580
20581              function buildHdr(fp, sz) {
20582                var hdr = new Uint8Array(BLOCK);
20583                var enc = new TextEncoder();
20584                function wS(o, l, s) { var b = enc.encode(s); for (var i = 0; i < Math.min(b.length, l); i++) hdr[o + i] = b[i]; }
20585                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20586                var nm = fp, pfx = '';
20587                if (fp.length > 99) { var sp = fp.lastIndexOf('/', 154); if (sp > 0 && fp.length - sp - 1 <= 99) { pfx = fp.substring(0, sp); nm = fp.substring(sp + 1); } else { nm = fp.substring(0, 99); } }
20588                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20589                for (var i = 148; i < 156; i++) hdr[i] = 32;
20590                hdr[156] = 48; wS(157,100,''); wS(257,6,'ustar'); wS(263,2,'00'); wS(265,32,''); wS(297,32,''); wO(329,8,0); wO(337,8,0); wS(345,155,pfx);
20591                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20592                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20593                return hdr;
20594              }
20595
20596              for (var i = 0; i < codeEntries.length; i++) {
20597                var ce = codeEntries[i];
20598                var buf = await ce.file.arrayBuffer();
20599                var data = new Uint8Array(buf);
20600                await wtr.write(buildHdr(ce.path, data.length));
20601                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20602                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20603                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20604              }
20605              await wtr.write(new Uint8Array(BLOCK * 2));
20606              await wtr.close();
20607              await collecting;
20608
20609              var blob = new Blob(chunks, { type: 'application/gzip' });
20610              var sizeMB = (blob.size / 1048576).toFixed(1);
20611              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20612              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20613              var d = await resp.json();
20614              if (d && d.tmp_path) {
20615                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20616              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20617            } catch (err) {
20618              showBannerToast('Upload failed: ' + String(err), true);
20619              if (btn) btn.disabled = false;
20620            }
20621          }).catch(function(err) {
20622            showBannerToast('Could not read folder: ' + String(err), true);
20623            if (btn) btn.disabled = false;
20624          });
20625        });
20626      }
20627      setupPathDropZone();
20628      if (browseCoverage) {
20629        browseCoverage.addEventListener("click", function () {
20630          pickDirectory(coverageInput || pathInput, "coverage");
20631        });
20632      }
20633
20634      function setCovStatus(state, opts) {
20635        if (!covScanStatus) return;
20636        opts = opts || {};
20637        covScanStatus.className = "cov-scan-status cov-scan-" + state;
20638        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
20639        var ICON_SCAN = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>';
20640        var ICON_OK   = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12l3 3 5-5"/></svg>';
20641        var ICON_WARN = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
20642        var ICON_NONE = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';
20643        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
20644        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
20645        if (state === "scanning") {
20646          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
20647        } else if (state === "found") {
20648          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20649          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
20650          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
20651          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
20652        } else if (state === "hint") {
20653          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
20654          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
20655          html += '<div class="cov-scan-sub">Generate a report with your test framework\'s coverage tool, then browse to the output file. Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML &middot; coverage.py JSON &middot; Istanbul JSON</div>';
20656        } else if (state === "none") {
20657          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
20658          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
20659        }
20660        html += '</div></div>';
20661        covScanStatus.innerHTML = html;
20662        if (state === "found") {
20663          var useBtn = covScanStatus.querySelector(".cov-scan-use");
20664          if (useBtn) useBtn.addEventListener("click", function () {
20665            if (coverageInput) coverageInput.value = "";
20666            covAutoFilled = false;
20667            setCovStatus("idle");
20668          });
20669        }
20670      }
20671
20672      function suggestCoverageFile(projectPath) {
20673        if (!coverageInput || !covScanStatus) return;
20674        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20675        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
20676        clearTimeout(coverageSuggestTimer);
20677        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
20678        setCovStatus("scanning");
20679        coverageSuggestTimer = setTimeout(function () {
20680          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
20681            .then(function (r) { return r.json(); })
20682            .then(function (d) {
20683              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
20684              if (!d) { setCovStatus("none"); return; }
20685              if (d.found) {
20686                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
20687                setCovStatus("found", { found: d.found, tool: d.tool });
20688              } else if (d.tool && d.hint) {
20689                setCovStatus("hint", { tool: d.tool, hint: d.hint });
20690              } else {
20691                setCovStatus("none");
20692              }
20693            })
20694            .catch(function () { setCovStatus("idle"); });
20695        }, 600);
20696      }
20697
20698      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
20699
20700      if (coverageInput) coverageInput.addEventListener("input", function () {
20701        covAutoFilled = false;
20702        if (!this.value.trim()) setCovStatus("idle");
20703      });
20704
20705      // ── Language pill overflow: collapse to "+N more" chip ─────────────
20706      function collapseLanguagePills() {
20707        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
20708        rows.forEach(function(row) {
20709          // Remove any previous overflow chip
20710          var prev = row.querySelector('.lang-overflow-chip');
20711          if (prev) prev.remove();
20712          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
20713          pills.forEach(function(p) { p.style.display = ''; });
20714          if (!pills.length) return;
20715
20716          // Measure after restoring all pills
20717          var containerRight = row.getBoundingClientRect().right;
20718          var hidden = [];
20719          for (var i = pills.length - 1; i >= 1; i--) {
20720            var rect = pills[i].getBoundingClientRect();
20721            if (rect.right > containerRight + 2) {
20722              hidden.unshift(pills[i]);
20723              pills[i].style.display = 'none';
20724            } else {
20725              break;
20726            }
20727          }
20728
20729          if (hidden.length) {
20730            var chip = document.createElement('button');
20731            chip.type = 'button';
20732            chip.className = 'language-pill lang-overflow-chip';
20733            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
20734            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
20735            row.appendChild(chip);
20736          }
20737        });
20738      }
20739
20740      // Run after preview loads (preview panel populates language pills)
20741      var _origLoadPreviewCb = window.__previewLoaded;
20742      document.addEventListener('previewLoaded', collapseLanguagePills);
20743      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
20744      setTimeout(collapseLanguagePills, 400);
20745
20746      // ── Project history & output dir auto-set ──────────────────────────
20747      var wsOutputRoot   = document.getElementById("ws-output-root");
20748      var wsScanCount    = document.getElementById("ws-scan-count");
20749      var wsLastScan     = document.getElementById("ws-last-scan");
20750      var historyBadge   = document.getElementById("path-history-badge");
20751      var historyTimer   = null;
20752
20753      var wsOutputLink = document.getElementById("ws-output-link");
20754      function syncStripOutputRoot() {
20755        var val = outputDirInput ? outputDirInput.value : "";
20756        var display = val || "project/sloc";
20757        if (wsOutputRoot) wsOutputRoot.textContent = display;
20758        if (wsOutputLink) wsOutputLink.dataset.folder = val;
20759      }
20760
20761      function scrollInputToEnd(input) {
20762        if (!input) return;
20763        // Defer so the DOM has the new value before we measure scroll width.
20764        requestAnimationFrame(function () {
20765          input.scrollLeft = input.scrollWidth;
20766          input.selectionStart = input.selectionEnd = input.value.length;
20767        });
20768      }
20769
20770      function autoSetOutputDir(projectPath) {
20771        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
20772        if (GIT_MODE && GIT_OUTPUT_DIR) {
20773          outputDirInput.value = GIT_OUTPUT_DIR;
20774          scrollInputToEnd(outputDirInput);
20775          syncStripOutputRoot();
20776          updateReview();
20777          return;
20778        }
20779        if (!projectPath || !projectPath.trim()) return;
20780        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
20781        outputDirInput.value = cleaned + "/sloc";
20782        scrollInputToEnd(outputDirInput);
20783        syncStripOutputRoot();
20784        updateReview();
20785      }
20786
20787      var wsBranch = document.getElementById("ws-branch");
20788
20789      function fetchProjectHistory(projectPath) {
20790        if (!projectPath || !projectPath.trim()) {
20791          if (wsScanCount) wsScanCount.textContent = "\u2014";
20792          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
20793          if (wsBranch)    wsBranch.textContent    = "\u2014";
20794          if (historyBadge) historyBadge.style.display = "none";
20795          return;
20796        }
20797        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
20798          .then(function (r) { return r.ok ? r.json() : null; })
20799          .then(function (data) {
20800            if (!data) return;
20801            var countStr = data.scan_count > 0
20802              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
20803              : "never";
20804            var tsStr = data.last_scan_timestamp
20805              ? data.last_scan_timestamp.replace(" UTC","")
20806              : "\u2014";
20807            if (wsScanCount) wsScanCount.textContent = countStr;
20808            if (wsLastScan)  wsLastScan.textContent  = tsStr;
20809            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
20810            if (data.scan_count > 0) {
20811              if (historyBadge) {
20812                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
20813                historyBadge.textContent = data.scan_count + " previous scan" +
20814                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
20815                  "Last: " + (data.last_scan_timestamp || "\u2014") +
20816                  " \u2014 " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?(v/1e3).toFixed(1).replace(/\.0$/,'')+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
20817                historyBadge.className = "path-history-badge found";
20818                historyBadge.style.display = "";
20819              }
20820            } else {
20821              if (historyBadge) historyBadge.style.display = "none";
20822            }
20823          })
20824          .catch(function () {});
20825      }
20826
20827      function onPathChange() {
20828        var val = pathInput ? pathInput.value : "";
20829        // Discard stale upload sizes when the user edits the path manually.
20830        window._lastUploadSizes = null;
20831        updateReportTitleFromPath();
20832        autoSetOutputDir(val);
20833        updateSidebarSummary();
20834        clearTimeout(historyTimer);
20835        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
20836        if (previewTimer) clearTimeout(previewTimer);
20837        previewTimer = setTimeout(loadPreview, 280);
20838        suggestCoverageFile(val);
20839      }
20840
20841      if (pathInput) {
20842        pathInput.addEventListener("input", onPathChange);
20843      }
20844
20845      if (outputDirInput) {
20846        outputDirInput.addEventListener("input", function () {
20847          outputDirInput.dataset.userEdited = "1";
20848          syncStripOutputRoot();
20849          updateReview();
20850        });
20851      }
20852
20853      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
20854        if (!node) return;
20855        node.addEventListener("input", function () {
20856          updateReview();
20857          if (previewTimer) clearTimeout(previewTimer);
20858          previewTimer = setTimeout(loadPreview, 280);
20859        });
20860      });
20861
20862      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
20863        var node = document.getElementById(id);
20864        if (node) node.addEventListener("change", updateReview);
20865      });
20866
20867      if (reportTitleInput) {
20868        reportTitleInput.addEventListener("input", function () {
20869          reportTitleTouched = reportTitleInput.value.trim().length > 0;
20870          updateReportTitleFromPath();
20871          updateReview();
20872        });
20873      }
20874
20875      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
20876      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
20877      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
20878      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
20879
20880      if (coverageInput) {
20881        coverageInput.addEventListener("input", function () {
20882          if (coverageInput.value.trim()) setCovStatus("idle");
20883        });
20884      }
20885
20886      if (form && loading && submitButton) {
20887        form.addEventListener("submit", function (e) {
20888          e.preventDefault();
20889          submitButton.disabled = true;
20890          submitButton.textContent = "Scanning...";
20891          startAsyncAnalysis(new FormData(form));
20892        });
20893      }
20894
20895      function openPath(folder) {
20896        if (!folder) return;
20897        fetch('/open-path?path=' + encodeURIComponent(folder))
20898          .then(function (r) { return r.json(); })
20899          .then(function (d) {
20900            if (d && d.server_mode_disabled)
20901              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
20902          })
20903          .catch(function () {});
20904      }
20905
20906      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
20907        btn.addEventListener('click', function () {
20908          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
20909        });
20910      });
20911
20912      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
20913      if (wsOutputLink) {
20914        wsOutputLink.addEventListener('click', function () {
20915          openPath(wsOutputLink.dataset.folder || '');
20916        });
20917      }
20918
20919      loadSavedTheme();
20920      updateMixedPolicyUI();
20921      updatePythonDocstringUI();
20922      applyScanPreset();
20923      updatePresetDescriptions();
20924      applyArtifactPreset();
20925      updateReview();
20926      updateScrollProgress(); // initialise bar to 0% (step 1)
20927      window.addEventListener("scroll", updateScrollProgress, { passive: true });
20928      onPathChange();         // seed output dir, history badge, and preview from initial path
20929      updateStepNav(1);
20930
20931      // Restore step from URL hash on initial load (e.g., back-forward cache)
20932      (function() {
20933        var hashMatch = location.hash.match(/^#step([1-4])$/);
20934        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
20935      })();
20936
20937      (function randomizeWatermarks() {
20938        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
20939        if (!wms.length) return;
20940        var placed = [];
20941        function tooClose(top, left) {
20942          for (var i = 0; i < placed.length; i++) {
20943            var dt = Math.abs(placed[i][0] - top);
20944            var dl = Math.abs(placed[i][1] - left);
20945            if (dt < 16 && dl < 12) return true;
20946          }
20947          return false;
20948        }
20949        function pick(leftBand) {
20950          for (var attempt = 0; attempt < 50; attempt++) {
20951            var top = Math.random() * 88 + 2;
20952            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20953            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
20954          }
20955          var top = Math.random() * 88 + 2;
20956          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
20957          placed.push([top, left]);
20958          return [top, left];
20959        }
20960        var half = Math.floor(wms.length / 2);
20961        wms.forEach(function (img, i) {
20962          var pos = pick(i < half);
20963          var size = Math.floor(Math.random() * 80 + 110);
20964          var rot = (Math.random() * 360).toFixed(1);
20965          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
20966          img.style.width=size+"px";img.style.top=pos[0].toFixed(1)+"%";img.style.left=pos[1].toFixed(1)+"%";img.style.transform="rotate("+rot+"deg)";img.style.opacity=op;
20967        });
20968      })();
20969
20970      (function spawnCodeParticles() {
20971        var container = document.getElementById('code-particles');
20972        if (!container) return;
20973        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'];
20974        for (var i = 0; i < 38; i++) {
20975          (function(idx) {
20976            var el = document.createElement('span');
20977            el.className = 'code-particle';
20978            el.textContent = snippets[idx % snippets.length];
20979            var left = Math.random() * 94 + 2;
20980            var top = Math.random() * 88 + 6;
20981            var dur = (Math.random() * 10 + 9).toFixed(1);
20982            var delay = (Math.random() * 18).toFixed(1);
20983            var rot = (Math.random() * 26 - 13).toFixed(1);
20984            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
20985            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
20986            container.appendChild(el);
20987          })(i);
20988        }
20989      })();
20990    })();
20991  </script>
20992  <script nonce="{{ csp_nonce }}">
20993    (function () {
20994      var raw = {{ prefill_json|safe }};
20995      if (!raw || typeof raw !== 'object' || !raw.path) return;
20996      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
20997      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
20998      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
20999      setVal('path', raw.path || '');
21000      setVal('include_globs', raw.include_globs || '');
21001      setVal('exclude_globs', raw.exclude_globs || '');
21002      setVal('output_dir', raw.output_dir || '');
21003      setVal('report_title', raw.report_title || '');
21004      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21005      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21006      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21007      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21008      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21009      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21010      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21011      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21012      setChecked('generate_html', raw.generate_html !== false);
21013      setChecked('generate_pdf', !!raw.generate_pdf);
21014      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21015      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21016      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21017      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21018      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21019      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21020      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21021      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21022      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21023      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21024      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21025      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21026      // Trigger dynamic UI updates after pre-fill.
21027      setTimeout(function () {
21028        var pathEl = document.getElementById('path');
21029        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21030        var policyEl = document.getElementById('mixed_line_policy');
21031        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21032      }, 80);
21033    })();
21034  </script>
21035  <script nonce="{{ csp_nonce }}">
21036  (function(){
21037    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'}];
21038    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);});}
21039    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21040    function init(){
21041      var btn=document.getElementById('settings-btn');if(!btn)return;
21042      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21043      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>';
21044      document.body.appendChild(m);
21045      var g=document.getElementById('scheme-grid');
21046      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);});
21047      var cl=document.getElementById('settings-close');
21048      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);
21049      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');});
21050      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21051      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21052    }
21053    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21054  }());
21055  </script>
21056  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21057    <div class="wb-ftip-arrow"></div>
21058    <span id="wb-ftip-text"></span>
21059  </div>
21060  <script nonce="{{ csp_nonce }}">(function(){
21061    var tip=document.getElementById('wb-ftip');
21062    var txt=document.getElementById('wb-ftip-text');
21063    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21064    if(!tip||!txt)return;
21065    function pos(el){
21066      var r=el.getBoundingClientRect();
21067      tip.style.display='block';
21068      var tw=tip.offsetWidth;
21069      var lx=r.left+r.width/2-tw/2;
21070      if(lx<8)lx=8;
21071      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21072      tip.style.left=lx+'px';
21073      tip.style.top=(r.bottom+8)+'px';
21074      if(arr){var al=r.left+r.width/2-lx-6;al=Math.max(10,Math.min(tw-22,al));arr.style.left=al+'px';}
21075    }
21076    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21077      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21078      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21079    });
21080    window.addEventListener('blur',function(){tip.style.display='none';});
21081    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21082  })();
21083  (function(){
21084    function fixArtifactHintSpacing(){
21085      var grid=document.querySelector('.artifact-grid');
21086      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21087    }
21088    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21089  }());
21090  (function(){
21091    var dot=document.getElementById('status-dot');
21092    var pingEl=document.getElementById('server-ping-ms');
21093    var tipEl=document.getElementById('server-tip-ping');
21094    var fm=document.getElementById('footer-mode');
21095    function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
21096    function doPing(){
21097      var t0=performance.now();
21098      fetch('/healthz',{cache:'no-store'})
21099        .then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
21100        .catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
21101    }
21102    doPing();
21103    setInterval(doPing,5000);
21104    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
21105  })();
21106  </script>
21107  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21108  <footer class="site-footer">
21109    local code analysis - metrics, history and reports
21110    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
21111    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21112    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21113    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21114    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21115  </footer>
21116</body>
21117</html>
21118"##,
21119    ext = "html"
21120)]
21121struct IndexTemplate {
21122    version: &'static str,
21123    prefill_json: String,
21124    csp_nonce: String,
21125    git_repo: String,
21126    git_ref: String,
21127    git_label_json: String,
21128    git_output_dir_json: String,
21129    server_mode: bool,
21130}
21131
21132// ── SplashTemplate ────────────────────────────────────────────────────────────
21133
21134#[derive(Template)]
21135#[template(
21136    source = r##"
21137<!doctype html>
21138<html lang="en">
21139<head>
21140  <meta charset="utf-8">
21141  <meta name="viewport" content="width=device-width, initial-scale=1">
21142  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21143  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21144  <script type="application/ld+json">
21145  {
21146    "@context": "https://schema.org",
21147    "@type": "SoftwareApplication",
21148    "name": "oxide-sloc",
21149    "applicationCategory": "DeveloperApplication",
21150    "operatingSystem": "Windows, Linux",
21151    "description": "IEEE 1045-1992 SLOC analysis workbench — CLI, web UI, MCP server, 60 languages, offline-first. Counts code, comment, and blank lines; detects unit tests; produces HTML and PDF reports.",
21152    "softwareVersion": "{{ version }}",
21153    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21154    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21155    "url": "https://github.com/oxide-sloc/oxide-sloc",
21156    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21157    "featureList": "60 language analysis, IEEE 1045-1992 SLOC counting, HTML and PDF reports, REST API, MCP server, CI/CD integration, trend reports, test metrics, git integration",
21158    "programmingLanguage": "Rust",
21159    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21160  }
21161  </script>
21162  <style nonce="{{ csp_nonce }}">
21163    :root {
21164      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21165      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21166      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21167      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21168      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21169    }
21170    body.dark-theme {
21171      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21172      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21173    }
21174    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
21175    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21176    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21177    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21178    .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;}
21179    @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));}}
21180    .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);}
21181    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21182    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
21183    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21184    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
21185    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21186    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21187    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
21188    .nav-pill,.theme-toggle{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;white-space:nowrap;text-decoration:none;}
21189    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21190    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21191    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21192    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21193    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21194    .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;}
21195    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21196    .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);}
21197    .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;}
21198    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21199    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21200    .settings-modal-body{padding:14px 16px 16px;}
21201    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21202    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21203    .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;}
21204    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21205    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21206    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21207    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21208    .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;}
21209    .tz-select:focus{border-color:var(--oxide);}
21210    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
21211    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
21212    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21213    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21214    .hero{text-align:center;margin:0 auto 18px;}
21215    .hero-logo-wrap{display:inline-block;cursor:default;}
21216    .hero-logo{width:66px;height:73px;object-fit:contain;margin-bottom:0;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));display:block;}
21217    .hero-logo-shadow{width:52px;height:8px;background:radial-gradient(ellipse,rgba(211,122,76,0.55),transparent 70%);border-radius:50%;margin:0 auto 6px;}
21218    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21219    .hero-title-aura{position:absolute;inset:-40px -80px;background:radial-gradient(ellipse at 50% 55%,rgba(211,122,76,0.20) 0%,rgba(211,122,76,0.056) 45%,transparent 72%);pointer-events:none;z-index:0;}
21220    body.dark-theme .hero-title-aura{background:radial-gradient(ellipse at 50% 55%,rgba(211,122,76,0.29) 0%,rgba(211,122,76,0.10) 45%,transparent 72%);}
21221    .hero-title{font-size:36px;font-weight:900;letter-spacing:-0.04em;margin:0 0 6px;display:inline-block;position:relative;z-index:1;will-change:transform;transition:transform 0.08s linear;
21222      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21223      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21224      clip-path:inset(0 100% 0 0);animation:titleReveal 0.65s cubic-bezier(.4,0,.2,1) 0.12s forwards,titleShimmer 4s linear 0.82s infinite;}
21225    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21226    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21227    body.dark-theme .hero-title{background:linear-gradient(90deg,#d37a4c 0%,#f0a070 25%,#9bb8ff 50%,#d37a4c 75%,#f0a070 100%);background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
21228    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21229    .hero-cursor{display:inline-block;width:2px;height:0.9em;background:var(--oxide);vertical-align:text-bottom;margin-left:1px;border-radius:1px;animation:cursorBlink 0.72s step-end infinite;}
21230    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21231    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21232    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21233    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21234    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21235    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21236    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21237    .action-card{display:flex;flex-direction:column;align-items:flex-start;padding:12px 15px 10px;border-radius:var(--radius);border:1px solid var(--line-strong);background:var(--surface);box-shadow:var(--shadow);text-decoration:none;color:var(--text);transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;animation:cardRise 0.7s ease both;}
21238    .action-card:nth-child(1){animation-delay:0.1s;} .action-card:nth-child(2){animation-delay:0.2s;} .action-card:nth-child(3){animation-delay:0.3s;} .action-card:nth-child(4){animation-delay:0.4s;} .action-card:nth-child(5){animation-delay:0.5s;} .action-card:nth-child(6){animation-delay:0.6s;} .action-card:nth-child(7){animation-delay:0.7s;}
21239    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21240    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21241    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21242    .action-card-icon{width:40px;height:40px;border-radius:12px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;flex:0 0 auto;transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
21243    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21244    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21245    .action-card.scan .action-card-icon{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 8px 22px rgba(184,80,40,0.30);}
21246    .action-card.view .action-card-icon{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 8px 22px rgba(59,130,246,0.28);}
21247    .action-card.compare .action-card-icon{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 8px 22px rgba(139,92,246,0.28);}
21248    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21249    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21250    .action-card-cta{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:800;color:var(--oxide-2);transition:gap 0.15s ease;}
21251    body.dark-theme .action-card-cta{color:var(--oxide);}
21252    .action-card.view .action-card-cta{color:var(--accent-2);}
21253    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21254    .action-card.compare .action-card-cta{color:#7c3aed;}
21255    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21256    .action-card.git-tools .action-card-icon{background:linear-gradient(135deg,#16a34a,#15803d);color:#fff;box-shadow:0 8px 22px rgba(22,163,74,0.28);}
21257    .action-card.git-tools .action-card-cta{color:#15803d;}
21258    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21259    .action-card.trend .action-card-icon{background:linear-gradient(135deg,#0891b2,#0e7490);color:#fff;box-shadow:0 8px 22px rgba(8,145,178,0.28);}
21260    .action-card.trend .action-card-cta{color:#0e7490;}
21261    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21262    .action-card.automation .action-card-icon{background:linear-gradient(135deg,#d97706,#b45309);color:#fff;box-shadow:0 8px 22px rgba(217,119,6,0.28);}
21263    .action-card.automation .action-card-cta{color:#b45309;}
21264    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21265    .action-card.test-metrics .action-card-icon{background:linear-gradient(135deg,#ec4899,#be185d);color:#fff;box-shadow:0 8px 22px rgba(236,72,153,0.28);}
21266    .action-card.test-metrics .action-card-cta{color:#be185d;}
21267    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21268    .action-card:hover .action-card-cta{gap:12px;}
21269    .action-card.card-split{flex-direction:row;align-items:stretch;}
21270    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21271    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21272    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21273    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21274    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21275    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21276    .ac-badge{display:inline-block;padding:3px 8px;border-radius:20px;font-size:10px;font-weight:700;letter-spacing:.04em;border:1px solid transparent;transition:opacity .3s;opacity:0.45;}
21277    .ac-badge.active{opacity:1;}
21278    .ac-badge.github{border-color:#555;color:#555;}
21279    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21280    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21281    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21282    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21283    body.dark-theme .ac-right-row{color:var(--muted);}
21284    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21285    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21286    .divider{height:1px;background:var(--line);margin:32px 0;}
21287    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21288    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21289    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21290    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21291      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21292    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21293    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21294    body.dark-theme .info-chip-val{color:var(--oxide);}
21295    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21296    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21297      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21298      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21299    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21300      border:6px solid transparent;border-top-color:var(--text);}
21301    .info-chip:hover .info-chip-tip{display:block;}
21302    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21303    .chip-slide.fading{filter:blur(5px);opacity:0;}
21304    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21305    .site-footer a{color:var(--muted);}
21306    .lan-card{border-radius:var(--radius);border:1.5px solid var(--line-strong);background:var(--surface);box-shadow:var(--shadow);padding:18px 22px;margin:0 0 20px;animation:cardRise 0.7s ease both;}
21307    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21308    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21309    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21310    .lan-badge{display:inline-flex;align-items:center;gap:6px;background:#3b82f6;color:#fff;border-radius:999px;padding:3px 10px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;}
21311    .lan-badge.local{background:var(--oxide-2);}
21312    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21313    .lan-url{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:16px;font-weight:700;color:#2563eb;background:rgba(59,130,246,0.08);border-radius:8px;padding:6px 12px;border:1px solid rgba(59,130,246,0.20);}
21314    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21315    .lan-copy-btn{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background 0.15s,border-color 0.15s;}
21316    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21317    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21318    .lan-auth-row{display:flex;align-items:flex-start;gap:10px;background:rgba(0,0,0,0.03);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-x:auto;}
21319    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21320    .lan-local-hint{display:table;margin:20px auto 0;text-align:center;padding:7px 20px;border:1px solid rgba(0,0,0,0.08);border-radius:20px;background:rgba(0,0,0,0.03);font-size:11px;color:var(--muted);line-height:1.7;max-width:720px;opacity:0.7;}
21321    .lan-local-hint code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:rgba(0,0,0,0.05);border-radius:4px;padding:1px 5px;font-size:10.5px;color:var(--muted);}
21322    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21323    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21324    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21325    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
21326    @media (max-height: 1100px) {
21327      .page{padding-top:10px;}
21328      .hero{margin-bottom:10px;}
21329      .hero-logo{width:54px;height:60px;}
21330      .hero-logo-shadow{width:42px;}
21331      .hero-title{font-size:28px;}
21332      .hero-subtitle{font-size:13px;}
21333      .card-sections{gap:12px;margin-bottom:6px;}
21334      .card-section-grid-2,.card-section-grid-3{gap:10px;}
21335      .action-card{padding:8px 15px 8px;}
21336      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21337      .action-card-icon svg{width:18px;height:18px;}
21338      .action-card-title{font-size:13px;}
21339      .action-card-desc{font-size:11px;margin-bottom:6px;}
21340      .action-card-cta{font-size:11px;}
21341      .ac-right-row{font-size:11px;}
21342      .divider{margin:14px 0;}
21343      .info-strip{gap:7px;margin-bottom:8px;}
21344      .info-chip{padding:7px 10px;}
21345      .info-chip-val{font-size:13px;}
21346      .info-chip-label{font-size:9px;}
21347      .site-footer{padding:8px 24px;font-size:12px;}
21348      .lan-local-hint{margin-top:8px;}
21349    }
21350    @media (max-height: 850px) {
21351      .page{padding-top:6px;}
21352      .hero{margin-bottom:6px;}
21353      .hero-logo{width:42px;height:46px;}
21354      .hero-title{font-size:22px;}
21355      .hero-subtitle{font-size:12px;}
21356      .card-sections{gap:10px;}
21357      .action-card-desc{margin-bottom:4px;}
21358      .divider{margin:8px 0;}
21359      .info-strip{margin-bottom:6px;}
21360      .lan-local-hint{margin-top:10px;}
21361    }
21362  </style>
21363</head>
21364<body>
21365  <div class="background-watermarks" aria-hidden="true">
21366    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21367    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21368    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21369    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21370    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21371    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21372    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21373  </div>
21374  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21375  <div class="top-nav">
21376    <div class="top-nav-inner">
21377      <a class="brand" href="/">
21378        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21379        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21380      </a>
21381      <div class="nav-right">
21382        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21383        <div class="nav-dropdown">
21384          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
21385          <div class="nav-dropdown-menu">
21386            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
21387          </div>
21388        </div>
21389        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21390        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21391        <div class="nav-dropdown">
21392          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
21393          <div class="nav-dropdown-menu">
21394            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
21395          </div>
21396        </div>
21397        <div class="server-status-wrap" id="server-status-wrap">
21398          <div class="nav-pill server-online-pill" id="server-status-pill">
21399            <span class="status-dot" id="status-dot"></span>
21400            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21401            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21402          </div>
21403          <div class="server-status-tip">
21404            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21405            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21406          </div>
21407        </div>
21408        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21409          <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>
21410        </button>
21411        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21412          <svg class="icon-moon" viewBox="0 0 24 24"><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>
21413          <svg class="icon-sun" viewBox="0 0 24 24"><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>
21414        </button>
21415      </div>
21416    </div>
21417  </div>
21418
21419  <div class="page">
21420    <div class="hero">
21421      <div class="hero-logo-wrap" id="hero-logo-wrap">
21422        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21423      </div>
21424      <div class="hero-logo-shadow"></div>
21425      <div class="hero-title-wrap">
21426        <div class="hero-title-aura" aria-hidden="true"></div>
21427        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21428      </div>
21429      <p class="hero-subtitle" id="hero-subtitle">A fast, self-contained local code analysis tool. Count SLOC, measure test coverage, track trends, compare snapshots, and automate scans via webhook — no setup required.</p>
21430    </div>
21431
21432    <div class="card-sections">
21433
21434      <div>
21435        <div class="card-section-label">Analysis</div>
21436        <div class="card-section-grid-2">
21437          <a class="action-card scan card-split" href="/scan-setup">
21438            <div class="action-card-left">
21439              <div class="action-card-icon">
21440                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21441              </div>
21442              <div class="action-card-title">Scan Project</div>
21443              <p class="action-card-desc">Start a new scan, reload saved settings from a config file, or quickly re-run a recent project with one click. All scan history stays accessible for instant revisiting.</p>
21444              <span class="action-card-cta">Start scanning <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21445            </div>
21446            <div class="action-card-sep"></div>
21447            <div class="action-card-right">
21448              <div class="ac-right-row"><svg viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 .49-3.51"></path></svg><span>Re-run last scan</span></div>
21449              <div class="ac-right-row"><svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg><span>Load from config</span></div>
21450              <div class="ac-right-row"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg><span>Browse history</span></div>
21451              <div class="ac-right-stat" id="acp-scan-stat"></div>
21452            </div>
21453          </a>
21454          <a class="action-card test-metrics card-split" href="/test-metrics">
21455            <div class="action-card-left">
21456              <div class="action-card-icon">
21457                <svg viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>
21458              </div>
21459              <div class="action-card-title">Test Metrics</div>
21460              <p class="action-card-desc">Detect test files and functions across your codebase, measure test-to-code ratios, and view unit test coverage data alongside your SLOC metrics.</p>
21461              <span class="action-card-cta">View test metrics <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21462            </div>
21463            <div class="action-card-sep"></div>
21464            <div class="action-card-right">
21465              <div class="ac-right-row"><svg viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg><span>Unit test detection</span></div>
21466              <div class="ac-right-row"><svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg><span>Assertion counting</span></div>
21467              <div class="ac-right-row"><svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg><span>LCOV coverage</span></div>
21468              <div class="ac-right-stat" id="acp-test-stat"></div>
21469            </div>
21470          </a>
21471        </div>
21472      </div>
21473
21474      <div>
21475        <div class="card-section-label">Reports &amp; Insights</div>
21476        <div class="card-section-grid-3">
21477          <a class="action-card view" href="/view-reports">
21478            <div class="action-card-icon">
21479              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21480            </div>
21481            <div class="action-card-title">View Reports</div>
21482            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21483            <span class="action-card-cta">Open reports <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21484          </a>
21485          <a class="action-card compare" href="/compare-scans">
21486            <div class="action-card-icon">
21487              <svg viewBox="0 0 24 24"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>
21488            </div>
21489            <div class="action-card-title">Compare Scans</div>
21490            <p class="action-card-desc">Pick any two builds for a side-by-side diff — added, removed, and changed files with exact line-count deltas.</p>
21491            <span class="action-card-cta">Compare builds <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21492          </a>
21493          <a class="action-card trend" href="/trend-reports">
21494            <div class="action-card-icon">
21495              <svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
21496            </div>
21497            <div class="action-card-title">Trend Report</div>
21498            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21499            <span class="action-card-cta">View trends <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21500          </a>
21501        </div>
21502      </div>
21503
21504      <div>
21505        <div class="card-section-label">Developer Tools</div>
21506        <div class="card-section-grid-2">
21507          <a class="action-card git-tools card-split" href="/git-browser">
21508            <div class="action-card-left">
21509              <div class="action-card-icon">
21510                <svg viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M13 6h3a2 2 0 0 1 2 2v7"></path><line x1="6" y1="9" x2="6" y2="21"></line></svg>
21511              </div>
21512              <div class="action-card-title">Git Browser</div>
21513              <p class="action-card-desc">Browse branches and commits, scan any ref on demand, and diff two refs side-by-side — all from within the browser, without any local setup.</p>
21514              <span class="action-card-cta">Open Git Browser <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21515            </div>
21516            <div class="action-card-sep"></div>
21517            <div class="action-card-right">
21518              <div class="ac-right-row"><svg viewBox="0 0 24 24"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg><span>Branches &amp; tags</span></div>
21519              <div class="ac-right-row"><svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg><span>On-demand scanning</span></div>
21520              <div class="ac-right-row"><svg viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg><span>Side-by-side diff</span></div>
21521            </div>
21522          </a>
21523          <a class="action-card automation card-split" href="/integrations">
21524            <div class="action-card-left">
21525              <div class="action-card-icon">
21526                <svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
21527              </div>
21528              <div class="action-card-title">Integrations</div>
21529              <p class="action-card-desc">Connect GitHub, GitLab, or Bitbucket webhooks to trigger scans on every push, or publish results directly to Atlassian Confluence.</p>
21530              <span class="action-card-cta">Set up integrations <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
21531            </div>
21532            <div class="action-card-sep"></div>
21533            <div class="action-card-right">
21534              <div class="ac-badges-grid">
21535                <span class="ac-badge github"     id="acp-gh">GitHub</span>
21536                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
21537                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
21538                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21539              </div>
21540              <div class="ac-right-stat" id="acp-int-stat"></div>
21541            </div>
21542          </a>
21543        </div>
21544      </div>
21545
21546    </div>
21547
21548    {% if server_mode %}
21549    <div class="lan-card server">
21550      <div class="lan-card-header">
21551        <span class="lan-badge">LAN server</span>
21552        Accessible on your network
21553      </div>
21554      {% if let Some(ip) = lan_ip %}
21555      <div class="lan-url-row">
21556        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21557        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21558          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
21559          Copy URL
21560        </button>
21561      </div>
21562      <p class="lan-hint">Share this address with anyone on the same network.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured — all endpoints are open.{% endif %}</p>
21563      {% if has_api_key %}
21564      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
21565      {% endif %}
21566      {% else %}
21567      <p class="lan-hint">Could not auto-detect your LAN IP. Find it with <code>hostname -I</code> (Linux) or <code>ipconfig</code> (Windows), then open <code>http://&lt;your-ip&gt;:{{ port }}</code>.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured.{% endif %}</p>
21568      {% endif %}
21569    </div>
21570    {% endif %}
21571
21572    <div class="divider"></div>
21573
21574    <div class="info-strip">
21575      <div class="info-chip">
21576        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21577        <div class="chip-slide">
21578          <div class="info-chip-val">60</div>
21579          <div class="info-chip-label">Languages</div>
21580        </div>
21581      </div>
21582      <div class="info-chip">
21583        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21584        <div class="chip-slide">
21585          <div class="info-chip-val">100%</div>
21586          <div class="info-chip-label">Self-contained</div>
21587        </div>
21588      </div>
21589      <div class="info-chip">
21590        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21591        <div class="chip-slide">
21592          <div class="info-chip-val">HTML+PDF</div>
21593          <div class="info-chip-label">Exportable reports</div>
21594        </div>
21595      </div>
21596      <div class="info-chip">
21597        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21598        <div class="chip-slide">
21599          <div class="info-chip-val">Webhook</div>
21600          <div class="info-chip-label">3 platforms</div>
21601        </div>
21602      </div>
21603      <div class="info-chip">
21604        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21605        <div class="chip-slide">
21606          <div class="info-chip-val">IEEE</div>
21607          <div class="info-chip-label">1045-1992</div>
21608        </div>
21609      </div>
21610    </div>
21611
21612    {% if lan_ip.is_none() %}
21613    <div class="lan-local-hint">
21614      <strong>Want teammates on the same network to access this?</strong><br>
21615      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
21616    </div>
21617    {% endif %}
21618  </div>
21619
21620  <footer class="site-footer">
21621    local code analysis - metrics, history and reports
21622    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
21623    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21624    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21625    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21626    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21627  </footer>
21628
21629  <script nonce="{{ csp_nonce }}">
21630    (function () {
21631      var storageKey = 'oxide-sloc-theme';
21632      var body = document.body;
21633      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
21634      var toggle = document.getElementById('theme-toggle');
21635      if (toggle) toggle.addEventListener('click', function () {
21636        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
21637        body.classList.toggle('dark-theme', next === 'dark');
21638        try { localStorage.setItem(storageKey, next); } catch(e) {}
21639      });
21640      var copyBtn = document.getElementById('lan-copy-btn');
21641      if (copyBtn) copyBtn.addEventListener('click', function() {
21642        var btn = this;
21643        var el = document.getElementById('lan-url-val');
21644        if (!el) return;
21645        var url = el.textContent.trim();
21646        if (navigator.clipboard) {
21647          navigator.clipboard.writeText(url).then(function() {
21648            var orig = btn.innerHTML;
21649            btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
21650            setTimeout(function() { btn.innerHTML = orig; }, 1800);
21651          });
21652        }
21653      });
21654      (function randomizeWatermarks() {
21655        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
21656        if (!wms.length) return;
21657        var placed = [];
21658        function tooClose(top, left) {
21659          for (var i = 0; i < placed.length; i++) {
21660            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
21661            if (dt < 16 && dl < 12) return true;
21662          }
21663          return false;
21664        }
21665        function pick(leftBand) {
21666          for (var attempt = 0; attempt < 50; attempt++) {
21667            var top = Math.random() * 88 + 2;
21668            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21669            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21670          }
21671          var top = Math.random() * 88 + 2;
21672          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21673          placed.push([top, left]); return [top, left];
21674        }
21675        var half = Math.floor(wms.length / 2);
21676        wms.forEach(function (img, i) {
21677          var pos = pick(i < half);
21678          var size = Math.floor(Math.random() * 100 + 120);
21679          var rot = (Math.random() * 360).toFixed(1);
21680          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
21681          img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
21682        });
21683      })();
21684
21685      (function spawnCodeParticles() {
21686        var container = document.getElementById('code-particles');
21687        if (!container) return;
21688        var snippets = [
21689          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
21690          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
21691          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
21692          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
21693          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
21694        ];
21695        var count = 38;
21696        for (var i = 0; i < count; i++) {
21697          (function(idx) {
21698            var el = document.createElement('span');
21699            el.className = 'code-particle';
21700            var text = snippets[idx % snippets.length];
21701            el.textContent = text;
21702            var left = Math.random() * 94 + 2;
21703            var top = Math.random() * 88 + 6;
21704            var dur = (Math.random() * 10 + 9).toFixed(1);
21705            var delay = (Math.random() * 18).toFixed(1);
21706            var rot = (Math.random() * 26 - 13).toFixed(1);
21707            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21708            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
21709              + '--rot:' + rot + 'deg;--op:' + op + ';'
21710              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
21711            container.appendChild(el);
21712          })(i);
21713        }
21714      })();
21715      (function heroAnimations() {
21716        var sub = document.getElementById('hero-subtitle');
21717        if (sub) {
21718          var full = sub.textContent.trim();
21719          sub.textContent = '';
21720          sub.style.opacity = '1';
21721          var cursor = document.createElement('span');
21722          cursor.className = 'hero-cursor';
21723          sub.appendChild(cursor);
21724          var i = 0;
21725          setTimeout(function() {
21726            var iv = setInterval(function() {
21727              if (i < full.length) {
21728                sub.insertBefore(document.createTextNode(full[i]), cursor);
21729                i++;
21730              } else {
21731                clearInterval(iv);
21732                setTimeout(function() {
21733                  cursor.style.transition = 'opacity 1s ease';
21734                  cursor.style.opacity = '0';
21735                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
21736                }, 2400);
21737              }
21738            }, 11);
21739          }, 374);
21740        }
21741      })();
21742      (function logoBob() {
21743        var logo = document.querySelector('.hero-logo');
21744        var shadow = document.querySelector('.hero-logo-shadow');
21745        if (!logo) return;
21746        var cycleStart = null, cycleDur = 3600;
21747        var peakY = -14, peakScale = 1.07, peakRot = 0;
21748        function newCycle() {
21749          cycleDur = 3000 + Math.random() * 1840;
21750          peakY = -(9 + Math.random() * 13.8);
21751          peakScale = 1.04 + Math.random() * 0.081;
21752          peakRot = (Math.random() * 11.5 - 5.75);
21753        }
21754        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
21755        newCycle();
21756        function frame(ts) {
21757          if (cycleStart === null) cycleStart = ts;
21758          var t = (ts - cycleStart) / cycleDur;
21759          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
21760          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
21761          var y = peakY * phase;
21762          var sc = 1 + (peakScale - 1) * phase;
21763          var rot = peakRot * Math.sin(Math.PI * phase);
21764          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
21765          if (shadow) {
21766            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
21767            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
21768          }
21769          requestAnimationFrame(frame);
21770        }
21771        requestAnimationFrame(frame);
21772      })();
21773      (function mouseEffects() {
21774        var heroTitle = document.getElementById('hero-title');
21775        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
21776        function tick() {
21777          raf = null;
21778          if (heroTitle) {
21779            var r = heroTitle.getBoundingClientRect();
21780            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
21781            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
21782            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
21783          }
21784        }
21785        document.addEventListener('mousemove', function(e) {
21786          mx = e.clientX; my = e.clientY;
21787          if (!raf) raf = requestAnimationFrame(tick);
21788        });
21789        document.addEventListener('mouseleave', function() {
21790          if (heroTitle) {
21791            heroTitle.style.transition = 'transform 0.5s ease';
21792            heroTitle.style.transform = '';
21793            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
21794          }
21795        });
21796        document.querySelectorAll('.action-card').forEach(function(card) {
21797          card.addEventListener('mousemove', function(e) {
21798            var rect = card.getBoundingClientRect();
21799            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
21800            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
21801            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
21802            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
21803          });
21804          card.addEventListener('mouseleave', function() {
21805            card.style.transition = '';
21806            card.style.transform = '';
21807          });
21808        });
21809      })();
21810      (function chipSlideshow() {
21811        var slides = [
21812          [{v:'60',l:'Languages'},{v:'Rust \u00b7 Go \u00b7 Python',l:'and 57 more'},{v:'C \u00b7 Java \u00b7 TypeScript',l:'Swift \u00b7 Kotlin \u00b7 Zig'}],
21813          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
21814          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
21815          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
21816          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
21817        ];
21818        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
21819        var indices = [0,0,0,0,0];
21820        var paused = [false,false,false,false,false];
21821        chips.forEach(function(chip, i) {
21822          chip.addEventListener('mouseenter', function() { paused[i] = true; });
21823          chip.addEventListener('mouseleave', function() { paused[i] = false; });
21824        });
21825        function advance(i) {
21826          if (paused[i]) return;
21827          var chip = chips[i];
21828          var inner = chip.querySelector('.chip-slide');
21829          if (!inner) return;
21830          inner.classList.add('fading');
21831          setTimeout(function() {
21832            indices[i] = (indices[i] + 1) % slides[i].length;
21833            var s = slides[i][indices[i]];
21834            chip.querySelector('.info-chip-val').textContent = s.v;
21835            chip.querySelector('.info-chip-label').textContent = s.l;
21836            inner.classList.remove('fading');
21837          }, 720);
21838        }
21839        setInterval(function() {
21840          chips.forEach(function(chip, i) { advance(i); });
21841        }, 6000);
21842      })();
21843      (function cardLiveData() {
21844        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
21845          var el = document.getElementById('acp-scan-stat');
21846          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
21847        }).catch(function(){});
21848        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
21849          var el = document.getElementById('acp-test-stat');
21850          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
21851        }).catch(function(){});
21852        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
21853          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
21854          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
21855          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
21856          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
21857          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
21858          var stat = document.getElementById('acp-int-stat');
21859          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
21860        }).catch(function(){});
21861        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
21862          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
21863        }).catch(function(){});
21864      })();
21865    })();
21866  </script>
21867  <script nonce="{{ csp_nonce }}">
21868  (function(){
21869    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'}];
21870    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);});}
21871    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21872    function init(){
21873      var btn=document.getElementById('settings-btn');if(!btn)return;
21874      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21875      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>';
21876      document.body.appendChild(m);
21877      var g=document.getElementById('scheme-grid');
21878      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);});
21879      var cl=document.getElementById('settings-close');
21880      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);
21881      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');});
21882      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21883      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21884    }
21885    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21886  }());
21887  </script>
21888  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
21889</body>
21890</html>
21891"##,
21892    ext = "html"
21893)]
21894struct SplashTemplate {
21895    csp_nonce: String,
21896    server_mode: bool,
21897    lan_ip: Option<String>,
21898    port: u16,
21899    version: &'static str,
21900    has_api_key: bool,
21901}
21902
21903// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
21904
21905#[derive(Template)]
21906#[template(
21907    source = r##"
21908<!doctype html>
21909<html lang="en">
21910<head>
21911  <meta charset="utf-8">
21912  <meta name="viewport" content="width=device-width, initial-scale=1">
21913  <title>OxideSLOC — Start a Scan</title>
21914  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21915  <style nonce="{{ csp_nonce }}">
21916    :root {
21917      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
21918      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21919      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21920      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21921      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21922    }
21923    body.dark-theme {
21924      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21925      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21926    }
21927    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
21928    .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);}
21929    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21930    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
21931    .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));}
21932    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
21933    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
21934    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
21935    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21936    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21937    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
21938    .nav-pill,.theme-toggle{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;white-space:nowrap;text-decoration:none;}
21939    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21940    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21941    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21942    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21943    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21944    .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;}
21945    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21946    .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);}
21947    .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;}
21948    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21949    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21950    .settings-modal-body{padding:14px 16px 16px;}
21951    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21952    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21953    .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;}
21954    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21955    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21956    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21957    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21958    .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;}
21959    .tz-select:focus{border-color:var(--oxide);}
21960    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
21961    .page-header{text-align:center;margin-bottom:16px;}
21962    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
21963    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
21964    /* Cards */
21965    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
21966    .option-card-wrap{position:relative;}
21967    .option-card{background:var(--surface);border:1.5px solid var(--line-strong);border-radius:var(--radius);padding:20px 24px;box-shadow:var(--shadow);transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;position:relative;z-index:1;display:flex;align-items:center;gap:20px;animation:cardRise 0.7s ease both;}
21968    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
21969    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21970    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
21971    .option-card-wrap:nth-child(1) .option-card{animation-delay:0.1s;} .option-card-wrap:nth-child(2) .option-card{animation-delay:0.2s;} .option-card-wrap:nth-child(3) .option-card{animation-delay:0.3s;}
21972    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
21973    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
21974    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
21975    .card-top-row{display:flex;align-items:center;gap:20px;}
21976    /* Two-column layout inside each card */
21977    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
21978    .card-left{display:flex;align-items:flex-start;min-width:0;}
21979    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
21980    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
21981    .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 10px 30px rgba(224,123,58,0.55),0 4px 10px rgba(0,0,0,0.22);}
21982    .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);box-shadow:0 10px 30px rgba(59,130,246,0.55),0 4px 10px rgba(0,0,0,0.22);}
21983    .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);box-shadow:0 10px 30px rgba(139,92,246,0.55),0 4px 10px rgba(0,0,0,0.22);}
21984    .card-text{min-width:0;}
21985    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
21986    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
21987    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
21988    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
21989    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
21990    /* Right CTA column */
21991    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
21992    .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:8px 16px;border-radius:10px;font-size:13px;font-weight:700;text-decoration:none;cursor:pointer;border:none;transition:transform 0.15s ease,box-shadow 0.15s ease;white-space:nowrap;}
21993    /* Re-scan count badge */
21994    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
21995    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
21996    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
21997    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
21998    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
21999    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22000    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22001    body.dark-theme .btn-secondary{color:var(--oxide);}
22002    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22003    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22004    /* File input overlay — must be full-width so it aligns with other card-right buttons */
22005    .file-input-wrap{position:relative;width:100%;}
22006    .file-input-wrap .btn{width:100%;}
22007    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22008    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22009    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22010    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22011    .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;}
22012    @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));}}
22013    /* Recent list (card 3 — full-width section below header) */
22014    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22015    .recent-list{display:flex;flex-direction:column;gap:8px;}
22016    .recent-item{display:flex;align-items:center;gap:12px;padding:11px 16px;border-radius:10px;border:1px solid var(--line);background:var(--surface-2);cursor:pointer;transition:border-color 0.15s ease,background 0.15s ease;}
22017    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22018    .recent-item-info{flex:1;min-width:0;}
22019    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22020    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22021    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22022    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22023    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22024    .site-footer a{color:var(--muted);}
22025    @media(max-width:680px){
22026      .card-body{grid-template-columns:1fr;}
22027      .card-right{flex-direction:row;flex-wrap:wrap;}
22028      .btn{flex:1;}
22029    }
22030    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
22031    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
22032    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{visibility:hidden;opacity:0;pointer-events:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);border:1px solid rgba(255,255,255,0.10);transition:opacity 0.15s ease;}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip{visibility:visible;opacity:1;pointer-events:auto;}
22033  </style>
22034</head>
22035<body>
22036  <div class="background-watermarks" aria-hidden="true">
22037    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22038    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22039    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22040    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22041    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22042    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22043    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22044  </div>
22045  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22046  <div class="top-nav">
22047    <div class="top-nav-inner">
22048      <a class="brand" href="/">
22049        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22050        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22051      </a>
22052      <div class="nav-right">
22053        <a class="nav-pill" href="/">Home</a>
22054        <div class="nav-dropdown">
22055          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
22056          <div class="nav-dropdown-menu">
22057            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
22058          </div>
22059        </div>
22060        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22061        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22062        <div class="nav-dropdown">
22063          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
22064          <div class="nav-dropdown-menu">
22065            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
22066          </div>
22067        </div>
22068        <div class="server-status-wrap" id="server-status-wrap">
22069          <div class="nav-pill server-online-pill" id="server-status-pill">
22070            <span class="status-dot" id="status-dot"></span>
22071            <span id="server-status-label">Server</span>
22072            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22073          </div>
22074          <div class="server-status-tip">
22075            OxideSLOC is running — accessible on your network.
22076            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22077          </div>
22078        </div>
22079        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22080          <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>
22081        </button>
22082        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22083          <svg class="icon-moon" viewBox="0 0 24 24"><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>
22084          <svg class="icon-sun" viewBox="0 0 24 24"><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>
22085        </button>
22086      </div>
22087    </div>
22088  </div>
22089
22090  <div class="page">
22091    <div class="page-header">
22092      <h1>How would you like to scan?</h1>
22093      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22094    </div>
22095
22096    <div class="option-grid">
22097
22098      <!-- Option 1: New scan -->
22099      <div class="option-card-wrap">
22100        <div class="option-card">
22101        <div class="option-icon new-scan">
22102          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22103        </div>
22104        <div class="card-body">
22105          <div class="card-left">
22106            <div class="card-text">
22107              <div class="option-title">Start a new scan</div>
22108              <p class="option-desc">Walk through the 4-step guided wizard — pick a project folder, configure counting rules, choose output formats, then review before running.</p>
22109              <ul class="feature-list">
22110                <li>Live project scope preview before you run</li>
22111                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22112                <li>HTML, PDF, and JSON output — your choice</li>
22113              </ul>
22114            </div>
22115          </div>
22116          <div class="card-right">
22117            <a class="btn btn-primary" href="/scan">
22118              Configure &amp; scan
22119              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22120            </a>
22121            <p class="card-tip">Full 4-step setup · all options</p>
22122          </div>
22123        </div>
22124        </div>
22125      </div>
22126
22127      <!-- Option 2: Load from config file -->
22128      <div class="option-card-wrap">
22129        <div class="option-card">
22130        <div class="option-icon load-config">
22131          <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="12" y1="18" x2="12" y2="12"></line><line x1="9" y1="15" x2="15" y2="15"></line></svg>
22132        </div>
22133        <div class="card-body">
22134          <div class="card-left">
22135            <div class="card-text">
22136              <div class="option-title">Load a saved config</div>
22137              <p class="option-desc">Upload a <strong>scan-config.json</strong> exported from a previous run. The wizard opens pre-filled — you can still tweak anything before running.</p>
22138              <ul class="feature-list">
22139                <li>All 15 settings restored from the file</li>
22140                <li>Fully editable — change path or output dir</li>
22141                <li>Works with any scan-config.json</li>
22142              </ul>
22143            </div>
22144          </div>
22145          <div class="card-right">
22146            <div class="file-input-wrap">
22147              <button class="btn btn-secondary" id="load-config-btn" type="button">
22148                <svg viewBox="0 0 24 24"><polyline points="16 16 12 12 8 16"></polyline><line x1="12" y1="12" x2="12" y2="21"></line><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path></svg>
22149                Choose config file
22150              </button>
22151              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22152            </div>
22153            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22154          </div>
22155        </div>
22156        </div>
22157      </div>
22158
22159      <!-- Option 3: Re-scan recent project -->
22160      <div class="option-card-wrap">
22161        <div class="option-card" id="recent-card">
22162        <div class="card-top-row">
22163          <div class="option-icon rescan">
22164            <svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
22165          </div>
22166          <div class="card-body">
22167            <div class="card-left">
22168              <div class="card-text">
22169                <div class="option-title">Re-scan a recent project</div>
22170                <p class="option-desc">Pick a recent run to instantly restore all its settings in the wizard — path, output folder, filters, and more. Tweak anything before scanning.</p>
22171                <ul class="feature-list">
22172                  <li>All 15+ settings restored from the saved config</li>
22173                  <li>Path and output dir are editable before running</li>
22174                  <li>Only scans with a saved config appear here</li>
22175                </ul>
22176              </div>
22177            </div>
22178            <div class="card-right">
22179              <div class="rescan-count-box">
22180                <div class="rescan-count-num" id="rescan-count-num">—</div>
22181                <div class="rescan-count-label">saved configs</div>
22182              </div>
22183              <a class="btn btn-secondary" href="/view-reports">
22184                <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
22185                View all runs
22186              </a>
22187              <p class="card-tip">Opens run history</p>
22188            </div>
22189          </div>
22190        </div>
22191        <div class="section-divider"></div>
22192        <div class="recent-list" id="recent-list">
22193          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22194        </div>
22195        </div>
22196      </div>
22197
22198    </div>
22199  </div>
22200
22201  <footer class="site-footer">
22202    local code analysis - metrics, history and reports
22203    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22204    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22205    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22206    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22207    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22208  </footer>
22209
22210  <script nonce="{{ csp_nonce }}">
22211    (function () {
22212      var storageKey = 'oxide-sloc-theme';
22213      var body = document.body;
22214      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22215      var toggle = document.getElementById('theme-toggle');
22216      if (toggle) toggle.addEventListener('click', function () {
22217        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22218        body.classList.toggle('dark-theme', next === 'dark');
22219        try { localStorage.setItem(storageKey, next); } catch(e) {}
22220      });
22221
22222      (function randomizeWatermarks() {
22223        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22224        if (!wms.length) return;
22225        var placed = [];
22226        function tooClose(top, left) { for (var i = 0; i < placed.length; i++) { var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left); if (dt < 16 && dl < 12) return true; } return false; }
22227        function pick(leftBand) { for (var attempt = 0; attempt < 50; attempt++) { var top = Math.random() * 88 + 2; var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74; if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; } } var top = Math.random() * 88 + 2; var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74; placed.push([top, left]); return [top, left]; }
22228        var half = Math.floor(wms.length / 2);
22229        wms.forEach(function (img, i) { var pos = pick(i < half); var size = Math.floor(Math.random() * 100 + 120); var rot = (Math.random() * 360).toFixed(1); var op = (Math.random() * 0.08 + 0.12).toFixed(2); img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op; });
22230      })();
22231      (function spawnCodeParticles() {
22232        var container = document.getElementById('code-particles');
22233        if (!container) return;
22234        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'];
22235        var count = 38;
22236        for (var i = 0; i < count; i++) { (function(idx) { var el = document.createElement('span'); el.className = 'code-particle'; el.textContent = snippets[idx % snippets.length]; var left = Math.random() * 94 + 2; var top = Math.random() * 88 + 6; var dur = (Math.random() * 10 + 9).toFixed(1); var delay = (Math.random() * 18).toFixed(1); var rot = (Math.random() * 26 - 13).toFixed(1); var op = (Math.random() * 0.09 + 0.06).toFixed(3); el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s'; container.appendChild(el); })(i); }
22237      })();
22238      // Recent scans data injected from server
22239      var recentScans = {{ recent_scans_json|safe }};
22240
22241      function configToParams(cfg) {
22242        var p = new URLSearchParams();
22243        p.set('prefilled', '1');
22244        if (cfg.path) p.set('path', cfg.path);
22245        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22246        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22247        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22248        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22249        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22250        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22251        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22252        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22253        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22254        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22255        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22256        if (cfg.report_title) p.set('report_title', cfg.report_title);
22257        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22258        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22259        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22260        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22261        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22262        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22263        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22264        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22265        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22266        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22267        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22268        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22269        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22270        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22271        return p;
22272      }
22273
22274      // Build recent scan list (capped at 3 visible entries)
22275      var list = document.getElementById('recent-list');
22276      var noNote = document.getElementById('no-recent-note');
22277      var hasAny = false;
22278      var MAX_RECENT = 3;
22279      if (Array.isArray(recentScans)) {
22280        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22281        var shown = 0;
22282        validEntries.forEach(function (entry) {
22283          if (shown >= MAX_RECENT) return;
22284          shown++;
22285          hasAny = true;
22286          var item = document.createElement('div');
22287          item.className = 'recent-item';
22288          item.title = 'Restore all settings and open wizard';
22289          item.innerHTML =
22290            '<div class="recent-item-info">' +
22291              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22292              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
22293            '</div>' +
22294            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22295          item.addEventListener('click', function () {
22296            var params = configToParams(entry.config);
22297            window.location.href = '/scan?' + params.toString();
22298          });
22299          list.appendChild(item);
22300        });
22301        if (validEntries.length > MAX_RECENT) {
22302          var moreEl = document.createElement('div');
22303          moreEl.className = 'recent-more-link';
22304          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
22305          list.appendChild(moreEl);
22306        }
22307      }
22308      if (hasAny && noNote) noNote.style.display = 'none';
22309      // Update count badge
22310      var countEl = document.getElementById('rescan-count-num');
22311      if (countEl) {
22312        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22313        countEl.textContent = total > 0 ? total : '0';
22314      }
22315
22316      // Config file loader
22317      var fileInput = document.getElementById('config-file-input');
22318      var fileName = document.getElementById('config-file-name');
22319      var loadBtn = document.getElementById('load-config-btn');
22320      // Wire the visible button to open the hidden file picker.
22321      if (loadBtn && fileInput) {
22322        loadBtn.addEventListener('click', function () { fileInput.click(); });
22323      }
22324      if (fileInput) {
22325        fileInput.addEventListener('change', function () {
22326          var file = fileInput.files && fileInput.files[0];
22327          if (!file) return;
22328          if (fileName) fileName.textContent = '\u2713 ' + file.name;
22329          var reader = new FileReader();
22330          reader.onload = function (e) {
22331            try {
22332              var cfg = JSON.parse(e.target.result);
22333              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22334              var params = configToParams(cfg);
22335              window.location.href = '/scan?' + params.toString();
22336            } catch (err) {
22337              alert('Could not parse config file: ' + err.message);
22338            }
22339          };
22340          reader.readAsText(file);
22341        });
22342      }
22343
22344      function escHtml(s) {
22345        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
22346      }
22347    })();
22348  </script>
22349  <script nonce="{{ csp_nonce }}">
22350  (function(){
22351    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'}];
22352    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);});}
22353    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22354    function init(){
22355      var btn=document.getElementById('settings-btn');if(!btn)return;
22356      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22357      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>';
22358      document.body.appendChild(m);
22359      var g=document.getElementById('scheme-grid');
22360      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);});
22361      var cl=document.getElementById('settings-close');
22362      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);
22363      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');});
22364      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22365      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22366    }
22367    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22368  }());
22369  </script>
22370  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
22371  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
22372  if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
22373</body>
22374</html>
22375"##,
22376    ext = "html"
22377)]
22378struct ScanSetupTemplate {
22379    version: &'static str,
22380    recent_scans_json: String,
22381    csp_nonce: String,
22382}
22383
22384#[derive(Template)]
22385#[template(
22386    source = r##"
22387<!doctype html>
22388<html lang="en">
22389<head>
22390  <meta charset="utf-8">
22391  <meta name="viewport" content="width=device-width, initial-scale=1">
22392  <title>OxideSLOC | {{ report_title }} | Report</title>
22393  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22394  <style nonce="{{ csp_nonce }}">
22395    :root {
22396      --radius: 18px;
22397      --bg: #f5efe8;
22398      --surface: rgba(255,255,255,0.82);
22399      --surface-2: #fbf7f2;
22400      --surface-3: #efe6dc;
22401      --line: #e6d0bf;
22402      --line-strong: #dcb89f;
22403      --text: #43342d;
22404      --muted: #7b675b;
22405      --muted-2: #a08777;
22406      --nav: #b85d33;
22407      --nav-2: #7a371b;
22408      --accent: #6f9bff;
22409      --accent-2: #4a78ee;
22410      --oxide: #d37a4c;
22411      --oxide-2: #b35428;
22412      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22413      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22414      --success-bg: #e8f5ed;
22415      --success-text: #1a8f47;
22416      --info-bg: #eef3ff;
22417      --info-text: #4467d8;
22418    }
22419
22420    body.dark-theme {
22421      --bg: #1b1511;
22422      --surface: #261c17;
22423      --surface-2: #2d221d;
22424      --surface-3: #372922;
22425      --line: #524238;
22426      --line-strong: #6c5649;
22427      --text: #f5ece6;
22428      --muted: #c7b7aa;
22429      --muted-2: #aa9485;
22430      --nav: #b85d33;
22431      --nav-2: #7a371b;
22432      --accent: #6f9bff;
22433      --accent-2: #4a78ee;
22434      --oxide: #d37a4c;
22435      --oxide-2: #b35428;
22436      --shadow: 0 18px 42px rgba(0,0,0,0.28);
22437      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22438      --success-bg: #163927;
22439      --success-text: #8fe2a8;
22440      --info-bg: #1c2847;
22441      --info-text: #a9c1ff;
22442    }
22443
22444    * { box-sizing: border-box; }
22445    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); }
22446    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22447    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22448    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22449    .top-nav, .page { position: relative; z-index: 2; }
22450    .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); }
22451    .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: 18px; }
22452    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22453    .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)); }
22454    .brand-mark { width: 42px; height: 42px; border-radius: 14px; background: radial-gradient(circle at 35% 35%, #f2a578, var(--oxide) 58%, var(--oxide-2)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.22), 0 8px 18px rgba(0,0,0,0.22); flex: 0 0 auto; }
22455    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22456    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22457    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22458    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22459    .nav-project-pill { width: 100%; max-width: 260px; display:inline-flex; align-items:center; justify-content:center; gap: 10px; 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.10); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
22460    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22461    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22462    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22463    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22464    @media (max-width: 1150px) { .nav-status { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
22465    .nav-pill, .theme-toggle { 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: inset 0 1px 0 rgba(255,255,255,0.08); white-space: nowrap; text-decoration: none; }
22466    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22467    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22468    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22469    .theme-toggle .icon-sun { display:none; }
22470    body.dark-theme .theme-toggle .icon-sun { display:block; }
22471    body.dark-theme .theme-toggle .icon-moon { display:none; }
22472    .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;}
22473    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22474    .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);}
22475    .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;}
22476    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22477    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22478    .settings-modal-body{padding:14px 16px 16px;}
22479    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22480    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22481    .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;}
22482    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22483    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22484    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22485    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22486    .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;}
22487    .tz-select:focus{border-color:var(--oxide);}
22488    .status-dot { width: 8px; height: 8px; border-radius: 999px; background: #26d768; box-shadow: 0 0 0 4px rgba(38,215,104,0.14); flex:0 0 auto; }
22489    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
22490    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22491    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22492    .hero, .panel { padding: 22px; }
22493    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22494    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22495    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22496    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22497    .compare-banner { margin-top: 18px; background: var(--info-bg, #eef3ff); border: 1px solid rgba(100,130,220,0.25); border-radius: 14px; padding: 14px 18px; }
22498    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22499    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22500    .compare-banner-actions { display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; border-top: 1px solid rgba(100,130,220,0.15); padding-top: 10px; }
22501    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22502    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22503    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22504    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22505    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22506    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22507    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
22508    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22509    .delta-card-val { font-size:16px; font-weight:800; }
22510    .delta-card-val.pos { color:#1e7e34; }
22511    .delta-card-val.neg { color:var(--neg); }
22512    .delta-card-val.mod { color:#b35428; }
22513    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22514    .delta-card-tip { 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; 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; }
22515    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22516    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22517    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22518    .compare-ts { font-size:13px; color:var(--muted); }
22519    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22520    .compare-arrow { color: var(--muted); }
22521    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22522    .action-card { padding: 12px 14px 14px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); display:flex; flex-direction:column; align-items:center; justify-content:center; }
22523    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22524    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22525    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22526    .run-mgmt-card { flex:1; min-width:220px; padding:12px 16px; border-radius:14px; border:1px solid var(--line); background:var(--surface-2); display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center; }
22527    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22528    .run-mgmt-card .action-buttons { justify-content:center; }
22529    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22530    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22531    .button, .copy-button {
22532      display: inline-flex; align-items: center; justify-content: center; border-radius: 14px; border: 1px solid rgba(111, 144, 255, 0.30); padding: 11px 14px; text-decoration: none; color: white; background: linear-gradient(135deg, var(--accent), var(--accent-2)); font-weight: 800; font-size: 14px; box-shadow: 0 12px 24px rgba(73, 106, 255, 0.22); cursor: pointer;
22533    }
22534    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22535    @keyframes spin { to { transform: rotate(360deg); } }
22536    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22537    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22538    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22539    .path-item strong { display: block; margin-bottom: 6px; }
22540    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22541    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22542    .path-subitem { flex: 1; }
22543    .path-item-scan-badge { display:inline-flex; align-items:center; padding: 2px 8px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); font-size: 11px; font-weight: 700; color: var(--muted); }
22544    code { display: inline-block; max-width: 100%; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--surface-3); border: 1px solid var(--line); padding: 2px 6px; border-radius: 8px; color: var(--text); }
22545    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22546    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22547    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22548    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22549    th { color: var(--muted); font-weight: 700; }
22550    tr:last-child td { border-bottom: none; }
22551    #subm-tbl col:nth-child(1){width:15%;}
22552    #subm-tbl col:nth-child(2){width:31%;}
22553    #subm-tbl col:nth-child(3){width:9%;}
22554    #subm-tbl col:nth-child(4){width:9%;}
22555    #subm-tbl col:nth-child(5){width:9%;}
22556    #subm-tbl col:nth-child(6){width:9%;}
22557    #subm-tbl col:nth-child(7){width:9%;}
22558    #subm-tbl col:nth-child(8){width:9%;}
22559    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22560    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22561    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22562    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22563    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22564    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22565    .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; }
22566    .soft-chip.success { gap:5px; padding:0 10px 0 8px; min-height:22px; background:rgba(26,143,71,0.06); color:var(--muted); border:1px solid rgba(26,143,71,0.18); font-size:11px; font-weight:600; letter-spacing:0.03em; }
22567    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22568    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22569    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22570    .muted { color: var(--muted); }
22571    /* Run-ID chip row (mirrors HTML report) */
22572    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22573    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22574    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22575    .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; min-width:0; }
22576    .run-id-chip[data-copy] { cursor:pointer; }
22577    a.run-id-chip { text-decoration:none; cursor:pointer; }
22578    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22579    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22580    .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; }
22581    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22582    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22583    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22584    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22585    a.commit-link-value { color:inherit; text-decoration:none; }
22586    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22587    .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; }
22588    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22589    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22590    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22591    .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; align-self:center; }
22592    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22593    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22594    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22595    /* Meta chips row */
22596    .meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 0; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); width:100%; }
22597    .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; }
22598    .meta-chip:last-child { border-right:none; }
22599    .meta-chip b { color:var(--text); font-weight:700; }
22600    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22601    .site-footer a{color:var(--muted);}
22602    .open-path-btn { display:inline-flex; align-items:center; justify-content:center; border-radius: 14px; border: 1px solid var(--line-strong); padding: 11px 14px; color: var(--text); background: var(--surface-3); font-weight: 800; font-size: 14px; cursor: pointer; text-decoration: none; }
22603    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22604    .empty-card-note { padding: 18px; color: var(--muted); font-size: 14px; line-height: 1.65; border-radius: 12px; border: 1px dashed var(--line-strong); background: var(--surface-2); margin-top: 8px; }
22605    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22606    /* Stat chips (matches HTML report) */
22607    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22608    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22609    /* Hero stat strip: uniform grid where every card is the same width and the
22610       columns line up across both rows. JS sets the column count to ceil(n/2) so
22611       the cards always occupy exactly two rows; when the count is odd the last
22612       card spans two columns to fill the trailing cell with no empty gap. */
22613    .summary-strip-hero { align-items:stretch; }
22614    .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); overflow:visible; }
22615    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22616    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22617    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22618    .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; }
22619    .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; 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); }
22620    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22621    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22622    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
22623    .cocomo-box-head { display:flex; align-items:center; gap:10px; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
22624    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
22625    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
22626    .cocomo-mode-pill { display:inline-flex; align-items:center; padding:3px 10px; border-radius:999px; background:var(--surface-3); border:1px solid var(--line-strong); font-size:11px; font-weight:700; color:var(--muted); }
22627    .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); }
22628    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
22629    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
22630    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
22631    /* Submodule panel */
22632    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
22633    /* Metrics tables stack */
22634    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
22635    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22636    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
22637    .metrics-table-title { padding: 10px 16px 6px; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); border-bottom: 1px solid var(--line); background: linear-gradient(180deg, var(--surface-2), var(--surface-3)); }
22638    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
22639    /* Metrics table */
22640    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
22641    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
22642    .metrics-table thead th { padding: 10px 16px; background: linear-gradient(180deg, var(--surface-2), var(--surface-3)); font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); border-bottom: 2px solid var(--line-strong); text-align: left; }
22643    .metrics-table thead th:not(:first-child) { text-align: right; }
22644    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
22645    .metrics-table tbody tr:last-child td { border-bottom: none; }
22646    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
22647    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
22648    .metrics-table tbody tr:hover td { background: var(--surface-2); }
22649    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
22650    .metrics-section-header td { background: linear-gradient(180deg, rgba(184,93,51,0.04), transparent); font-size: 11px !important; font-weight: 900 !important; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2) !important; padding: 8px 16px !important; border-bottom: 1px solid var(--line) !important; }
22651    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
22652    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
22653    .mt-val-pos { color: var(--pos); font-weight: 700; }
22654    .mt-val-neg { color: var(--neg); font-weight: 700; }
22655    .mt-val-zero { color: var(--muted); }
22656    .mt-val-mod { color: var(--oxide-2); }
22657    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
22658    @media (max-width: 1180px) {
22659      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
22660      .nav-project-slot, .nav-status { justify-content:flex-start; }
22661      .hero-top { flex-direction: column; }
22662      .run-mgmt-strip { flex-direction: column; }
22663    }
22664    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
22665    @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));}}
22666    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
22667    /* ── Result-page chart controls ─────────────────────────────────────────── */
22668    .r-chart-section{margin-bottom:24px;}
22669    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
22670    .section-pair > .panel{flex-shrink:0;}
22671    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
22672    .r-chart-select{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:4px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}
22673    .r-chart-select:focus{border-color:var(--accent);}
22674    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
22675    .r-chart-container svg{display:block;width:100%;height:auto;}
22676    .r-expand-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}
22677    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
22678    .r-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;}
22679    .r-chart-modal{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:960px;width:100%;max-height:85vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
22680    .r-chart-modal-title{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}
22681    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
22682    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
22683    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
22684    .r-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;}
22685    .r-chart-modal-close:hover{opacity:.7;}
22686    body.dark-theme .r-chart-modal{background:var(--surface);}
22687    .r-chart-container .rchit,.r-expand-modal-chart .rchit,#result-lang-charts .rchit,#result-lang-overview-modal-wrap .rchit{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}
22688    .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover,#result-lang-charts .rchit:hover,#result-lang-overview-modal-wrap .rchit:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
22689    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
22690    .lang-bar-row:hover{transform:translateY(-2px);}
22691    .lang-bar-row .rchit:hover{filter:none;transform:none;}
22692    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
22693    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
22694    .r-chart-tab{padding:4px 14px;border-radius:20px;border:1px solid var(--line-strong);cursor:pointer;font-size:12px;font-weight:700;color:var(--muted);background:var(--surface-2);transition:background .13s,color .13s;}
22695    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
22696    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
22697    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
22698    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
22699    #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;}
22700    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
22701    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
22702    .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;}
22703    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
22704    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
22705    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;}
22706    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
22707    .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;}
22708    .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;}
22709    body.has-report-banner .top-nav{top:27px;}
22710    body.has-report-banner{padding-bottom:27px;}
22711  </style>
22712</head>
22713<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
22714  <div class="background-watermarks" aria-hidden="true">
22715    <img src="/images/logo/logo-text.png" alt="" />
22716    <img src="/images/logo/logo-text.png" alt="" />
22717    <img src="/images/logo/logo-text.png" alt="" />
22718    <img src="/images/logo/logo-text.png" alt="" />
22719    <img src="/images/logo/logo-text.png" alt="" />
22720    <img src="/images/logo/logo-text.png" alt="" />
22721    <img src="/images/logo/logo-text.png" alt="" />
22722    <img src="/images/logo/logo-text.png" alt="" />
22723    <img src="/images/logo/logo-text.png" alt="" />
22724    <img src="/images/logo/logo-text.png" alt="" />
22725    <img src="/images/logo/logo-text.png" alt="" />
22726    <img src="/images/logo/logo-text.png" alt="" />
22727    <img src="/images/logo/logo-text.png" alt="" />
22728    <img src="/images/logo/logo-text.png" alt="" />
22729  </div>
22730  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22731  {% if let Some(banner) = report_header_footer %}
22732  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
22733  {% endif %}
22734  <div class="top-nav">
22735    <div class="top-nav-inner">
22736      <a class="brand" href="/">
22737        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22738        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22739      </a>
22740      <div class="nav-project-slot">
22741        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
22742      </div>
22743      <div class="nav-status">
22744        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
22745        <div class="nav-dropdown">
22746          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
22747          <div class="nav-dropdown-menu">
22748            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
22749          </div>
22750        </div>
22751        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
22752        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22753        <div class="nav-dropdown">
22754          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
22755          <div class="nav-dropdown-menu">
22756            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
22757          </div>
22758        </div>
22759        <div class="server-status-wrap" id="server-status-wrap">
22760          <div class="nav-pill server-online-pill" id="server-status-pill">
22761            <span class="status-dot" id="status-dot"></span>
22762            <span id="server-status-label">Server</span>
22763            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22764          </div>
22765          <div class="server-status-tip">
22766            OxideSLOC is running — accessible on your network.
22767            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22768          </div>
22769        </div>
22770        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22771          <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>
22772        </button>
22773        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
22774          <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>
22775          <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>
22776        </button>
22777      </div>
22778    </div>
22779  </div>
22780
22781  <div class="page">
22782    <section class="hero">
22783      <div class="hero-top">
22784        <div>
22785          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
22786            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
22787            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
22788            <div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
22789          </div>
22790        </div>
22791        <div class="hero-quick-actions">
22792          {% if server_mode %}
22793          <button type="button" class="copy-button secondary" disabled title="Output folder is on the server — path is not meaningful for remote users" style="opacity:0.45;cursor:not-allowed;">Copy output folder</button>
22794          {% else %}
22795          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
22796          {% endif %}
22797          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
22798          {% if !server_mode %}
22799          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
22800          {% endif %}
22801          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
22802          <button class="copy-button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;color:#fff;box-shadow:0 12px 24px rgba(178,48,48,0.11);">Delete this run</button>
22803        </div>
22804      </div>
22805
22806      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
22807      <div class="run-id-row">
22808        <span class="run-id-chip" data-copy="{{ run_id }}">
22809          <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" aria-hidden="true"><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>
22810          <span class="run-id-chip-value">{{ run_id }}</span>
22811          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
22812        </span>
22813        {% match git_commit_long %}
22814          {% when Some with (long_sha) %}
22815          {% match git_commit_url %}
22816            {% when Some with (commit_url) %}
22817            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
22818              <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" aria-hidden="true"><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-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><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>
22819              <span class="run-id-chip-value">{{ long_sha }}</span>
22820              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
22821            </a>
22822            {% when None %}
22823            <span class="run-id-chip" data-copy="{{ long_sha }}">
22824              <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" aria-hidden="true"><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>
22825              <span class="run-id-chip-value">{{ long_sha }}</span>
22826              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
22827            </span>
22828          {% endmatch %}
22829          {% when None %}
22830          <span class="run-id-chip muted-chip">
22831            <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" aria-hidden="true"><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>
22832            <span class="run-id-chip-value">Not detected</span>
22833            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
22834          </span>
22835        {% endmatch %}
22836        {% match git_branch %}
22837          {% when Some with (branch) %}
22838          {% match git_branch_url %}
22839            {% when Some with (branch_url) %}
22840            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
22841              <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" aria-hidden="true"><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-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><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>
22842              <span class="run-id-chip-value">{{ branch }}</span>
22843              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
22844            </a>
22845            {% when None %}
22846            <span class="run-id-chip">
22847              <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" aria-hidden="true"><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>
22848              <span class="run-id-chip-value">{{ branch }}</span>
22849              <span class="chip-tooltip">Git branch active at scan time</span>
22850            </span>
22851          {% endmatch %}
22852          {% when None %}
22853          <span class="run-id-chip muted-chip">
22854            <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" aria-hidden="true"><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>
22855            <span class="run-id-chip-value">Not detected</span>
22856            <span class="chip-tooltip">No Git branch was found for this scan</span>
22857          </span>
22858        {% endmatch %}
22859        {% match git_author %}
22860          {% when Some with (author) %}
22861          <span class="run-id-chip" data-author="{{ author }}">
22862            <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" aria-hidden="true"><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>
22863            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
22864            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
22865          </span>
22866          {% when None %}
22867          <span class="run-id-chip muted-chip">
22868            <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" aria-hidden="true"><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>
22869            <span class="run-id-chip-value">Not detected</span>
22870            <span class="chip-tooltip">No commit author was found for this scan</span>
22871          </span>
22872        {% endmatch %}
22873      </div>
22874
22875      <!-- Scan metadata row -->
22876      <div class="meta">
22877        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
22878        <span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
22879        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
22880        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
22881        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
22882      </div>
22883
22884      <!-- All summary stat chips in one unified strip (8 columns) -->
22885      <div class="summary-strip summary-strip-hero">
22886        <div class="stat-chip" data-raw="{{ physical_lines }}">
22887          <div class="stat-chip-label">Physical lines</div>
22888          <div class="stat-chip-val">{{ physical_lines }}</div>
22889          <div class="stat-chip-exact"></div>
22890          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
22891        </div>
22892        <div class="stat-chip" data-raw="{{ code_lines }}">
22893          <div class="stat-chip-label">Code</div>
22894          <div class="stat-chip-val">{{ code_lines }}</div>
22895          <div class="stat-chip-exact"></div>
22896          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
22897        </div>
22898        <div class="stat-chip" data-raw="{{ comment_lines }}">
22899          <div class="stat-chip-label">Comments</div>
22900          <div class="stat-chip-val">{{ comment_lines }}</div>
22901          <div class="stat-chip-exact"></div>
22902          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
22903        </div>
22904        <div class="stat-chip" data-raw="{{ blank_lines }}">
22905          <div class="stat-chip-label">Blank</div>
22906          <div class="stat-chip-val">{{ blank_lines }}</div>
22907          <div class="stat-chip-exact"></div>
22908          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
22909        </div>
22910        <div class="stat-chip" data-raw="{{ mixed_lines }}">
22911          <div class="stat-chip-label">Mixed separate</div>
22912          <div class="stat-chip-val">{{ mixed_lines }}</div>
22913          <div class="stat-chip-exact"></div>
22914          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
22915        </div>
22916        <div class="stat-chip" data-raw="{{ functions }}">
22917          <div class="stat-chip-label">Functions</div>
22918          <div class="stat-chip-val">{{ functions }}</div>
22919          <div class="stat-chip-exact"></div>
22920          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
22921        </div>
22922        <div class="stat-chip" data-raw="{{ classes }}">
22923          <div class="stat-chip-label">Classes / Types</div>
22924          <div class="stat-chip-val">{{ classes }}</div>
22925          <div class="stat-chip-exact"></div>
22926          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
22927        </div>
22928        <div class="stat-chip" data-raw="{{ variables }}">
22929          <div class="stat-chip-label">Variables</div>
22930          <div class="stat-chip-val">{{ variables }}</div>
22931          <div class="stat-chip-exact"></div>
22932          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
22933        </div>
22934        <div class="stat-chip" data-raw="{{ imports }}">
22935          <div class="stat-chip-label">Imports</div>
22936          <div class="stat-chip-val">{{ imports }}</div>
22937          <div class="stat-chip-exact"></div>
22938          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
22939        </div>
22940        <div class="stat-chip" data-raw="{{ test_count }}">
22941          <div class="stat-chip-label">Tests</div>
22942          <div class="stat-chip-val">{{ test_count }}</div>
22943          <div class="stat-chip-exact"></div>
22944          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
22945        </div>
22946        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
22947          <div class="stat-chip-label">Code density</div>
22948          <div class="stat-chip-val stat-chip-density-val">—</div>
22949          <div class="stat-chip-exact"></div>
22950          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
22951        </div>
22952        <div class="stat-chip" data-raw="{{ files_analyzed }}">
22953          <div class="stat-chip-label">Files analyzed</div>
22954          <div class="stat-chip-val">{{ files_analyzed }}</div>
22955          <div class="stat-chip-exact"></div>
22956          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
22957        </div>
22958        {% if cyclomatic_complexity > 0 %}
22959        <div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}style="border-color:var(--oxide-2);"{% endif %}>
22960          <div class="stat-chip-label">Complexity score</div>
22961          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
22962          <div class="stat-chip-exact"></div>
22963          <div class="stat-chip-tip">Sum of branch decision keywords (if, for, while, ||, &amp;&amp;, …) across all code lines — a lexical approximation of McCabe cyclomatic complexity.{% if complexity_alert > 0 %} Alert threshold: {{ complexity_alert }}.{% endif %}</div>
22964        </div>
22965        {% endif %}
22966        {% if let Some(ls) = lsloc %}
22967        <div class="stat-chip" data-raw="{{ ls }}">
22968          <div class="stat-chip-label">Logical SLOC</div>
22969          <div class="stat-chip-val">{{ ls }}</div>
22970          <div class="stat-chip-exact"></div>
22971          <div class="stat-chip-tip">Count of executable statements (semicolons for C/Java/Go/Rust; non-continuation lines for Python/Ruby/Shell). Normalises across formatting styles.</div>
22972        </div>
22973        {% endif %}
22974        {% if uloc > 0 %}
22975        <div class="stat-chip" data-raw="{{ uloc }}">
22976          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
22977          <div class="stat-chip-val">{{ uloc }}</div>
22978          <div class="stat-chip-exact"></div>
22979          <div class="stat-chip-tip">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>
22980        </div>
22981        {% endif %}
22982        {% if uloc > 0 && dryness_pct_str != "" %}
22983        <div class="stat-chip">
22984          <div class="stat-chip-label">DRYness</div>
22985          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
22986          <div class="stat-chip-exact"></div>
22987          <div class="stat-chip-tip">ULOC &divide; Code Lines — the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div>
22988        </div>
22989        {% endif %}
22990        {% if duplicate_group_count > 0 %}
22991        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
22992          <div class="stat-chip-label">Duplicate groups</div>
22993          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
22994          <div class="stat-chip-exact"></div>
22995          <div class="stat-chip-tip">Groups of files with identical content detected. These may inflate SLOC counts. Enable "Exclude duplicates" in scan settings to remove them from totals.</div>
22996        </div>
22997        {% endif %}
22998        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
22999             odd, so the strip always forms exactly two full rows with every column
23000             aligned and every card the same width (no oversized card, no gap). -->
23001        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23002          <div class="stat-chip-label">Assertions</div>
23003          <div class="stat-chip-val">{{ test_assertion_count }}</div>
23004          <div class="stat-chip-exact"></div>
23005          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23006        </div>
23007      </div>
23008
23009      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23010      <div class="compare-banner">
23011        <div class="compare-banner-body">
23012          <div class="compare-banner-top">
23013          <div class="compare-banner-meta">
23014            <span class="compare-label">Previous scan</span>
23015            <span class="compare-ts">{{ prev_ts }}</span>
23016            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23017            {% if let Some(prev_code) = prev_run_code_lines %}
23018            <div class="compare-banner-stats" style="margin-top:4px;">
23019              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23020              <span class="compare-arrow">→</span>
23021              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23022              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23023              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23024            </div>
23025            {% endif %}
23026          </div>
23027          {% if delta_lines_added.is_some() %}
23028          <div class="delta-cards-inline">
23029            <div class="delta-card-inline">
23030              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23031              <div class="delta-card-lbl">lines added</div>
23032              <div class="delta-card-tip">Code lines added since the previous scan</div>
23033            </div>
23034            <div class="delta-card-inline">
23035              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23036              <div class="delta-card-lbl">lines removed</div>
23037              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23038            </div>
23039            <div class="delta-card-inline">
23040              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23041              <div class="delta-card-lbl">unmodified lines</div>
23042              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23043            </div>
23044            <div class="delta-card-inline">
23045              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23046              <div class="delta-card-lbl">files modified</div>
23047              <div class="delta-card-tip">Files with at least one line changed</div>
23048            </div>
23049            <div class="delta-card-inline">
23050              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23051              <div class="delta-card-lbl">files added</div>
23052              <div class="delta-card-tip">New files added since the previous scan</div>
23053            </div>
23054            <div class="delta-card-inline">
23055              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23056              <div class="delta-card-lbl">files removed</div>
23057              <div class="delta-card-tip">Files deleted since the previous scan</div>
23058            </div>
23059            <div class="delta-card-inline">
23060              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23061              <div class="delta-card-lbl">files unchanged</div>
23062              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23063            </div>
23064          </div>
23065          {% else %}
23066          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23067            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23068          </p>
23069          {% endif %}
23070          </div>
23071          <div class="compare-banner-actions">
23072            <div class="compare-banner-actions-left">
23073              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23074              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23075            </div>
23076            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23077          </div>
23078        </div>
23079      </div>
23080      {% endif %}{% endif %}
23081
23082      <div class="action-grid">
23083        <div class="action-card">
23084          <h3>HTML report</h3>
23085          <div class="action-buttons">
23086            {% match html_url %}
23087              {% when Some with (url) %}
23088                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23089              {% when None %}{% endmatch %}
23090            {% match html_download_url %}
23091              {% when Some with (url) %}
23092                <a class="button secondary" href="{{ url }}">Download HTML</a>
23093              {% when None %}{% endmatch %}
23094            {% match html_path %}
23095              {% when Some with (_path) %}{% when None %}{% endmatch %}
23096            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23097          </div>
23098        </div>
23099        <div class="action-card">
23100          <h3>PDF report</h3>
23101          <div class="action-buttons">
23102            {% match pdf_url %}
23103              {% when Some with (url) %}
23104                {% if pdf_generating %}
23105                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23106                    <span style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.4);border-top-color:#fff;border-radius:50%;display:inline-block;animation:spin .75s linear infinite;flex:0 0 auto;"></span>
23107                    Generating PDF…
23108                  </button>
23109                {% else %}
23110                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23111                {% endif %}
23112              {% when None %}
23113                {% match html_url %}
23114                  {% when Some with (_hurl) %}
23115                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23116                    <p class="action-empty-note" style="margin-top:6px;font-size:11px;">Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
23117                  {% when None %}
23118                    <p class="action-empty-note" style="color:var(--muted);font-size:12px;background:rgba(0,0,0,0.04);border:1px solid var(--line);border-radius:8px;padding:10px 12px;">
23119                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23120                    </p>
23121                {% endmatch %}
23122            {% endmatch %}
23123            {% match pdf_download_url %}
23124              {% when Some with (url) %}
23125                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23126              {% when None %}{% endmatch %}
23127            {% match pdf_url %}
23128              {% when Some with (_) %}
23129                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23130              {% when None %}{% endmatch %}
23131          </div>
23132        </div>
23133        <div class="action-card">
23134          <h3>JSON result</h3>
23135          <div class="action-buttons">
23136            {% match json_url %}
23137              {% when Some with (url) %}
23138                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23139              {% when None %}{% endmatch %}
23140            {% match json_download_url %}
23141              {% when Some with (url) %}
23142                <a class="button secondary" href="{{ url }}">Download JSON</a>
23143              {% when None %}{% endmatch %}
23144            {% match json_path %}
23145              {% when Some with (_path) %}
23146                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23147              {% when None %}
23148                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23149              {% endmatch %}
23150          </div>
23151        </div>
23152        <div class="action-card">
23153          <h3>Scan config</h3>
23154          <div class="action-buttons">
23155            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23156            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23157            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23158          </div>
23159        </div>
23160        {% if confluence_configured %}
23161        <div class="action-card" id="confluenceCard">
23162          <h3>Confluence</h3>
23163          <div class="action-buttons">
23164            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23165            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23166          </div>
23167          <p class="action-empty-note" style="margin-top:6px;">Create or update a Confluence page with this scan result, or copy wiki markup for manual paste.</p>
23168        </div>
23169        {% endif %}
23170      </div>
23171      {% if confluence_configured %}
23172      <div id="confluenceModal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.45);align-items:center;justify-content:center;">
23173        <div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:480px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">
23174          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23175          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23176          <input id="confPageTitle" type="text" value="OxideSLOC — {{ report_title }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
23177          <label style="font-size:12px;font-weight:700;color:var(--muted);">Report URL <span style="font-weight:400;">(optional — linked in page body)</span></label>
23178          <input id="confReportUrl" type="url" placeholder="http://127.0.0.1:4317/runs/result/{{ run_id }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
23179          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23180          <div style="display:flex;gap:10px;justify-content:flex-end;">
23181            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23182            <button class="button" id="confSubmitBtn" type="button">Post</button>
23183          </div>
23184        </div>
23185      </div>
23186      {% endif %}
23187      <div id="delete-run-modal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.90);align-items:center;justify-content:center;">
23188        <div style="background:var(--surface);border:1px solid var(--line);border-radius:22px;padding:56px 72px;max-width:820px;width:95%;box-shadow:0 24px 72px rgba(0,0,0,0.55);">
23189          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23190          <p style="font-size:17px;color:var(--text);margin:0 0 28px;">This will permanently delete all artifacts for this run from disk (HTML, PDF, JSON, CSV, scan config). <strong>This cannot be undone</strong> and the run will no longer be accessible by anyone.</p>
23191          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23192          <div style="display:flex;gap:18px;justify-content:flex-end;">
23193            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23194            <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;font-size:15px;padding:12px 28px;">Yes, delete permanently</button>
23195          </div>
23196        </div>
23197      </div>
23198      {% if !submodule_rows.is_empty() %}
23199      <div class="submodule-panel">
23200        <div class="toolbar-row">
23201          <div>
23202            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23203            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23204          </div>
23205          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23206        </div>
23207        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23208        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23209          <colgroup><col style="width:24%"><col style="width:22%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"></colgroup>
23210          <thead>
23211            <tr>
23212              <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Submodule</th>
23213              <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;white-space:nowrap;">Path</th>
23214              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Files</th>
23215              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Physical</th>
23216              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Code</th>
23217              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Comments</th>
23218              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Blank</th>
23219              <th style="padding:9px 8px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">Report</th>
23220            </tr>
23221          </thead>
23222          <tbody>
23223            {% for row in submodule_rows %}
23224            <tr>
23225              <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
23226              <td style="padding:10px 14px;border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;" title="{{ row.relative_path }}"><code style="font-size:12px;white-space:nowrap;word-break:keep-all;overflow-wrap:normal;">{{ row.relative_path }}</code></td>
23227              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23228              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23229              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23230              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23231              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23232              <td style="padding:10px 8px;border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 10px;min-height:0;display:block;margin:0 auto;width:fit-content;">View</a>{% else %}<span style="color:var(--muted);font-size:12px;">—</span>{% endif %}</td>
23233            </tr>
23234            {% endfor %}
23235          </tbody>
23236        </table>
23237        </div>
23238      </div>
23239      {% endif %}
23240
23241      <div class="metrics-tables-stack">
23242
23243        <div class="metrics-table-wrap">
23244          <div class="metrics-table-title">Files</div>
23245          <table class="metrics-table">
23246            <thead>
23247              <tr>
23248                <th>Metric</th>
23249                <th>This Run</th>
23250                <th>Previous</th>
23251                <th>Change</th>
23252              </tr>
23253            </thead>
23254            <tbody>
23255              <tr>
23256                <td>Files analyzed</td>
23257                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23258                <td>{{ prev_fa_str|commas }}</td>
23259                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23260              </tr>
23261              <tr>
23262                <td>Files skipped</td>
23263                <td>{{ files_skipped|commas }}</td>
23264                <td>{{ prev_fs_str|commas }}</td>
23265                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23266              </tr>
23267              <tr>
23268                <td>Files modified</td>
23269                <td class="mt-val-na">—</td>
23270                <td class="mt-val-na">—</td>
23271                <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v|commas }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23272              </tr>
23273              <tr>
23274                <td>Files unchanged</td>
23275                <td class="mt-val-na">—</td>
23276                <td class="mt-val-na">—</td>
23277                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23278              </tr>
23279            </tbody>
23280          </table>
23281        </div>
23282
23283        <div class="metrics-table-wrap">
23284          <div class="metrics-table-title">Line Counts</div>
23285          <table class="metrics-table">
23286            <thead>
23287              <tr>
23288                <th>Metric</th>
23289                <th>This Run</th>
23290                <th>Previous</th>
23291                <th>Change</th>
23292              </tr>
23293            </thead>
23294            <tbody>
23295              <tr>
23296                <td>Physical lines</td>
23297                <td class="mt-val-large">{{ physical_lines|commas }}</td>
23298                <td>{{ prev_pl_str|commas }}</td>
23299                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23300              </tr>
23301              <tr>
23302                <td>Code lines</td>
23303                <td class="mt-val-large">{{ code_lines|commas }}</td>
23304                <td>{{ prev_cl_str|commas }}</td>
23305                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23306              </tr>
23307              <tr>
23308                <td>Comment lines</td>
23309                <td>{{ comment_lines|commas }}</td>
23310                <td>{{ prev_cml_str|commas }}</td>
23311                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23312              </tr>
23313              <tr>
23314                <td>Blank lines</td>
23315                <td>{{ blank_lines|commas }}</td>
23316                <td>{{ prev_bl_str|commas }}</td>
23317                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23318              </tr>
23319              <tr>
23320                <td>Mixed (separate)</td>
23321                <td>{{ mixed_lines|commas }}</td>
23322                <td class="mt-val-na">—</td>
23323                <td class="mt-val-na">—</td>
23324              </tr>
23325            </tbody>
23326          </table>
23327        </div>
23328
23329        <div class="metrics-tables-lower">
23330          <div class="metrics-table-wrap">
23331            <div class="metrics-table-title">Code Structure</div>
23332            <table class="metrics-table">
23333              <thead>
23334                <tr>
23335                  <th>Metric</th>
23336                  <th>This Run</th>
23337                </tr>
23338              </thead>
23339              <tbody>
23340                <tr>
23341                  <td>Functions</td>
23342                  <td>{{ functions|commas }}</td>
23343                </tr>
23344                <tr>
23345                  <td>Classes / Types</td>
23346                  <td>{{ classes|commas }}</td>
23347                </tr>
23348                <tr>
23349                  <td>Variables</td>
23350                  <td>{{ variables|commas }}</td>
23351                </tr>
23352                <tr>
23353                  <td>Imports</td>
23354                  <td>{{ imports|commas }}</td>
23355                </tr>
23356              </tbody>
23357            </table>
23358          </div>
23359
23360          <div class="metrics-table-wrap">
23361            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23362            <table class="metrics-table">
23363              <thead>
23364                <tr>
23365                  <th>Metric</th>
23366                  <th>Change</th>
23367                </tr>
23368              </thead>
23369              <tbody>
23370                <tr>
23371                  <td>Lines added</td>
23372                  <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23373                </tr>
23374                <tr>
23375                  <td>Lines removed</td>
23376                  <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">&minus;{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23377                </tr>
23378                <tr>
23379                  <td>Lines modified (net)</td>
23380                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23381                </tr>
23382                <tr>
23383                  <td>Lines unmodified</td>
23384                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23385                </tr>
23386              </tbody>
23387            </table>
23388          </div>
23389        </div>
23390
23391      </div>
23392
23393      <div class="path-list">
23394        <div class="path-item">
23395          <div class="path-item-label">Project path</div>
23396          {% if project_path.is_empty() %}<code style="color:var(--muted)" title="The scanned project path was not recorded in this run's metadata.">Not recorded for this scan</code>{% else %}<code>{{ project_path }}</code>{% endif %}
23397        </div>
23398        <div class="path-item">
23399          <div class="path-item-label">Git branch</div>
23400          {% if let Some(branch) = git_branch %}
23401          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23402          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23403          {% else %}
23404          <code style="color:var(--muted)">—</code>
23405          {% endif %}
23406        </div>
23407        <div class="path-item">
23408          <div class="path-item-label">Output folder</div>
23409          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23410        </div>
23411        <div class="path-item">
23412          <div class="path-item-label">Run ID</div>
23413          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23414            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23415            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23416          </div>
23417        </div>
23418      </div>
23419    </section>
23420
23421    {% if has_cocomo %}
23422    <div class="cocomo-box" style="margin-top:24px;">
23423      <div class="cocomo-box-head">
23424        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
23425        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23426          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23427          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23428        </span>
23429      </div>
23430      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23431        <div class="stat-chip">
23432          <div class="stat-chip-label">Person-months</div>
23433          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23434          <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 ({{ cocomo_mode_label }} mode).</div>
23435        </div>
23436        <div class="stat-chip">
23437          <div class="stat-chip-label">Schedule (months)</div>
23438          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23439          <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>
23440        </div>
23441        <div class="stat-chip">
23442          <div class="stat-chip-label">Avg. Team Size</div>
23443          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23444          <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>
23445        </div>
23446        <div class="stat-chip">
23447          <div class="stat-chip-label">Input KSLOC</div>
23448          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23449          <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 from this total.</div>
23450        </div>
23451      </div>
23452      <div class="cocomo-box-note" style="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.</div>
23453    </div>
23454    {% endif %}
23455
23456    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23457    <div class="cocomo-box" style="margin-top:24px;">
23458      <div class="cocomo-box-head">
23459        <span class="cocomo-box-title">Tests &amp; Coverage</span>
23460        {% if has_coverage_data %}
23461        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23462          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23463        </span>
23464        {% endif %}
23465      </div>
23466      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23467        <div class="stat-chip">
23468          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23469          <div class="stat-chip-label">Test Functions</div>
23470          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23471        </div>
23472        <div class="stat-chip">
23473          {% if has_coverage_data %}
23474          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23475          {% else %}
23476          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23477          {% endif %}
23478          <div class="stat-chip-label">Line Coverage</div>
23479          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23480        </div>
23481        <div class="stat-chip">
23482          {% if !cov_fn_pct.is_empty() %}
23483          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23484          {% else %}
23485          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23486          {% endif %}
23487          <div class="stat-chip-label">Fn Coverage</div>
23488          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23489        </div>
23490        <div class="stat-chip">
23491          {% if !cov_branch_pct.is_empty() %}
23492          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23493          {% else %}
23494          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23495          {% endif %}
23496          <div class="stat-chip-label">Branch Coverage</div>
23497          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23498        </div>
23499      </div>
23500      {% if has_coverage_data %}
23501      <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> &nbsp;&middot;&nbsp; Open the full HTML report for a per-file breakdown.</div>
23502      {% else %}
23503      <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path &lt;coverage.info&gt;</code> to populate this section.</div>
23504      {% endif %}
23505    </div>
23506
23507    <div class="section-pair">
23508    <section class="panel">
23509        <div class="toolbar-row">
23510          <div>
23511            <h2>Language Breakdown</h2>
23512            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23513          </div>
23514          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23515        </div>
23516        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23517    </section>
23518
23519    <section class="panel r-chart-section">
23520      <div class="toolbar-row" style="margin-bottom:16px;">
23521        <div>
23522          <h2>Visualizations</h2>
23523          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23524        </div>
23525      </div>
23526
23527      <div class="r-viz-grid">
23528        <div class="r-viz-card">
23529          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23530            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23531            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23532          </div>
23533          <div class="r-chart-tab-bar">
23534            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23535            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23536          </div>
23537          <div class="r-chart-container" id="r-composition-chart"></div>
23538        </div>
23539        <div class="r-viz-card">
23540          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23541            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23542            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23543          </div>
23544          <div class="r-chart-container" id="r-scatter-chart"></div>
23545        </div>
23546        {% if has_semantic_data %}
23547        <div class="r-viz-card">
23548          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23549            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23550            <select class="r-chart-select" id="r-semantic-metric">
23551              <option value="functions">Functions</option>
23552              <option value="classes">Classes</option>
23553              <option value="variables">Variables</option>
23554              <option value="imports">Imports</option>
23555              <option value="tests">Tests</option>
23556            </select>
23557            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23558          </div>
23559          <div class="r-chart-container" id="r-semantic-chart"></div>
23560        </div>
23561        {% endif %}
23562        <div class="r-viz-card">
23563          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23564            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23565            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23566          </div>
23567          <div class="r-chart-container" id="r-density-chart"></div>
23568        </div>
23569        <div class="r-viz-card">
23570          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23571            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23572            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23573          </div>
23574          <div class="r-chart-container" id="r-avglines-chart"></div>
23575        </div>
23576        <div class="r-viz-card">
23577          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23578            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23579            <select class="r-chart-select" id="r-sub-metric">
23580              <option value="code">Code Lines</option>
23581              <option value="comment">Comments</option>
23582              <option value="blank">Blank Lines</option>
23583              <option value="physical">Physical Lines</option>
23584              <option value="files">Files</option>
23585            </select>
23586            <select class="r-chart-select" id="r-sub-sort">
23587              <option value="desc">Value ↓</option>
23588              <option value="asc">Value ↑</option>
23589              <option value="name">Name A→Z</option>
23590            </select>
23591            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23592          </div>
23593          <div class="r-chart-container" id="r-submodule-chart"></div>
23594        </div>
23595      </div>
23596
23597    </section>
23598    </div>
23599
23600  </div>
23601
23602  <div id="r-tt" aria-hidden="true"></div>
23603
23604  <script nonce="{{ csp_nonce }}">
23605    (function () {
23606      var body = document.body;
23607      var themeToggle = document.getElementById('theme-toggle');
23608      var storageKey = 'oxide-sloc-theme';
23609
23610      function applyTheme(theme) {
23611        body.classList.toggle('dark-theme', theme === 'dark');
23612      }
23613
23614      function loadSavedTheme() {
23615        try {
23616          var saved = localStorage.getItem(storageKey);
23617          if (saved === 'dark' || saved === 'light') {
23618            applyTheme(saved);
23619          }
23620        } catch (e) {}
23621      }
23622
23623      if (themeToggle) {
23624        themeToggle.addEventListener('click', function () {
23625          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
23626          applyTheme(nextTheme);
23627          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
23628        });
23629      }
23630
23631      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
23632        button.addEventListener('click', function () {
23633          var value = button.getAttribute('data-copy-value') || '';
23634          if (!value) return;
23635          var originalText = button.textContent;
23636          function flashSuccess() {
23637            button.textContent = 'Copied!';
23638            setTimeout(function () { button.textContent = originalText; }, 1800);
23639          }
23640          function flashFail() {
23641            button.textContent = 'Copy failed';
23642            setTimeout(function () { button.textContent = originalText; }, 2000);
23643          }
23644          if (navigator.clipboard && navigator.clipboard.writeText) {
23645            navigator.clipboard.writeText(value).then(flashSuccess, function () {
23646              fallbackCopy(value, flashSuccess, flashFail);
23647            });
23648          } else {
23649            fallbackCopy(value, flashSuccess, flashFail);
23650          }
23651        });
23652      });
23653      function fallbackCopy(text, onSuccess, onFail) {
23654        try {
23655          var ta = document.createElement('textarea');
23656          ta.value = text;
23657          ta.style.position = 'fixed';
23658          ta.style.top = '-9999px';
23659          ta.style.left = '-9999px';
23660          document.body.appendChild(ta);
23661          ta.focus();
23662          ta.select();
23663          var ok = document.execCommand('copy');
23664          document.body.removeChild(ta);
23665          if (ok) { onSuccess(); } else { onFail(); }
23666        } catch (e) { onFail(); }
23667      }
23668
23669      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
23670        btn.addEventListener('click', function () {
23671          var folder = btn.getAttribute('data-folder') || '';
23672          if (!folder) return;
23673          var orig = btn.textContent;
23674          fetch('/open-path?path=' + encodeURIComponent(folder))
23675            .then(function (r) { return r.json(); })
23676            .then(function (d) {
23677              if (d && d.server_mode_disabled) {
23678                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
23679              } else if (d && d.ok) {
23680                btn.textContent = 'Opened!';
23681                setTimeout(function () { btn.textContent = orig; }, 1800);
23682              }
23683            })
23684            .catch(function () {
23685              btn.textContent = 'Failed';
23686              setTimeout(function () { btn.textContent = orig; }, 2000);
23687            });
23688        });
23689      });
23690
23691      loadSavedTheme();
23692
23693      // ── Compact number formatting for stat chips ──────────────────────────
23694      (function(){
23695        function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
23696        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
23697          var raw=parseInt(chip.getAttribute('data-raw'),10);
23698          if(isNaN(raw))return;
23699          var valEl=chip.querySelector('.stat-chip-val');
23700          if(valEl)valEl.textContent=fmt(raw);
23701          var exactEl=chip.querySelector('.stat-chip-exact');
23702          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
23703        });
23704        // Code density chip
23705        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
23706          var code=parseInt(chip.getAttribute('data-code'),10);
23707          var phys=parseInt(chip.getAttribute('data-physical'),10);
23708          if(isNaN(code)||isNaN(phys)||phys===0)return;
23709          var pct=(code/phys*100).toFixed(1)+'%';
23710          var valEl=chip.querySelector('.stat-chip-val');
23711          if(valEl)valEl.textContent=pct;
23712        });
23713        // Populate author handle from data-author attribute
23714        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
23715          var author=chip.getAttribute('data-author');
23716          var el=chip.querySelector('.author-handle');
23717          if(el)el.textContent='/'+author.replace(/\s+/g,'');
23718        });
23719        // Click-to-copy on run-id-chip elements
23720        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
23721          chip.addEventListener('click',function(){
23722            var val=chip.getAttribute('data-copy');
23723            if(!val)return;
23724            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
23725            else{var ta=document.createElement('textarea');ta.value=val;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);}
23726            chip.classList.add('chip-copied-flash');
23727            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
23728          });
23729        });
23730        // Format delta card values with data-raw using comma-separated full numbers
23731        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
23732          var raw=parseInt(card.getAttribute('data-raw'),10);
23733          if(isNaN(raw))return;
23734          var valEl=card.querySelector('.delta-card-val');
23735          if(valEl)valEl.textContent=raw.toLocaleString();
23736        });
23737        // Format code-before / code-now numbers in the compare banner stats line
23738        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
23739          var raw=parseInt(el.getAttribute('data-raw'),10);
23740          if(!isNaN(raw))el.textContent=raw.toLocaleString();
23741        });
23742      })();
23743
23744      // ── Shared tooltip for all result-page charts ─────────────────────────
23745      var rTT=(function(){
23746        var el=document.getElementById('r-tt');
23747        if(!el)return{s:function(){},h:function(){},m:function(){}};
23748        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
23749        function hide(){el.style.display='none';}
23750        function move(e){
23751          var x=e.clientX+16,y=e.clientY-12;
23752          var r=el.getBoundingClientRect();
23753          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
23754          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
23755          el.style.left=x+'px';el.style.top=y+'px';
23756        }
23757        return{s:show,h:hide,m:move};
23758      })();
23759      window.rTT=rTT;
23760
23761      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
23762      (function(){
23763        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23764        document.addEventListener('mouseover',function(e){
23765          var t=e.target;
23766          while(t&&t.getAttribute){
23767            var l=t.getAttribute('data-ttl');
23768            if(l!==null){
23769              var v=t.getAttribute('data-ttv')||'';
23770              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
23771              return;
23772            }
23773            t=t.parentNode;
23774          }
23775        });
23776        document.addEventListener('mouseout',function(e){
23777          var t=e.target;
23778          while(t&&t.getAttribute){
23779            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
23780            t=t.parentNode;
23781          }
23782        });
23783        document.addEventListener('mousemove',function(e){
23784          var el=document.getElementById('r-tt');
23785          if(el&&el.style.display!=='none')rTT.m(e);
23786        });
23787        window.addEventListener('blur',function(){rTT.h();});
23788        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
23789      })();
23790
23791      // ── Language overview charts ───────────────────────────────────────────
23792      (function(){
23793        var D={{ lang_chart_json|safe }};
23794        if(!D||!D.length)return;
23795        var el=document.getElementById('result-lang-charts');
23796        if(!el)return;
23797        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
23798        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
23799        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23800        function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
23801        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23802        function px(n){return Math.round(n);}
23803        function tt(label,val){var l=String(label).replace(/&/g,'&amp;').replace(/"/g,'&quot;'),v=String(val).replace(/&/g,'&amp;').replace(/"/g,'&quot;');return' class="rchit" data-ttl="'+l+'" data-ttv="'+v+'"';}
23804        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
23805        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
23806        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
23807        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;}
23808        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
23809
23810        // Donut chart — height matches the stacked-bar chart so both panels align
23811        var rHb_d=28;
23812        var DH=Math.max(220,D.length*rHb_d+32);
23813        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
23814        var legX=208,DW=395;
23815        var legCount=D.length;
23816        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
23817        var legYStart=Math.round((DH-legCount*legSpacing)/2);
23818        var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
23819        if(D.length===1){
23820          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
23821          ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
23822        } else {
23823          var smalls=[];
23824          var ang=-Math.PI/2;
23825          D.forEach(function(d,i){
23826            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
23827            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
23828            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
23829            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
23830            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
23831            var pct=Math.round(d.code/tot*100);
23832            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="'+(COLS[i%COLS.length])+'" stroke="white" stroke-width="2"/>';
23833            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
23834            ang+=sw;
23835          });
23836          // Small slices (<5%) get outside labels positioned near each slice's own
23837          // angular position (a slice on the left gets its label/leader on the left),
23838          // then nudged apart horizontally so text never overlaps. Leader lines point
23839          // from each slice to its label. Horizontal text keeps long names legible;
23840          // the whole SVG scales up in Full View so these stay readable there too.
23841          if(smalls.length){
23842            smalls.sort(function(a,b){return a.mAng-b.mAng;});
23843            var sPad=6,sRowY=11;
23844            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)));});
23845            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;}
23846            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
23847            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
23848            smalls.forEach(function(sm){
23849              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
23850              ds+='<line x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
23851              ds+='<text x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="pointer-events:none;">'+esc(sm.txt)+'</text>';
23852            });
23853          }
23854        }
23855        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
23856        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
23857        D.forEach(function(d,i){
23858          var ly=legYStart+i*legSpacing;
23859          var pctL=Math.round(d.code/tot*100);
23860          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
23861          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
23862          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
23863          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
23864          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
23865          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
23866          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>';
23867          ds+='</g>';
23868        });
23869        ds+='</svg>';
23870
23871        // Horizontal stacked-bar chart — fills container width
23872        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
23873        var LW=108,BW=260,svgW=LW+BW+68;
23874        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
23875        var barBH=Math.min(32,Math.round(barRhb*0.7));
23876        var SH=DH;
23877        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
23878        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">';
23879        D.forEach(function(d,i){
23880          var y=barTopPad+i*barRhb,x=LW;
23881          var phys=d.physical||d.code+d.comments+d.blanks;
23882          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
23883          var lmid=y+barBH/2+4;
23884          // Combined breakdown shown when hovering the row, the language name, or the
23885          // total at the bar end (\n becomes a line break in the tooltip).
23886          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
23887          bs+='<g class="lang-bar-row">';
23888          // Hit area ends just past the total label so empty space to the right of the
23889          // bar does not trigger the tooltip — only the name, bar and total are hot.
23890          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
23891          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
23892          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>';
23893          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+'" rx="0"/>';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;}
23894          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+'" rx="0"/>';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;}
23895          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+'" rx="0"/>';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>';}
23896          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>';
23897          bs+='</g>';
23898        });
23899        var ly=SH-14;
23900        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
23901        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
23902        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
23903        var totAll=totC+totCm+totBl||1;
23904        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
23905        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
23906        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
23907        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
23908        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
23909        bs+='<g data-kind="code" style="cursor:pointer;">'
23910          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
23911          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
23912          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
23913          +'</g>';
23914        bs+='<g data-kind="comment" style="cursor:pointer;">'
23915          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
23916          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
23917          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
23918          +'</g>';
23919        bs+='<g data-kind="blank" style="cursor:pointer;">'
23920          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
23921          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
23922          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
23923          +'</g>';
23924        bs+='</svg>';
23925        el.innerHTML='<div class="r-lang-overview">'+
23926          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
23927          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
23928        '</div>';
23929        function wireDonutLegend(svg){
23930          if(!svg)return;
23931          var paths=svg.querySelectorAll('path[data-lang]');
23932          function hl(lang){for(var i=0;i<paths.length;i++){if(paths[i].getAttribute('data-lang')===lang){paths[i].style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.25))';paths[i].style.transform='scale(1.05)';paths[i].style.opacity='1';}else{paths[i].style.opacity='0.32';paths[i].style.filter='none';paths[i].style.transform='none';}}}
23933          function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
23934          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();});
23935          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();});
23936          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
23937        }
23938        function wireMixLegend(svg){
23939          if(!svg)return;
23940          var legGs=svg.querySelectorAll('g[data-kind]');
23941          var allRects=svg.querySelectorAll('rect[data-kind]');
23942          if(!legGs.length)return;
23943          function hlKind(kind){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';}}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}}
23944          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='';}}
23945          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]);}
23946        }
23947        wireDonutLegend(el.querySelector('svg'));
23948        wireMixLegend(el.querySelectorAll('svg')[1]);
23949
23950        // ── Language breakdown Full View expand ─────────────────────────────────
23951        var langOvBtn=document.getElementById('result-lang-overview-expand');
23952        if(langOvBtn){langOvBtn.addEventListener('click',function(){
23953          var src=document.getElementById('result-lang-charts');
23954          if(!src)return;
23955          var overlay=document.createElement('div');
23956          overlay.className='r-chart-modal-overlay';
23957          overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
23958          document.body.appendChild(overlay);
23959          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
23960          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
23961          var wrap=document.getElementById('result-lang-overview-modal-wrap');
23962          if(wrap){
23963            wrap.innerHTML=src.innerHTML;
23964            var svgs=wrap.querySelectorAll('svg');
23965            for(var i=0;i<svgs.length;i++){
23966              svgs[i].removeAttribute('width');
23967              svgs[i].removeAttribute('height');
23968              svgs[i].style.cssText='display:block;width:100%;height:auto;';
23969            }
23970            var ov=wrap.querySelector('.r-lang-overview');
23971            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
23972            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
23973            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
23974            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
23975            wireDonutLegend(wrap.querySelector('svg'));
23976            wireMixLegend(wrap.querySelectorAll('svg')[1]);
23977            requestAnimationFrame(function(){
23978              var ss=wrap.querySelectorAll('svg');
23979              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%;';}}
23980            });
23981          }
23982        });}
23983      })();
23984
23985      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
23986      (function(){
23987        var LANG_D={{ lang_chart_json|safe }};
23988        var SCAT_D={{ scatter_chart_json|safe }};
23989        var SEM_D={{ semantic_chart_json|safe }};
23990        var SUB_D={{ submodule_chart_json|safe }};
23991        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
23992        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
23993        function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
23994        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23995        function px(n){return Math.round(n);}
23996        function tt(label,val){var l=String(label).replace(/&/g,'&amp;').replace(/"/g,'&quot;'),v=String(val).replace(/&/g,'&amp;').replace(/"/g,'&quot;');return' class="rchit" data-ttl="'+l+'" data-ttv="'+v+'"';}
23997        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
23998        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
23999        // rather than disappear; the SVG scales up in Full View).
24000        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;}
24001
24002        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24003        function renderCompositionInEl(el,mode,shOvr){
24004          if(!el||!LANG_D||!LANG_D.length)return;
24005          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24006          var LW=110,SH=shOvr||300;
24007          var svgW=Math.max(320,el.offsetWidth||480);
24008          var BW=Math.max(120,svgW-LW-80);
24009          var legendH=24,topPad=4;
24010          var n=LANG_D.length||1;
24011          var rowTotal=Math.floor((SH-legendH-topPad)/n);
24012          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24013          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">';
24014          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24015          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24016          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24017          var totAll2=totC2+totCm2+totBl2||1;
24018          if(mode==='pct'){
24019            LANG_D.forEach(function(d,i){
24020              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24021              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24022              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24023              var lmid=y+Math.floor(bH/2)+4;
24024              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24025              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24026              if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<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||0)+'</text>';x+=cW;}
24027              if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<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||0)+'</text>';x+=cmW;}
24028              if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<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||0)+'</text>';}
24029              var pct=Math.round((d.code||0)/tot2*100);
24030              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+pct+'%</text>';
24031            });
24032          } else {
24033            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24034            LANG_D.forEach(function(d,i){
24035              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24036              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24037              var lmid=y+Math.floor(bH/2)+4;
24038              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));
24039              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24040              if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<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||0)+'</text>';x+=cW;}
24041              if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<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||0)+'</text>';x+=cmW;}
24042              if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<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||0)+'</text>';}
24043              s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
24044            });
24045          }
24046          var ly=SH-legendH+4;
24047          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24048          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24049          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24050          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24051          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24052          s+='<g data-kind="code" style="cursor:pointer;">'
24053            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24054            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24055            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24056            +'</g>';
24057          s+='<g data-kind="comment" style="cursor:pointer;">'
24058            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24059            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24060            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24061            +'</g>';
24062          s+='<g data-kind="blank" style="cursor:pointer;">'
24063            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24064            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24065            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24066            +'</g>';
24067          s+='</svg>';
24068          el.innerHTML=s;
24069          wireMixLegendEl(el);
24070        }
24071        function wireMixLegendEl(container){
24072          var svg=container&&container.querySelector('svg');
24073          if(!svg)return;
24074          var legGs=svg.querySelectorAll('g[data-kind]');
24075          var allRects=svg.querySelectorAll('rect[data-kind]');
24076          if(!legGs.length)return;
24077          function hlKind(kind){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';}}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}}
24078          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='';}}
24079          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]);}
24080        }
24081        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24082        renderComposition('abs');
24083        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24084          btn.addEventListener('click',function(){
24085            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24086            btn.classList.add('active');
24087            renderComposition(btn.getAttribute('data-rcomp'));
24088          });
24089        });
24090
24091        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24092        function wireScatterLegend(container){
24093          var svg=container&&container.querySelector('svg');
24094          if(!svg)return;
24095          var legGs=svg.querySelectorAll('g[data-lang]');
24096          var circs=svg.querySelectorAll('circle[data-lang]');
24097          if(!legGs.length)return;
24098          function hl(lang){for(var i=0;i<circs.length;i++){var c=circs[i];if(c.getAttribute('data-lang')===lang){c.style.opacity='1';c.style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.28))';}else{c.style.opacity='0.12';c.style.filter='none';}}
24099            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24100          function rst(){for(var i=0;i<circs.length;i++){circs[i].style.opacity='';circs[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24101          for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hl(g.getAttribute('data-lang'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
24102        }
24103        function renderScatterInEl(el,hOvr){
24104          if(!el||!SCAT_D||!SCAT_D.length)return;
24105          var n=SCAT_D.length;
24106          var H=hOvr||300,PL=52,PB=36,PT=44;
24107          var W=Math.max(320,el.offsetWidth||480);
24108          var cH=H-PT-PB;
24109          // Legend: max 2 columns, fills vertical space. The compact card shows the
24110          // top languages by code lines plus a "+N more" row linking to Full View;
24111          // Full View (hOvr set) shows every language across up to 2 tall columns.
24112          var compact=!hOvr;
24113          var availH=Math.max(120,H-24);
24114          var rowsFit=Math.max(2,Math.floor(availH/18));
24115          var legTrunc=compact&&(n>2*rowsFit);
24116          var legShown=legTrunc?(2*rowsFit-1):n;
24117          var legTotal=legTrunc?(2*rowsFit):n;
24118          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24119          var legPerCol=Math.ceil(legTotal/legCols);
24120          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24121          var legColW=hOvr?144:130;
24122          var LG=26;
24123          var legW=legCols*legColW;
24124          var cW=W-PL-LG-legW;
24125          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24126          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24127          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24128          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24129          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24130          var logMaxF=Math.log1p(maxF);
24131          var s='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24132          // Y grid lines (linear)
24133          [0,0.25,0.5,0.75,1].forEach(function(t){
24134            var y=PT+cH*(1-t);
24135            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24136            if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(Math.round(maxC*t))+'</text>';
24137          });
24138          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24139          [0,0.25,0.5,0.75,1].forEach(function(t){
24140            var x=PL+cW*t;
24141            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24142            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24143            if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(xVal)+'</text>';
24144          });
24145          // Full View (hOvr set) has the vertical room to show the per-bubble value
24146          // line; the compact card shows only the language label to avoid the
24147          // overlapping-label clutter seen when bubbles cluster together.
24148          var showVal=!!hOvr;
24149          SCAT_D.forEach(function(d,i){
24150            // X uses log1p so outlier languages (many files) don't push others to the far left
24151            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24152            var cy2=PT+cH-d.code/maxC*cH;
24153            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24154            s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' data-lang="'+esc(d.lang)+'" cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
24155            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24156            if(showVal){
24157              var ty2=Math.max(24,px(cy2)-px(r)-3);
24158              var ty1=Math.max(12,ty2-14);
24159              s+='<text x="'+px(cx2)+'" y="'+ty1+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24160              s+='<text x="'+px(cx2)+'" y="'+ty2+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor" opacity="0.88" style="pointer-events:none;">'+fmt(d.code)+'</text>';
24161            }else{
24162              var ly2=Math.max(12,px(cy2)-px(r)-3);
24163              s+='<text x="'+px(cx2)+'" y="'+ly2+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24164            }
24165          });
24166          s+='<text x="'+(PL+cW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75">Files Analyzed</text>';
24167          s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
24168          // Legend (right side — top languages, max 2 columns, fills height)
24169          var legX=PL+cW+LG;
24170          var legBlockH=legPerCol*legRowH;
24171          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24172          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24173          for(var lk=0;lk<legShown;lk++){
24174            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24175            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24176            s+='<g data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" style="cursor:pointer;">';
24177            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24178            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24179            s+='<text x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" style="pointer-events:none;">'+esc(ld.lang)+'</text>';
24180            s+='</g>';
24181          }
24182          if(legTrunc){
24183            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24184            s+='<g data-more="1" style="cursor:pointer;">';
24185            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24186            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24187            s+='<text x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" style="pointer-events:none;">+'+(n-legShown)+' more</text>';
24188            s+='</g>';
24189          }
24190          s+='</svg>';
24191          el.innerHTML=s;
24192          wireScatterLegend(el);
24193          var moreEl=el.querySelector('g[data-more]');
24194          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24195        }
24196        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24197
24198        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24199        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24200        // the old vertical column layout on wide containers.
24201        function renderSemanticInEl(el,key,sh){
24202          if(!el||!SEM_D||!SEM_D.length)return;
24203          var n2=SEM_D.length||1;
24204          var LW=112,SH=sh||Math.max(180,n2*28+26);
24205          var svgW=Math.max(320,el.offsetWidth||480);
24206          var BW=Math.max(120,svgW-LW-80);
24207          var topPad=4,botPad=14;
24208          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24209          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24210          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24211          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">';
24212          SEM_D.forEach(function(d,i){
24213            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24214            s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.lang)+'</text>';
24215            if(bw>0.5)s+='<rect'+tt(d.lang,fmt(v)+' '+key)+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
24216            s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
24217          });
24218          s+='</svg>';
24219          el.innerHTML=s;
24220        }
24221        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24222        var semSel=document.getElementById('r-semantic-metric');
24223        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24224        var semExpand=document.getElementById('r-semantic-expand');
24225        if(semExpand){
24226          semExpand.addEventListener('click',function(){
24227            var key=semSel?semSel.value:'functions';
24228            var n=SEM_D.length||1;
24229            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24230            var modalH=Math.min(Math.max(360,n*38+60),maxH);
24231            var overlay=document.createElement('div');
24232            overlay.className='r-chart-modal-overlay';
24233            var optHtml=
24234              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24235              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24236              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24237              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24238              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24239            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics \u2014 Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" class="r-expand-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
24240            document.body.appendChild(overlay);
24241            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24242            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24243            var modalEl=document.getElementById('r-sem-modal-chart');
24244            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24245            var modalSel=document.getElementById('r-sem-modal-metric');
24246            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24247          });
24248        }
24249
24250        // ── Expand buttons: re-render charts at large size inside modal ──────────
24251        (function(){
24252          function makeExpandModal(title,mH,subtitle,ctrlHtml){
24253            var overlay=document.createElement('div');
24254            overlay.className='r-chart-modal-overlay';
24255            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24256            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24257            overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">&times;</button>'+hdr+subHtml+'<div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
24258            document.body.appendChild(overlay);
24259            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24260            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24261            return overlay.querySelector('.r-expand-modal-chart');
24262          }
24263          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24264          var compExpandBtn=document.getElementById('r-composition-expand');
24265          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24266            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24267            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24268            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24269              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24270            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24271            if(wrap){
24272              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24273              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24274                btn.addEventListener('click',function(){
24275                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24276                  btn.classList.add('active');
24277                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24278                });
24279              });
24280            }
24281          });}
24282          var scatExpandBtn=document.getElementById('r-scatter-expand');
24283          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24284            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24285            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24286          });}
24287          var densExpandBtn=document.getElementById('r-density-expand');
24288          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24289            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24290            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24291            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24292          });}
24293          var avgExpandBtn=document.getElementById('r-avglines-expand');
24294          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24295            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24296            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24297            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24298          });}
24299          var subExpandBtn=document.getElementById('r-submodule-expand');
24300          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24301            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24302            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24303            var metCtrl=
24304              '<select class="r-chart-select" id="r-sub-modal-metric">'
24305              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24306              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24307              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24308              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24309              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24310              +'</select>';
24311            var sortCtrl=
24312              '<select class="r-chart-select" id="r-sub-modal-sort">'
24313              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24314              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24315              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24316              +'</select>';
24317            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24318            if(wrap){
24319              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24320              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24321              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24322              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24323              if(mSub)mSub.addEventListener('change',reRenderSub);
24324              if(mSort)mSort.addEventListener('change',reRenderSub);
24325            }
24326          });}
24327        })();
24328
24329        // ── Comment Density: comments / (code + comments) per language ───────────
24330        function renderDensityInEl(el,shOvr){
24331          if(!el||!LANG_D||!LANG_D.length)return;
24332          var n=LANG_D.length||1;
24333          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24334          var svgW=Math.max(320,el.offsetWidth||480);
24335          var BW=Math.max(120,svgW-LW-80);
24336          var topPad=4,botPad=26;
24337          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24338          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24339          var densities=LANG_D.map(function(d){
24340            var sig=(d.code||0)+(d.comments||0);
24341            return sig>0?(d.comments||0)/sig:0;
24342          });
24343          var maxDen=Math.max.apply(null,densities)||1;
24344          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">';
24345          LANG_D.forEach(function(d,i){
24346            var den=densities[i],bw=den/maxDen*BW;
24347            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24348            var pct=Math.round(den*100);
24349            s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.lang)+'</text>';
24350            if(bw>0.5)s+='<rect'+tt(d.lang,pct+'% of significant lines are comments')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
24351            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24352            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+pct+'%</text>';
24353          });
24354          s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">comment ratio (higher = more documented)</text>';
24355          s+='</svg>';
24356          el.innerHTML=s;
24357        }
24358        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24359        renderDensity();
24360
24361        // ── Avg Lines per File: code / files per language ─────────────────────
24362        function renderAvgLinesInEl(el,shOvr){
24363          if(!el||!LANG_D||!LANG_D.length)return;
24364          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24365          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24366          var n=data.length||1;
24367          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24368          var svgW=Math.max(320,el.offsetWidth||480);
24369          var BW=Math.max(120,svgW-LW-80);
24370          var topPad=4,botPad=26;
24371          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24372          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24373          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24374          var maxAvg=Math.max.apply(null,avgs)||1;
24375          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">';
24376          data.forEach(function(d,i){
24377            var avg=avgs[i],bw=avg/maxAvg*BW;
24378            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24379            s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.lang)+'</text>';
24380            if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file \u00b7 '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
24381            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24382            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
24383          });
24384          s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">avg code lines per file (higher = larger files)</text>';
24385          s+='</svg>';
24386          el.innerHTML=s;
24387        }
24388        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24389        renderAvgLines();
24390
24391        // ── Repository Overview: overall row + per-submodule rows ────────────
24392        function renderSubmoduleInEl(el,key,sort,shOvr){
24393          if(!el)return;
24394          var overall={
24395            name:'Overall',
24396            code:{{ code_lines }},
24397            comment:{{ comment_lines }},
24398            blank:{{ blank_lines }},
24399            physical:{{ physical_lines }},
24400            files:{{ files_analyzed }},
24401            isOverall:true
24402          };
24403          var subs=SUB_D.slice();
24404          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24405          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24406          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24407          var data=[overall].concat(subs);
24408          var sepH=subs.length>0?14:0;
24409          var naturalH=data.length*32+sepH+16;
24410          var SH=shOvr||Math.max(100,naturalH);
24411          var svgW=Math.max(320,el.offsetWidth||480);
24412          var LW=116,BW=Math.max(200,svgW-LW-54);
24413          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24414          var OVERALL_COL='#6b7280';
24415          var topPad=4,botPad=8;
24416          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24417          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24418          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">';
24419          var yOff=topPad;
24420          data.forEach(function(d,i){
24421            var v=d[key]||0,bw=v/maxV*BW;
24422            var y=yOff+Math.floor((rowSlot-bH)/2);
24423            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24424            var label=d.name||d.path||'?';
24425            s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
24426            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24427            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24428            s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
24429            yOff+=rowSlot;
24430            if(d.isOverall&&subs.length>0){
24431              yOff+=sepH;
24432            }
24433          });
24434          s+='</svg>';
24435          el.innerHTML=s;
24436        }
24437        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24438        var subSel=document.getElementById('r-sub-metric');
24439        var sortSel=document.getElementById('r-sub-sort');
24440        renderSubmodule('code','desc');
24441        if(subSel){
24442          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24443          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24444        }
24445
24446        // Equalise heights within each chart row: if one chart in a grid row is taller
24447        // than its neighbour, re-render the shorter one at the taller height so bars fill
24448        // the available vertical space instead of leaving a gap.
24449        function syncRowHeights(){
24450          var avgEl=document.getElementById('r-avglines-chart');
24451          var subEl=document.getElementById('r-submodule-chart');
24452          if(avgEl&&subEl){
24453            var avgSvg=avgEl.querySelector('svg');
24454            var subSvg=subEl.querySelector('svg');
24455            if(avgSvg&&subSvg){
24456              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24457              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24458              var key=subSel?subSel.value||'code':'code';
24459              var sort=sortSel?sortSel.value:'desc';
24460              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24461              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24462            }
24463          }
24464          var semEl=document.getElementById('r-semantic-chart');
24465          var denEl=document.getElementById('r-density-chart');
24466          if(semEl&&denEl){
24467            var semSvg=semEl.querySelector('svg');
24468            var denSvg=denEl.querySelector('svg');
24469            if(semSvg&&denSvg){
24470              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24471              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24472              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24473              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24474            }
24475          }
24476        }
24477        syncRowHeights();
24478
24479        // Re-render all SVG charts when the window is resized so bars fill the card.
24480        var _rResizeTimer;
24481        window.addEventListener('resize',function(){
24482          clearTimeout(_rResizeTimer);
24483          _rResizeTimer=setTimeout(function(){
24484            var rcompBtn=document.querySelector('[data-rcomp].active');
24485            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24486            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24487            if(semSel)renderSemantic(semSel.value||'functions');
24488            renderDensity();
24489            renderAvgLines();
24490            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24491            syncRowHeights();
24492          },120);
24493        });
24494      })();
24495
24496      (function randomizeWatermarks() {
24497        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24498        if (!wms.length) return;
24499        var placed = [];
24500        function tooClose(top, left) {
24501          for (var i = 0; i < placed.length; i++) {
24502            var dt = Math.abs(placed[i][0] - top);
24503            var dl = Math.abs(placed[i][1] - left);
24504            if (dt < 20 && dl < 18) return true;
24505          }
24506          return false;
24507        }
24508        function pick(leftBand) {
24509          for (var attempt = 0; attempt < 50; attempt++) {
24510            var top = Math.random() * 85 + 5;
24511            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24512            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24513          }
24514          var top = Math.random() * 85 + 5;
24515          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24516          placed.push([top, left]);
24517          return [top, left];
24518        }
24519        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24520        var half = Math.floor(wms.length / 2);
24521        wms.forEach(function (img, i) {
24522          var pos = pick(i < half);
24523          var size = Math.floor(Math.random() * 100 + 160);
24524          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24525          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24526          img.style.width=size+"px";img.style.top=pos[0].toFixed(1)+"%";img.style.left=pos[1].toFixed(1)+"%";img.style.transform="rotate("+rot.toFixed(1)+"deg)";img.style.opacity=op;
24527        });
24528      })();
24529
24530      (function spawnCodeParticles() {
24531        var container = document.getElementById('code-particles');
24532        if (!container) return;
24533        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'];
24534        for (var i = 0; i < 38; i++) {
24535          (function(idx) {
24536            var el = document.createElement('span');
24537            el.className = 'code-particle';
24538            el.textContent = snippets[idx % snippets.length];
24539            var left = Math.random() * 94 + 2;
24540            var top = Math.random() * 88 + 6;
24541            var dur = (Math.random() * 10 + 9).toFixed(1);
24542            var delay = (Math.random() * 18).toFixed(1);
24543            var rot = (Math.random() * 26 - 13).toFixed(1);
24544            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24545            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
24546            container.appendChild(el);
24547          })(i);
24548        }
24549      })();
24550
24551      {% if pdf_generating %}
24552      // Poll for PDF readiness and swap the disabled button to a live link once done.
24553      (function() {
24554        var openBtn = document.getElementById('pdf-open-btn');
24555        var dlBtn = document.getElementById('pdf-download-btn');
24556        function checkPdf() {
24557          fetch('/api/runs/{{ run_id }}/pdf-status')
24558            .then(function(r) { return r.json(); })
24559            .then(function(d) {
24560              if (d.ready) {
24561                if (openBtn) {
24562                  var a = document.createElement('a');
24563                  a.className = 'button';
24564                  a.id = 'pdf-open-btn';
24565                  a.href = '/runs/pdf/{{ run_id }}';
24566                  a.target = '_blank';
24567                  a.rel = 'noopener';
24568                  a.textContent = 'Open PDF';
24569                  openBtn.replaceWith(a);
24570                }
24571                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24572              } else {
24573                setTimeout(checkPdf, 3000);
24574              }
24575            })
24576            .catch(function() { setTimeout(checkPdf, 5000); });
24577        }
24578        setTimeout(checkPdf, 3000);
24579      })();
24580      {% endif %}
24581
24582    })();
24583  </script>
24584  <script nonce="{{ csp_nonce }}">
24585  (function(){
24586    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'}];
24587    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);});}
24588    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
24589    function init(){
24590      var btn=document.getElementById('settings-btn');if(!btn)return;
24591      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
24592      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>';
24593      document.body.appendChild(m);
24594      var g=document.getElementById('scheme-grid');
24595      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);});
24596      var cl=document.getElementById('settings-close');
24597      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);
24598      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');});
24599      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
24600      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
24601    }
24602    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
24603  }());
24604  </script>
24605  <footer class="site-footer">
24606    local code analysis - metrics, history and reports
24607    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
24608    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
24609    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
24610    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
24611    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
24612  </footer>
24613  {% if confluence_configured %}
24614  <script nonce="{{ csp_nonce }}">
24615  (function() {
24616    var postBtn = document.getElementById('postConfluenceBtn');
24617    var copyBtn = document.getElementById('copyWikiBtn');
24618    var modal   = document.getElementById('confluenceModal');
24619    if (!postBtn || !modal) return;
24620
24621    postBtn.addEventListener('click', function() {
24622      document.getElementById('confStatus').style.display = 'none';
24623      modal.style.display = 'flex';
24624    });
24625    document.getElementById('confCancelBtn').addEventListener('click', function() {
24626      modal.style.display = 'none';
24627    });
24628    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24629
24630    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
24631      var btn = this;
24632      btn.disabled = true;
24633      var status = document.getElementById('confStatus');
24634      status.style.display = 'block';
24635      status.style.background = '#dbeafe';
24636      status.style.color = '#1e40af';
24637      status.textContent = 'Posting to Confluence\u2026';
24638      var resp = await fetch('/api/confluence/post', {
24639        method: 'POST',
24640        headers: { 'Content-Type': 'application/json' },
24641        body: JSON.stringify({
24642          run_id: '{{ run_id }}',
24643          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
24644          report_url: document.getElementById('confReportUrl').value.trim() || null
24645        })
24646      });
24647      var data = await resp.json();
24648      if (data.ok) {
24649        status.style.background = '#dcfce7'; status.style.color = '#166534';
24650        status.textContent = 'Posted! Page ID: ' + data.page_id;
24651      } else {
24652        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24653        status.textContent = 'Error: ' + (data.error || 'Unknown error');
24654      }
24655      btn.disabled = false;
24656    });
24657
24658    if (copyBtn) {
24659      copyBtn.addEventListener('click', async function() {
24660        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
24661        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
24662        var text = await resp.text();
24663        try {
24664          await navigator.clipboard.writeText(text);
24665          var orig = copyBtn.textContent;
24666          copyBtn.textContent = 'Copied!';
24667          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
24668        } catch(e) {
24669          alert('Clipboard write failed \u2014 check browser permissions.');
24670        }
24671      });
24672    }
24673  })();
24674  </script>
24675  {% endif %}
24676  <script nonce="{{ csp_nonce }}">
24677  (function() {
24678    var deleteBtn = document.getElementById('delete-run-btn');
24679    var modal     = document.getElementById('delete-run-modal');
24680    var cancelBtn = document.getElementById('delete-run-cancel');
24681    var confirmBtn= document.getElementById('delete-run-confirm');
24682    if (!deleteBtn || !modal) return;
24683    deleteBtn.addEventListener('click', function() {
24684      document.getElementById('delete-run-status').style.display = 'none';
24685      modal.style.display = 'flex';
24686    });
24687    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
24688    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
24689    confirmBtn.addEventListener('click', async function() {
24690      confirmBtn.disabled = true;
24691      cancelBtn.disabled = true;
24692      var status = document.getElementById('delete-run-status');
24693      status.style.display = 'block';
24694      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
24695      status.textContent = 'Deleting\u2026';
24696      try {
24697        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
24698        if (resp.status === 204 || resp.ok) {
24699          status.style.background = '#dcfce7'; status.style.color = '#166534';
24700          status.textContent = 'Deleted. Redirecting\u2026';
24701          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
24702        } else {
24703          var d = await resp.json().catch(function(){return {};});
24704          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24705          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
24706          confirmBtn.disabled = false;
24707          cancelBtn.disabled = false;
24708        }
24709      } catch (e) {
24710        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
24711        status.textContent = 'Network error: ' + String(e);
24712        confirmBtn.disabled = false;
24713        cancelBtn.disabled = false;
24714      }
24715    });
24716  })();
24717  </script>
24718  <script nonce="{{ csp_nonce }}">(function(){
24719    var bundleBtn = document.getElementById('download-bundle-btn');
24720    if (bundleBtn) {
24721      bundleBtn.addEventListener('click', function() {
24722        bundleBtn.disabled = true;
24723        var orig = bundleBtn.textContent;
24724        bundleBtn.textContent = 'Preparing\u2026';
24725        fetch('/api/runs/{{ run_id }}/bundle')
24726          .then(function(r) {
24727            if (!r.ok) throw new Error('HTTP ' + r.status);
24728            return r.blob();
24729          })
24730          .then(function(blob) {
24731            var url = URL.createObjectURL(blob);
24732            var a = document.createElement('a');
24733            a.href = url;
24734            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
24735            document.body.appendChild(a);
24736            a.click();
24737            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
24738            bundleBtn.disabled = false;
24739            bundleBtn.textContent = orig;
24740          })
24741          .catch(function(e) {
24742            bundleBtn.disabled = false;
24743            bundleBtn.textContent = orig;
24744            alert('Bundle download failed: ' + String(e));
24745          });
24746      });
24747    }
24748  })();</script>
24749  <script nonce="{{ csp_nonce }}">(function(){
24750    var dot=document.getElementById('status-dot');
24751    var pingEl=document.getElementById('server-ping-ms');
24752    var tipEl=document.getElementById('server-tip-ping');
24753    var fm=document.getElementById('footer-mode');
24754    function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
24755    function doPing(){
24756      var t0=performance.now();
24757      fetch('/healthz',{cache:'no-store'})
24758        .then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
24759        .catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
24760    }
24761    doPing();
24762    setInterval(doPing,5000);
24763    if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
24764  })();</script>
24765  <script nonce="{{ csp_nonce }}">(function(){var s=document.querySelector('.summary-strip-hero');if(!s)return;var pad=s.querySelector('.stat-chip-pad');var real=Array.prototype.slice.call(s.querySelectorAll('.stat-chip')).filter(function(el){return el!==pad;});if(!real.length)return;function upd(){var n=real.length;if(pad){if(n%2===1){pad.style.display='';n++;}else{pad.style.display='none';}}var perRow=window.innerWidth<=640?2:Math.ceil(n/2);s.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';}upd();window.addEventListener('resize',upd);})();</script>
24766  {% if let Some(banner) = report_header_footer %}
24767  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
24768  {% endif %}
24769</body>
24770</html>
24771"##,
24772    ext = "html"
24773)]
24774// Template structs need many bool fields to pass Askama rendering flags.
24775#[allow(clippy::struct_excessive_bools)]
24776struct ResultTemplate {
24777    version: &'static str,
24778    report_title: String,
24779    project_path: String,
24780    output_dir: String,
24781    run_id: String,
24782    files_analyzed: u64,
24783    files_skipped: u64,
24784    physical_lines: u64,
24785    code_lines: u64,
24786    comment_lines: u64,
24787    blank_lines: u64,
24788    mixed_lines: u64,
24789    functions: u64,
24790    classes: u64,
24791    variables: u64,
24792    imports: u64,
24793    html_url: Option<String>,
24794    pdf_url: Option<String>,
24795    json_url: Option<String>,
24796    html_download_url: Option<String>,
24797    pdf_download_url: Option<String>,
24798    json_download_url: Option<String>,
24799    html_path: Option<String>,
24800    json_path: Option<String>,
24801    prev_run_id: Option<String>,
24802    prev_run_timestamp: Option<String>,
24803    prev_run_code_lines: Option<u64>,
24804    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
24805    prev_fa_str: String,
24806    prev_fs_str: String,
24807    prev_pl_str: String,
24808    prev_cl_str: String,
24809    prev_cml_str: String,
24810    prev_bl_str: String,
24811    // Signed change column for main metrics
24812    delta_fa_str: String,
24813    delta_fa_class: String,
24814    delta_fs_str: String,
24815    delta_fs_class: String,
24816    delta_pl_str: String,
24817    delta_pl_class: String,
24818    delta_cl_str: String,
24819    delta_cl_class: String,
24820    delta_cml_str: String,
24821    delta_cml_class: String,
24822    delta_bl_str: String,
24823    delta_bl_class: String,
24824    // delta vs previous scan
24825    delta_lines_added: Option<i64>,
24826    delta_lines_removed: Option<i64>,
24827    delta_lines_net_str: String,
24828    delta_lines_net_class: String,
24829    delta_files_added: Option<usize>,
24830    delta_files_removed: Option<usize>,
24831    delta_files_modified: Option<usize>,
24832    delta_files_unchanged: Option<usize>,
24833    delta_unmodified_lines: Option<u64>,
24834    // git context
24835    git_branch: Option<String>,
24836    git_branch_url: Option<String>,
24837    git_commit: Option<String>,
24838    git_commit_long: Option<String>,
24839    git_author: Option<String>,
24840    git_commit_url: Option<String>,
24841    // scan metadata for hero section
24842    scan_performed_by: String,
24843    scan_time_display: String,
24844    os_display: String,
24845    test_count: u64,
24846    // reserve "pad" card, revealed by JS only when the visible card count is odd
24847    test_assertion_count: u64,
24848    // history
24849    prev_scan_count: usize,
24850    current_scan_number: usize,
24851    // submodule breakdown (empty when not requested)
24852    submodule_rows: Vec<SubmoduleRow>,
24853    scan_config_url: String,
24854    lang_chart_json: String,
24855    // Askama reads these via proc-macro expansion; clippy can't trace through it.
24856    #[allow(dead_code)]
24857    scatter_chart_json: String,
24858    #[allow(dead_code)]
24859    semantic_chart_json: String,
24860    #[allow(dead_code)]
24861    submodule_chart_json: String,
24862    #[allow(dead_code)]
24863    has_submodule_data: bool,
24864    #[allow(dead_code)]
24865    has_semantic_data: bool,
24866    pdf_generating: bool,
24867    csp_nonce: String,
24868    /// Whether Confluence integration is configured — shows Post button when true.
24869    confluence_configured: bool,
24870    server_mode: bool,
24871    /// Header/footer identification banner, mirrored from the HTML/PDF report.
24872    report_header_footer: Option<String>,
24873    run_id_short: String,
24874    /// True when rendering a static offline file (index.html); hides server-only actions.
24875    #[allow(dead_code)]
24876    is_offline: bool,
24877    /// Total cyclomatic complexity score across all analyzed files.
24878    cyclomatic_complexity: u64,
24879    /// Logical SLOC (statement count) when available; None for unsupported languages.
24880    lsloc: Option<u64>,
24881    /// Unique Lines of Code across all analyzed files.
24882    uloc: u64,
24883    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
24884    dryness_pct_str: String,
24885    /// Number of duplicate file groups detected.
24886    duplicate_group_count: usize,
24887    /// Whether a COCOMO estimate is available to display.
24888    has_cocomo: bool,
24889    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
24890    cocomo_effort_str: String,
24891    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
24892    cocomo_duration_str: String,
24893    /// Pre-formatted average team size, e.g. "2.32".
24894    cocomo_staff_str: String,
24895    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
24896    cocomo_ksloc_str: String,
24897    /// COCOMO mode label shown in the card (e.g. "Organic").
24898    cocomo_mode_label: String,
24899    /// Tooltip text explaining the selected COCOMO mode.
24900    cocomo_mode_tooltip: String,
24901    /// Per-file complexity alert threshold. 0 = off (no highlighting).
24902    complexity_alert: u32,
24903    /// Whether any file has coverage data attached.
24904    has_coverage_data: bool,
24905    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
24906    cov_line_pct: String,
24907    /// Overall function coverage percentage string — empty if no data.
24908    cov_fn_pct: String,
24909    /// Overall branch coverage percentage string — empty if no branch data.
24910    cov_branch_pct: String,
24911    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
24912    cov_lines_summary: String,
24913}
24914
24915#[derive(Template)]
24916#[template(
24917    source = r##"
24918<!doctype html>
24919<html lang="en">
24920<head>
24921  <meta charset="utf-8">
24922  <meta name="viewport" content="width=device-width, initial-scale=1">
24923  <title>OxideSLOC | Analyzing…</title>
24924  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
24925  <style nonce="{{ csp_nonce }}">
24926    :root {
24927      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
24928      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
24929      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
24930      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
24931    }
24932    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
24933    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
24934    .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);}
24935    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
24936    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
24937    .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));}
24938    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
24939    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
24940    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
24941    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
24942    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
24943    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
24944    .nav-pill,.theme-toggle{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;text-decoration:none;transition:background .15s ease,transform .15s ease;}
24945    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
24946    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
24947    .page-body{padding:32px 24px 36px;}
24948    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
24949    .wait-badge{display:inline-flex;align-items:center;gap:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.3);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:20px;}
24950    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
24951    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
24952    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
24953    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
24954    .path-block{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.85rem;color:var(--muted);word-break:break-all;margin-bottom:24px;}
24955    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
24956    .metric-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:140px;flex:1;text-align:center;}
24957    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
24958    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
24959    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
24960    .progress-bar{height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg,var(--accent-2),var(--oxide));animation:indeterminate 1.8s ease-in-out infinite;}
24961    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
24962    .hidden{display:none!important;}
24963    .warn-slow{background:rgba(230,160,50,0.12);border:1px solid rgba(230,160,50,0.3);border-radius:10px;padding:12px 16px;font-size:13px;color:#8a6a10;margin-bottom:20px;}
24964    .err-panel{background:rgba(180,40,40,0.08);border:1px solid rgba(180,40,40,0.25);border-radius:10px;padding:14px 18px;margin-bottom:20px;}
24965    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
24966    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
24967    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
24968    .btn-primary{display:inline-flex;align-items:center;gap:8px;padding:10px 22px;border-radius:999px;background:linear-gradient(135deg,var(--oxide),var(--nav-2));color:#fff;font-size:13px;font-weight:700;text-decoration:none;border:none;cursor:pointer;transition:transform .15s,box-shadow .15s;box-shadow:0 4px 12px rgba(185,93,51,0.3);}
24969    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
24970    .btn-outline{display:inline-flex;align-items:center;gap:8px;padding:10px 22px;border-radius:999px;background:transparent;color:var(--nav);border:2px solid var(--nav);font-size:13px;font-weight:700;text-decoration:none;cursor:pointer;transition:background .15s,transform .15s;}
24971    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
24972    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24973    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
24974    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
24975    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
24976    .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;}
24977    @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));}}
24978    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
24979    .site-footer a{color:var(--muted);}
24980    .theme-toggle{width:38px;height:38px;justify-content:center;padding:0;cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;display:inline-flex;align-items:center;}
24981    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
24982    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
24983    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
24984  </style>
24985</head>
24986<body>
24987  <div class="background-watermarks" aria-hidden="true">
24988    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24989    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24990    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24991    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24992    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24993    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
24994  </div>
24995  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
24996  <nav class="top-nav">
24997    <div class="top-nav-inner">
24998      <a href="/" class="brand">
24999        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25000        <div class="brand-copy">
25001          <h1 class="brand-title">OxideSLOC</h1>
25002          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25003        </div>
25004      </a>
25005      <div class="nav-right">
25006        <a class="nav-pill" href="/">Home</a>
25007        <div class="nav-dropdown">
25008          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
25009          <div class="nav-dropdown-menu">
25010            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
25011          </div>
25012        </div>
25013        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25014        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25015        <div class="nav-dropdown">
25016          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
25017          <div class="nav-dropdown-menu">
25018            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
25019          </div>
25020        </div>
25021        <div class="server-status-wrap" id="server-status-wrap">
25022          <div class="nav-pill server-online-pill" id="server-status-pill">
25023            <span class="status-dot" id="status-dot"></span>
25024            <span id="server-status-label">Server</span>
25025            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25026          </div>
25027          <div class="server-status-tip">
25028            OxideSLOC is running — accessible on your network.
25029            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25030          </div>
25031        </div>
25032        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25033          <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>
25034        </button>
25035        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25036          <svg class="icon-moon" viewBox="0 0 24 24"><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>
25037          <svg class="icon-sun" viewBox="0 0 24 24"><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>
25038        </button>
25039      </div>
25040    </div>
25041  </nav>
25042  <div class="page-body">
25043    <div class="wait-panel">
25044      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25045      <h2 class="wait-title">Analyzing your project…</h2>
25046      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25047      <div class="path-block">{{ project_path }}</div>
25048      <div class="metrics-row">
25049        <div class="metric-card">
25050          <div class="metric-label">Elapsed</div>
25051          <div class="metric-value" id="elapsed">0s</div>
25052        </div>
25053        <div class="metric-card">
25054          <div class="metric-label">Phase</div>
25055          <div class="metric-value" id="phase">Starting</div>
25056        </div>
25057        <div class="metric-card hidden" id="files-card">
25058          <div class="metric-label">Files</div>
25059          <div class="metric-value" id="files-progress">0</div>
25060        </div>
25061      </div>
25062      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25063      <div class="warn-slow hidden" id="warn-slow">
25064        This is taking longer than usual. Large repositories with many files can take several minutes. Hang tight — the analysis is still running in the background.
25065      </div>
25066      <div class="err-panel hidden" id="err-panel">
25067        <strong>Analysis failed</strong>
25068        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25069      </div>
25070      <div class="actions hidden" id="actions">
25071        <a href="/scan" class="btn-primary">Try Again</a>
25072        <a href="/view-reports" class="btn-outline">View Reports</a>
25073      </div>
25074    </div>
25075  </div>
25076  <script nonce="{{ csp_nonce }}">
25077    (function() {
25078      var WAIT_ID = {{ wait_id_json|safe }};
25079      var startTime = Date.now();
25080      var pollInterval = 1500;
25081      var retries = 0;
25082      var maxRetries = 5;
25083      var warnShown = false;
25084
25085      function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
25086
25087      function elapsed() {
25088        return Math.floor((Date.now() - startTime) / 1000);
25089      }
25090
25091      function updateElapsed() {
25092        var s = elapsed();
25093        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25094      }
25095
25096      function setPhase(txt) {
25097        document.getElementById('phase').textContent = txt;
25098      }
25099
25100      var elapsedTimer = setInterval(updateElapsed, 1000);
25101
25102      function poll() {
25103        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25104          .then(function(r) {
25105            if (!r.ok) throw new Error('HTTP ' + r.status);
25106            return r.json();
25107          })
25108          .then(function(data) {
25109            retries = 0;
25110            if (data.state === 'complete') {
25111              clearInterval(elapsedTimer);
25112              setPhase('Done');
25113              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25114            } else if (data.state === 'failed') {
25115              clearInterval(elapsedTimer);
25116              setPhase('Failed');
25117              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25118              document.getElementById('err-panel').classList.remove('hidden');
25119              document.getElementById('actions').classList.remove('hidden');
25120            } else {
25121              // still running
25122              var s = elapsed();
25123              if (s > 90 && !warnShown) {
25124                warnShown = true;
25125                document.getElementById('warn-slow').classList.remove('hidden');
25126              }
25127              setPhase(data.phase || 'Running');
25128              var fd = data.files_done || 0, ft = data.files_total || 0;
25129              if (ft > 0) {
25130                var card = document.getElementById('files-card');
25131                if (card) card.classList.remove('hidden');
25132                var fp = document.getElementById('files-progress');
25133                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25134              }
25135              setTimeout(poll, pollInterval);
25136            }
25137          })
25138          .catch(function(err) {
25139            retries++;
25140            if (retries >= maxRetries) {
25141              clearInterval(elapsedTimer);
25142              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25143              document.getElementById('err-panel').classList.remove('hidden');
25144              document.getElementById('actions').classList.remove('hidden');
25145            } else {
25146              // exponential back-off capped at 8s
25147              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25148            }
25149          });
25150      }
25151
25152      setTimeout(poll, pollInterval);
25153
25154      // If the browser restores this page from bfcache (Back after viewing results),
25155      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25156      window.addEventListener("pageshow", function(e) {
25157        if (e.persisted) { setTimeout(poll, 200); }
25158      });
25159    })();
25160  </script>
25161  <footer class="site-footer">
25162    local code analysis - metrics, history and reports
25163    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25164    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25165    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25166    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25167    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25168  </footer>
25169  <script nonce="{{ csp_nonce }}">
25170    (function(){
25171      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25172      if(s==="dark")b.classList.add("dark-theme");
25173      var tt=document.getElementById("theme-toggle");
25174      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25175    })();
25176    (function spawnCodeParticles(){
25177      var c=document.getElementById('code-particles');if(!c)return;
25178      var sn=['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()','sloc_core','render_html','2,163 code'];
25179      for(var i=0;i<32;i++){(function(idx){
25180        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25181        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25182        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25183        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25184        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25185        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25186        c.appendChild(el);
25187      })(i);}
25188    })();
25189    (function randomizeWatermarks(){
25190      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25191      var placed=[];
25192      function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
25193      function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
25194      var half=Math.floor(wms.length/2);
25195      wms.forEach(function(img,i){
25196        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25197        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25198        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25199        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25200        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25201        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25202      });
25203    })();
25204  </script>
25205  <script nonce="{{ csp_nonce }}">
25206  (function(){
25207    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'}];
25208    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);});}
25209    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25210    function init(){
25211      var btn=document.getElementById('settings-btn');if(!btn)return;
25212      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25213      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>';
25214      document.body.appendChild(m);
25215      var g=document.getElementById('scheme-grid');
25216      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);});
25217      var cl=document.getElementById('settings-close');
25218      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);
25219      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');});
25220      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25221      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25222    }
25223    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25224  }());
25225  </script>
25226  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
25227  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
25228  if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
25229</body>
25230</html>
25231"##,
25232    ext = "html"
25233)]
25234struct ScanWaitTemplate {
25235    version: &'static str,
25236    wait_id_json: String,
25237    project_path: String,
25238    csp_nonce: String,
25239}
25240
25241#[derive(Template)]
25242#[template(
25243    source = r##"
25244<!doctype html>
25245<html lang="en">
25246<head>
25247  <meta charset="utf-8">
25248  <meta name="viewport" content="width=device-width, initial-scale=1">
25249  <title>OxideSLOC | Error</title>
25250  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25251  <style nonce="{{ csp_nonce }}">
25252    :root {
25253      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25254      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25255      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25256      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25257    }
25258    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25259    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
25260    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25261    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25262    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25263    .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);}
25264    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25265    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
25266    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25267    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25268    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25269    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25270    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
25271    .nav-pill,.theme-toggle{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;text-decoration:none;transition:background .15s ease,transform .15s ease;}
25272    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25273    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25274    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25275    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25276    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25277    .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;}
25278    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25279    .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);}
25280    .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;}
25281    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25282    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25283    .settings-modal-body{padding:14px 16px 16px;}
25284    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25285    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25286    .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;}
25287    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25288    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25289    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25290    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25291    .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;}
25292    .tz-select:focus{border-color:var(--oxide);}
25293    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25294    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25295    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25296    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25297    .error-box{border-radius:16px;border:1px solid var(--line);background:var(--surface-2);padding:16px 18px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.55;font-size:13px;}
25298    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25299    .btn-primary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid rgba(111,144,255,0.30);text-decoration:none;color:white;background:linear-gradient(135deg,var(--accent),var(--accent-2));font-weight:800;font-size:14px;box-shadow:0 10px 22px rgba(73,106,255,0.22);}
25300    .btn-secondary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid var(--line-strong);text-decoration:none;color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}
25301    .btn-secondary:hover{background:var(--line);}
25302    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25303    .bug-report-trigger{display:inline-flex;align-items:center;gap:10px;padding:11px 22px;border-radius:14px;border:2px solid var(--oxide);background:transparent;color:var(--oxide);font-size:14px;font-weight:700;cursor:pointer;transition:background .18s ease,color .18s ease,box-shadow .18s ease;letter-spacing:.02em;}
25304    .bug-report-trigger:hover,.bug-report-trigger:focus-visible{background:var(--oxide);color:#fff;box-shadow:0 4px 20px rgba(174,92,32,.28);outline:none;}
25305    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25306    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25307    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25308    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25309    .bug-report-panel.open{display:flex;}
25310    .br-network-badge{display:none;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;font-size:11px;font-weight:700;width:fit-content;}
25311    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25312    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25313    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25314    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25315    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25316    .br-network-badge.online .br-net-dot{background:#2a6846;}
25317    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25318    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25319    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25320    .bug-report-pre{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.65;color:var(--text);white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow-y:auto;}
25321    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25322    .btn-sm{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;text-decoration:none;transition:background .15s ease;}
25323    .btn-sm:hover{background:var(--line);}
25324    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25325    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25326    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25327    .bug-report-hint a:hover{text-decoration:underline;}
25328    .site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
25329    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25330    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
25331    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
25332    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
25333    @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));}}
25334    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
25335  </style>
25336</head>
25337<body>
25338  <div class="background-watermarks" aria-hidden="true">
25339    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25340    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25341    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25342    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25343    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25344    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25345  </div>
25346  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25347  <div class="top-nav">
25348    <div class="top-nav-inner">
25349      <a class="brand" href="/">
25350        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25351        <div class="brand-copy">
25352          <div class="brand-title">OxideSLOC</div>
25353          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25354        </div>
25355      </a>
25356      <div class="nav-right">
25357        <a class="nav-pill" href="/">Home</a>
25358        <div class="nav-dropdown">
25359          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
25360          <div class="nav-dropdown-menu">
25361            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
25362          </div>
25363        </div>
25364        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25365        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25366        <div class="nav-dropdown">
25367          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
25368          <div class="nav-dropdown-menu">
25369            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
25370          </div>
25371        </div>
25372        <div class="server-status-wrap" id="server-status-wrap">
25373          <div class="nav-pill server-online-pill" id="server-status-pill">
25374            <span class="status-dot" id="status-dot"></span>
25375            <span id="server-status-label">Server</span>
25376            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25377          </div>
25378          <div class="server-status-tip">
25379            OxideSLOC is running — accessible on your network.
25380            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25381          </div>
25382        </div>
25383        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25384          <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>
25385        </button>
25386        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25387          <svg class="icon-moon" viewBox="0 0 24 24"><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>
25388          <svg class="icon-sun" viewBox="0 0 24 24"><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>
25389        </button>
25390      </div>
25391    </div>
25392  </div>
25393
25394  <div class="page">
25395    <div class="panel">
25396      <h1>Error</h1>
25397      <div class="error-box" id="error-msg-text">{{ message }}</div>
25398      <div id="br-meta" hidden
25399        data-version="{{ version }}"
25400        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25401        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25402      <div class="actions">
25403        <a class="btn-primary" href="/scan">Back to setup</a>
25404        {% if let Some(report_url) = last_report_url %}
25405        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25406        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25407        {% else %}
25408        <a class="btn-secondary" href="/view-reports">View Reports</a>
25409        {% endif %}
25410      </div>
25411      <div class="bug-report-section" id="bug-report-section">
25412        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25413          <svg class="br-icon" viewBox="0 0 24 24"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
25414          Generate Bug Report
25415          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25416        </button>
25417        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25418          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
25419          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
25420          <div class="bug-report-btns">
25421            <button type="button" class="btn-sm" id="bug-report-copy">
25422              <svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
25423              Copy to clipboard
25424            </button>
25425            <a class="btn-sm" id="bug-report-github-link" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer" style="display:none;">
25426              <svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/></svg>
25427              Open GitHub Issue
25428            </a>
25429            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25430              <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
25431              Save as file
25432            </button>
25433          </div>
25434          <p class="bug-report-hint" id="br-hint-online" style="display:none;">Paste the report into a new GitHub issue, or click <strong>Open GitHub Issue</strong> to open a pre-filled draft. Remove any file paths you prefer not to share before posting.</p>
25435          <p class="bug-report-hint" id="br-hint-offline" style="display:none;"><strong>Air-gapped system detected</strong> &mdash; GitHub is not reachable from this machine. Copy or save the report above, then open a <a href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">GitHub issue</a> from a connected machine and paste it there.</p>
25436        </div>
25437      </div>
25438    </div>
25439  </div>
25440  <footer class="site-footer">
25441    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
25442    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25443    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25444    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25445    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25446  </footer>
25447  <script nonce="{{ csp_nonce }}">(function(){
25448    var meta=document.getElementById('br-meta');
25449    var pre=document.getElementById('bug-report-pre');
25450    var copyBtn=document.getElementById('bug-report-copy');
25451    var trigger=document.getElementById('bug-report-trigger');
25452    var panel=document.getElementById('bug-report-panel');
25453    var networkBadge=document.getElementById('br-network-badge');
25454    var networkLabel=document.getElementById('br-network-label');
25455    var ghLink=document.getElementById('bug-report-github-link');
25456    var saveBtn=document.getElementById('bug-report-save');
25457    var hintOnline=document.getElementById('br-hint-online');
25458    var hintOffline=document.getElementById('br-hint-offline');
25459    if(!meta||!pre)return;
25460    var ver=meta.getAttribute('data-version')||'';
25461    var runId=meta.getAttribute('data-run-id')||'';
25462    var code=meta.getAttribute('data-error-code')||'';
25463    var msgEl=document.getElementById('error-msg-text');
25464    var msg=msgEl?msgEl.textContent.trim():'';
25465    function getBrowser(){
25466      var ua=navigator.userAgent;
25467      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25468      if(!m)return 'Unknown browser';
25469      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25470      return n+' '+m[2];
25471    }
25472    var lines=['oxide-sloc Bug Report','==============================',''];
25473    lines.push('App version:  v'+ver);
25474    if(code)lines.push('HTTP status:  '+code);
25475    if(runId)lines.push('Run ID:       '+runId);
25476    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
25477    lines.push('Timestamp:    '+new Date().toISOString());
25478    lines.push('Browser:      '+getBrowser());
25479    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
25480    lines.push('');
25481    lines.push('Error message:');
25482    lines.push(msg);
25483    lines.push('');
25484    lines.push('Steps to reproduce:');
25485    lines.push('  1. ');
25486    lines.push('');
25487    lines.push('Expected behavior:');
25488    lines.push('  ');
25489    pre.textContent=lines.join('\n');
25490    function applyNetwork(online){
25491      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25492      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25493      if(ghLink){
25494        if(online){
25495          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25496          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25497        }
25498        ghLink.style.display=online?'inline-flex':'none';
25499      }
25500      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25501      if(hintOnline)hintOnline.style.display=online?'block':'none';
25502      if(hintOffline)hintOffline.style.display=online?'none':'block';
25503    }
25504    applyNetwork(navigator.onLine);
25505    var probed=false;
25506    function probeNetwork(){
25507      if(probed)return;probed=true;
25508      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25509      var probeIdx=0;
25510      function tryNext(){
25511        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25512        var u=probeUrls[probeIdx++];
25513        var c2=new AbortController();
25514        var t2=setTimeout(function(){c2.abort();},4000);
25515        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25516          .then(function(){clearTimeout(t2);applyNetwork(true);})
25517          .catch(function(){clearTimeout(t2);tryNext();});
25518      }
25519      tryNext();
25520    }
25521    if(trigger&&panel){
25522      trigger.addEventListener('click',function(){
25523        var open=panel.classList.toggle('open');
25524        trigger.classList.toggle('open',open);
25525        trigger.setAttribute('aria-expanded',open?'true':'false');
25526        if(open)probeNetwork();
25527      });
25528    }
25529    if(copyBtn){
25530      copyBtn.addEventListener('click',function(){
25531        var txt=pre.textContent;
25532        if(navigator.clipboard&&navigator.clipboard.writeText){
25533          navigator.clipboard.writeText(txt).then(function(){
25534            copyBtn.textContent='\u2713 Copied!';
25535            setTimeout(function(){copyBtn.innerHTML='<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy to clipboard';},2000);
25536          });
25537        }else{
25538          var ta=document.createElement('textarea');
25539          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25540          document.body.appendChild(ta);ta.select();
25541          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25542          document.body.removeChild(ta);
25543        }
25544      });
25545    }
25546    if(saveBtn){
25547      saveBtn.addEventListener('click',function(){
25548        var txt=pre.textContent;
25549        var blob=new Blob([txt],{type:'text/plain'});
25550        var url=URL.createObjectURL(blob);
25551        var a=document.createElement('a');
25552        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25553        document.body.appendChild(a);a.click();
25554        document.body.removeChild(a);URL.revokeObjectURL(url);
25555      });
25556    }
25557  })();</script>
25558  <script nonce="{{ csp_nonce }}">
25559    (function(){var k="oxide-theme",b=document.body,s=localStorage.getItem(k);if(s==="dark")b.classList.add("dark-theme");document.getElementById("theme-toggle").addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});})();
25560    (function spawnCodeParticles() {
25561      var container = document.getElementById('code-particles');
25562      if (!container) return;
25563      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'];
25564      for (var i = 0; i < 38; i++) {
25565        (function(idx) {
25566          var el = document.createElement('span');
25567          el.className = 'code-particle';
25568          el.textContent = snippets[idx % snippets.length];
25569          var left = Math.random() * 94 + 2;
25570          var top = Math.random() * 88 + 6;
25571          var dur = (Math.random() * 10 + 9).toFixed(1);
25572          var delay = (Math.random() * 18).toFixed(1);
25573          var rot = (Math.random() * 26 - 13).toFixed(1);
25574          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25575          el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25576          container.appendChild(el);
25577        })(i);
25578      }
25579    })();
25580    (function randomizeWatermarks() {
25581      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25582      var placed = [];
25583      function tooClose(t, l) { for (var i = 0; i < placed.length; i++) { if (Math.abs(placed[i][0]-t)<16 && Math.abs(placed[i][1]-l)<12) return true; } return false; }
25584      function pick(leftBand) { for (var a = 0; a < 50; a++) { var t=Math.random()*88+2, l=leftBand?Math.random()*24+1:Math.random()*24+74; if (!tooClose(t,l)) { placed.push([t,l]); return [t,l]; } } var t=Math.random()*88+2, l=leftBand?Math.random()*24+1:Math.random()*24+74; placed.push([t,l]); return [t,l]; }
25585      var half = Math.floor(wms.length/2);
25586      wms.forEach(function(img, i) {
25587        var pos = pick(i < half);
25588        var w = Math.floor(Math.random()*60+80);
25589        var rot = (Math.random()*40-20).toFixed(1);
25590        var op = (Math.random()*0.08+0.05).toFixed(2);
25591        var animDur = (Math.random()*6+5).toFixed(1);
25592        var animDelay = (Math.random()*10).toFixed(1);
25593        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;img.style.animation='wmFade '+animDur+'s ease-in-out -'+animDelay+'s infinite alternate';
25594      });
25595    })();
25596  </script>
25597  <script nonce="{{ csp_nonce }}">
25598  (function(){
25599    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'}];
25600    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);});}
25601    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25602    function init(){
25603      var btn=document.getElementById('settings-btn');if(!btn)return;
25604      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25605      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>';
25606      document.body.appendChild(m);
25607      var g=document.getElementById('scheme-grid');
25608      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);});
25609      var cl=document.getElementById('settings-close');
25610      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);
25611      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');});
25612      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25613      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25614    }
25615    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25616  }());
25617  </script>
25618  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
25619  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
25620  if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
25621</body>
25622</html>
25623"##,
25624    ext = "html"
25625)]
25626struct ErrorTemplate {
25627    message: String,
25628    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
25629    last_report_url: Option<String>,
25630    /// Label for the secondary action button; defaults to "View last report" when None.
25631    last_report_label: Option<String>,
25632    /// Run ID to surface in the bug report; `None` when not applicable.
25633    run_id: Option<String>,
25634    /// HTTP status code to surface in the bug report; `None` when unknown.
25635    error_code: Option<u16>,
25636    csp_nonce: String,
25637    version: &'static str,
25638}
25639
25640// ── LocateFileTemplate ────────────────────────────────────────────────────────
25641
25642#[derive(Template)]
25643#[template(
25644    source = r##"
25645<!doctype html>
25646<html lang="en">
25647<head>
25648  <meta charset="utf-8">
25649  <meta name="viewport" content="width=device-width, initial-scale=1">
25650  <title>OxideSLOC | Locate Report</title>
25651  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25652  <style nonce="{{ csp_nonce }}">
25653    :root{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;--muted-2:#a08878;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--accent-2:#4a78ee;--oxide:#d37a4c;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}
25654    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
25655    *{box-sizing:border-box;}html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}body{display:flex;flex-direction:column;}
25656    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25657    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25658    .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);}
25659    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25660    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}.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));}
25661    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25662    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}.brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25663    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25664    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
25665    @media(max-width:1150px){.nav-right{gap:4px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 8px;font-size:11px;min-height:34px;}.brand-subtitle{display:none;}.server-online-pill{width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px;}}
25666    .nav-pill,.theme-toggle{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;text-decoration:none;transition:background .15s ease,transform .15s ease;}
25667    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25668    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25669    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25670    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25671    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
25672    .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;}
25673    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25674    .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);}
25675    .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;}
25676    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25677    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25678    .settings-modal-body{padding:14px 16px 16px;}
25679    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25680    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25681    .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;}
25682    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25683    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25684    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25685    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25686    .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;}
25687    .tz-select:focus{border-color:var(--oxide);}
25688    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25689    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25690    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25691    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
25692    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
25693    .filename-chip{display:inline-flex;align-items:center;gap:8px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:9px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;margin-bottom:22px;word-break:break-all;}
25694    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
25695    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
25696    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
25697    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
25698    .locate-row{display:flex;gap:8px;align-items:stretch;}
25699    .locate-input{flex:1;min-width:0;padding:10px 14px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
25700    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
25701    body.dark-theme .locate-input{background:var(--surface-2);}
25702    .warning-banner{display:none;align-items:center;gap:8px;background:#fff4e5;border:1px solid #f5a623;border-radius:8px;padding:10px 14px;font-size:12px;color:#7a4f00;margin-top:8px;line-height:1.4;}
25703    .warning-banner.show{display:flex;}
25704    .warning-banner svg{flex:0 0 auto;}
25705    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
25706    .error-inline{display:none;align-items:flex-start;gap:10px;background:#fde8e8;border:1px solid #e07070;border-radius:10px;padding:12px 16px;font-size:13px;color:#7a1e1e;margin-top:12px;line-height:1.55;}
25707    .error-inline.show{display:flex;}
25708    .error-inline svg{flex:0 0 auto;margin-top:2px;}
25709    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
25710    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
25711    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
25712    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
25713    .err-kv-p{margin:0 0 4px;}
25714    .success-inline{display:none;align-items:center;gap:10px;background:#e8faf0;border:1px solid #4caf80;border-radius:10px;padding:12px 16px;font-size:13px;color:#1a6b3c;margin-top:12px;}
25715    .success-inline.show{display:flex;}
25716    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
25717    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
25718    .folder-hint-hdr{padding:11px 16px;background:linear-gradient(180deg,var(--surface-2),rgba(255,255,255,0.35));border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.07em;}
25719    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
25720    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
25721    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
25722    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
25723    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
25724    .fh-row:last-child{border-bottom:none;}
25725    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
25726    .fh-dir{font-weight:800;color:var(--text);}
25727    .fh-hl{color:var(--oxide);font-weight:700;}
25728    .fh-muted{color:var(--muted);}
25729    .fh-badge{margin-left:auto;font-size:11px;font-weight:700;color:var(--oxide);background:rgba(184,93,51,0.10);border:1px solid rgba(184,93,51,0.25);border-radius:6px;padding:2px 8px;white-space:nowrap;}
25730    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
25731    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
25732    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
25733    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
25734    .btn-primary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 22px;border-radius:14px;border:none;color:white;background:linear-gradient(135deg,var(--accent),var(--accent-2));font-weight:800;font-size:14px;box-shadow:0 10px 22px rgba(73,106,255,0.22);cursor:pointer;}
25735    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
25736    .btn-secondary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid var(--line-strong);text-decoration:none;color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;cursor:pointer;}
25737    .btn-secondary:hover{background:var(--line);}
25738    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
25739    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
25740    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
25741    @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));}}
25742    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
25743    .site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
25744    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25745  </style>
25746</head>
25747<body>
25748  <div class="background-watermarks" aria-hidden="true">
25749    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25750    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25751    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25752    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25753    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25754    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25755  </div>
25756  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25757  <div class="top-nav">
25758    <div class="top-nav-inner">
25759      <a class="brand" href="/">
25760        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25761        <div class="brand-copy">
25762          <div class="brand-title">OxideSLOC</div>
25763          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25764        </div>
25765      </a>
25766      <div class="nav-right">
25767        <a class="nav-pill" href="/">Home</a>
25768        <div class="nav-dropdown">
25769          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
25770          <div class="nav-dropdown-menu">
25771            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
25772          </div>
25773        </div>
25774        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25775        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25776        <div class="nav-dropdown">
25777          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
25778          <div class="nav-dropdown-menu">
25779            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
25780          </div>
25781        </div>
25782        <div class="server-status-wrap" id="server-status-wrap">
25783          <div class="nav-pill server-online-pill" id="server-status-pill">
25784            <span class="status-dot" id="status-dot"></span>
25785            <span id="server-status-label">Server</span>
25786            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25787          </div>
25788          <div class="server-status-tip">
25789            OxideSLOC is running &mdash; accessible on your network.
25790            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25791          </div>
25792        </div>
25793        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25794          <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>
25795        </button>
25796        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25797          <svg class="icon-moon" viewBox="0 0 24 24"><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>
25798          <svg class="icon-sun" viewBox="0 0 24 24"><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>
25799        </button>
25800      </div>
25801    </div>
25802  </div>
25803
25804  <div class="page">
25805    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
25806    <div class="panel">
25807      <h1>Report File Not Found</h1>
25808      <p class="panel-subtitle">The report file could not be found &mdash; the output folder may have been moved or renamed. Select the <strong>top-level scan output folder</strong> to restore it.</p>
25809      <div class="field-label">Missing file</div>
25810      <div class="filename-chip">
25811        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
25812        {{ expected_filename }}
25813      </div>
25814      <div class="locate-section">
25815        <h2>Locate Scan Output Folder</h2>
25816        <p>Select the <strong>top-level scan output folder</strong> (the one named like <code>project_20260601-…</code> that contains the <code>html/</code>, <code>json/</code>, and <code>pdf/</code> subfolders).</p>
25817        <p>OxideSLOC will find the correct files inside automatically.</p>
25818        <div class="locate-row">
25819          <input type="text" id="locate-file-input"
25820                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
25821                 class="locate-input" autocomplete="off" spellcheck="false">
25822          {% if !server_mode %}
25823          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
25824          {% endif %}
25825        </div>
25826        <div class="warning-banner" id="filename-warning">
25827          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
25828          <span>Tip: select the <strong>folder</strong>, not an individual file. If you must pick a file directly, its name must match <strong>{{ expected_filename }}</strong>.</span>
25829        </div>
25830        <div class="error-inline" id="locate-error">
25831          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;margin-top:2px;"><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>
25832          <span id="locate-error-text"></span>
25833        </div>
25834        <div class="success-inline" id="locate-success">
25835          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;"><polyline points="20 6 9 17 4 12"/></svg>
25836          <span>Scan restored &mdash; loading report&hellip;</span>
25837        </div>
25838        <div class="btn-row">
25839          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
25840          <a class="btn-secondary" href="/view-reports">View Reports</a>
25841        </div>
25842        <div class="folder-hint-shell">
25843          <div class="folder-hint-hdr">
25844            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
25845            Expected Folder Structure &mdash; Select the Top-Level Folder
25846          </div>
25847          <div class="folder-hint-body">
25848            <div class="fh-row">
25849              <span class="fh-tog">&#9658;</span>
25850              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
25851              <span class="fh-badge">&larr; select this</span>
25852            </div>
25853            <div class="fh-row fh-i1">
25854              <span class="fh-tog">&#9658;</span>
25855              <span class="fh-dir">html/</span>
25856            </div>
25857            <div class="fh-row fh-i2">
25858              <span class="fh-bul">&#8226;</span>
25859              <span class="fh-hl">{{ expected_filename }}</span>
25860            </div>
25861            <div class="fh-row fh-i1">
25862              <span class="fh-tog">&#9658;</span>
25863              <span class="fh-dir">json/</span>
25864            </div>
25865            <div class="fh-row fh-i2">
25866              <span class="fh-bul">&#8226;</span>
25867              <span class="fh-muted">result_*.json</span>
25868            </div>
25869            <div class="fh-row fh-i1">
25870              <span class="fh-tog">&#9658;</span>
25871              <span class="fh-dir">pdf/</span>
25872            </div>
25873            <div class="fh-row fh-i2">
25874              <span class="fh-bul">&#8226;</span>
25875              <span class="fh-muted">report_*.pdf</span>
25876            </div>
25877            <div class="fh-row fh-i1">
25878              <span class="fh-tog">&#9658;</span>
25879              <span class="fh-dir">excel/</span>
25880            </div>
25881            <div class="fh-row fh-i2">
25882              <span class="fh-bul">&#8226;</span>
25883              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
25884            </div>
25885          </div>
25886        </div>
25887      </div>
25888    </div>
25889  </div>
25890  <footer class="site-footer">
25891    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
25892    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25893    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25894    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25895    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25896  </footer>
25897  <script nonce="{{ csp_nonce }}">(function(){
25898    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25899    if(s==="dark")b.classList.add("dark-theme");
25900    document.getElementById("theme-toggle").addEventListener("click",function(){
25901      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
25902    });
25903  })();</script>
25904  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
25905    var c=document.getElementById('code-particles');if(!c)return;
25906    var snips=['report moved','fn analyze()','locate file','.html report','restore path','folder path','result.json','run_id','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];
25907    for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}
25908  })();
25909  (function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));if(!wms.length)return;var placed=[];function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}var half=Math.floor(wms.length/2);wms.forEach(function(img,i){var pos=pick(i<half),w=Math.floor(Math.random()*100+120),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.08+0.12).toFixed(2);img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});})();</script>
25910  <script nonce="{{ csp_nonce }}">(function(){
25911    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'}];
25912    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);});}
25913    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25914    function init(){var btn=document.getElementById('settings-btn');if(!btn)return;var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';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>';document.body.appendChild(m);var g=document.getElementById('scheme-grid');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);});var cl=document.getElementById('settings-close');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);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');});if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});}
25915    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25916  }());</script>
25917  <script nonce="{{ csp_nonce }}">(function(){
25918    var meta=document.getElementById('locate-meta');
25919    var inp=document.getElementById('locate-file-input');
25920    var browseBtn=document.getElementById('browse-locate-btn');
25921    var submitBtn=document.getElementById('locate-submit-btn');
25922    var warning=document.getElementById('filename-warning');
25923    var errBox=document.getElementById('locate-error');
25924    var errText=document.getElementById('locate-error-text');
25925    var okBox=document.getElementById('locate-success');
25926    var expected=meta?meta.getAttribute('data-expected'):'';
25927    var runId=meta?meta.getAttribute('data-run-id'):'';
25928    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
25929    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
25930    function showErr(msg){
25931      if(errText){
25932        errText.innerHTML='';
25933        var lines=msg.split('\n');
25934        var hasPairs=lines.some(function(l){return / : /.test(l);});
25935        if(!hasPairs){errText.textContent=msg;}
25936        else{
25937          var frag=document.createDocumentFragment();var tbl=null;
25938          lines.forEach(function(line){
25939            var m=line.match(/^(.*?) : (.*)$/);
25940            if(m){
25941              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
25942              var tr=document.createElement('tr');
25943              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
25944              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
25945              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
25946            } else {
25947              tbl=null;
25948              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
25949            }
25950          });
25951          errText.appendChild(frag);
25952        }
25953      }
25954      if(errBox)errBox.classList.add('show');
25955      if(okBox)okBox.classList.remove('show');
25956    }
25957    function clearErr(){
25958      if(errBox)errBox.classList.remove('show');
25959      if(okBox)okBox.classList.remove('show');
25960    }
25961    function validate(){
25962      var val=inp?inp.value.trim():'';
25963      clearErr();
25964      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
25965      if(submitBtn)submitBtn.disabled=false;
25966      if(warning){
25967        var name=basename(val);
25968        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
25969        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
25970        else warning.classList.remove('show');
25971      }
25972    }
25973    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
25974    if(browseBtn){
25975      browseBtn.addEventListener('click',function(){
25976        browseBtn.disabled=true;browseBtn.textContent='...';
25977        fetch('/pick-directory')
25978          .then(function(r){return r.ok?r.json():{cancelled:true};})
25979          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
25980          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
25981      });
25982    }
25983    if(submitBtn){
25984      submitBtn.addEventListener('click',function(){
25985        var folder=inp?inp.value.trim():'';
25986        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
25987        clearErr();
25988        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
25989        var body=new URLSearchParams();
25990        body.set('file_path',folder);
25991        body.set('redirect_url',redirectUrl);
25992        body.set('expected_run_id',runId);
25993        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
25994          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
25995          .then(function(d){
25996            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
25997            if(d&&d.ok){
25998              if(okBox)okBox.classList.add('show');
25999              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26000            } else {
26001              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26002            }
26003          })
26004          .catch(function(e){
26005            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26006            showErr('Network error: '+String(e));
26007          });
26008      });
26009    }
26010  })();</script>
26011  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
26012</body>
26013</html>
26014"##,
26015    ext = "html"
26016)]
26017struct LocateFileTemplate {
26018    run_id: String,
26019    artifact_type: String,
26020    expected_filename: String,
26021    server_mode: bool,
26022    csp_nonce: String,
26023    version: &'static str,
26024}
26025
26026// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26027
26028#[derive(Template)]
26029#[template(
26030    source = r##"
26031<!doctype html>
26032<html lang="en">
26033<head>
26034  <meta charset="utf-8">
26035  <meta name="viewport" content="width=device-width, initial-scale=1">
26036  <title>OxideSLOC | Locate Scan Files</title>
26037  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26038  <style nonce="{{ csp_nonce }}">
26039    :root {
26040      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26041      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26042      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26043      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26044    }
26045    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26046    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
26047    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26048    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26049    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26050    .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);}
26051    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26052    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
26053    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26054    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26055    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26056    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26057    @media (max-width:1150px){.nav-right{gap:4px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 8px;font-size:11px;min-height:34px;}.brand-subtitle{display:none;}.server-online-pill{width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px;}}
26058    .nav-pill,.theme-toggle{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;text-decoration:none;transition:background .15s ease,transform .15s ease;}
26059    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26060    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26061    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26062    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26063    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26064    .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;}
26065    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26066    .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);}
26067    .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;}
26068    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26069    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26070    .settings-modal-body{padding:14px 16px 16px;}
26071    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26072    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26073    .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;}
26074    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26075    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26076    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26077    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26078    .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;}
26079    .tz-select:focus{border-color:var(--oxide);}
26080    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26081    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26082    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26083    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26084    .error-box{border-radius:16px;border:1px solid var(--line);background:var(--surface-2);padding:16px 18px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.55;font-size:12.5px;margin-bottom:22px;}
26085    .error-box.hidden{display:none;}
26086    .success-box{border-radius:16px;border:1px solid #a3d9b5;background:#eafaf0;padding:16px 18px;font-size:13px;font-weight:600;color:#1a6b3c;margin-bottom:22px;display:none;}
26087    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26088    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26089    .btn-primary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid rgba(111,144,255,0.30);text-decoration:none;color:white;background:linear-gradient(135deg,var(--accent),var(--accent-2));font-weight:800;font-size:14px;box-shadow:0 10px 22px rgba(73,106,255,0.22);cursor:pointer;}
26090    .site-footer{margin-top:auto;padding:18px 24px;text-align:center;font-size:12px;color:var(--muted);border-top:1px solid var(--line);background:transparent;}
26091    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26092    .btn-secondary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 18px;border-radius:14px;border:1px solid var(--line-strong);text-decoration:none;color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;cursor:pointer;}
26093    .btn-secondary:hover{background:var(--line);}
26094    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
26095    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
26096    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
26097    @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));}}
26098    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
26099    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26100    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26101    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26102    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26103    .relocate-input{flex:1;min-width:0;padding:10px 14px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
26104    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26105    body.dark-theme .relocate-input{background:var(--surface-2);}
26106  </style>
26107</head>
26108<body>
26109  <div class="background-watermarks" aria-hidden="true">
26110    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26111    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26112    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26113    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26114    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26115    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26116  </div>
26117  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26118  <div class="top-nav">
26119    <div class="top-nav-inner">
26120      <a class="brand" href="/">
26121        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26122        <div class="brand-copy">
26123          <div class="brand-title">OxideSLOC</div>
26124          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26125        </div>
26126      </a>
26127      <div class="nav-right">
26128        <a class="nav-pill" href="/">Home</a>
26129        <div class="nav-dropdown">
26130          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
26131          <div class="nav-dropdown-menu">
26132            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
26133          </div>
26134        </div>
26135        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26136        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26137        <div class="nav-dropdown">
26138          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
26139          <div class="nav-dropdown-menu">
26140            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
26141          </div>
26142        </div>
26143        <div class="server-status-wrap" id="server-status-wrap">
26144          <div class="nav-pill server-online-pill" id="server-status-pill">
26145            <span class="status-dot" id="status-dot"></span>
26146            <span id="server-status-label">Server</span>
26147            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26148          </div>
26149          <div class="server-status-tip">
26150            OxideSLOC is running — accessible on your network.
26151            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26152          </div>
26153        </div>
26154        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26155          <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>
26156        </button>
26157        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26158          <svg class="icon-moon" viewBox="0 0 24 24"><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>
26159          <svg class="icon-sun" viewBox="0 0 24 24"><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>
26160        </button>
26161      </div>
26162    </div>
26163  </div>
26164
26165  <div class="page">
26166    <div class="panel">
26167      <h1>Scan Files Moved</h1>
26168      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26169      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26170      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26171      <div class="relocate-section">
26172        <h2>Locate Scan Output</h2>
26173        <p>Select the <strong>top-level</strong> scan output folder (the one named <code>project_YYYYMMDD-HHMM-&hellip;</code>). Result files will be found inside it automatically &mdash; do not navigate into a subfolder.</p>
26174        <div class="relocate-row">
26175          <input type="text" id="relocate-folder" name="folder_path"
26176                 value="{{ folder_hint }}"
26177                 placeholder="Path to folder containing scan output..."
26178                 class="relocate-input" autocomplete="off" spellcheck="false">
26179          {% if !server_mode %}
26180          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
26181          {% endif %}
26182        </div>
26183        <div style="margin-top:12px;">
26184          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26185        </div>
26186      </div>
26187      <div class="actions">
26188        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26189        <a class="btn-secondary" href="/view-reports">View Reports</a>
26190      </div>
26191    </div>
26192  </div>
26193  <footer class="site-footer">
26194    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
26195    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26196    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26197    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26198    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26199  </footer>
26200  <script nonce="{{ csp_nonce }}">
26201    (function(){var k="oxide-theme",b=document.body,s=localStorage.getItem(k);if(s==="dark")b.classList.add("dark-theme");document.getElementById("theme-toggle").addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});})();
26202    (function spawnCodeParticles(){var c=document.getElementById('code-particles');if(!c)return;var snips=['scan moved','fn analyze()','result.json','.html .pdf','locate files','restore scan','folder path','result*.json','run_id','compare','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}})();
26203    (function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));if(!wms.length)return;var placed=[];function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}var half=Math.floor(wms.length/2);wms.forEach(function(img,i){var pos=pick(i<half),w=Math.floor(Math.random()*100+120),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.08+0.12).toFixed(2);img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});})();
26204  </script>
26205  <script nonce="{{ csp_nonce }}">
26206  (function(){
26207    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'}];
26208    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);});}
26209    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26210    function init(){
26211      var btn=document.getElementById('settings-btn');if(!btn)return;
26212      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26213      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>';
26214      document.body.appendChild(m);
26215      var g=document.getElementById('scheme-grid');
26216      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);});
26217      var cl=document.getElementById('settings-close');
26218      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);
26219      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');});
26220      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26221      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26222    }
26223    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26224  }());
26225  (function(){
26226    var browseBtn=document.getElementById('browse-relocate-btn');
26227    if(browseBtn){
26228      browseBtn.addEventListener('click',function(){
26229        browseBtn.disabled=true;browseBtn.textContent='...';
26230        var inp=document.getElementById('relocate-folder');
26231        var hint=inp?inp.value:'';
26232        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
26233          .then(function(r){return r.ok?r.json():{cancelled:true};})
26234          .then(function(d){
26235            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26236            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26237          })
26238          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26239      });
26240    }
26241    var restoreBtn=document.getElementById('restore-btn');
26242    var errBox=document.getElementById('relocate-error-box');
26243    var okBox=document.getElementById('relocate-success-box');
26244    if(restoreBtn){
26245      restoreBtn.addEventListener('click',function(){
26246        var inp=document.getElementById('relocate-folder');
26247        var folder=inp?inp.value.trim():'';
26248        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26249        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26250        var body=new URLSearchParams();
26251        body.set('run_id','{{ run_id }}');
26252        body.set('redirect_url','{{ redirect_url }}');
26253        body.set('folder_path',folder);
26254        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26255          .then(function(r){return r.json();})
26256          .then(function(d){
26257            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26258            if(d&&d.ok){
26259              if(errBox)errBox.classList.add('hidden');
26260              if(okBox){okBox.style.display='block';}
26261              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26262            } else {
26263              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26264            }
26265          })
26266          .catch(function(e){
26267            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26268            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26269          });
26270      });
26271    }
26272  }());
26273  </script>
26274  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
26275  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
26276  if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
26277</body>
26278</html>
26279"##,
26280    ext = "html"
26281)]
26282struct RelocateScanTemplate {
26283    message: String,
26284    run_id: String,
26285    folder_hint: String,
26286    redirect_url: String,
26287    server_mode: bool,
26288    csp_nonce: String,
26289    version: &'static str,
26290}
26291
26292// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
26293
26294#[derive(Template)]
26295#[template(
26296    source = r##"
26297<!doctype html>
26298<html lang="en">
26299<head>
26300  <meta charset="utf-8">
26301  <meta name="viewport" content="width=device-width, initial-scale=1">
26302  <title>OxideSLOC | View Reports</title>
26303  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26304  <style nonce="{{ csp_nonce }}">
26305    :root {
26306      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26307      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26308      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26309      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26310      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26311    }
26312    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e; }
26313    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
26314    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26315    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26316    .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);}
26317    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26318    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
26319    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26320    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26321    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26322    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26323    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
26324    .nav-pill,.theme-toggle{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;text-decoration:none;transition:background .15s ease,transform .15s ease;}
26325    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26326    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26327    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26328    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26329    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26330    .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;}
26331    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26332    .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);}
26333    .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;}
26334    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26335    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26336    .settings-modal-body{padding:14px 16px 16px;}
26337    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26338    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26339    .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;}
26340    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26341    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26342    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26343    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26344    .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;}
26345    .tz-select:focus{border-color:var(--oxide);}
26346    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26347    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26348    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26349    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26350    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26351    .panel-meta{font-size:13px;color:var(--muted);}
26352    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26353    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26354    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26355    .per-page-label{font-size:13px;color:var(--muted);}
26356    select.per-page,.filter-input,.filter-select{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
26357    .filter-input{min-width:180px;cursor:text;}
26358    .table-wrap{width:100%;overflow-x:auto;}
26359    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26360    th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
26361    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26362    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26363    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26364    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26365    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26366    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26367    tr:last-child td{border-bottom:none;}
26368    tr:hover td{background:var(--surface-2);}
26369    .run-id-chip{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}
26370    .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);}
26371    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26372    .metric-num{font-weight:700;color:var(--text);}
26373    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26374    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26375    .git-commit-chip{cursor:help;}
26376    .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
26377    .btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
26378    .btn:hover{background:var(--line);}
26379    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26380    .btn.primary:hover{opacity:.9;}
26381    .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;}
26382    .btn-back:hover{background:var(--line);}
26383    .export-btn{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s ease;}
26384    .export-btn:hover{background:var(--line);}
26385    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26386    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26387    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26388    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26389    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26390    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26391    .pagination-info{font-size:13px;color:var(--muted);}
26392    .pagination-btns{display:flex;gap:6px;}
26393    .pg-btn{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}
26394    .pg-btn:hover:not(:disabled){background:var(--line);}
26395    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26396    .pg-btn:disabled{opacity:.35;cursor:default;}
26397    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26398    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26399    .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);}
26400    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26401    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26402    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26403    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;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 14px rgba(0,0,0,0.2);}
26404    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26405    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26406    .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;}
26407    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26408    .site-footer a{color:var(--muted);}
26409    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26410    .locate-bar{display:inline-flex;align-items:center;gap:10px;margin-bottom:14px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex-wrap:wrap;max-width:100%;}
26411    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26412    .toast-success{display:flex;align-items:center;gap:10px;background:#e8f5ed;border:1px solid #a3d9b1;border-radius:10px;padding:10px 16px;margin-bottom:14px;font-size:13px;color:#1a5c35;font-weight:600;}
26413    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26414    .toast-error{display:flex;align-items:center;gap:10px;background:#fde8e8;border:1px solid #f5a3a3;border-radius:10px;padding:10px 16px;margin-bottom:14px;font-size:13px;color:#7a1a1a;font-weight:600;}
26415    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26416    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
26417    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
26418    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
26419    @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));}}
26420    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
26421    .watched-bar{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}
26422    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26423    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26424    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26425    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26426    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26427    .watched-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}
26428    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26429    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26430    .watched-chip-rm:hover{color:var(--oxide);}
26431    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26432    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26433    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26434    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26435    .rpt-btn{min-width:58px;justify-content:center;}
26436    .flex-row{display:flex;align-items:center;gap:8px;}
26437    .report-cell{overflow:visible;white-space:normal;}
26438    #history-table col:nth-child(1){width:185px;}
26439    #history-table col:nth-child(2){width:220px;}
26440    #history-table col:nth-child(3){width:100px;}
26441    #history-table col:nth-child(4){width:72px;}
26442    #history-table col:nth-child(5){width:82px;}
26443    #history-table col:nth-child(6){width:82px;}
26444    #history-table col:nth-child(7){width:65px;}
26445    #history-table col:nth-child(8){width:90px;}
26446    #history-table col:nth-child(9){width:85px;}
26447    #history-table col:nth-child(10){width:115px;}
26448    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26449    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26450    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26451    .submod-details summary::-webkit-details-marker{display:none;}
26452.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26453    .submod-view-btn{display:inline-flex;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.22);color:var(--accent-2);text-decoration:none;white-space:nowrap;}
26454    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26455    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26456  </style>
26457</head>
26458<body>
26459  <div class="background-watermarks" aria-hidden="true">
26460    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26461    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26462    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26463    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26464    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26465    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26466  </div>
26467  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26468  <div class="top-nav">
26469    <div class="top-nav-inner">
26470      <a class="brand" href="/">
26471        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26472        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26473      </a>
26474      <div class="nav-right">
26475        <a class="nav-pill" href="/">Home</a>
26476        <div class="nav-dropdown">
26477          <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
26478          <div class="nav-dropdown-menu">
26479            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
26480          </div>
26481        </div>
26482        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26483        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26484        <div class="nav-dropdown">
26485          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
26486          <div class="nav-dropdown-menu">
26487            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
26488          </div>
26489        </div>
26490        <div class="server-status-wrap" id="server-status-wrap">
26491          <div class="nav-pill server-online-pill" id="server-status-pill">
26492            <span class="status-dot" id="status-dot"></span>
26493            <span id="server-status-label">Server</span>
26494            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26495          </div>
26496          <div class="server-status-tip">
26497            OxideSLOC is running — accessible on your network.
26498            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26499          </div>
26500        </div>
26501        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26502          <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>
26503        </button>
26504        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26505          <svg class="icon-moon" viewBox="0 0 24 24"><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>
26506          <svg class="icon-sun" viewBox="0 0 24 24"><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>
26507        </button>
26508      </div>
26509    </div>
26510  </div>
26511
26512  <div class="page">
26513    {% if let Some(err) = browse_error %}
26514    <div class="toast-error">
26515      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
26516      {{ err }}
26517    </div>
26518    {% endif %}
26519    {% if linked_count > 0 %}
26520    <div class="toast-success">
26521      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="20 6 9 17 4 12"></polyline></svg>
26522      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26523    </div>
26524    {% endif %}
26525    <div class="watched-bar">
26526      <div class="watched-bar-left">
26527        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
26528        <span class="watched-label">Watched Folders</span>
26529        <div class="watched-chips">
26530          {% if server_mode %}
26531          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26532          {% else %}
26533          {% for dir in watched_dirs %}
26534          <span class="watched-chip">
26535            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26536            <form method="POST" action="/watched-dirs/remove" style="display:contents">
26537              <input type="hidden" name="folder_path" value="{{ dir }}">
26538              <input type="hidden" name="redirect_to" value="/view-reports">
26539              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
26540            </form>
26541          </span>
26542          {% endfor %}
26543          {% if watched_dirs.is_empty() %}
26544          <span class="watched-none">No folders watched — click Choose to add one</span>
26545          {% endif %}
26546          {% endif %}
26547        </div>
26548      </div>
26549      {% if !server_mode %}
26550      <div class="watched-bar-right">
26551        <button type="button" class="btn" id="add-watched-btn">
26552          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
26553          Choose
26554        </button>
26555        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26556          <input type="hidden" name="redirect_to" value="/view-reports">
26557          <button type="submit" class="btn">&#8635; Refresh</button>
26558        </form>
26559      </div>
26560      {% endif %}
26561    </div>
26562    {% if total_scans > 0 %}
26563    <div class="summary-strip">
26564      <div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">{{ total_scans }}</div><div class="stat-chip-label">Total scans</div></div>
26565      <div class="stat-chip"><div class="stat-chip-tip">Source lines of code in the most recent scan — excludes comments and blank lines</div><div class="stat-chip-val" id="agg-code">—</div><div class="stat-chip-label">Latest code lines</div></div>
26566      <div class="stat-chip"><div class="stat-chip-tip">Number of source files analyzed in the most recent scan</div><div class="stat-chip-val" id="agg-files">—</div><div class="stat-chip-label">Latest files</div></div>
26567      <div class="stat-chip"><div class="stat-chip-tip">Number of distinct projects tracked across all scans in this workspace</div><div class="stat-chip-val" id="agg-projects">—</div><div class="stat-chip-label">Projects tracked</div></div>
26568    </div>
26569    {% endif %}
26570
26571    <section class="panel">
26572      <div class="panel-header">
26573        <div>
26574          <h1>View Reports</h1>
26575          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
26576          {% if server_mode %}<p class="panel-meta" style="margin-top:4px;color:var(--muted);">Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
26577        </div>
26578        <div class="flex-row">
26579          <button type="button" class="export-btn" id="export-csv-btn">
26580            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
26581            Export CSV
26582          </button>
26583          <button type="button" class="export-btn" id="export-xls-btn">
26584            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
26585            Export Excel
26586          </button>
26587        </div>
26588      </div>
26589
26590      {% if entries.is_empty() %}
26591      <div class="empty-state">
26592        <strong>No reports with viewable HTML yet</strong>
26593        Run a new analysis from the <a href="/scan">scan page</a>, or click <strong>Choose</strong> above to watch a folder containing saved reports.
26594      </div>
26595      {% else %}
26596      <div class="filter-row">
26597        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
26598        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
26599        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
26600      </div>
26601      <div class="table-wrap">
26602        <table id="history-table">
26603          <colgroup>
26604            <col><col><col><col><col><col><col><col><col><col>
26605          </colgroup>
26606          <thead>
26607            <tr id="history-thead">
26608              <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26609              <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26610              <th>Run ID<div class="col-resize-handle"></div></th>
26611              <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26612              <th class="sortable" data-sort-col="code" data-sort-type="num">Code Lines<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26613              <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26614              <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26615              <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26616              <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
26617              <th>Report<div class="col-resize-handle"></div></th>
26618            </tr>
26619          </thead>
26620          <tbody id="history-tbody">
26621            {% for entry in entries %}
26622            <tr class="history-row" data-run="{{ entry.run_id }}"
26623                data-timestamp="{{ entry.timestamp }}"
26624                data-project="{{ entry.project_label }}"
26625                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
26626                data-skipped="{{ entry.files_skipped }}"
26627                data-comments="{{ entry.comment_lines }}"
26628                data-blank="{{ entry.blank_lines }}"
26629                data-physical="{{ entry.total_physical_lines }}"
26630                data-functions="{{ entry.functions }}"
26631                data-classes="{{ entry.classes }}"
26632                data-variables="{{ entry.variables }}"
26633                data-imports="{{ entry.imports }}"
26634                data-tests="{{ entry.test_count }}"
26635                data-branch="{{ entry.git_branch }}"
26636                data-commit="{{ entry.git_commit }}"
26637                data-has-json="{{ entry.has_json }}"
26638                data-html-url="/runs/html/{{ entry.run_id }}">
26639              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
26640              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
26641              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
26642              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary"><span class="skipped-pill">{{ entry.files_skipped|commas }} skipped</span></div></td>
26643              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
26644              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
26645              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
26646              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
26647              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
26648              <td class="report-cell">
26649                <div class="actions-cell">
26650                  {% if entry.has_json %}<a class="btn primary rpt-btn" href="/runs/result/{{ entry.run_id }}" target="_blank" rel="noopener" title="Open full interactive result report">View</a>{% else %}<a class="btn primary rpt-btn" href="/runs/html/{{ entry.run_id }}" target="_blank" rel="noopener" title="View HTML report">View</a>{% endif %}
26651                  {% if entry.has_pdf %}<a class="btn primary rpt-btn" href="/runs/pdf/{{ entry.run_id }}" target="_blank" rel="noopener" title="View PDF report">PDF</a>{% endif %}
26652                </div>
26653                {% if !entry.submodule_links.is_empty() %}
26654                <details class="submod-details">
26655                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
26656                  <div class="submod-link-list">
26657                    {% for sub in entry.submodule_links %}
26658                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
26659                    {% endfor %}
26660                  </div>
26661                </details>
26662                {% endif %}
26663              </td>
26664            </tr>
26665            {% endfor %}
26666          </tbody>
26667        </table>
26668      </div>
26669      <div class="pagination">
26670        <span class="pagination-info" id="pagination-info"></span>
26671        <div class="pagination-btns" id="pagination-btns"></div>
26672        <div class="flex-row">
26673          <span class="per-page-label">Show</span>
26674          <select class="per-page" id="per-page-sel">
26675            <option value="10">10 per page</option>
26676            <option value="25" selected>25 per page</option>
26677            <option value="50">50 per page</option>
26678            <option value="100">100 per page</option>
26679          </select>
26680          <span class="per-page-label" id="page-range-label"></span>
26681        </div>
26682      </div>
26683      {% endif %}
26684    </section>
26685  </div>
26686
26687  <footer class="site-footer">
26688    local code analysis - metrics, history and reports
26689    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
26690    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26691    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26692    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26693    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26694  </footer>
26695
26696  <script nonce="{{ csp_nonce }}">
26697    (function () {
26698      // ── Theme ──────────────────────────────────────────────────────────────
26699      var storageKey = 'oxide-sloc-theme';
26700      var body = document.body;
26701      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26702      var toggle = document.getElementById('theme-toggle');
26703      if (toggle) toggle.addEventListener('click', function () {
26704        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26705        body.classList.toggle('dark-theme', next === 'dark');
26706        try { localStorage.setItem(storageKey, next); } catch(e) {}
26707      });
26708
26709      // ── State ─────────────────────────────────────────────────────────────
26710      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
26711      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
26712      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
26713
26714      // Aggregate stats from first (most recent) row
26715      if (allRows.length) {
26716        var first = allRows[0];
26717        function slocFmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
26718        function setChipVal(id,n){var el=document.getElementById(id);if(!el)return;var compact=slocFmt(n),full=Number(n).toLocaleString();el.innerHTML=compact+(compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'');}
26719        setChipVal('agg-code', first.dataset.code);
26720        setChipVal('agg-files', first.dataset.files);
26721        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
26722        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
26723        Array.prototype.forEach.call(document.querySelectorAll('#history-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
26724      }
26725
26726      // ── Branch filter population ──────────────────────────────────────────
26727      (function() {
26728        var branches = {};
26729        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
26730        var sel = document.getElementById('branch-filter');
26731        if (sel) Object.keys(branches).sort().forEach(function(b) {
26732          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
26733        });
26734      })();
26735
26736      // ── Filter ────────────────────────────────────────────────────────────
26737      function getFilteredRows() {
26738        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
26739        var branch = ((document.getElementById('branch-filter') || {}).value || '');
26740        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
26741          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
26742          if (branch && (r.dataset.branch || '') !== branch) return false;
26743          return true;
26744        });
26745      }
26746
26747      // ── Pagination ────────────────────────────────────────────────────────
26748      function renderPage() {
26749        var filtered = getFilteredRows();
26750        var total = filtered.length;
26751        var totalPages = Math.max(1, Math.ceil(total / perPage));
26752        currentPage = Math.min(currentPage, totalPages);
26753        var start = (currentPage - 1) * perPage;
26754        var end = Math.min(start + perPage, total);
26755        var shown = {};
26756        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
26757        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
26758          r.style.display = shown[r.dataset.run] ? '' : 'none';
26759        });
26760        var rl = document.getElementById('page-range-label');
26761        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
26762        var info = document.getElementById('pagination-info');
26763        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
26764        var btns = document.getElementById('pagination-btns');
26765        if (!btns) return;
26766        btns.innerHTML = '';
26767        function makeBtn(lbl, pg, active, disabled) {
26768          var b = document.createElement('button');
26769          b.className = 'pg-btn' + (active ? ' active' : '');
26770          b.textContent = lbl; b.disabled = disabled;
26771          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
26772          return b;
26773        }
26774        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
26775        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
26776        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
26777        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
26778      }
26779
26780      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
26781      window.applyFilters = function() { currentPage = 1; renderPage(); };
26782
26783      // ── Sorting ───────────────────────────────────────────────────────────
26784      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
26785      function doSort(col, type, order) {
26786        var tbody = document.getElementById('history-tbody');
26787        if (!tbody) return;
26788        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26789        rows.sort(function(a, b) {
26790          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
26791          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
26792          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
26793          return va < vb ? 1 : va > vb ? -1 : 0;
26794        });
26795        rows.forEach(function(r) { tbody.appendChild(r); });
26796        currentPage = 1; renderPage();
26797      }
26798      sortHeaders.forEach(function(th) {
26799        th.addEventListener('click', function(e) {
26800          if (e.target.classList.contains('col-resize-handle')) return;
26801          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
26802          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
26803          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26804          th.classList.add('sort-' + sortOrder);
26805          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
26806          doSort(col, type, sortOrder);
26807        });
26808      });
26809
26810      // ── Column resize ─────────────────────────────────────────────────────
26811      (function() {
26812        var table = document.getElementById('history-table');
26813        if (!table) return;
26814        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
26815        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
26816        ths.forEach(function(th, i) {
26817          var handle = th.querySelector('.col-resize-handle');
26818          if (!handle || !cols[i]) return;
26819          var startX, startW;
26820          handle.addEventListener('mousedown', function(e) {
26821            e.stopPropagation(); e.preventDefault();
26822            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
26823            handle.classList.add('dragging');
26824            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
26825            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
26826            document.addEventListener('mousemove', onMove);
26827            document.addEventListener('mouseup', onUp);
26828          });
26829        });
26830      })();
26831
26832      // ── Full-commit hover tooltip ─────────────────────────────────────────
26833      // The commit chips live inside an overflow:auto table wrapper, which would
26834      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
26835      // (escaping the scroll container) and follow the cursor. Event delegation
26836      // keeps it working after pagination/sorting re-renders the rows.
26837      (function() {
26838        var tip = document.createElement('div');
26839        tip.className = 'commit-tip';
26840        tip.setAttribute('role', 'tooltip');
26841        document.body.appendChild(tip);
26842        var shown = false;
26843        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
26844        function place(e) {
26845          var pad = 14, r = tip.getBoundingClientRect();
26846          var x = e.clientX + pad, y = e.clientY + pad;
26847          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
26848          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
26849          tip.style.left = x + 'px'; tip.style.top = y + 'px';
26850        }
26851        function hide() { tip.style.display = 'none'; shown = false; }
26852        document.addEventListener('mouseover', function(e) {
26853          var chip = chipFrom(e.target);
26854          if (!chip) return;
26855          var full = chip.getAttribute('data-full-commit');
26856          if (!full) return;
26857          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
26858        });
26859        document.addEventListener('mousemove', function(e) {
26860          if (!shown) return;
26861          if (chipFrom(e.target)) place(e); else hide();
26862        });
26863        document.addEventListener('mouseout', function(e) {
26864          if (chipFrom(e.target)) hide();
26865        });
26866      })();
26867
26868      // ── Reset view ────────────────────────────────────────────────────────
26869      window.resetView = function() {
26870        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
26871        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
26872        sortCol = null; sortOrder = 'asc';
26873        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
26874        var tbody = document.getElementById('history-tbody');
26875        if (tbody) {
26876          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
26877          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
26878          rows.forEach(function(r) { tbody.appendChild(r); });
26879        }
26880        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
26881        var table = document.getElementById('history-table');
26882        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
26883        currentPage = 1; renderPage();
26884      };
26885
26886      renderPage();
26887
26888      // ── Export helpers ────────────────────────────────────────────────────
26889      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
26890      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
26891      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);}
26892      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;');}
26893      function slocXlsx(fname,sheet,hdrs,rows){
26894        var enc=new TextEncoder();
26895        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;}
26896        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;}
26897        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
26898        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
26899        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
26900        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;}
26901        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
26902        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];}
26903        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
26904        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
26905        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
26906          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
26907          +'<fonts count="2">'
26908            +'<font><sz val="11"/><name val="Calibri"/></font>'
26909            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
26910          +'</fonts>'
26911          +'<fills count="3">'
26912            +'<fill><patternFill patternType="none"/></fill>'
26913            +'<fill><patternFill patternType="gray125"/></fill>'
26914            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
26915          +'</fills>'
26916          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
26917          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
26918          +'<cellXfs count="4">'
26919            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
26920            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
26921            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
26922            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
26923          +'</cellXfs>'
26924          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
26925          +'</styleSheet>';
26926        var rx='<row r="1">';
26927        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
26928        rx+='</row>';
26929        rows.forEach(function(row,ri){
26930          var rn=ri+2;rx+='<row r="'+rn+'">';
26931          row.forEach(function(cell,c){
26932            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
26933            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
26934            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
26935            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
26936            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
26937            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
26938          });
26939          rx+='</row>';
26940        });
26941        var lastCol=hdrs.length,lastRow=rows.length+1;
26942        var tableRef='A1:'+colNm(lastCol)+lastRow;
26943        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26944          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
26945          +'<autoFilter ref="'+tableRef+'"/>'
26946          +'<tableColumns count="'+lastCol+'">'
26947          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
26948          +'</tableColumns>'
26949          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
26950          +'</table>';
26951        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
26952          +'<Relationships xmlns="'+pns+'relationships">'
26953          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
26954          +'</Relationships>';
26955        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>';
26956        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
26957          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
26958          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
26959          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
26960          +'</worksheet>';
26961        var F={
26962          '[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"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/></Types>',
26963          '_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>',
26964          '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>',
26965          '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>',
26966          'xl/styles.xml':stl,
26967          'xl/sharedStrings.xml':ssXml,
26968          'xl/worksheets/sheet1.xml':sh,
26969          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
26970          'xl/tables/table1.xml':tableXml
26971        };
26972        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','xl/worksheets/_rels/sheet1.xml.rels','xl/tables/table1.xml'];
26973        var zparts=[],zcds=[],zoff=0,znf=0;
26974        order.forEach(function(name){
26975          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
26976          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]);
26977          var entry=new Uint8Array(lha.length+nb.length+sz);
26978          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
26979          zparts.push(entry);
26980          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));
26981          var cde=new Uint8Array(cda.length+nb.length);
26982          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
26983          zcds.push(cde);zoff+=entry.length;znf++;
26984        });
26985        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
26986        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]);
26987        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
26988        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
26989        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
26990        zout.set(new Uint8Array(ea),zpos);
26991        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
26992      }
26993
26994      // Multi-sheet XLSX builder for the scan-history export.
26995      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
26996      function slocXlsxMulti(fname,sheets){
26997        var enc=new TextEncoder();
26998        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;}
26999        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;}
27000        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27001        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27002        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27003        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];}
27004        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;}
27005        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27006        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27007        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27008          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27009          +'<fonts count="3">'
27010            +'<font><sz val="11"/><name val="Calibri"/></font>'
27011            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27012            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27013          +'</fonts>'
27014          +'<fills count="4">'
27015            +'<fill><patternFill patternType="none"/></fill>'
27016            +'<fill><patternFill patternType="gray125"/></fill>'
27017            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27018            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27019          +'</fills>'
27020          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27021          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27022          +'<cellXfs count="7">'
27023            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27024            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27025            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27026            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27027            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27028            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27029            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27030          +'</cellXfs>'
27031          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27032          +'</styleSheet>';
27033        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27034        sheets.forEach(function(sh,sheetIdx){
27035          var rx='<row r="1">';
27036          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27037          rx+='</row>';
27038          var rn=2;
27039          sh.rows.forEach(function(row){
27040            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27041            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27042              rx+='<row r="'+rn+'">';
27043              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27044              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27045              rx+='</row>';rn++;return;
27046            }
27047            rx+='<row r="'+rn+'">';
27048            row.forEach(function(cell,c){
27049              var ref=colRef(c,rn);
27050              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27051              if(typeof cell==='object'&&cell!==null){
27052                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27053                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27054                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27055                return;
27056              }
27057              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27058              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27059            });
27060            rx+='</row>';rn++;
27061          });
27062          var cw='';
27063          if(sh.colWidths&&sh.colWidths.length>0){
27064            cw='<cols>';
27065            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27066            cw+='</cols>';
27067          }
27068          var tblParts='';
27069          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27070            tableCounter++;
27071            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27072            var tRef='A1:'+colNm(colCount)+rowCount;
27073            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27074              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27075              +'<autoFilter ref="'+tRef+'"/>'
27076              +'<tableColumns count="'+colCount+'">'
27077              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27078              +'</tableColumns>'
27079              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27080              +'</table>';
27081            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27082              +'<Relationships xmlns="'+pns+'relationships">'
27083              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27084              +'</Relationships>';
27085            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27086          }
27087          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27088            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27089            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27090        });
27091        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>';
27092        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27093        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27094        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>';
27095        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27096        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27097        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27098        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27099          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27100        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27101        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};
27102        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27103        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27104        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27105        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27106        var zparts=[],zcds=[],zoff=0,znf=0;
27107        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++;});
27108        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27109        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]);
27110        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27111        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27112        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27113        zout.set(new Uint8Array(ea),zpos);
27114        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27115      }
27116
27117      var LANG_NAMES={'c':'C','cpp':'C++','c_sharp':'C#','go':'Go','java':'Java','java_script':'JavaScript','python':'Python','rust':'Rust','shell':'Shell','power_shell':'PowerShell','type_script':'TypeScript','assembly':'Assembly','clojure':'Clojure','css':'CSS','dart':'Dart','dockerfile':'Dockerfile','elixir':'Elixir','erlang':'Erlang','f_sharp':'F#','groovy':'Groovy','haskell':'Haskell','html':'HTML','julia':'Julia','kotlin':'Kotlin','lua':'Lua','makefile':'Makefile','nim':'Nim','objective_c':'Objective-C','ocaml':'OCaml','perl':'Perl','php':'PHP','r':'R','ruby':'Ruby','scala':'Scala','scss':'SCSS','sql':'SQL','svelte':'Svelte','swift':'Swift','vue':'Vue','xml':'XML','zig':'Zig','solidity':'Solidity','protobuf':'Protocol Buffers','hcl':'HCL/Terraform','graph_ql':'GraphQL','ada':'Ada','vhdl':'VHDL','verilog':'Verilog/SystemVerilog','tcl':'Tcl','pascal':'Pascal/Delphi','visual_basic':'Visual Basic','lisp':'Lisp/Scheme','fortran':'Fortran','nix':'Nix','crystal':'Crystal','d':'D','glsl':'GLSL/HLSL','cmake':'CMake','elm':'Elm','awk':'Awk'};
27118      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27119
27120      var _hh = ['Timestamp','Project','Run ID','Physical Lines','Code Lines','Comments','Blank Lines','Files Analyzed','Files Skipped','Functions','Classes','Variables','Imports','Tests','Code Density','Branch','Commit'];
27121      function getHistoryRows(){
27122        var r=[];
27123        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27124          var code=Number(tr.getAttribute('data-code'))||0;
27125          var phys=Number(tr.getAttribute('data-physical'))||0;
27126          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27127          r.push([
27128            tr.getAttribute('data-timestamp')||'',
27129            tr.getAttribute('data-project')||'',
27130            tr.getAttribute('data-run')||'',
27131            tr.getAttribute('data-physical')||'',
27132            tr.getAttribute('data-code')||'',
27133            tr.getAttribute('data-comments')||'',
27134            tr.getAttribute('data-blank')||'',
27135            tr.getAttribute('data-files')||'',
27136            tr.getAttribute('data-skipped')||'',
27137            tr.getAttribute('data-functions')||'',
27138            tr.getAttribute('data-classes')||'',
27139            tr.getAttribute('data-variables')||'',
27140            tr.getAttribute('data-imports')||'',
27141            tr.getAttribute('data-tests')||'',
27142            dens,
27143            tr.getAttribute('data-branch')||'',
27144            tr.getAttribute('data-commit')||''
27145          ]);
27146        });
27147        return r;
27148      }
27149      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27150      window.exportHistoryXls = function(){
27151        var histRows=getHistoryRows();
27152        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27153        var xlsxRows=histRows.map(function(r){return[r[0],r[1],r[2],toN(r[3]),toN(r[4]),toN(r[5]),toN(r[6]),toN(r[7]),toN(r[8]),toN(r[9]),toN(r[10]),toN(r[11]),toN(r[12]),toN(r[13]),{v:r[14],s:6},r[15],r[16]];});
27154        var histSheet={name:'Scan History',hdrs:_hh,rows:xlsxRows,colWidths:[18,14,22,14,12,12,12,12,12,11,10,10,10,8,13,10,12]};
27155        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27156        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27157        var runId=jsonRow.getAttribute('data-run')||'';
27158        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27159        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27160        fetch('/runs/json/'+runId)
27161          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27162          .then(function(run){
27163            var tot=run.summary_totals||{};
27164            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27165            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27166            function B(v){return{v:v,s:4};}
27167            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27168            var sumRows=[
27169              [{_sec:true,v:'RUN INFORMATION'}],
27170              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27171              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27172              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27173              [B('Branch'),run.git_branch||''],
27174              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27175              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27176              [B('Files Analyzed'),N(tot.files_analyzed)],
27177              [B('Files Skipped'),N(tot.files_skipped)],
27178              [],
27179              [{_sec:true,v:'CODE METRICS'}],
27180              [B('Physical Lines'),N(phys)],
27181              [B('Code Lines'),N(code)],
27182              [B('Comments'),N(tot.comment_lines)],
27183              [B('Blank Lines'),N(tot.blank_lines)],
27184              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27185              [B('Functions'),N(tot.functions)],
27186              [B('Classes / Types'),N(tot.classes)],
27187              [B('Variables'),N(tot.variables)],
27188              [B('Imports'),N(tot.imports)],
27189              [B('Tests'),N(tot.test_count)],
27190              [B('Assertions'),N(tot.test_assertion_count)],
27191              [B('Test Suites'),N(tot.test_suite_count)],
27192              [B('Code Density'),{v:dens,s:6}],
27193              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27194            ];
27195            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27196            var langRows=(run.totals_by_language||[]).map(function(l){
27197              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27198              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27199              return [langName(l.language),l.files||0,lp,lc,{v:ld,s:6},l.comment_lines||0,l.blank_lines||0,l.functions||0,l.classes||0,l.variables||0,l.imports||0,l.test_count||0,l.test_assertion_count||0,l.test_suite_count||0];
27200            });
27201            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27202            var pfRows=(run.per_file_records||[]).map(function(r){
27203              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27204              return [r.relative_path,langName(r.language),rc.total_physical_lines||0,ec.code_lines||0,ec.comment_lines||0,ec.blank_lines||0,rc.functions||0,rc.classes||0,rc.variables||0,rc.imports||0,rc.test_count||0,rc.test_assertion_count||0,r.size_bytes||0];
27205            });
27206            var skHdrs=['File','Status','Size (bytes)'];
27207            var skRows=(run.skipped_file_records||[]).map(function(r){
27208              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27209            });
27210            slocXlsxMulti('scan-history.xlsx',[
27211              histSheet,
27212              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27213              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27214              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27215              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27216            ]);
27217          })
27218          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27219      };
27220
27221      var csvBtn = document.getElementById('export-csv-btn');
27222      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27223      var xlsBtn = document.getElementById('export-xls-btn');
27224      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27225
27226      // ── Remaining CSP-safe event bindings ────────────────────────────────
27227      (function wireEvents() {
27228        var el;
27229        el = document.getElementById('reset-view-btn');
27230        if (el) el.addEventListener('click', window.resetView);
27231        el = document.getElementById('project-filter');
27232        if (el) el.addEventListener('input', window.applyFilters);
27233        el = document.getElementById('branch-filter');
27234        if (el) el.addEventListener('change', window.applyFilters);
27235        el = document.getElementById('per-page-sel');
27236        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27237        el = document.getElementById('add-watched-btn');
27238        if (el) el.addEventListener('click', function() {
27239          fetch('/pick-directory?kind=reports')
27240            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27241            .then(function(data) {
27242              if (!data.cancelled && data.selected_path) {
27243                var form = document.createElement('form');
27244                form.method = 'POST';
27245                form.action = '/watched-dirs/add';
27246                var ri = document.createElement('input');
27247                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27248                var fi = document.createElement('input');
27249                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27250                form.appendChild(ri); form.appendChild(fi);
27251                document.body.appendChild(form);
27252                form.submit();
27253              }
27254            })
27255            .catch(function(e) { alert('Could not open folder picker: ' + e); });
27256        });
27257      })();
27258
27259      (function randomizeWatermarks() {
27260        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27261        if (!wms.length) return;
27262        var placed = [];
27263        function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
27264        function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
27265        var half=Math.floor(wms.length/2);
27266        wms.forEach(function(img,i){var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});
27267      })();
27268
27269      (function spawnCodeParticles() {
27270        var container = document.getElementById('code-particles');
27271        if (!container) return;
27272        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'];
27273        for (var i = 0; i < 38; i++) {
27274          (function(idx) {
27275            var el = document.createElement('span');
27276            el.className = 'code-particle';
27277            el.textContent = snippets[idx % snippets.length];
27278            var left = Math.random() * 94 + 2;
27279            var top = Math.random() * 88 + 6;
27280            var dur = (Math.random() * 10 + 9).toFixed(1);
27281            var delay = (Math.random() * 18).toFixed(1);
27282            var rot = (Math.random() * 26 - 13).toFixed(1);
27283            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27284            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
27285            container.appendChild(el);
27286          })(i);
27287        }
27288      })();
27289    })();
27290  </script>
27291  <script nonce="{{ csp_nonce }}">
27292  (function(){
27293    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'}];
27294    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);});}
27295    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27296    function init(){
27297      var btn=document.getElementById('settings-btn');if(!btn)return;
27298      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27299      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>';
27300      document.body.appendChild(m);
27301      var g=document.getElementById('scheme-grid');
27302      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);});
27303      var cl=document.getElementById('settings-close');
27304      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);
27305      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');});
27306      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27307      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27308    }
27309    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27310  }());
27311  </script>
27312  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
27313</body>
27314</html>
27315"##,
27316    ext = "html"
27317)]
27318struct HistoryTemplate {
27319    version: &'static str,
27320    entries: Vec<HistoryEntryRow>,
27321    total_scans: usize,
27322    linked_count: usize,
27323    browse_error: Option<String>,
27324    watched_dirs: Vec<String>,
27325    csp_nonce: String,
27326    server_mode: bool,
27327}
27328
27329// ── CompareSelectTemplate ──────────────────────────────────────────────────────
27330
27331#[derive(Template)]
27332#[template(
27333    source = r##"
27334<!doctype html>
27335<html lang="en">
27336<head>
27337  <meta charset="utf-8">
27338  <meta name="viewport" content="width=device-width, initial-scale=1">
27339  <title>OxideSLOC | Compare Scans</title>
27340  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27341  <style nonce="{{ csp_nonce }}">
27342    :root {
27343      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27344      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27345      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27346      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27347      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27348    }
27349    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27350    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
27351    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27352    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27353    .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);}
27354    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27355    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
27356    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27357    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
27358    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27359    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27360    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
27361    .nav-pill,.theme-toggle{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;text-decoration:none;transition:background .15s ease,transform .15s ease;}
27362    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27363    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27364    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27365    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27366    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27367    .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;}
27368    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27369    .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);}
27370    .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;}
27371    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27372    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27373    .settings-modal-body{padding:14px 16px 16px;}
27374    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27375    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27376    .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;}
27377    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27378    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27379    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27380    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27381    .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;}
27382    .tz-select:focus{border-color:var(--oxide);}
27383    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27384    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27385    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27386    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27387    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27388    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27389    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27390    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27391    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27392    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27393    .per-page-label{font-size:13px;color:var(--muted);}
27394    select.per-page,.filter-input,.filter-select{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
27395    .filter-input{min-width:180px;cursor:text;}
27396    .table-wrap{width:100%;overflow-x:auto;}
27397    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27398    th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
27399    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27400    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27401    #compare-table th:nth-child(1),#compare-table td:nth-child(1){min-width:52px;width:52px;padding-left:10px;padding-right:10px;box-sizing:border-box;text-align:center;}
27402    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27403    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27404    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27405    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27406    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27407    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27408    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27409    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27410    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27411    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27412    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27413    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27414    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27415    tr:last-child td{border-bottom:none;}
27416    tr.selected td{background:var(--sel-bg);}
27417    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27418    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27419    tr{cursor:pointer;}
27420    tr.row-locked{opacity:.35;cursor:not-allowed;}
27421    tr.row-locked td{pointer-events:none;}
27422    .compare-all-bar{display:flex;flex-wrap:wrap;gap:8px;padding:10px 14px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;margin:10px 0 14px;align-items:center;}
27423    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27424    .compare-all-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:7px;border:1px solid var(--accent-2);background:rgba(111,155,255,0.08);color:var(--accent-2);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
27425    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27426    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27427    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27428    .run-id-chip{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}
27429    .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);}
27430    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27431    .metric-num{font-weight:700;color:var(--text);}
27432    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27433    .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
27434    .sel-badge{display:block;width:22px;height:22px;margin:0 auto;border-radius:6px;border:1.5px solid var(--line-strong);background:var(--surface-2);line-height:20px;text-align:center;font-size:11px;font-weight:900;color:var(--muted-2);transition:background .12s,border-color .12s;}
27435    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27436    .btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
27437    .btn:hover{background:var(--line);}
27438    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27439    .btn.primary:hover{opacity:.9;}
27440    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27441    .watched-bar{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}
27442    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27443    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27444    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27445    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27446    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27447    .watched-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}
27448    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27449    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27450    .watched-chip-rm:hover{color:var(--oxide);}
27451    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27452    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27453    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27454    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27455    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27456    .submod-overflow-badge{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 6px;border-radius:5px;background:var(--surface);border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap;}
27457    .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;}
27458    .btn-back:hover{background:var(--line);}
27459    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27460    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27461    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27462    .pagination-info{font-size:13px;color:var(--muted);}
27463    .pagination-btns{display:flex;gap:6px;}
27464    .pg-btn{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}
27465    .pg-btn:hover:not(:disabled){background:var(--line);}
27466    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27467    .pg-btn:disabled{opacity:.35;cursor:default;}
27468    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27469    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27470    .site-footer a{color:var(--muted);}
27471    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27472    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
27473    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
27474    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
27475    @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));}}
27476    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27477    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27478    .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);}
27479    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27480    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27481    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27482    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.4;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 14px rgba(0,0,0,0.2);}
27483    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27484    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27485    .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;}
27486    .sel-count{font-size:11px;background:rgba(255,255,255,0.22);border-radius:999px;padding:1px 8px;font-weight:800;letter-spacing:.02em;margin-left:2px;}
27487    .instruction-bar{background:rgba(111,155,255,0.08);border:1px solid rgba(111,155,255,0.22);border-radius:10px;padding:8px 14px;font-size:13px;color:var(--accent-2);display:inline-flex;align-items:center;gap:8px;margin-bottom:14px;width:fit-content;max-width:100%;}
27488    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27489    .submod-chip{display:inline-flex;align-items:center;font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.25);color:var(--accent-2);margin:1px 2px 1px 0;white-space:nowrap;}
27490    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27491    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27492    .hidden{display:none!important;}
27493    .scope-panel{background:rgba(111,155,255,0.06);border:1.5px solid rgba(111,155,255,0.28);border-radius:12px;padding:12px 16px;margin-bottom:14px;animation:fadeIn .15s ease;display:inline-block;width:auto;max-width:100%;}
27494    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27495    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27496    .scope-panel-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:10px;display:flex;align-items:center;gap:6px;}
27497    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27498    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27499    .scope-option{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface);cursor:pointer;font-size:12px;font-weight:700;color:var(--text);transition:border-color .12s,background .12s,color .12s;user-select:none;}
27500    .scope-option:hover{background:var(--line);}
27501    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27502    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27503    .scope-option-radio{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--line-strong);background:var(--surface-2);flex:0 0 auto;position:relative;transition:border-color .12s;}
27504    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27505    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27506    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27507    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
27508  </style>
27509</head>
27510<body>
27511  <div class="background-watermarks" aria-hidden="true">
27512    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27513    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27514    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27515    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27516    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27517    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27518  </div>
27519  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27520  <div class="top-nav">
27521    <div class="top-nav-inner">
27522      <a class="brand" href="/">
27523        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27524        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27525      </a>
27526      <div class="nav-right">
27527        <a class="nav-pill" href="/">Home</a>
27528        <div class="nav-dropdown">
27529          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
27530          <div class="nav-dropdown-menu">
27531            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
27532          </div>
27533        </div>
27534        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27535        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27536        <div class="nav-dropdown">
27537          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
27538          <div class="nav-dropdown-menu">
27539            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
27540          </div>
27541        </div>
27542        <div class="server-status-wrap" id="server-status-wrap">
27543          <div class="nav-pill server-online-pill" id="server-status-pill">
27544            <span class="status-dot" id="status-dot"></span>
27545            <span id="server-status-label">Server</span>
27546            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27547          </div>
27548          <div class="server-status-tip">
27549            OxideSLOC is running — accessible on your network.
27550            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27551          </div>
27552        </div>
27553        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27554          <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>
27555        </button>
27556        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27557          <svg class="icon-moon" viewBox="0 0 24 24"><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>
27558          <svg class="icon-sun" viewBox="0 0 24 24"><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>
27559        </button>
27560      </div>
27561    </div>
27562  </div>
27563
27564  <div class="page">
27565    <div class="watched-bar">
27566      <div class="watched-bar-left">
27567        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
27568        <span class="watched-label">Watched Folders</span>
27569        <div class="watched-chips">
27570          {% if server_mode %}
27571          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27572          {% else %}
27573          {% for dir in watched_dirs %}
27574          <span class="watched-chip">
27575            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27576            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27577              <input type="hidden" name="folder_path" value="{{ dir }}">
27578              <input type="hidden" name="redirect_to" value="/compare-scans">
27579              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27580            </form>
27581          </span>
27582          {% endfor %}
27583          {% if watched_dirs.is_empty() %}
27584          <span class="watched-none">No folders watched — click Choose to add one</span>
27585          {% endif %}
27586          {% endif %}
27587        </div>
27588      </div>
27589      {% if !server_mode %}
27590      <div class="watched-bar-right">
27591        <button type="button" class="btn" id="add-watched-btn">
27592          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
27593          Choose
27594        </button>
27595        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27596          <input type="hidden" name="redirect_to" value="/compare-scans">
27597          <button type="submit" class="btn">&#8635; Refresh</button>
27598        </form>
27599      </div>
27600      {% endif %}
27601    </div>
27602    {% if total_scans > 0 %}
27603    <div class="summary-strip">
27604      <div class="stat-chip"><div class="stat-chip-tip">Total scan runs available for comparison</div><div class="stat-chip-val">{{ total_scans }}</div><div class="stat-chip-label">Total scans</div></div>
27605      <div class="stat-chip"><div class="stat-chip-tip">Source lines of code in the most recent scan — excludes comments and blank lines</div><div class="stat-chip-val" id="agg-code">—</div><div class="stat-chip-label">Latest code lines</div></div>
27606      <div class="stat-chip"><div class="stat-chip-tip">Number of source files analyzed in the most recent scan</div><div class="stat-chip-val" id="agg-files">—</div><div class="stat-chip-label">Latest files</div></div>
27607      <div class="stat-chip"><div class="stat-chip-tip">Number of distinct projects tracked across all scans in this workspace</div><div class="stat-chip-val" id="agg-projects">—</div><div class="stat-chip-label">Projects tracked</div></div>
27608    </div>
27609    {% endif %}
27610    <section class="panel">
27611      <div class="panel-header">
27612        <div>
27613          <h1>Compare Scans</h1>
27614          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
27615        </div>
27616        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
27617          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
27618            <button class="btn primary" id="compare-btn" disabled>
27619              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>
27620              Compare <span class="sel-count" id="sel-count">0</span> Selected
27621            </button>
27622          </div>
27623        </div>
27624      </div>
27625
27626      {% if entries.is_empty() %}
27627      <div class="empty-state">
27628        <strong>No scans yet</strong>
27629        Run your first analysis from the <a href="/scan">scan page</a>, or click <strong>Choose</strong> above to watch a folder containing saved reports.
27630      </div>
27631      {% else %}
27632      <div class="filter-row">
27633        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27634        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27635        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27636      </div>
27637      <div class="scope-panel hidden" id="scope-panel">
27638        <div class="scope-panel-label">
27639          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
27640          Compare scope — choose what to include
27641        </div>
27642        <div class="scope-options" id="scope-options"></div>
27643      </div>
27644      {% if total_scans > 0 %}
27645      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
27646        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
27647          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
27648          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
27649        </div>
27650      </div>
27651      {% endif %}
27652      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
27653        <span class="compare-all-label">
27654          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline></svg>
27655          Quick Compare All
27656        </span>
27657      </div>
27658      <div class="table-wrap">
27659        <table id="compare-table">
27660          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
27661          <thead>
27662            <tr id="compare-thead">
27663              <th><div class="col-resize-handle"></div></th>
27664              <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27665              <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27666              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
27667              <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27668              <th class="sortable" data-sort-col="code" data-sort-type="num">Code Lines<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27669              <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27670              <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27671              <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27672              <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
27673              <th>Submodules<div class="col-resize-handle"></div></th>
27674            </tr>
27675          </thead>
27676          <tbody id="compare-tbody">
27677            {% for entry in entries %}
27678            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
27679                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
27680                data-project="{{ entry.project_label }}"
27681                data-files="{{ entry.files_analyzed }}"
27682                data-code="{{ entry.code_lines }}"
27683                data-comments="{{ entry.comment_lines }}"
27684                data-blank="{{ entry.blank_lines }}"
27685                data-branch="{{ entry.git_branch }}"
27686                data-commit="{{ entry.git_commit }}"
27687                data-submodules="{{ entry.submodule_names_csv }}">
27688              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
27689              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27690              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27691              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
27692              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
27693              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27694              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27695              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27696              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
27697              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" style="cursor:help;" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
27698              <td style="white-space:normal;vertical-align:middle;">{% if !entry.submodule_links.is_empty() %}<div class="submod-chips-cell">{% for sub in entry.submodule_links %}<span class="submod-chip">{{ sub.name }}</span>{% endfor %}</div>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
27699            </tr>
27700            {% endfor %}
27701          </tbody>
27702        </table>
27703      </div>
27704      <div class="pagination">
27705        <span class="pagination-info" id="pagination-info"></span>
27706        <div class="pagination-btns" id="pagination-btns"></div>
27707        <div class="flex-row">
27708          <span class="per-page-label">Show</span>
27709          <select class="per-page" id="per-page-sel">
27710            <option value="10">10 per page</option>
27711            <option value="25" selected>25 per page</option>
27712            <option value="50">50 per page</option>
27713            <option value="100">100 per page</option>
27714          </select>
27715          <span class="per-page-label" id="page-range-label"></span>
27716        </div>
27717      </div>
27718      {% endif %}
27719    </section>
27720  </div>
27721
27722  <footer class="site-footer">
27723    local code analysis - metrics, history and reports
27724    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27725    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27726    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27727    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27728    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27729  </footer>
27730
27731  <script nonce="{{ csp_nonce }}">
27732    (function () {
27733      // ── Theme ──────────────────────────────────────────────────────────────
27734      var storageKey = 'oxide-sloc-theme';
27735      var body = document.body;
27736      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27737      var toggle = document.getElementById('theme-toggle');
27738      if (toggle) toggle.addEventListener('click', function () {
27739        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27740        body.classList.toggle('dark-theme', next === 'dark');
27741        try { localStorage.setItem(storageKey, next); } catch(e) {}
27742      });
27743
27744      // ── State ─────────────────────────────────────────────────────────────
27745      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
27746      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
27747      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27748      window._allCompareRows = allRows;
27749
27750      // ── Stat chips ────────────────────────────────────────────────────────
27751      (function() {
27752        var projects = {}, latestTs = '', latestRow = null;
27753        allRows.forEach(function(r) {
27754          var p = r.dataset.project || ''; if (p) projects[p] = true;
27755          var ts = r.dataset.timestamp || '';
27756          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
27757        });
27758        function slocFmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
27759        function setChipVal(id,n){var el=document.getElementById(id);if(!el)return;var compact=slocFmt(n),full=Number(n).toLocaleString();el.innerHTML=compact+(compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'');}
27760        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
27761        if (latestRow) {
27762          setChipVal('agg-code', latestRow.dataset.code);
27763          setChipVal('agg-files', latestRow.dataset.files);
27764        }
27765        Array.prototype.forEach.call(document.querySelectorAll('#compare-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
27766      })();
27767
27768      // ── Branch filter population ──────────────────────────────────────────
27769      (function() {
27770        var branches = {};
27771        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27772        var sel = document.getElementById('branch-filter');
27773        if (sel) Object.keys(branches).sort().forEach(function(b) {
27774          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27775        });
27776      })();
27777
27778      // ── Filter ────────────────────────────────────────────────────────────
27779      function getFilteredRows() {
27780        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27781        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27782        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
27783          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27784          if (branch && (r.dataset.branch || '') !== branch) return false;
27785          return true;
27786        });
27787      }
27788
27789      // ── Pagination ────────────────────────────────────────────────────────
27790      function renderPage() {
27791        var filtered = getFilteredRows();
27792        var total = filtered.length;
27793        var totalPages = Math.max(1, Math.ceil(total / perPage));
27794        currentPage = Math.min(currentPage, totalPages);
27795        var start = (currentPage - 1) * perPage;
27796        var end = Math.min(start + perPage, total);
27797        var shown = {};
27798        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27799        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
27800          r.style.display = shown[r.dataset.run] ? '' : 'none';
27801        });
27802        var rl = document.getElementById('page-range-label');
27803        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27804        var info = document.getElementById('pagination-info');
27805        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27806        var btns = document.getElementById('pagination-btns');
27807        if (!btns) return;
27808        btns.innerHTML = '';
27809        function makeBtn(lbl, pg, active, disabled) {
27810          var b = document.createElement('button');
27811          b.className = 'pg-btn' + (active ? ' active' : '');
27812          b.textContent = lbl; b.disabled = disabled;
27813          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27814          return b;
27815        }
27816        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27817        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27818        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27819        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27820      }
27821
27822      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27823      window.applyFilters = function() { currentPage = 1; renderPage(); };
27824
27825      // ── Sorting ───────────────────────────────────────────────────────────
27826      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
27827      function doSort(col, type, order) {
27828        var tbody = document.getElementById('compare-tbody');
27829        if (!tbody) return;
27830        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27831        rows.sort(function(a, b) {
27832          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27833          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27834          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27835          return va < vb ? 1 : va > vb ? -1 : 0;
27836        });
27837        rows.forEach(function(r) { tbody.appendChild(r); });
27838        currentPage = 1; renderPage();
27839      }
27840      sortHeaders.forEach(function(th) {
27841        th.addEventListener('click', function(e) {
27842          if (e.target.classList.contains('col-resize-handle')) return;
27843          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27844          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27845          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27846          th.classList.add('sort-' + sortOrder);
27847          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27848          doSort(col, type, sortOrder);
27849        });
27850      });
27851
27852      // Apply default sort (timestamp desc) on initial load
27853      (function() {
27854        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
27855        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
27856      })();
27857
27858      // ── Column resize ─────────────────────────────────────────────────────
27859      (function() {
27860        var table = document.getElementById('compare-table');
27861        if (!table) return;
27862        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27863        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
27864        ths.forEach(function(th, i) {
27865          var handle = th.querySelector('.col-resize-handle');
27866          if (!handle || !cols[i]) return;
27867          var startX, startW;
27868          handle.addEventListener('mousedown', function(e) {
27869            e.stopPropagation(); e.preventDefault();
27870            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27871            handle.classList.add('dragging');
27872            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27873            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27874            document.addEventListener('mousemove', onMove);
27875            document.addEventListener('mouseup', onUp);
27876          });
27877        });
27878      })();
27879
27880      // ── Full-commit hover tooltip ─────────────────────────────────────────
27881      // The commit chips live inside an overflow:auto table wrapper, which would
27882      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27883      // (escaping the scroll container) and follow the cursor. Event delegation
27884      // keeps it working after pagination/sorting re-renders the rows.
27885      (function() {
27886        var tip = document.createElement('div');
27887        tip.className = 'commit-tip';
27888        tip.setAttribute('role', 'tooltip');
27889        document.body.appendChild(tip);
27890        var shown = false;
27891        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27892        function place(e) {
27893          var pad = 14, r = tip.getBoundingClientRect();
27894          var x = e.clientX + pad, y = e.clientY + pad;
27895          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27896          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27897          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27898        }
27899        function hide() { tip.style.display = 'none'; shown = false; }
27900        document.addEventListener('mouseover', function(e) {
27901          var chip = chipFrom(e.target);
27902          if (!chip) return;
27903          var full = chip.getAttribute('data-full-commit');
27904          if (!full) return;
27905          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27906        });
27907        document.addEventListener('mousemove', function(e) {
27908          if (!shown) return;
27909          if (chipFrom(e.target)) place(e); else hide();
27910        });
27911        document.addEventListener('mouseout', function(e) {
27912          if (chipFrom(e.target)) hide();
27913        });
27914      })();
27915
27916      // ── Reset view ────────────────────────────────────────────────────────
27917      window.resetView = function() {
27918        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27919        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27920        sortCol = null; sortOrder = 'asc';
27921        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27922        var tbody = document.getElementById('compare-tbody');
27923        if (tbody) {
27924          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
27925          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27926          rows.forEach(function(r) { tbody.appendChild(r); });
27927        }
27928        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27929        var table = document.getElementById('compare-table');
27930        currentPage = 1; renderPage();
27931        currentPage = 1; renderPage();
27932      };
27933
27934      renderPage();
27935      buildCompareAllBar();
27936
27937      // ── Row selection state ───────────────────────────────────────────────
27938      var selected = [];
27939      var lockedProject = null; // project label of first selected scan
27940
27941      function updateCompareBtn() {
27942        var btn = document.getElementById('compare-btn');
27943        var cnt = document.getElementById('sel-count');
27944        if (!btn) return;
27945        btn.disabled = selected.length < 2;
27946        if (cnt) cnt.textContent = selected.length;
27947      }
27948
27949      function applyProjectLock() {
27950        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27951        allRows.forEach(function(r) {
27952          if (lockedProject === null) {
27953            r.classList.remove('row-locked');
27954          } else {
27955            var proj = r.dataset.project || '';
27956            if (proj !== lockedProject) {
27957              r.classList.add('row-locked');
27958            } else {
27959              r.classList.remove('row-locked');
27960            }
27961          }
27962        });
27963      }
27964
27965      function toggleRow(row) {
27966        if (row.classList.contains('row-locked')) return;
27967        var vid = row.dataset.vid || row.dataset.run;
27968        var idx = selected.indexOf(vid);
27969        if (idx >= 0) {
27970          selected.splice(idx, 1);
27971          row.classList.remove('selected');
27972          var b = document.getElementById('badge-' + vid);
27973          if (b) b.textContent = '';
27974          // Release project lock if nothing selected
27975          if (selected.length === 0) lockedProject = null;
27976        } else {
27977          // Set project lock on first selection
27978          if (selected.length === 0) lockedProject = row.dataset.project || null;
27979          selected.push(vid);
27980          row.classList.add('selected');
27981        }
27982        selected.forEach(function(v, i) {
27983          var b = document.getElementById('badge-' + v);
27984          if (b) b.textContent = i + 1;
27985        });
27986        applyProjectLock();
27987        updateCompareBtn();
27988        buildScopePanel();
27989      }
27990
27991      // ── Compare-All bar ───────────────────────────────────────────────────
27992      function buildCompareAllBar() {
27993        var bar = document.getElementById('compare-all-bar');
27994        if (!bar) return;
27995        // Group all rows by project label.
27996        var groups = {};
27997        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
27998        // Use all rows from the source data (not just visible).
27999        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28000        // We need ALL rows across all pages, not just the rendered ones.
28001        // Use the underlying allRows array that the pagination JS also uses.
28002        var sourceRows = window._allCompareRows || allRowsAll;
28003        sourceRows.forEach(function(r) {
28004          var proj = r.dataset.project || '';
28005          var vid = r.dataset.vid || r.dataset.run || '';
28006          if (!proj || !vid) return;
28007          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28008          groups[proj].ids.push(vid);
28009          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28010        });
28011        // Build buttons for each project with >= 2 scans.
28012        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28013        if (!keys.length) { bar.style.display = 'none'; return; }
28014        bar.style.display = 'flex';
28015        // Remove old buttons (keep label).
28016        var oldBtns = bar.querySelectorAll('.compare-all-btn');
28017        oldBtns.forEach(function(b) { b.remove(); });
28018        keys.sort();
28019        keys.forEach(function(proj) {
28020          var g = groups[proj];
28021          var btn = document.createElement('button');
28022          btn.className = 'compare-all-btn';
28023          btn.type = 'button';
28024          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28025          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28026          btn.addEventListener('click', function() {
28027            // Sort ids by timestamp (ascending).
28028            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28029            pairs.sort(function(a, b) { return a.ts - b.ts; });
28030            var sorted = pairs.map(function(p) { return p.id; });
28031            if (sorted.length === 2) {
28032              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28033            } else {
28034              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28035            }
28036          });
28037          bar.appendChild(btn);
28038        });
28039      }
28040
28041      // ── Scope panel ───────────────────────────────────────────────────────
28042      var selectedScope = 'all';
28043
28044      function buildScopePanel() {
28045        var panel = document.getElementById('scope-panel');
28046        var opts = document.getElementById('scope-options');
28047        if (!panel || !opts) return;
28048        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28049
28050        // Collect union of submodules from all selected rows.
28051        var allSubs = {};
28052        selected.forEach(function(vid) {
28053          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28054          if (!row) return;
28055          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28056        });
28057        var subList = Object.keys(allSubs).sort();
28058        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28059
28060        panel.classList.remove('hidden');
28061        opts.innerHTML = '';
28062
28063        function makeOption(value, label, title) {
28064          var div = document.createElement('div');
28065          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28066          div.dataset.scopeValue = value;
28067          if (title) div.title = title;
28068          var radio = document.createElement('span');
28069          radio.className = 'scope-option-radio';
28070          var lbl = document.createElement('span');
28071          lbl.textContent = label;
28072          div.appendChild(radio);
28073          div.appendChild(lbl);
28074          div.addEventListener('click', function() {
28075            selectedScope = value;
28076            opts.querySelectorAll('.scope-option').forEach(function(o) {
28077              o.classList.toggle('selected', o.dataset.scopeValue === value);
28078            });
28079          });
28080          return div;
28081        }
28082
28083        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28084        var sep = document.createElement('span');
28085        sep.className = 'scope-option-sep';
28086        opts.appendChild(sep);
28087        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28088        subList.forEach(function(s) {
28089          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28090        });
28091      }
28092
28093      function doCompare() {
28094        if (selected.length < 2) return;
28095        if (selected.length === 2) {
28096          // Two-scan delta (existing flow with scope support).
28097          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28098          if (selectedScope === 'super') url += '&scope=super';
28099          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28100          window.location.href = url;
28101        } else {
28102          // Multi-scan timeline (N >= 3) — pass scope params too.
28103          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28104          if (selectedScope === 'super') url += '&scope=super';
28105          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28106          window.location.href = url;
28107        }
28108      }
28109
28110      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28111      var cbtn = document.getElementById('compare-btn');
28112      if (cbtn) cbtn.addEventListener('click', doCompare);
28113      var pfEl = document.getElementById('project-filter');
28114      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28115      var bfEl = document.getElementById('branch-filter');
28116      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28117      var rvBtn = document.getElementById('reset-view-btn');
28118      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28119      var ppSel = document.getElementById('per-page-sel');
28120      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28121
28122      var cmpTbody = document.getElementById('compare-tbody');
28123      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28124        var row = e.target.closest('.compare-row');
28125        if (row) toggleRow(row);
28126      });
28127
28128      (function randomizeWatermarks() {
28129        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28130        if (!wms.length) return;
28131        var placed = [];
28132        function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
28133        function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
28134        var half=Math.floor(wms.length/2);
28135        wms.forEach(function(img,i){var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});
28136      })();
28137
28138      (function spawnCodeParticles() {
28139        var container = document.getElementById('code-particles');
28140        if (!container) return;
28141        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'];
28142        for (var i = 0; i < 38; i++) {
28143          (function(idx) {
28144            var el = document.createElement('span');
28145            el.className = 'code-particle';
28146            el.textContent = snippets[idx % snippets.length];
28147            var left = Math.random() * 94 + 2;
28148            var top = Math.random() * 88 + 6;
28149            var dur = (Math.random() * 10 + 9).toFixed(1);
28150            var delay = (Math.random() * 18).toFixed(1);
28151            var rot = (Math.random() * 26 - 13).toFixed(1);
28152            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28153            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
28154            container.appendChild(el);
28155          })(i);
28156        }
28157      })();
28158
28159      // ── Watched folder picker ─────────────────────────────────────────────
28160      (function() {
28161        var btn = document.getElementById('add-watched-btn');
28162        if (!btn) return;
28163        btn.addEventListener('click', function() {
28164          fetch('/pick-directory?kind=reports')
28165            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28166            .then(function(data) {
28167              if (!data.cancelled && data.selected_path) {
28168                var form = document.createElement('form');
28169                form.method = 'POST';
28170                form.action = '/watched-dirs/add';
28171                var ri = document.createElement('input');
28172                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28173                var fi = document.createElement('input');
28174                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28175                form.appendChild(ri); form.appendChild(fi);
28176                document.body.appendChild(form);
28177                form.submit();
28178              }
28179            })
28180            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28181        });
28182      })();
28183
28184      // ── Submodule chip truncation ─────────────────────────────────────────
28185      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28186        var chips = cell.querySelectorAll('.submod-chip');
28187        var MAX = 4;
28188        if (chips.length <= MAX) return;
28189        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28190        var badge = document.createElement('span');
28191        badge.className = 'submod-overflow-badge';
28192        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28193        badge.textContent = '+' + (chips.length - MAX) + ' more';
28194        cell.appendChild(badge);
28195        cell.style.maxHeight = 'none';
28196      });
28197    })();
28198  </script>
28199  <script nonce="{{ csp_nonce }}">
28200  (function(){
28201    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'}];
28202    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);});}
28203    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28204    function init(){
28205      var btn=document.getElementById('settings-btn');if(!btn)return;
28206      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28207      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>';
28208      document.body.appendChild(m);
28209      var g=document.getElementById('scheme-grid');
28210      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);});
28211      var cl=document.getElementById('settings-close');
28212      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);
28213      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');});
28214      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28215      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28216    }
28217    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28218  }());
28219  </script>
28220  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
28221  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
28222  if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
28223</body>
28224</html>
28225"##,
28226    ext = "html"
28227)]
28228struct CompareSelectTemplate {
28229    version: &'static str,
28230    entries: Vec<HistoryEntryRow>,
28231    total_scans: usize,
28232    watched_dirs: Vec<String>,
28233    csp_nonce: String,
28234    server_mode: bool,
28235}
28236
28237// ── CompareTemplate ────────────────────────────────────────────────────────────
28238
28239#[derive(Template)]
28240#[template(
28241    source = r##"
28242<!doctype html>
28243<html lang="en">
28244<head>
28245  <meta charset="utf-8">
28246  <meta name="viewport" content="width=device-width, initial-scale=1">
28247  <title>OxideSLOC | Scan Delta</title>
28248  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28249  <style nonce="{{ csp_nonce }}">
28250    :root {
28251      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28252      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28253      --nav:#283790; --nav-2:#013e6b;
28254      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28255      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28256      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28257    }
28258    body.dark-theme {
28259      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28260      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28261    }
28262    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
28263    .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);}
28264    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}
28265    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
28266    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28267    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;} .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
28268    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28269    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28270    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
28271    .nav-pill,.theme-toggle{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;white-space:nowrap;text-decoration:none;}
28272    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28273    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28274    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28275    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28276    .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;}
28277    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28278    .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);}
28279    .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;}
28280    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28281    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28282    .settings-modal-body{padding:14px 16px 16px;}
28283    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28284    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28285    .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;}
28286    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28287    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28288    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28289    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28290    .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;}
28291    .tz-select:focus{border-color:var(--oxide);}
28292    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28293    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28294    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28295    .hero{background:linear-gradient(180deg,rgba(255,255,255,0.20),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 28px 28px;margin-bottom:18px;}
28296    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28297    .hero-body{display:block;}
28298    .btn-back{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
28299    .btn-back:hover{background:var(--line);}
28300    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28301    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28302    .delta-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 4px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
28303    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28304    body.dark-theme .delta-title{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
28305    .muted{color:var(--muted);font-size:14px;}
28306    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28307    .vpill{display:inline-flex;flex-direction:column;gap:2px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;}
28308    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28309    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28310    .vpill-arrow{font-size:20px;color:var(--muted);}
28311    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28312    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28313    .delta-card{background:var(--surface-2);border:1px solid var(--line);border-radius:14px;padding:22px 22px;display:flex;flex-direction:column;justify-content:center;min-height:150px;position:relative;cursor:default;}
28314    .delta-card.delta-card-wide{padding:22px 24px;}
28315    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28316    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28317    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28318    .delta-card-from{font-size:15px;color:var(--muted);}
28319    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28320    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28321    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28322    .meta-card-project{font-size:15px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;}
28323    .meta-scope-tag{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:800;padding:3px 10px;border-radius:6px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}
28324    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28325    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28326    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28327    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28328    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28329    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28330    .meta-card-commit{display:block;font-family:ui-monospace,monospace;font-size:28px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:16px;word-break:break-all;}
28331    .meta-card-commit:hover{color:var(--oxide);}
28332    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28333    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28334    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28335    .meta-value{color:var(--text);font-size:13px;}
28336    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28337    .dc-tip{display:none;position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:200;background:rgba(20,12,8,0.96);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:11.5px;font-weight:500;line-height:1.6;width:290px;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);text-transform:none;letter-spacing:0;}
28338    .dc-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.96);}
28339    .delta-card:hover .dc-tip{display:block;}
28340    .export-btn{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s ease;}
28341    .export-btn:hover{background:var(--line);}
28342    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28343    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28344    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28345    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28346    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28347    .delta-card-change.zero{color:var(--muted);background:transparent;}
28348    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28349    .delta-card-pct.pos{color:var(--pos);}
28350    .delta-card-pct.neg{color:var(--neg);}
28351    .delta-card-pct.zero{color:var(--muted);}
28352    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28353    .insight-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1;min-width:120px;position:relative;cursor:default;}
28354    .insight-card.insight-flag{border-color:var(--oxide);}
28355    .insight-card:hover .dc-tip{display:block;}
28356    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28357    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28358    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28359    .insight-label.flag{color:var(--oxide);}
28360    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28361    .insight-val.pos{color:var(--pos);}
28362    .insight-val.neg{color:var(--neg);}
28363    .insight-val.high{color:#c0392a;}
28364    .insight-val.med{color:#926000;}
28365    .insight-val.low{color:var(--pos);}
28366    body.dark-theme .insight-val.high{color:#ff6b6b;}
28367    body.dark-theme .insight-val.med{color:#f0c060;}
28368    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28369    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28370    .fc-row{display:flex;align-items:center;gap:8px;}
28371    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28372    .fc-label{color:var(--muted);}
28373    .fc-modified .fc-count{color:#926000;}
28374    .fc-added .fc-count{color:var(--pos);}
28375    .fc-removed .fc-count{color:var(--neg);}
28376    .fc-unchanged .fc-count{color:var(--muted);}
28377    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28378    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28379    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28380    .chip.modified{background:#fff2d8;color:#926000;}
28381    .chip.added{background:#e8f5ed;color:#1a8f47;}
28382    .chip.removed{background:#fdeaea;color:#b33b3b;}
28383    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28384    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28385    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28386    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28387    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28388    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28389    .tab-btn{padding:6px 16px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s ease;}
28390    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28391    .tab-btn:hover:not(.active){background:var(--line);}
28392    .btn-reset{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
28393    .btn-reset:hover{background:var(--line);}
28394    .table-wrap{width:100%;overflow-x:auto;}
28395    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28396    th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;background:var(--surface-2);}
28397    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28398    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28399    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28400    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28401    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28402    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28403    tr:last-child td{border-bottom:none;}
28404    tr:hover td{background:var(--surface-2);}
28405    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28406    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28407    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28408    /* Fixed layout: column widths come from the colgroup, not from scanning every
28409       row. With auto layout a large file matrix forces the browser to re-measure
28410       all cells on each reflow, which freezes the page during sort/resize. */
28411    #delta-table{table-layout:fixed;}
28412    #delta-table col:nth-child(1){width:32%;}
28413    #delta-table col:nth-child(2){width:11%;}
28414    #delta-table col:nth-child(3){width:11%;}
28415    #delta-table col:nth-child(4){width:16%;}
28416    #delta-table col:nth-child(5){width:10%;}
28417    #delta-table col:nth-child(6){width:10%;}
28418    #delta-table col:nth-child(7){width:10%;}
28419    tr.row-added td{background:rgba(26,143,71,0.04);}
28420    tr.row-removed td{background:rgba(179,59,59,0.06);}
28421    tr.row-modified td{background:rgba(146,96,0,0.04);}
28422    tr.row-unchanged td{color:var(--muted);}
28423    tr.row-unchanged .status-badge{opacity:.65;}
28424    .file-path{font-family:ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;display:inline-block;vertical-align:middle;}
28425    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28426    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28427    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28428    .status-badge.modified{background:#fff2d8;color:#926000;}
28429    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28430    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28431    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28432    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28433    .delta-val{font-weight:700;}
28434    .delta-val.pos{color:var(--pos);}
28435    .delta-val.neg{color:var(--neg);}
28436    .delta-val.zero{color:var(--muted);}
28437    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28438    .from-to strong{color:var(--text);font-weight:700;}
28439    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28440    .from-to .ft-absent{color:var(--muted);font-weight:600;}
28441    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28442    .site-footer a{color:var(--muted);}
28443    body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-reset,body.pdf-mode .filter-tabs,body.pdf-mode .filter-tabs-row,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .settings-modal,body.pdf-mode .site-footer,body.pdf-mode .scope-bar,body.pdf-mode .submod-scope-bar{display:none!important;}
28444    body.pdf-mode{background:#fff!important;}
28445    body.pdf-mode .page{padding:4px 6px 4px!important;}
28446    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28447    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28448    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28449    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28450    .status-dot{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}
28451    .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{display:block;}
28452    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.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;}
28453    @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));}}
28454    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28455    .path-link:hover{color:var(--oxide-2);}
28456    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28457    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28458    a.vpill-id:hover{color:var(--oxide);}
28459    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28460    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28461    .pagination-info{font-size:13px;color:var(--muted);}
28462    .pagination-btns{display:flex;gap:6px;}
28463    .pg-btn{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}
28464    .pg-btn:hover:not(:disabled){background:var(--line);}
28465    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28466    .pg-btn:disabled{opacity:.35;cursor:default;}
28467    .per-page-label{font-size:13px;color:var(--muted);}
28468    select.per-page{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}
28469    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28470    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28471    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28472    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28473    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28474    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28475    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28476    .tab-btn.tab-unchanged{color:var(--muted);}
28477    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28478    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28479    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28480    .nav-dropdown{position:relative;display:inline-flex;}.nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}.nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}.nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}.nav-dropdown-menu a:last-child{border-bottom:none;}.nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}.nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
28481    .submod-scope-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1.5px solid var(--line-strong);border-radius:12px;margin:12px 0 18px;}
28482    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28483    .submod-scope-label{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);flex-shrink:0;white-space:nowrap;}
28484    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28485    .submod-scope-btn{padding:5px 13px;border-radius:7px;border:1.5px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;transition:background .12s ease,border-color .12s ease,color .12s ease;}
28486    .submod-scope-btn:hover{background:var(--line);}
28487    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28488    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28489    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28490    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28491    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28492    body.dark-theme .ic-card{background:var(--surface-2);}
28493    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28494    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28495    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28496    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28497    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28498    .ic-cb{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}.ic-cb:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
28499    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28500    .ic-card-h2-row .ic-card-h2{margin:0;}
28501    .ic-expand-btn{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;margin-left:auto;}
28502    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28503    .ic-svg-modal-ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
28504    .ic-svg-modal-ov.open{display:flex;}
28505    .ic-svg-modal{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:1100px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
28506    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28507    .ic-svg-modal-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}
28508    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28509    .ic-svg-modal-close{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}
28510    .ic-svg-modal-close:hover{background:var(--line);}
28511    .chart-metric-btn{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
28512    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28513    .chart-metric-btn:hover:not(.active){background:var(--line);}
28514    .chart-wrap{width:100%;overflow-x:auto;}
28515    #cmp-tl-svg{display:block;width:100%;}
28516    .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);}
28517    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28518    #ic-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);max-width:240px;white-space:nowrap;}
28519  </style>
28520</head>
28521<body>
28522  {{ loading_overlay|safe }}
28523  <div class="background-watermarks" aria-hidden="true">
28524    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28525    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28526    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28527    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28528    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28529    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28530  </div>
28531  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28532  <div class="top-nav">
28533    <div class="top-nav-inner">
28534      <a class="brand" href="/">
28535        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28536        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28537      </a>
28538      <div class="nav-right">
28539        <a class="nav-pill" href="/">Home</a>
28540        <div class="nav-dropdown">
28541          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
28542          <div class="nav-dropdown-menu">
28543            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
28544          </div>
28545        </div>
28546        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28547        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28548        <div class="nav-dropdown">
28549          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
28550          <div class="nav-dropdown-menu">
28551            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
28552          </div>
28553        </div>
28554        <div class="server-status-wrap" id="server-status-wrap">
28555          <div class="nav-pill server-online-pill" id="server-status-pill">
28556            <span class="status-dot" id="status-dot"></span>
28557            <span id="server-status-label">Server</span>
28558            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28559          </div>
28560          <div class="server-status-tip">
28561            OxideSLOC is running — accessible on your network.
28562            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28563          </div>
28564        </div>
28565        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28566          <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>
28567        </button>
28568        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28569          <svg class="icon-moon" viewBox="0 0 24 24"><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>
28570          <svg class="icon-sun" viewBox="0 0 24 24"><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>
28571        </button>
28572      </div>
28573    </div>
28574  </div>
28575
28576  <div class="page">
28577    <section class="hero">
28578      <div class="hero-header">
28579        <div>
28580          <h1 class="delta-title">Scan Delta</h1>
28581          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
28582          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
28583            {% if let Some(sub) = active_submodule %}
28584            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
28585            {% else if super_scope_active %}
28586            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
28587            {% else %}
28588            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
28589            {% endif %}
28590            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
28591          </div>
28592        </div>
28593        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
28594          <a class="btn-back" href="/compare-scans">
28595            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg>
28596            Compare Scans
28597          </a>
28598          <div class="export-group" style="margin-top:12px;">
28599            <button type="button" class="export-btn" id="page-export-html-btn" title="Export page as HTML report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export HTML</button>
28600            <button type="button" class="export-btn" id="page-export-pdf-btn" title="Export page as PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
28601          </div>
28602        </div>
28603      </div>
28604      {% if has_any_submodule_data %}
28605      <div class="submod-scope-bar">
28606        <span class="submod-scope-label">
28607          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
28608          Scope:
28609        </span>
28610        <div class="submod-scope-divider"></div>
28611        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
28612           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
28613           title="All files — super-repo and all submodules combined">Full scan</a>
28614        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
28615           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
28616           title="Only files that are not part of any submodule">Super-repo only</a>
28617        {% for sub in submodule_options %}
28618        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
28619           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
28620           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
28621        {% endfor %}
28622      </div>
28623      {% endif %}
28624      <div class="hero-body">
28625      <div class="meta-strip">
28626        <div class="delta-card delta-card-meta">
28627          <div class="meta-card-header">
28628            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
28629            <div class="meta-card-project-col">
28630              <div class="meta-card-project">{{ project_name }}</div>
28631              {% if has_any_submodule_data %}
28632              {% if let Some(sub) = active_submodule %}
28633              <span class="meta-scope-tag scope-sub"><svg width="11" height="11" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>{{ sub }}</span>
28634              {% else if super_scope_active %}
28635              <span class="meta-scope-tag scope-super"><svg width="11" height="11" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>Super-repo only</span>
28636              {% else %}
28637              <span class="meta-scope-tag scope-full"><svg width="11" height="11" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>Full scan</span>
28638              {% endif %}
28639              {% endif %}
28640            </div>
28641          </div>
28642          {% if !baseline_git_commit.is_empty() %}
28643          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
28644          {% else %}
28645          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
28646          {% endif %}
28647          <div class="meta-card-rows">
28648            <div class="meta-card-row"><span class="meta-label">Branch:</span>{% if !baseline_git_branch.is_empty() %}<span class="git-chip">{{ baseline_git_branch }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
28649            <div class="meta-card-row"><span class="meta-label">Last commit on:</span>{% if let Some(date) = baseline_git_commit_date %}<span class="meta-value">{{ date }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
28650            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
28651            <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value ts-local" data-utc-ms="{{ baseline_timestamp_utc_ms }}">{{ baseline_timestamp }}</span></div>
28652            {% if let Some(tags) = baseline_git_tags %}
28653            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28654            {% endif %}
28655          </div>
28656        </div>
28657        <div class="delta-card delta-card-meta">
28658          <div class="meta-card-header">
28659            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
28660            <div class="meta-card-project-col">
28661              <div class="meta-card-project">{{ project_name }}</div>
28662              {% if has_any_submodule_data %}
28663              {% if let Some(sub) = active_submodule %}
28664              <span class="meta-scope-tag scope-sub"><svg width="11" height="11" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>{{ sub }}</span>
28665              {% else if super_scope_active %}
28666              <span class="meta-scope-tag scope-super"><svg width="11" height="11" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>Super-repo only</span>
28667              {% else %}
28668              <span class="meta-scope-tag scope-full"><svg width="11" height="11" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>Full scan</span>
28669              {% endif %}
28670              {% endif %}
28671            </div>
28672          </div>
28673          {% if !current_git_commit.is_empty() %}
28674          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
28675          {% else %}
28676          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
28677          {% endif %}
28678          <div class="meta-card-rows">
28679            <div class="meta-card-row"><span class="meta-label">Branch:</span>{% if !current_git_branch.is_empty() %}<span class="git-chip">{{ current_git_branch }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
28680            <div class="meta-card-row"><span class="meta-label">Last commit on:</span>{% if let Some(date) = current_git_commit_date %}<span class="meta-value">{{ date }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
28681            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
28682            <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value ts-local" data-utc-ms="{{ current_timestamp_utc_ms }}">{{ current_timestamp }}</span></div>
28683            {% if let Some(tags) = current_git_tags %}
28684            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
28685            {% endif %}
28686          </div>
28687        </div>
28688      </div>
28689      <div class="delta-strip">
28690        <div class="delta-card">
28691          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
28692          <div class="delta-card-label">Code lines</div>
28693          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
28694          <div class="delta-card-to">{{ current_code_fmt }}</div>
28695          {% if code_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ code_lines_delta_str }}</span><div class="delta-card-pct pos">{{ code_lines_pct_str }}</div>
28696          {% else if code_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ code_lines_delta_str }}</span><div class="delta-card-pct neg">{{ code_lines_pct_str }}</div>
28697          {% else %}<div class="delta-card-pct zero">±0%</div>
28698          {% endif %}
28699        </div>
28700        <div class="delta-card">
28701          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
28702          <div class="delta-card-label">Files analyzed</div>
28703          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
28704          <div class="delta-card-to">{{ current_files_fmt }}</div>
28705          {% if files_analyzed_delta_class == "pos" %}<span class="delta-card-change pos">{{ files_analyzed_delta_str }}</span><div class="delta-card-pct pos">{{ files_analyzed_pct_str }}</div>
28706          {% else if files_analyzed_delta_class == "neg" %}<span class="delta-card-change neg">{{ files_analyzed_delta_str }}</span><div class="delta-card-pct neg">{{ files_analyzed_pct_str }}</div>
28707          {% else %}<div class="delta-card-pct zero">±0%</div>
28708          {% endif %}
28709        </div>
28710        <div class="delta-card">
28711          <div class="dc-tip">Comment-only lines per the active parser policy.<br>A rise indicates more docs; a drop may reflect comment cleanup.</div>
28712          <div class="delta-card-label">Comment lines</div>
28713          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
28714          <div class="delta-card-to">{{ current_comments_fmt }}</div>
28715          {% if comment_lines_delta_class == "pos" %}<span class="delta-card-change pos">{{ comment_lines_delta_str }}</span><div class="delta-card-pct pos">{{ comment_lines_pct_str }}</div>
28716          {% else if comment_lines_delta_class == "neg" %}<span class="delta-card-change neg">{{ comment_lines_delta_str }}</span><div class="delta-card-pct neg">{{ comment_lines_pct_str }}</div>
28717          {% else %}<div class="delta-card-pct zero">±0%</div>
28718          {% endif %}
28719        </div>
28720        {{ coverage_delta_card|safe }}
28721        <div class="delta-card delta-card-wide">
28722          <div class="dc-tip">Per-file breakdown.<br>Modified = at least one count changed.<br>Unchanged = identical counts in both scans.<br>Added/Removed = only in one scan.</div>
28723          <div class="delta-card-label">File changes</div>
28724          <div class="file-changes-grid">
28725            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
28726            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
28727            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
28728            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
28729          </div>
28730        </div>
28731      </div>
28732      <div class="insights-panel">
28733        <div class="insight-card">
28734          <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans.<br>Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
28735          <div class="insight-label">Lines Added</div>
28736          <div class="insight-val pos">+{{ code_lines_added }}</div>
28737          <div class="insight-sub">New or grown source lines</div>
28738        </div>
28739        <div class="insight-card">
28740          <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans.<br>Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
28741          <div class="insight-label">Lines Removed</div>
28742          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
28743          <div class="insight-sub">Deleted or shrunk source lines</div>
28744        </div>
28745        <div class="insight-card">
28746          <div class="dc-tip up">Measures total editing activity relative to codebase size.<br>Formula: (lines added + lines removed) &divide; baseline code lines &times; 100%.<br>Above 20% = high activity<br>5&ndash;20% = normal velocity<br>Below 5% = stable baseline.</div>
28747          <div class="insight-label">Churn Rate</div>
28748          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
28749          <div class="insight-sub">{% if new_scope %}No prior baseline for this scope{% else if churn_rate_class == "high" %}High activity — verify scope{% else if churn_rate_class == "med" %}Normal development velocity{% else %}Stable baseline{% endif %} · (added + removed) ÷ baseline</div>
28750        </div>
28751        {% if scope_flag %}
28752        <div class="insight-card insight-flag">
28753          <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new.<br>Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline.<br>This often signals a large feature branch, a bulk import, or a generated-file inclusion.<br>Review the file-level delta below to confirm scope.{% endif %}</div>
28754          <div class="insight-label flag">Scope Signal</div>
28755          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
28756          <div class="insight-sub">{% if new_scope %}New scope — no prior baseline for this selection{% else %}Added &gt; 20% of baseline — large feature addition detected{% endif %}</div>
28757        </div>
28758        {% endif %}
28759      </div>
28760      </div>
28761    </section>
28762
28763    <section class="panel" id="inline-charts-section">
28764      <div class="panel-title">Scan Delta Charts</div>
28765      <div class="ic-grid">
28766        <div class="ic-card" style="grid-column:span 2">
28767          <div class="ic-card-h2-row">
28768            <span class="ic-card-h2">Timeline</span>
28769            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
28770              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
28771              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
28772              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
28773              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
28774              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
28775            </div>
28776            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
28777          </div>
28778          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
28779        </div>
28780        <div class="ic-card">
28781          <div class="ic-card-h2-row"><span class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</span><button class="ic-expand-btn" data-expand-src="ic-c1" data-expand-title="Code Metrics — Baseline vs Current">&#x2922; Full View</button></div>
28782          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#C45C10"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#2A6846"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#D4A017"></span><span style="color:#D4A017;font-weight:600">Comments</span></span></div>
28783          <div id="ic-c1"></div>
28784        </div>
28785        <div class="ic-card" id="ic-lang-card">
28786          <div class="ic-card-h2-row"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="ic-c3" data-expand-title="Language Code Delta">&#x2922; Full View</button></div>
28787          <div id="ic-c3"></div>
28788        </div>
28789        <div class="ic-card">
28790          <div class="ic-card-h2-row"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="ic-c2" data-expand-title="Delta by Metric">&#x2922; Full View</button></div>
28791          <div id="ic-c2"></div>
28792        </div>
28793        <div class="ic-card">
28794          <div class="ic-card-h2-row"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="ic-c4" data-expand-title="File Change Distribution">&#x2922; Full View</button></div>
28795          <div id="ic-c4"></div>
28796        </div>
28797      </div>
28798      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
28799        <div class="ic-svg-modal">
28800          <div class="ic-svg-modal-hdr">
28801            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
28802            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
28803          </div>
28804          <div id="ic-svg-modal-body"></div>
28805        </div>
28806      </div>
28807    </section>
28808
28809    <section class="panel">
28810      <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
28811      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
28812        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
28813          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
28814          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
28815          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
28816          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
28817          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
28818        </div>
28819        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28820          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
28821          <div class="export-group">
28822            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
28823            <button type="button" class="export-btn" id="delta-csv-btn">
28824              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
28825              CSV
28826            </button>
28827            <button type="button" class="export-btn" id="delta-xls-btn">
28828              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
28829              Excel
28830            </button>
28831          </div>
28832        </div>
28833      </div>
28834
28835      <div class="table-wrap">
28836      <table id="delta-table">
28837        <colgroup>
28838          <col>
28839          <col>
28840          <col>
28841          <col>
28842          <col>
28843          <col>
28844          <col>
28845        </colgroup>
28846        <thead>
28847          <tr id="delta-thead">
28848            <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28849            <th class="sortable hide-sm" data-sort-col="language" data-sort-type="str">Language<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28850            <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28851            <th class="sortable" data-sort-col="baseline_code" data-sort-type="num">Code before → after<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28852            <th class="sortable" data-sort-col="code_delta" data-sort-type="num">Code &Delta;<sup>*</sup><span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28853            <th class="sortable hide-sm" data-sort-col="comment_delta" data-sort-type="num">Comment &Delta;<sup>*</sup><span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28854            <th class="sortable" data-sort-col="total_delta" data-sort-type="num">Total &Delta;<sup>*</sup><span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
28855          </tr>
28856        </thead>
28857        <tbody id="delta-tbody">
28858          {% for row in file_rows %}
28859          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
28860              data-path="{{ row.relative_path }}"
28861              data-language="{{ row.language }}"
28862              data-baseline-code="{{ row.baseline_code }}"
28863              data-current-code="{{ row.current_code }}"
28864              data-code-delta="{{ row.code_delta_str }}"
28865              data-comment-delta="{{ row.comment_delta_str }}"
28866              data-total-delta="{{ row.total_delta_str }}"
28867              data-orig-idx="">
28868            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
28869            <td class="hide-sm">{{ row.language }}</td>
28870            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
28871            <td><span class="from-to" data-baseline="{{ row.baseline_code }}" data-current="{{ row.current_code }}">{% if row.baseline_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.baseline_code_display }}</strong>{% endif %}<span class="ft-sep">→</span>{% if row.current_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.current_code_display }}</strong>{% endif %}</span></td>
28872            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
28873            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
28874            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
28875          </tr>
28876          {% endfor %}
28877        </tbody>
28878      </table>
28879      </div>
28880      <div class="pagination">
28881        <span class="pagination-info" id="pg-range-label"></span>
28882        <div class="pagination-btns" id="pg-btns"></div>
28883        <div class="flex-row">
28884          <span class="per-page-label">Show</span>
28885          <select class="per-page" id="per-page-sel">
28886            <option value="10">10 per page</option>
28887            <option value="25" selected>25 per page</option>
28888            <option value="50">50 per page</option>
28889            <option value="100">100 per page</option>
28890          </select>
28891        </div>
28892      </div>
28893    </section>
28894  </div>
28895
28896  <div id="ic-tt"></div>
28897
28898  <footer class="site-footer">
28899    local code analysis - metrics, history and reports
28900    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} \u2014 Mode: Local</em>
28901    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28902    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28903    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28904    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28905  </footer>
28906
28907  <script nonce="{{ csp_nonce }}">
28908    (function () {
28909      var storageKey = 'oxide-sloc-theme';
28910      var body = document.body;
28911      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28912      var toggle = document.getElementById('theme-toggle');
28913      if (toggle) toggle.addEventListener('click', function () {
28914        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28915        body.classList.toggle('dark-theme', next === 'dark');
28916        try { localStorage.setItem(storageKey, next); } catch(e) {}
28917      });
28918
28919      (function randomizeWatermarks() {
28920        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28921        if (!wms.length) return;
28922        var placed = [];
28923        function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}
28924        function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}
28925        var half=Math.floor(wms.length/2);
28926        wms.forEach(function(img,i){var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;});
28927      })();
28928
28929      (function spawnCodeParticles() {
28930        var container = document.getElementById('code-particles');
28931        if (!container) return;
28932        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'];
28933        for (var i = 0; i < 38; i++) {
28934          (function(idx) {
28935            var el = document.createElement('span');
28936            el.className = 'code-particle';
28937            el.textContent = snippets[idx % snippets.length];
28938            var left = Math.random() * 94 + 2;
28939            var top = Math.random() * 88 + 6;
28940            var dur = (Math.random() * 10 + 9).toFixed(1);
28941            var delay = (Math.random() * 18).toFixed(1);
28942            var rot = (Math.random() * 26 - 13).toFixed(1);
28943            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28944            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
28945            container.appendChild(el);
28946          })(i);
28947        }
28948      })();
28949    })();
28950
28951    var activeStatusFilter = 'all';
28952    var deltaPerPage = 25, deltaCurrPage = 1;
28953
28954    function openFolder(path) {
28955      fetch('/open-path?path=' + encodeURIComponent(path))
28956        .then(function (r) { return r.json(); })
28957        .then(function (d) {
28958          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
28959        })
28960        .catch(function () {});
28961    }
28962
28963    // \u2500\u2500 File-matrix model (windowed render) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
28964    // The server renders every row once; we lift them into a plain-data array and
28965    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
28966    // filtering run on the array (no DOM churn) and each render rebuilds just one
28967    // page (~25 rows). This keeps every interaction O(page) instead of O(all
28968    // files): a 28k-row table previously re-touched every node on each click
28969    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
28970    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
28971
28972    function parseDeltaNum(str) {
28973      if (!str || str === '\u2014') return 0;
28974      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
28975    }
28976
28977    function captureDelta() {
28978      var tbody = document.getElementById('delta-tbody');
28979      if (!tbody) return;
28980      var rows = tbody.querySelectorAll('.delta-row');
28981      for (var i = 0; i < rows.length; i++) {
28982        var r = rows[i];
28983        DELTA.push({
28984          h: r.innerHTML,
28985          cls: r.className,
28986          path: r.getAttribute('data-path') || '',
28987          lang: r.getAttribute('data-language') || '',
28988          status: r.getAttribute('data-status') || '',
28989          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
28990          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
28991          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
28992          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
28993          td: parseDeltaNum(r.getAttribute('data-total-delta')),
28994          bcs: r.getAttribute('data-baseline-code') || '',
28995          ccs: r.getAttribute('data-current-code') || '',
28996          cds: r.getAttribute('data-code-delta') || '',
28997          cmds: r.getAttribute('data-comment-delta') || '',
28998          tds: r.getAttribute('data-total-delta') || ''
28999        });
29000      }
29001      tbody.innerHTML = '';
29002    }
29003
29004    function applyDeltaQuery() {
29005      var v = (activeStatusFilter === 'all') ? DELTA.slice()
29006        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29007      if (sortCol) {
29008        var asc = sortOrder === 'asc';
29009        v.sort(function(a, b) {
29010          var va, vb;
29011          if (sortCol === 'path') { va = a.path; vb = b.path; }
29012          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29013          else if (sortCol === 'status') { va = a.status; vb = b.status; }
29014          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29015          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29016          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29017          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29018          else { return 0; }
29019          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29020          return va < vb ? 1 : va > vb ? -1 : 0;
29021        });
29022      }
29023      _deltaView = v;
29024      deltaCurrPage = 1;
29025      renderDeltaPage();
29026    }
29027
29028    function renderDeltaPage() {
29029      var total = _deltaView.length;
29030      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29031      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29032      if (deltaCurrPage < 1) deltaCurrPage = 1;
29033      var start = (deltaCurrPage - 1) * deltaPerPage;
29034      var end = Math.min(start + deltaPerPage, total);
29035      var tbody = document.getElementById('delta-tbody');
29036      if (tbody) {
29037        var html = '';
29038        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29039        tbody.innerHTML = html;
29040      }
29041      var rl = document.getElementById('pg-range-label');
29042      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29043      var btns = document.getElementById('pg-btns');
29044      if (!btns) return;
29045      btns.innerHTML = '';
29046      if (totalPages <= 1) return;
29047      function makeBtn(lbl, pg, active, disabled) {
29048        var b = document.createElement('button');
29049        b.className = 'pg-btn' + (active ? ' active' : '');
29050        b.textContent = lbl; b.disabled = disabled;
29051        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29052        return b;
29053      }
29054      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29055      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29056      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29057      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29058    }
29059
29060    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29061
29062    function filterRows(status, btn) {
29063      activeStatusFilter = status;
29064      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29065        b.classList.remove('active');
29066      });
29067      if (btn) btn.classList.add('active');
29068      applyDeltaQuery();
29069    }
29070
29071    // ── Sorting ──────────────────────────────────────────────────────────────
29072    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29073    sortHeaders.forEach(function(th) {
29074      th.addEventListener('click', function(e) {
29075        if (e.target.classList.contains('col-resize-handle')) return;
29076        var col = th.dataset.sortCol;
29077        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29078        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29079        th.classList.add('sort-' + sortOrder);
29080        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29081        applyDeltaQuery();
29082      });
29083    });
29084
29085    // ── Column resize ─────────────────────────────────────────────────────────
29086    (function() {
29087      var table = document.getElementById('delta-table');
29088      if (!table) return;
29089      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29090      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29091      ths.forEach(function(th, i) {
29092        var handle = th.querySelector('.col-resize-handle');
29093        if (!handle || !cols[i]) return;
29094        handle.addEventListener('mousedown', function(e) {
29095          e.stopPropagation(); e.preventDefault();
29096          // Lock every column to its current rendered px width and size the table
29097          // to the column total. With table-layout:fixed + width:100% the table is
29098          // pinned to the container, so widening one <col> only rebalances the rest
29099          // and the drag looks inert; pinning px widths lets the column actually
29100          // grow while the wrapper (overflow-x:auto) scrolls.
29101          var startTableW = 0;
29102          for (var k = 0; k < ths.length; k++) {
29103            if (!cols[k]) continue;
29104            var w = ths[k].getBoundingClientRect().width;
29105            cols[k].style.width = w + 'px';
29106            startTableW += w;
29107          }
29108          table.style.width = startTableW + 'px';
29109          var startX = e.clientX;
29110          var startW = ths[i].getBoundingClientRect().width;
29111          handle.classList.add('dragging');
29112          function onMove(ev) {
29113            var newW = Math.max(40, startW + ev.clientX - startX);
29114            cols[i].style.width = newW + 'px';
29115            table.style.width = (startTableW + (newW - startW)) + 'px';
29116          }
29117          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29118          document.addEventListener('mousemove', onMove);
29119          document.addEventListener('mouseup', onUp);
29120        });
29121      });
29122    })();
29123
29124    // ── Reset ─────────────────────────────────────────────────────────────────
29125    window.resetDeltaTable = function() {
29126      sortCol = null; sortOrder = 'asc';
29127      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29128      var table = document.getElementById('delta-table');
29129      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29130      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29131      activeStatusFilter = 'all';
29132      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29133      var allBtn = document.querySelector('.tab-btn');
29134      if (allBtn) allBtn.classList.add('active');
29135      applyDeltaQuery();
29136    };
29137
29138    // Compact number formatter (shared by the delta table; charts define their own locally)
29139    function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
29140    function fmtFull(n){return Number(n).toLocaleString();}
29141
29142    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29143    function fmtFromTo() {
29144      var tbody = document.getElementById('delta-tbody');
29145      if (!tbody) return;
29146      tbody.querySelectorAll('.delta-row').forEach(function(row) {
29147        var status = row.dataset.status || '';
29148        var ft = row.querySelector('.from-to');
29149        if (!ft) return;
29150        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29151        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29152        var strongs = ft.querySelectorAll('strong');
29153        // Apply fmt() to non-absent strong values
29154        strongs.forEach(function(el) {
29155          var n = parseInt(el.textContent, 10);
29156          if (!isNaN(n)) el.textContent = fmtFull(n);
29157        });
29158        // Safety: force dash for genuinely absent sides
29159        if (status === 'added' && bv === 0) {
29160          var bs = ft.querySelector('strong:first-of-type');
29161          if (bs && bs.textContent === '0') {
29162            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29163          }
29164        }
29165        if (status === 'removed' && cv === 0) {
29166          var cs = ft.querySelector('strong:last-of-type');
29167          if (cs && cs.textContent === '0') {
29168            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29169          }
29170        }
29171      });
29172    }
29173    // Initialize: format the server-rendered rows, lift them into the data model
29174    // (which also clears the DOM), then render only the first page.
29175    fmtFromTo();
29176    captureDelta();
29177    applyDeltaQuery();
29178
29179    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29180    (function() {
29181      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29182        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29183      });
29184      var resetBtn = document.getElementById('delta-reset-btn');
29185      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29186      var csvBtn = document.getElementById('delta-csv-btn');
29187      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29188      var xlsBtn = document.getElementById('delta-xls-btn');
29189      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29190      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29191      function sdFetchUri(path) {
29192        return fetch(path).then(function(r){return r.blob();}).then(function(b){
29193          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29194        }).catch(function(){return '';});
29195      }
29196      function sdInlineImgs(html, cb) {
29197        var paths=[], seen={};
29198        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29199        if(!paths.length){cb(html);return;}
29200        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29201          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29202          .catch(function(){cb(html);});
29203      }
29204      function buildFullPageHtml(pdfMode) {
29205        if(pdfMode) document.body.classList.add('pdf-mode');
29206        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29207        renderDeltaPage();
29208        var html = document.documentElement.outerHTML;
29209        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29210        if(pdfMode) document.body.classList.remove('pdf-mode');
29211        return html;
29212      }
29213      var chartsBtn = document.getElementById('delta-charts-btn');
29214      if (chartsBtn) chartsBtn.addEventListener('click', function() {
29215        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29216        sdInlineImgs(buildFullPageHtml(false), function(html) {
29217          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29218          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29219          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29220          btn.disabled=false;btn.innerHTML=orig;
29221        });
29222      });
29223      var pageHtmlBtn = document.getElementById('page-export-html-btn');
29224      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29225        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29226        sdInlineImgs(buildFullPageHtml(false), function(html) {
29227          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29228          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29229          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29230          btn.disabled=false;btn.innerHTML=orig;
29231        });
29232      });
29233      // PDF export — clean document-style report, not a web page screenshot
29234      function buildDeltaPdfHtml() {
29235        var sd=_sd, dr=getDeltaExportRows();
29236        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29237        function pct(b,c){b=Number(b);c=Number(c);if(!b)return c>0?'new':'±0%';var v=(c-b)/b*100,t=v.toFixed(1);return(t==='0.0'||t==='-0.0')?'±0%':(v>0?'+':'')+t+'%';}
29238        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29239        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29240        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29241        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29242        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29243        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29244        function fmtN(n){return Number(n).toLocaleString();}
29245        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29246        function delt(v){var s=String(v==null?'\u2014':v);if(!s||s==='0'||s==='\u2014')return'<span>'+esc(s)+'</span>';return s.charAt(0)==='-'?'<span style="color:#b23030;font-weight:700">'+esc(s)+'</span>':'<span style="color:#2a6846;font-weight:700">'+esc(s)+'</span>';}
29247        var lm={};
29248        dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0,c=parseInt(r[4])||0;if(!lm[l])lm[l]={f:0,d:0,c:0};lm[l].f++;lm[l].d+=d;lm[l].c+=c;});
29249        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29250        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29251        // The header/footer flow in normal document order (NOT position:fixed).
29252        // A fixed header repeats on every printed page in Chromium and overlaps
29253        // the content beneath it — silently swallowing the first few table rows of
29254        // pages 2+ and clipping the summary cards on page 1. Letting the header
29255        // flow once at the top and relying on the table's <thead> (which Chromium
29256        // repeats per page) keeps every row visible. `.body` keeps a small inset
29257        // so nothing bleeds to the sheet edge.
29258        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29259          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29260          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29261          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29262          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29263          '.ph-brand em{color:#c45c10;font-style:normal;}'+
29264          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29265          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29266          '.info-bar{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29267          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29268          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29269          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29270          '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29271          '.body{padding:12px 18px 0;}'+
29272          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29273          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29274          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29275          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29276          '.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:8px 12px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
29277          '.meta>div{flex:1 1 0;}'+
29278          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29279          '.sec{margin-bottom:10px;}'+
29280          '.sh{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29281          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29282          '.pg-rhdr-in{display:flex;justify-content:space-between;align-items:center;padding:6px 11px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;}'+
29283          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29284          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29285          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29286          'th{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29287          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29288          'tr:nth-child(even) td{background:#faf8f6;}'+
29289          '.rfoot{position:fixed;left:0;right:0;bottom:0;height:20px;background:#1a2035;color:#9fb0c8;font-size:9px;display:flex;justify-content:space-between;align-items:center;padding:0 14px;box-sizing:border-box;z-index:99;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29290          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29291          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29292          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29293          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29294          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29295          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29296          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29297          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29298          '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29299          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29300          '.fcc-n{font-size:18px;font-weight:900;}'+
29301          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29302        var fileRows=dchg.map(function(r){
29303          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29304          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29305            '<td style="'+ss+'">'+esc(st)+'</td>'+
29306            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29307            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29308            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29309        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29310        var more='';
29311        var langRows=langs.map(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);return'<tr><td>'+esc(l)+'</td><td style="text-align:right">'+fmtN(e.f)+'</td><td style="text-align:right">'+fmtN(e.c)+'</td><td style="text-align:right">'+delt(dv)+'</td></tr>';}).join('');
29312        var extraCards='';
29313        if(Number(sd.btests||0)>0||Number(sd.ctests||0)>0){extraCards+='<div class="mcard"><div class="mc-l">Tests Detected</div><div class="mc-v">'+fullN(sd.ctests)+'</div><div class="mc-b">Before: '+fullN(sd.btests)+'</div><div class="mc-p '+pcls(sd.btests,sd.ctests)+'">'+pct(sd.btests,sd.ctests)+'</div></div>';}
29314        if(sd.bcov!=null||sd.ccov!=null){var _cc=(sd.ccov!=null?Number(sd.ccov).toFixed(1)+'%':'—'),_cb=(sd.bcov!=null?Number(sd.bcov).toFixed(1)+'%':'—');extraCards+='<div class="mcard"><div class="mc-l">Coverage</div><div class="mc-v">'+_cc+'</div><div class="mc-b">Before: '+_cb+'</div></div>';}
29315        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29316          '<div class="pdf-header">'+
29317          '<div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Scan Delta</div><div class="ph-date">'+esc(now)+'</div></div>'+
29318          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29319          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29320          '</div>'+
29321          '<div class="body">'+
29322          '<div class="sec"><p class="sh">Summary Metrics</p>'+
29323          '<div class="msec">'+
29324          '<div class="mcard"><div class="mc-l">Code Lines</div><div class="mc-v">'+fullN(sd.cc)+'</div><div class="mc-b">Before: '+fullN(sd.bc)+'</div><div class="mc-p '+pcls(sd.bc,sd.cc)+'">'+pct(sd.bc,sd.cc)+'</div></div>'+
29325          '<div class="mcard"><div class="mc-l">Files Analyzed</div><div class="mc-v">'+fullN(sd.cf)+'</div><div class="mc-b">Before: '+fullN(sd.bf)+'</div><div class="mc-p '+pcls(sd.bf,sd.cf)+'">'+pct(sd.bf,sd.cf)+'</div></div>'+
29326          '<div class="mcard"><div class="mc-l">Comment Lines</div><div class="mc-v">'+fullN(sd.ccm)+'</div><div class="mc-b">Before: '+fullN(sd.bcm)+'</div><div class="mc-p '+pcls(sd.bcm,sd.ccm)+'">'+pct(sd.bcm,sd.ccm)+'</div></div>'+
29327          '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v" style="color:#2a6846">+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
29328          '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v" style="color:#b23030">−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
29329          '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v" style="color:#1a2035">'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
29330          extraCards+'</div></div>'+
29331          '<div class="sec"><p class="sh">File Changes</p>'+
29332          '<div class="fcsec">'+
29333          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29334          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29335          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29336          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29337          '</div></div>'+
29338          (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
29339          '<div class="sec">'+
29340          '<table><thead>'+
29341          '<tr class="pg-rhdr"><th colspan="6"><div class="pg-rhdr-in"><span>File Delta &middot; '+fmtN(dchg.length)+' changed of '+fmtN(dr.length)+' files</span><span class="pg-rhdr-r"><em>oxide</em>-sloc &middot; Scan Delta &middot; '+esc(projName)+'</span></div></th></tr>'+
29342          '<tr><th>File</th><th>Language</th><th>Status</th>'+
29343          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29344          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29345          '</div>'+
29346          '<div class="rfoot">'+
29347          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29348          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29349          '</div>'+
29350          '</body></html>';
29351      }
29352      function doDeltaPdf(btn) {
29353        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29354      }
29355      var pdfBtn = document.getElementById('delta-pdf-btn');
29356      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29357      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29358      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29359      if (location.protocol === 'file:') {
29360        [pageHtmlBtn, chartsBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='Already viewing an exported HTML file'; b.textContent='Export HTML'; } });
29361        [pdfBtn, pagePdfBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='PDF export requires a running server'; b.textContent='Export PDF'; } });
29362      }
29363      var ppSel = document.getElementById('per-page-sel');
29364      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29365      var pathLink = document.getElementById('project-path-link');
29366      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29367    })();
29368
29369    // ── Export helpers ────────────────────────────────────────────────────────
29370    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
29371    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29372    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);}
29373    function slocMakeXlsx(fname,sd,dr){
29374      var enc=new TextEncoder();
29375      // CRC-32 table
29376      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;}
29377      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;}
29378      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29379      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29380      // Shared string table
29381      var ss=[],si={};
29382      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29383      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29384      // Worksheet builder — each WS() call gets its own row counter R
29385      function WS(){
29386        var R=0,buf=[];
29387        function cl(c){return String.fromCharCode(65+c);}
29388        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29389          '<v>'+S(v)+'</v></c>';}
29390        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29391          (st?' s="'+st+'"':'')+'>'+
29392          '<v>'+(+v)+'</v></c>';}
29393        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29394        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29395          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29396          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29397          '<sheetFormatPr defaultRowHeight="15"/>'+
29398          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29399        return{sc:sc,nc:nc,row:row,xml:xml};
29400      }
29401      // Language breakdown
29402      var lm={};
29403      dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0;if(!lm[l])lm[l]={f:0,d:0};lm[l].f++;lm[l].d+=d;});
29404      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29405      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29406      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29407      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29408      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29409      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29410      function _fp(b,c,st){if(st==='added'&&b===0)return'new';if(st==='removed')return'-100.0%';if(st==='unchanged')return'0.0%';return b>0?_sp(c-b,b):'';}
29411      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29412      // Summary sheet
29413      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29414      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29415      r1(s1(0,proj,2));
29416      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29417      r1('');
29418      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29419      r1(s1(0,'Code Lines')+n1(1,sd.bc,4)+n1(2,sd.cc,4)+s1(3,sd.cd,dstyle(sd.cd))+s1(4,_sp(sd.cc-sd.bc,sd.bc),_ps(_sp(sd.cc-sd.bc,sd.bc))));
29420      r1(s1(0,'Files Analyzed')+n1(1,sd.bf,4)+n1(2,sd.cf,4)+s1(3,sd.fd,dstyle(sd.fd))+s1(4,_sp(sd.cf-sd.bf,sd.bf),_ps(_sp(sd.cf-sd.bf,sd.bf))));
29421      r1(s1(0,'Comment Lines')+n1(1,sd.bcm,4)+n1(2,sd.ccm,4)+s1(3,sd.cmd,dstyle(sd.cmd))+s1(4,_sp(sd.ccm-sd.bcm,sd.bcm),_ps(_sp(sd.ccm-sd.bcm,sd.bcm))));
29422      r1('');
29423      r1(s1(0,'FILE CHANGES',8));
29424      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29425      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29426      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29427      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29428      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29429      if(langs.length){
29430        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29431        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29432        langs.forEach(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);r1(s1(0,l)+n1(1,e.f,4)+s1(2,dv,dstyle(dv)));});
29433      }
29434      r1('');r1(s1(0,'SCAN METADATA',8));
29435      r1(s1(1,_blabel)+s1(2,_clabel));
29436      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29437      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29438      var sh1=W1.xml('<col min="1" max="1" width="24" customWidth="1"/><col min="2" max="4" width="14" customWidth="1"/><col min="5" max="5" width="12" customWidth="1"/>');
29439      // File Delta sheet
29440      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29441      r2(s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3)+s2(3,'Code ('+_blabel+')',3)+s2(4,'Code ('+_clabel+')',3)+s2(5,'Code Delta',3)+s2(6,'Comment Delta',3)+s2(7,'Total Delta',3)+s2(8,'% Code Chg',3));
29442      dr.forEach(function(r){var b=parseInt(r[3])||0,c=parseInt(r[4])||0,st=r[2]||'',fp=_fp(b,c,st);r2(s2(0,r[0])+s2(1,r[1])+s2(2,r[2])+n2(3,r[3],4)+n2(4,r[4],4)+s2(5,r[5],dstyle(r[5]))+s2(6,r[6],dstyle(r[6]))+s2(7,r[7],dstyle(r[7]))+s2(8,fp,_ps(fp)));});
29443      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29444      // Shared strings XML
29445      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29446        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29447        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29448      // XLSX file map
29449      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29450      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/worksheets/sheet2.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>',
29451        '_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>',
29452        '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/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
29453        'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="16384" windowHeight="8192"/></bookViews><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="File Delta" sheetId="2" r:id="rId2"/></sheets></workbook>',
29454        'xl/styles.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="8"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="14"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font><font><sz val="10"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF155724"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF721C24"/><name val="Calibri"/></font><font><sz val="11"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFD4EDDA"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF8D7DA"/></patternFill></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="9"><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="2" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
29455        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29456      // ZIP packer — STORED (no compression), compatible with all XLSX readers
29457      var zparts=[],zcds=[],zoff=0,znf=0;
29458      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29459       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29460      ].forEach(function(name){
29461        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29462        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]);
29463        var entry=new Uint8Array(lha.length+nb.length+sz);
29464        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29465        zparts.push(entry);
29466        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));
29467        var cde=new Uint8Array(cda.length+nb.length);
29468        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29469        zcds.push(cde);zoff+=entry.length;znf++;
29470      });
29471      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29472      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]);
29473      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29474      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29475      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29476      zout.set(new Uint8Array(ea),zpos);
29477      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29478      var xurl=URL.createObjectURL(xblob);
29479      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29480      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29481      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29482    }
29483    function slocCsv(fname,hdrs,rows){var parts=[hdrs.map(slocEscCsv).join(',')];rows.forEach(function(r){parts.push(r.map(slocEscCsv).join(','));});slocDownload(parts.join('\r\n'),fname,'text/csv;charset=utf-8;');}
29484    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29485    function getExportFilename(ext){return _exportBase+'.'+ext;}
29486
29487    var _sd = {bc:{{ baseline_code }},cc:{{ current_code }},cd:'{{ code_lines_delta_str }}',bf:{{ baseline_files }},cf:{{ current_files }},fd:'{{ files_analyzed_delta_str }}',bcm:{{ baseline_comments }},ccm:{{ current_comments }},cmd:'{{ comment_lines_delta_str }}',fm:{{ files_modified }},fa:{{ files_added }},fr:{{ files_removed }},fu:{{ files_unchanged }},bts:'{{ baseline_timestamp }}',cts:'{{ current_timestamp }}',bid:'{{ baseline_run_id_short }}',cid:'{{ current_run_id_short }}',bbr:'{{ baseline_git_branch }}',cbr:'{{ current_git_branch }}',btag:'{% if let Some(t) = baseline_git_tags %}{{ t }}{% endif %}',ctag:'{% if let Some(t) = current_git_tags %}{{ t }}{% endif %}',bsha:'{{ baseline_git_commit }}',csha:'{{ current_git_commit }}',btests:{{ baseline_test_count }},ctests:{{ current_test_count }},bcov:{% if let Some(p) = baseline_coverage_pct %}{{ p }}{% else %}null{% endif %},ccov:{% if let Some(p) = current_coverage_pct %}{{ p }}{% else %}null{% endif %},cla:{{ code_lines_added }},clr:{{ code_lines_removed }},churn:'{{ churn_rate_str }}'};
29488    function _mkScanLabel(pfx,tag,br,sha){var ref=tag||(br||'');if(ref&&sha)return pfx+' ('+ref+' @ '+sha+')';if(ref)return pfx+' ('+ref+')';if(sha)return pfx+' ('+sha+')';return pfx;}
29489    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29490    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29491    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29492    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29493    function _filePct(b,c,st){if(st==='added'&&b===0)return'new';if(st==='removed')return'-100.0%';if(st==='unchanged')return'0.0%';return b>0?_slPct(c-b,b):'';}
29494    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29495    function getSummaryExportRows(){return[['Code Lines',String(_sd.bc),String(_sd.cc),_sd.cd,_slPct(_sd.cc-_sd.bc,_sd.bc)],['Files Analyzed',String(_sd.bf),String(_sd.cf),_sd.fd,_slPct(_sd.cf-_sd.bf,_sd.bf)],['Comment Lines',String(_sd.bcm),String(_sd.ccm),_sd.cmd,_slPct(_sd.ccm-_sd.bcm,_sd.bcm)],['Modified Files','0','0',String(_sd.fm),_tfPct(_sd.fm)],['Added Files','0','0',String(_sd.fa),_tfPct(_sd.fa)],['Removed Files','0','0',String(_sd.fr),_tfPct(_sd.fr)],['Unchanged Files','0','0',String(_sd.fu),_tfPct(_sd.fu)]];}
29496    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29497    function getDeltaExportRows(){return DELTA.map(function(d){var b=parseInt(d.bcs)||0,c=parseInt(d.ccs)||0;return [d.path,d.lang,d.status,d.bcs,d.ccs,d.cds,d.cmds,d.tds,_filePct(b,c,d.status)];});}
29498    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29499    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29500
29501    // ── Chart HTML report ─────────────────────────────────────────────────────
29502    function slocChartReport(fname, sd, dr) {
29503      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29504      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29505      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29506      function fmt(n){return Number(n).toLocaleString();}
29507      function px(n){return Math.round(n);}
29508      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29509      // Language map
29510      var lm={};
29511      dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0;if(!lm[l])lm[l]={f:0,d:0};lm[l].f++;lm[l].d+=d;});
29512      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29513
29514      // Builds onmouse* attrs for interactive tooltip on each SVG element
29515      function barTT(label,val){
29516        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29517      }
29518
29519      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29520      //    match the Language Code Delta column height) ────────────
29521      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#E3A876',cc:'#C45C10'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#9FC3AE',cc:'#2A6846'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#E0C58A',cc:'#BE8A2E'}];
29522      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29523      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29524      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29525      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29526      for(var gi=1;gi<=4;gi++){var gy=c1mt+c1ph*(1-gi/4);c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';}
29527      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29528      c1mets.forEach(function(m,i){
29529        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29530        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
29531        var gMax=Math.max(m.b,m.c)*1.15||1;
29532        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29533        c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="'+FONT_C+'" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
29534        c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
29535        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
29536        c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
29537        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
29538        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Before</text>';
29539        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">After</text>';
29540      });
29541      c1+='<text x="'+px(C1W/2)+'" y="'+(C1H-8)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Each metric uses its own scale — compare Before vs After within a metric</text>';
29542      c1+='</svg>';
29543
29544      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
29545      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#C45C10'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#2A6846'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#BE8A2E'}];
29546      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29547      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
29548      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29549      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29550      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29551      mets.forEach(function(m,i){
29552        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
29553        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
29554        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29555        c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
29556        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
29557        if(bw>=52){
29558          c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
29559        }else{
29560          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
29561          c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
29562        }
29563      });
29564      c2+='</svg>';
29565
29566      // ── Chart 3: Language Code Delta ─────────────────────────────────────
29567      var c3='';
29568      if(langs.length){
29569        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29570        var C3W=550,c3LW=124,c3FW=52;
29571        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
29572        var L3rH=30,C3H=langs.length*L3rH+20;
29573        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29574        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29575        langs.forEach(function(l,i){
29576          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
29577          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
29578          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29579          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#444">'+esc(l)+'</text>';
29580          c3+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"'+barTT(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+'/>';
29581          if(bw>=48){
29582            c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
29583          }else{
29584            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
29585            c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
29586          }
29587          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
29588        });
29589        c3+='</svg>';
29590      }
29591
29592      // ── Chart 4: File Change Donut — centered pie with legend below
29593      var segs=[{l:'Modified',v:sd.fm,c:OX},{l:'Added',v:sd.fa,c:GN},{l:'Removed',v:sd.fr,c:RD},{l:'Unchanged',v:sd.fu,c:'#CCCCCC'}].filter(function(s){return s.v>0;});
29594      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29595      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
29596      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">';
29597      var ang=-Math.PI/2;
29598      segs.forEach(function(s){
29599        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
29600        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
29601        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29602        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
29603        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29604        c4+='<path class="cb" 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="'+s.c+'" stroke="white" stroke-width="2.5"'+barTT(s.l,fmt(s.v)+' files \u2022 '+px(s.v/tot*100)+'%')+'/>';
29605        ang+=sw;
29606      });
29607      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
29608      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" fill="#888">total files</text>';
29609      segs.forEach(function(s,i){
29610        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
29611        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
29612        c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
29613      });
29614      c4+='</svg>';
29615
29616      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
29617      var ttJs='var tt=document.getElementById("ox-tt");'+
29618        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
29619        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
29620        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
29621        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
29622        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
29623        'function oxHT(){tt.style.display="none";}';
29624
29625      // body max-width keeps charts from inflating beyond design dimensions on
29626      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
29627      // each chart's height blows up proportionally, breaking the one-page layout.
29628      var css='*{box-sizing:border-box;}body{font-family:Inter,Calibri,Arial,sans-serif;margin:0 auto;padding:20px 30px 24px;max-width:1460px;background:#F7F3EE;color:#333;}'+
29629        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
29630        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
29631        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
29632        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
29633        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
29634        'svg{display:block;}'+
29635        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
29636        '#ox-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.08);max-width:240px;white-space:nowrap;}'+
29637        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29638      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
29639        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29640        '<div id="ox-tt"><\/div>'+
29641        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
29642        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
29643        '<div class="two-col">'+
29644        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
29645        '<div class="leg">'+
29646        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29647        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29648        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
29649        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
29650        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
29651        '<\/div>'+
29652        '<div class="two-col">'+
29653        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
29654        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
29655        '<\/div>'+
29656        '<script>'+ttJs+'<\/script>'+
29657        '<\/body><\/html>';
29658      slocDownload(html, fname, 'text/html;charset=utf-8;');
29659    }
29660    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
29661    window.buildDeltaChartsHtml = function() {
29662      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29663      var sd=_sd;
29664      var projEl=document.querySelector('[data-folder]');
29665      var proj=projEl?projEl.getAttribute('data-folder'):'';
29666      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
29667      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
29668      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
29669      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
29670      var ttJs='var tt=document.getElementById("ox-tt");function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+"px";tt.style.top=y+"px";}function oxHT(){tt.style.display="none";}';
29671      var css='*{box-sizing:border-box;}body{font-family:Inter,Calibri,Arial,sans-serif;margin:0 auto;padding:20px 30px 24px;max-width:1460px;background:#F7F3EE;color:#333;}h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}svg{display:block;}.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}#ox-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;max-width:240px;white-space:nowrap;}.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
29672      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
29673        '<div id="ox-tt"><\/div>'+
29674        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
29675        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
29676        '<div class="two-col">'+
29677        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
29678        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
29679        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
29680        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
29681        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
29682        '<\/div>'+
29683        '<div class="two-col">'+
29684        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
29685        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
29686        '<\/div>'+
29687        '<script>'+ttJs+'<\/script>'+
29688        '<\/body><\/html>';
29689    };
29690    // ── Inline delta charts ────────────────────────────────────────────────────
29691    var _icTT=document.getElementById('ic-tt');
29692    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
29693    window.icMT=function(e){if(!_icTT)return;var x=e.clientX+16,y=e.clientY-10,r=_icTT.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_icTT.style.left=x+'px';_icTT.style.top=y+'px';};
29694    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
29695    window.addEventListener('blur',function(){window.icHT();});
29696    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
29697    (function(){
29698      // Theme-aware palette — matches the canonical scheme used by /test-metrics
29699      // charts so every page renders bars/text/grid with the same colours and
29700      // adapts to dark mode (see Design section in CLAUDE.md).
29701      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
29702      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
29703      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
29704      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
29705      // washed) so the chart reads with the same weight as /test-metrics.
29706      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
29707      var FADE=dark?'#524238':'#e6d0bf';
29708      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
29709      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29710      function fmt(n){return Number(n).toLocaleString();}
29711      function px(n){return Math.round(n);}
29712      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29713      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
29714      function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t){var ttl=t.getAttribute('data-ttl');icTT(e,ttl,t.getAttribute('data-ttv'));el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});el.querySelectorAll('[data-ttl]').forEach(function(x){if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';});}else{icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';})}});el.addEventListener('mouseleave',function(){icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});});el.addEventListener('mousemove',function(e){icMT(e);});}
29715      var dr=getDeltaExportRows(),sd=_sd,lm={};
29716      dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0;if(!lm[l])lm[l]={f:0,d:0};lm[l].f++;lm[l].d+=d;});
29717      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29718      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
29719      // the bars are as tall as the (usually taller) Language Code Delta sibling that
29720      // shares the same grid row, instead of sitting short at the top.
29721      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:OXD,cc:OX},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:GND,cc:GN},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:GDD,cc:GD}];
29722      function drawC1(){
29723        var C1W=600,C1H=188;
29724        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
29725        if(host&&card&&host.clientWidth>0){
29726          var avW=host.clientWidth;
29727          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
29728          var wantH=availPx*C1W/avW;
29729          if(wantH>C1H)C1H=wantH;
29730        }
29731        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
29732        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29733        for(var gi=1;gi<=4;gi++){var gy=c1mt+c1ph*(1-gi/4);c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';}
29734        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
29735        c1mets.forEach(function(m,i){
29736          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
29737          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
29738          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
29739          var gMax=Math.max(m.b,m.c)*1.15||1;
29740          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
29741          c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
29742          c1+='<rect'+btt(m.l,'Baseline: '+fmt(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3"/>';
29743          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+fmt(m.b)+'</text>';
29744          c1+='<rect'+btt(m.l,'Current: '+fmt(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3"/>';
29745          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
29746          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">Before</text>';
29747          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">After</text>';
29748        });
29749        c1+='<text x="'+px(C1W/2)+'" y="'+px(C1H-6)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="8.5" fill="'+mutedCol+'">Each metric uses its own scale — compare Before vs After within a metric</text>';
29750        c1+='</svg>';
29751        return c1;
29752      }
29753      var c1=drawC1();
29754      // Chart 2: Delta by Metric
29755      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:OX},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:GN},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:GD}];
29756      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
29757      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
29758      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29759      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29760      mets.forEach(function(m,i){
29761        var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
29762        c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
29763        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
29764        if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
29765        else{var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+textCol+'">'+esc(vStr)+'</text>';}
29766      });
29767      c2+='</svg>';
29768      // Chart 3: Language Code Delta
29769      var c3='';
29770      if(langs.length){
29771        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
29772        var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4,L3rH=30,C3H=langs.length*L3rH+20;
29773        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29774        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
29775        langs.forEach(function(l,i){
29776          var e=lm[l],y=8+i*L3rH,bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
29777          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
29778          c3+='<rect'+btt(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
29779          if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
29780          else{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}
29781          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
29782        });
29783        c3+='</svg>';
29784      }
29785      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
29786      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
29787      var segs=[{l:'Modified',v:sd.fm,c:OX},{l:'Added',v:sd.fa,c:GN},{l:'Removed',v:sd.fr,c:RD},{l:'Unchanged',v:sd.fu,c:FADE}].filter(function(s){return s.v>0;});
29788      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
29789      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
29790      var legX=212,legCount=segs.length,legSpacing=Math.max(18,Math.min(30,Math.floor((DH-24)/Math.max(legCount,1)))),legYStart=Math.round((DH-legCount*legSpacing)/2);
29791      var c4='<svg viewBox="0 0 '+DW+' '+DH+'" width="100%" style="display:block;max-width:480px;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
29792      if(segs.length===1){
29793        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
29794        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files \u2022 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+rm+'" fill="none" stroke="'+segs[0].c+'" stroke-width="'+rsw+'"/>';
29795      } else {
29796        // Give every visible slice a small minimum sweep, taken from the largest
29797        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
29798        // arc whose start and end points coincide, so SVG renders nothing (blank).
29799        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
29800        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
29801        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
29802        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
29803        segs.forEach(function(s,si){
29804          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
29805          var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
29806          var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
29807          var pct=Math.round(s.v/tot*100);
29808          c4+='<path'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' 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="'+s.c+'" stroke="'+surfCol+'" stroke-width="2"/>';
29809          if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text x="'+px(cx4+mR*Math.cos(mAng))+'" y="'+px(cy4+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT4+'" font-size="11" font-weight="700" fill="'+(s.c===FADE?textCol:'#fff')+'" style="pointer-events:none;">'+pct+'%</text>';}
29810          ang+=sw;
29811        });
29812      }
29813      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
29814      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
29815      segs.forEach(function(s,i){
29816        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
29817        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
29818        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
29819        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
29820        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
29821        c4+='<text x="'+(legX+92)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(12,legSpacing-4)+'" font-weight="700" fill="'+mutedCol+'">'+fmt(s.v)+' ('+pct+'%)</text>';
29822        c4+='</g>';
29823      });
29824      c4+='</svg>';
29825      // Inject the fixed-height siblings first so the grid row settles to the (taller)
29826      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
29827      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
29828      var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}
29829      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
29830      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
29831      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
29832
29833      // Compare Timeline chart (Baseline vs Current, 2 points)
29834      (function() {
29835        var activeCmpMetric='code';
29836        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
29837        function renderCmpTL(metric, targetSvg, targetH) {
29838          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
29839          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
29840          svg.setAttribute('height',H);
29841          var pad={l:62,r:20,t:32,b:72};
29842          var dark=document.body.classList.contains('dark-theme');
29843          var cmpPts=[
29844            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
29845            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
29846          ];
29847          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
29848          var valid=pts.filter(function(v){return v!=null;});
29849          if(!valid.length){var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(cmpMetricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'Neither the baseline nor current scan reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+_nd_msg+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+_nd_sub+'</text>';return;}
29850          var minV=0,maxV=Math.max.apply(null,valid);
29851          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
29852          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
29853          var cx0=pad.l,cx1=pad.l+plotW;
29854          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29855          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
29856          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
29857          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
29858          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
29859          function fmtN(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
29860          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29861          var parts=[];
29862          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
29863          for(var gi=0;gi<5;gi++){
29864            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
29865            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
29866            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
29867          }
29868          parts.push('<path d="M '+cx0.toFixed(1)+' '+(pad.t+plotH)+' L '+cx0.toFixed(1)+' '+cy0.toFixed(1)+' L '+cx1.toFixed(1)+' '+cy1.toFixed(1)+' L '+cx1.toFixed(1)+' '+(pad.t+plotH)+' Z" fill="'+areaColor+'"/>');
29869          parts.push('<line x1="'+cx0.toFixed(1)+'" y1="'+cy0.toFixed(1)+'" x2="'+cx1.toFixed(1)+'" y2="'+cy1.toFixed(1)+'" stroke="#d37a4c" stroke-width="2.2"/>');
29870          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
29871                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
29872          dotPts.forEach(function(pt){
29873            parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(pt.cy-11).toFixed(1)+'" text-anchor="'+pt.anchor+'" font-size="11" font-weight="600" fill="'+textColor+'">'+Number(pt.v).toLocaleString()+'</text>');
29874            parts.push('<circle cx="'+pt.cx.toFixed(1)+'" cy="'+pt.cy.toFixed(1)+'" r="5" fill="#d37a4c" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5"/>');
29875            parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+18)+'" text-anchor="'+pt.anchor+'" font-size="15" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escH(pt.lbl)+'</text>');
29876            parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+32)+'" text-anchor="'+pt.anchor+'" font-size="9" font-weight="700" fill="'+textColor+'">'+escH(pt.lbl2)+'</text>');
29877          });
29878          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
29879          svg.setAttribute('viewBox','0 0 '+W+' '+H);
29880          svg.innerHTML=parts.join('');
29881          // Hover: crosshair + tooltip (matches multi-scan timeline)
29882          var cmpTT=document.getElementById('ic-tt');
29883          svg.onmousemove=function(e){
29884            var rect=svg.getBoundingClientRect();
29885            var scaleX=W/rect.width;
29886            var mouseX=(e.clientX-rect.left)*scaleX;
29887            var nearest=-1,minDist=Infinity;
29888            var cxArr=[cx0,cx1];
29889            for(var k=0;k<2;k++){if(pts[k]==null)continue;var dx=Math.abs(cxArr[k]-mouseX);if(dx<minDist){minDist=dx;nearest=k;}}
29890            if(nearest<0)return;
29891            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
29892            var xhair=svg.querySelector('.cmp-xhair');
29893            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
29894            xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
29895            if(!cmpTT)return;
29896            var clbl=cmpPts[nearest].label;
29897            var scanLbl=nearest===0?'Baseline':'Current';
29898            cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
29899            var bx=rect.left+(nc/W*rect.width)+18;
29900            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
29901            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
29902          };
29903          svg.onmouseleave=function(){
29904            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
29905            if(cmpTT)cmpTT.style.display='none';
29906          };
29907        }
29908        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
29909          btn.addEventListener('click',function(){
29910            activeCmpMetric=this.dataset.cmpMetric;
29911            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
29912            this.classList.add('active');
29913            renderCmpTL(activeCmpMetric);
29914          });
29915        });
29916        var ttgl=document.getElementById('theme-toggle');
29917        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
29918        if(typeof ResizeObserver!=='undefined'){
29919          var cmpSvg=document.getElementById('cmp-tl-svg');
29920          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
29921        }
29922        // Expose the timeline renderer + current metric so the Full View modal can
29923        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
29924        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
29925        window.__sdGetMetric=function(){return activeCmpMetric;};
29926        renderCmpTL(activeCmpMetric);
29927      })();
29928
29929      // HTML legend hover -> highlight matching SVG bars within the SAME card only
29930      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
29931        var metric=leg.getAttribute('data-highlight');
29932        var parentCard=leg.closest('.ic-card');
29933        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
29934        if(!chartEl)return;
29935        leg.addEventListener('mouseenter',function(){
29936          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
29937            if(x.getAttribute('data-ttl').indexOf(metric)===0){x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';x.style.opacity='1';}
29938            else{x.style.opacity='0.28';}
29939          });
29940        });
29941        leg.addEventListener('mouseleave',function(){
29942          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
29943        });
29944      });
29945
29946      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
29947      (function(){
29948        var ov=document.getElementById('ic-svg-modal-ov');
29949        var body=document.getElementById('ic-svg-modal-body');
29950        var ttl=document.getElementById('ic-svg-modal-title');
29951        var closeBtn=document.getElementById('ic-svg-modal-close');
29952        if(!ov||!body)return;
29953        function close(){
29954          ov.classList.remove('open');body.innerHTML='';
29955          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
29956          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
29957        }
29958        function open(srcId,title){
29959          var src=document.getElementById(srcId);if(!src)return;
29960          if(ttl)ttl.textContent=title||'';
29961          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
29962          // snapshot stretches and loses interactivity. Re-render it live into the modal at
29963          // full size instead — keeps proportions, animation, crosshair, tooltip and the
29964          // metric tabs working exactly like the inline chart.
29965          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
29966            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
29967            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
29968            var btnsHtml=mets.map(function(p){return '<button class="chart-metric-btn'+(p[0]===curM?' active':'')+'" data-fv-metric="'+p[0]+'">'+p[1]+'</button>';}).join('');
29969            body.innerHTML='<div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;">'+btnsHtml+'</div><div class="chart-wrap" style="width:100%;"><svg id="cmp-tl-fv-svg" width="100%" height="440" style="display:block;width:100%;"></svg></div>';
29970            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
29971            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
29972            ov.classList.add('open');
29973            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
29974            if(typeof ResizeObserver!=='undefined'){var ro=new ResizeObserver(function(){if(window.__sdFvTL)window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);});ro.observe(fvSvg);window.__sdFvTL.ro=ro;}
29975            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
29976              b.addEventListener('click',function(){
29977                if(!window.__sdFvTL)return;
29978                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
29979                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
29980                this.classList.add('active');
29981                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
29982              });
29983            });
29984            return;
29985          }
29986          var card=src.closest('.ic-card');
29987          var legHtml='';
29988          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
29989          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
29990          if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No chart data to display.</p>';ov.classList.add('open');return;}
29991          body.innerHTML=legHtml+inner;
29992          var svg=body.querySelector('svg');
29993          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
29994          addTT(body);
29995          ov.classList.add('open');
29996        }
29997        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
29998          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
29999        });
30000        if(closeBtn)closeBtn.addEventListener('click',close);
30001        ov.addEventListener('click',function(e){if(e.target===ov)close();});
30002        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30003      })();
30004
30005      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30006    })();
30007  </script>
30008  {{ toast_assets|safe }}
30009  <script nonce="{{ csp_nonce }}">
30010  (function(){
30011    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'}];
30012    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);});}
30013    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30014    function init(){
30015      var btn=document.getElementById('settings-btn');if(!btn)return;
30016      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30017      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>';
30018      document.body.appendChild(m);
30019      var g=document.getElementById('scheme-grid');
30020      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);});
30021      var cl=document.getElementById('settings-close');
30022      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);
30023      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');});
30024      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30025      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30026    }
30027    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30028  }());
30029  </script>
30030  <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
30031  if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
30032  if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
30033</body>
30034</html>
30035"##,
30036    ext = "html"
30037)]
30038// Template structs need many bool fields to pass Askama rendering flags.
30039#[allow(clippy::struct_excessive_bools)]
30040struct CompareTemplate {
30041    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30042    loading_overlay: String,
30043    version: &'static str,
30044    project_label: String,
30045    baseline_git_commit: String,
30046    current_git_commit: String,
30047    baseline_run_id: String,
30048    current_run_id: String,
30049    baseline_run_id_short: String,
30050    current_run_id_short: String,
30051    baseline_timestamp: String,
30052    baseline_timestamp_utc_ms: i64,
30053    current_timestamp: String,
30054    current_timestamp_utc_ms: i64,
30055    project_path: String,
30056    baseline_code: u64,
30057    current_code: u64,
30058    code_lines_delta_str: String,
30059    code_lines_delta_class: String,
30060    baseline_files: u64,
30061    current_files: u64,
30062    files_analyzed_delta_str: String,
30063    files_analyzed_delta_class: String,
30064    baseline_comments: u64,
30065    current_comments: u64,
30066    comment_lines_delta_str: String,
30067    comment_lines_delta_class: String,
30068    baseline_code_fmt: String,
30069    current_code_fmt: String,
30070    baseline_files_fmt: String,
30071    current_files_fmt: String,
30072    baseline_comments_fmt: String,
30073    current_comments_fmt: String,
30074    code_lines_pct_str: String,
30075    files_analyzed_pct_str: String,
30076    comment_lines_pct_str: String,
30077    code_lines_added: i64,
30078    code_lines_removed: i64,
30079    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30080    new_scope: bool,
30081    churn_rate_str: String,
30082    churn_rate_class: String,
30083    scope_flag: bool,
30084    files_added: usize,
30085    files_removed: usize,
30086    files_modified: usize,
30087    files_unchanged: usize,
30088    file_rows: Vec<CompareFileDeltaRow>,
30089    baseline_git_author: Option<String>,
30090    current_git_author: Option<String>,
30091    baseline_git_branch: String,
30092    current_git_branch: String,
30093    baseline_git_tags: Option<String>,
30094    current_git_tags: Option<String>,
30095    baseline_git_commit_date: Option<String>,
30096    current_git_commit_date: Option<String>,
30097    project_name: String,
30098    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30099    submodule_options: Vec<String>,
30100    /// True when either run has submodule data — controls whether the scope bar is shown.
30101    has_any_submodule_data: bool,
30102    /// The submodule currently being compared, if the `sub` query param was provided.
30103    active_submodule: Option<String>,
30104    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
30105    super_scope_active: bool,
30106    csp_nonce: String,
30107    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
30108    toast_assets: String,
30109    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
30110    coverage_delta_card: String,
30111    baseline_test_count: u64,
30112    current_test_count: u64,
30113    baseline_coverage_pct: Option<f64>,
30114    current_coverage_pct: Option<f64>,
30115}
30116
30117// ── LoginTemplate ──────────────────────────────────────────────────────────────
30118
30119#[derive(Template)]
30120#[template(
30121    source = r##"
30122<!doctype html>
30123<html lang="en">
30124<head>
30125  <meta charset="utf-8">
30126  <meta name="viewport" content="width=device-width, initial-scale=1">
30127  <title>OxideSLOC | Sign In</title>
30128  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30129  <style nonce="{{ csp_nonce }}">
30130    :root {
30131      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30132      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30133      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30134      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30135    }
30136    *{box-sizing:border-box;}
30137    html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}
30138    .top-nav{background:linear-gradient(180deg,var(--nav),var(--nav-2));padding:0 24px;min-height:56px;display:flex;align-items:center;box-shadow:0 4px 14px rgba(0,0,0,.18);}
30139    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30140    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30141    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30142    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30143    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30144    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30145    .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;}
30146    @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));}}
30147    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30148    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30149    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30150    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30151    .error{background:var(--err-bg);border:1px solid var(--err-border);color:var(--err-text);border-radius:8px;padding:12px 16px;font-size:14px;margin-bottom:20px;}
30152    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30153    input[type=password]{width:100%;padding:10px 14px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font-size:14px;font-family:ui-monospace,monospace;outline:none;transition:border-color .15s;}
30154    input[type=password]:focus{border-color:var(--oxide);}
30155    .btn{width:100%;padding:11px;border:none;border-radius:8px;background:var(--oxide-2);color:#fff;font-size:15px;font-weight:700;cursor:pointer;margin-top:20px;transition:opacity .15s;}
30156    .btn:hover{opacity:.88;}
30157    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30158    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30159  </style>
30160</head>
30161<body>
30162  <div class="background-watermarks" aria-hidden="true">
30163    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30164    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30165    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30166    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30167    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30168    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30169    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30170  </div>
30171  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30172<nav class="top-nav">
30173  <a class="brand" href="/">
30174    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30175    <span class="brand-title">OxideSLOC</span>
30176  </a>
30177</nav>
30178<main class="page">
30179  <div class="card">
30180    <h1>Sign In</h1>
30181    <p class="subtitle">Enter the API key printed when the server started.</p>
30182    {% if has_error %}
30183    <div class="error">Incorrect API key — please try again.</div>
30184    {% endif %}
30185    <form method="POST" action="/auth/login">
30186      <input type="hidden" name="next" value="{{ next_url|e }}">
30187      <label for="key">API Key</label>
30188      <input id="key" type="password" name="key" autocomplete="current-password"
30189             placeholder="Paste your API key here" autofocus>
30190      <button type="submit" class="btn">Sign In</button>
30191    </form>
30192    <p class="hint">
30193      The API key was printed in the terminal when the server started.<br>
30194      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30195      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30196    </p>
30197  </div>
30198</main>
30199<script nonce="{{ csp_nonce }}">
30200(function() {
30201  (function randomizeWatermarks() {
30202    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30203    if (!wms.length) return;
30204    var placed = [];
30205    function tooClose(top, left) {
30206      for (var i = 0; i < placed.length; i++) {
30207        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30208        if (dt < 16 && dl < 12) return true;
30209      }
30210      return false;
30211    }
30212    function pick(leftBand) {
30213      for (var attempt = 0; attempt < 50; attempt++) {
30214        var top = Math.random() * 88 + 2;
30215        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30216        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30217      }
30218      var top = Math.random() * 88 + 2;
30219      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30220      placed.push([top, left]); return [top, left];
30221    }
30222    var half = Math.floor(wms.length / 2);
30223    wms.forEach(function (img, i) {
30224      var pos = pick(i < half);
30225      var size = Math.floor(Math.random() * 100 + 120);
30226      var rot = (Math.random() * 360).toFixed(1);
30227      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30228      img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
30229    });
30230  })();
30231  (function spawnCodeParticles() {
30232    var container = document.getElementById('code-particles');
30233    if (!container) return;
30234    var snippets = [
30235      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30236      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30237      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30238      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30239      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30240    ];
30241    var count = 38;
30242    for (var i = 0; i < count; i++) {
30243      (function(idx) {
30244        var el = document.createElement('span');
30245        el.className = 'code-particle';
30246        el.textContent = snippets[idx % snippets.length];
30247        var left = Math.random() * 94 + 2;
30248        var top = Math.random() * 88 + 6;
30249        var dur = (Math.random() * 10 + 9).toFixed(1);
30250        var delay = (Math.random() * 18).toFixed(1);
30251        var rot = (Math.random() * 26 - 13).toFixed(1);
30252        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30253        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30254        container.appendChild(el);
30255      })(i);
30256    }
30257  })();
30258})();
30259</script>
30260</body>
30261</html>
30262"##,
30263    ext = "html"
30264)]
30265pub(crate) struct LoginTemplate {
30266    pub(crate) csp_nonce: String,
30267    pub(crate) has_error: bool,
30268    pub(crate) next_url: String,
30269    pub(crate) lockout_threshold: u32,
30270}
30271
30272// ── REST API reference page ────────────────────────────────────────────────────
30273
30274#[derive(Template)]
30275#[template(
30276    source = r##"
30277<!doctype html>
30278<html lang="en">
30279<head>
30280  <meta charset="utf-8">
30281  <meta name="viewport" content="width=device-width, initial-scale=1">
30282  <title>OxideSLOC — REST API Reference</title>
30283  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30284  <style nonce="{{ csp_nonce }}">
30285    :root {
30286      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30287      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30288      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30289      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30290      --success:#16a34a;
30291    }
30292    body.dark-theme {
30293      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30294      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30295    }
30296    *{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
30297    .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);}
30298    .top-nav-inner{max-width:960px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}
30299    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30300    .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));}
30301    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30302    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30303    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30304    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30305    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30306    @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
30307    .nav-pill{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;white-space:nowrap;text-decoration:none;}
30308    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30309    .nav-pill.active{background:rgba(255,255,255,0.22);}
30310    .nav-dropdown{position:relative;display:inline-flex;}
30311    .nav-dropdown-btn{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}
30312    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30313    .nav-dropdown-menu{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}
30314    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}
30315    .nav-dropdown-menu a{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}
30316    .nav-dropdown-menu a:last-child{border-bottom:none;}
30317    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30318    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30319    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;display:inline-flex;align-items:center;min-height:38px;}
30320    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30321    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30322    .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;}
30323    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30324    .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);}
30325    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30326    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30327    .settings-modal-body{padding:14px 16px 16px;}
30328    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30329    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30330    .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;}
30331    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30332    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30333    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30334    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30335    .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;}
30336    .tz-select:focus{border-color:var(--oxide);}
30337    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30338    .page-header{margin-bottom:28px;}
30339    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30340    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30341    .callout{border-radius:12px;padding:16px 20px;margin-bottom:28px;display:flex;align-items:flex-start;gap:14px;font-size:14px;line-height:1.6;}
30342    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30343    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30344    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30345    .callout strong{font-weight:800;}
30346    .callout code{background:rgba(0,0,0,0.07);border-radius:4px;padding:1px 5px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
30347    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30348    .base-url-bar{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:12px 16px;margin-bottom:28px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
30349    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30350    .base-url-value{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;color:var(--accent-2);flex:1;word-break:break-all;}
30351    body.dark-theme .base-url-value{color:var(--accent);}
30352    .section{margin-bottom:36px;}
30353    .section-title{font-size:18px;font-weight:850;letter-spacing:-0.02em;margin:0 0 14px;padding-bottom:10px;border-bottom:1px solid var(--line);}
30354    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30355    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30356    .ep-header:hover{background:var(--surface-2);}
30357    .method{display:inline-flex;align-items:center;justify-content:center;padding:3px 9px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:0.04em;flex:0 0 auto;text-transform:uppercase;}
30358    .method.get{background:#dcfce7;color:#166534;}
30359    .method.post{background:#dbeafe;color:#1e40af;}
30360    .method.delete{background:#fee2e2;color:#991b1b;}
30361    body.dark-theme .method.get{background:#14532d;color:#86efac;}
30362    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30363    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30364    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30365    .ep-path .param{color:var(--oxide-2);}
30366    body.dark-theme .ep-path .param{color:var(--oxide);}
30367    .auth-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700;flex:0 0 auto;}
30368    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30369    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30370    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30371    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30372    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30373    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30374    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30375    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30376    .ep-card.open .chevron{transform:rotate(180deg);}
30377    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30378    .ep-card.open .ep-body{display:block;}
30379    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30380    .ep-desc-full code{background:rgba(0,0,0,0.06);border-radius:4px;padding:1px 5px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
30381    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30382    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30383    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30384    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30385    table.params th{text-align:left;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted-2);padding:5px 8px;border-bottom:1px solid var(--line);}
30386    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30387    table.params tr:last-child td{border-bottom:none;}
30388    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30389    .pt-type{color:var(--muted-2);font-size:12px;}
30390    .pt-req{display:inline-block;background:rgba(239,68,68,0.10);color:#b91c1c;border-radius:4px;padding:1px 6px;font-size:10px;font-weight:800;}
30391    .pt-opt{display:inline-block;background:rgba(0,0,0,0.06);color:var(--muted);border-radius:4px;padding:1px 6px;font-size:10px;font-weight:800;}
30392    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30393    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30394    details.schema{margin-bottom:14px;}
30395    details.schema summary{cursor:pointer;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);padding:5px 0;user-select:none;}
30396    details.schema summary:hover{color:var(--text);}
30397    .schema-block{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre;margin-top:6px;}
30398    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30399    .curl-wrap{position:relative;}
30400    .curl-block{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 80px 10px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;margin:0;}
30401    .curl-copy-btn{position:absolute;right:8px;top:8px;padding:4px 10px;border-radius:6px;border:1px solid var(--line-strong);background:var(--surface);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
30402    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30403    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30404    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30405    .webhook-note a{color:var(--accent-2);text-decoration:none;}
30406    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30407    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30408    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30409    .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;}
30410    @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));}}
30411    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30412    .site-footer a{color:var(--muted);}
30413  </style>
30414</head>
30415<body>
30416  <div class="background-watermarks" aria-hidden="true">
30417    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30418    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30419    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30420    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30421    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30422    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30423    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30424  </div>
30425  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30426  <div class="top-nav">
30427    <div class="top-nav-inner">
30428      <a class="brand" href="/">
30429        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30430        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30431      </a>
30432      <div class="nav-right">
30433        <a class="nav-pill" href="/">Home</a>
30434        <div class="nav-dropdown">
30435          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
30436          <div class="nav-dropdown-menu">
30437            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
30438          </div>
30439        </div>
30440        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30441        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30442        <div class="nav-dropdown">
30443          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
30444          <div class="nav-dropdown-menu">
30445            <a href="/integrations"><svg viewBox="0 0 24 24"><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"></path></svg>Integrations</a>
30446          </div>
30447        </div>
30448        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30449          <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>
30450        </button>
30451        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30452          <svg class="icon-moon" viewBox="0 0 24 24"><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>
30453          <svg class="icon-sun" viewBox="0 0 24 24"><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>
30454        </button>
30455      </div>
30456    </div>
30457  </div>
30458
30459  <div class="page">
30460    <div class="page-header">
30461      <h1 class="page-title">REST API Reference</h1>
30462      <p class="page-subtitle">All endpoints exposed by this oxide-sloc server. Protected endpoints require authentication unless the server was started without an API key.</p>
30463    </div>
30464
30465    {% if has_api_key %}
30466    <div class="callout key-set">
30467      <svg class="callout-icon" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
30468      <div><strong>API key is configured.</strong> Protected endpoints require an <code>Authorization: Bearer &lt;key&gt;</code> header, an <code>X-API-Key: &lt;key&gt;</code> header, or an active session cookie from <code>POST /auth/login</code>.</div>
30469    </div>
30470    {% else %}
30471    <div class="callout no-key">
30472      <svg class="callout-icon" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2"><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>
30473      <div><strong>No API key set.</strong> All endpoints are publicly accessible on this server. Set <code>SLOC_API_KEY</code> or <code>SLOC_API_KEYS</code> to require authentication.</div>
30474    </div>
30475    {% endif %}
30476
30477    <div class="base-url-bar">
30478      <span class="base-url-label">Base URL</span>
30479      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30480    </div>
30481
30482    <!-- Health -->
30483    <div class="section">
30484      <h2 class="section-title">Health &amp; Status</h2>
30485      <div class="ep-card">
30486        <div class="ep-header">
30487          <span class="method get">GET</span>
30488          <span class="ep-path">/healthz</span>
30489          <span class="auth-badge public">Public</span>
30490          <span class="ep-desc">Server liveness check</span>
30491          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30492        </div>
30493        <div class="ep-body">
30494          <p class="ep-desc-full">Returns the plain text string <code>ok</code> when the server is running. Suitable for load-balancer health probes and uptime monitors.</p>
30495          <p class="params-heading">Response</p>
30496          <div class="schema-block">200 OK
30497Content-Type: text/plain
30498
30499ok</div>
30500          <p class="curl-heading">Example</p>
30501          <div class="curl-wrap">
30502            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30503            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30504          </div>
30505        </div>
30506      </div>
30507    </div>
30508
30509    <!-- Badges -->
30510    <div class="section">
30511      <h2 class="section-title">Badges</h2>
30512      <div class="ep-card">
30513        <div class="ep-header">
30514          <span class="method get">GET</span>
30515          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30516          <span class="auth-badge public">Public</span>
30517          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30518          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30519        </div>
30520        <div class="ep-body">
30521          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30522          <p class="params-heading">Path Parameters</p>
30523          <table class="params">
30524            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30525            <tr><td class="pt-name">metric</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>One of: <code>code_lines</code>, <code>comment_lines</code>, <code>blank_lines</code>, <code>files_analyzed</code></td></tr>
30526          </table>
30527          <p class="curl-heading">Example</p>
30528          <div class="curl-wrap">
30529            <pre class="curl-block" data-curl-id="c-badge">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/badge/code_lines</pre>
30530            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
30531          </div>
30532        </div>
30533      </div>
30534    </div>
30535
30536    <!-- Metrics -->
30537    <div class="section">
30538      <h2 class="section-title">Metrics</h2>
30539
30540      <div class="ep-card">
30541        <div class="ep-header">
30542          <span class="method get">GET</span>
30543          <span class="ep-path">/api/metrics/latest</span>
30544          <span class="auth-badge protected">Protected</span>
30545          <span class="ep-desc">Latest scan metrics (JSON)</span>
30546          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30547        </div>
30548        <div class="ep-body">
30549          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
30550          <details class="schema"><summary>Response schema</summary>
30551<div class="schema-block">{
30552  "run_id":    string,        // UUID
30553  "timestamp": string,        // ISO-8601 UTC
30554  "project":   string,        // scanned root path
30555  "summary": {
30556    "files_analyzed":       number,
30557    "files_skipped":        number,
30558    "code_lines":           number,
30559    "comment_lines":        number,
30560    "blank_lines":          number,
30561    "total_physical_lines": number,
30562    "functions":            number,
30563    "classes":              number,
30564    "variables":            number,
30565    "imports":              number
30566  },
30567  "languages": [
30568    { "name": string, "files": number, "code_lines": number,
30569      "comment_lines": number, "blank_lines": number,
30570      "functions": number, "classes": number,
30571      "variables": number, "imports": number }
30572  ]
30573}</div></details>
30574          <p class="curl-heading">Example</p>
30575          <div class="curl-wrap">
30576            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30577  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
30578            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
30579          </div>
30580        </div>
30581      </div>
30582
30583      <div class="ep-card">
30584        <div class="ep-header">
30585          <span class="method get">GET</span>
30586          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
30587          <span class="auth-badge protected">Protected</span>
30588          <span class="ep-desc">Metrics for a specific run</span>
30589          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30590        </div>
30591        <div class="ep-body">
30592          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
30593          <p class="params-heading">Path Parameters</p>
30594          <table class="params">
30595            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30596            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
30597          </table>
30598          <p class="curl-heading">Example</p>
30599          <div class="curl-wrap">
30600            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30601  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
30602            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
30603          </div>
30604        </div>
30605      </div>
30606
30607      <div class="ep-card">
30608        <div class="ep-header">
30609          <span class="method get">GET</span>
30610          <span class="ep-path">/api/metrics/history</span>
30611          <span class="auth-badge protected">Protected</span>
30612          <span class="ep-desc">Paginated scan history</span>
30613          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30614        </div>
30615        <div class="ep-body">
30616          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
30617          <p class="params-heading">Query Parameters</p>
30618          <table class="params">
30619            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30620            <tr><td class="pt-name">root</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter by scanned root path</td></tr>
30621            <tr><td class="pt-name">limit</td><td class="pt-type">number</td><td><span class="pt-opt">optional</span></td><td>Max entries to return (default: 50)</td></tr>
30622          </table>
30623          <details class="schema"><summary>Response schema</summary>
30624<div class="schema-block">[{
30625  "run_id":         string,
30626  "timestamp":      string,   // ISO-8601 UTC
30627  "commit":         string | null,
30628  "branch":         string | null,
30629  "tags":           string[],
30630  "code_lines":     number,
30631  "comment_lines":  number,
30632  "blank_lines":    number,
30633  "physical_lines": number,
30634  "files_analyzed": number,
30635  "project_label":  string,
30636  "html_url":       string | null
30637}]</div></details>
30638          <p class="curl-heading">Example</p>
30639          <div class="curl-wrap">
30640            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30641  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
30642            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
30643          </div>
30644        </div>
30645      </div>
30646
30647      <div class="ep-card">
30648        <div class="ep-header">
30649          <span class="method get">GET</span>
30650          <span class="ep-path">/api/project-history</span>
30651          <span class="auth-badge protected">Protected</span>
30652          <span class="ep-desc">Project-level scan summary</span>
30653          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30654        </div>
30655        <div class="ep-body">
30656          <p class="ep-desc-full">Returns a high-level project summary: total scans, last scan ID and timestamp, last code-line count, and most recent git metadata.</p>
30657          <p class="params-heading">Query Parameters</p>
30658          <table class="params">
30659            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30660            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter by root path</td></tr>
30661          </table>
30662          <details class="schema"><summary>Response schema</summary>
30663<div class="schema-block">{
30664  "scan_count":           number,
30665  "last_scan_id":         string | null,
30666  "last_scan_timestamp":  string | null,  // ISO-8601
30667  "last_scan_code_lines": number | null,
30668  "last_git_branch":      string | null,
30669  "last_git_commit":      string | null
30670}</div></details>
30671          <p class="curl-heading">Example</p>
30672          <div class="curl-wrap">
30673            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30674  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
30675            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
30676          </div>
30677        </div>
30678      </div>
30679
30680      <div class="ep-card">
30681        <div class="ep-header">
30682          <span class="method get">GET</span>
30683          <span class="ep-path">/api/metrics/submodules</span>
30684          <span class="auth-badge protected">Protected</span>
30685          <span class="ep-desc">List known git submodules across scans</span>
30686          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30687        </div>
30688        <div class="ep-body">
30689          <p class="ep-desc-full">Returns the distinct set of git submodules that have appeared in any stored scan, optionally filtered by project root path.</p>
30690          <p class="params-heading">Query Parameters</p>
30691          <table class="params">
30692            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30693            <tr><td class="pt-name">root</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter to scans whose input root matches this path</td></tr>
30694          </table>
30695          <details class="schema"><summary>Response schema</summary>
30696<div class="schema-block">[{
30697  "name":          string,  // submodule name
30698  "relative_path": string   // path relative to the project root
30699}]</div></details>
30700          <p class="curl-heading">Example</p>
30701          <div class="curl-wrap">
30702            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30703  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
30704            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
30705          </div>
30706        </div>
30707      </div>
30708    </div>
30709
30710    <!-- Async Run Status -->
30711    <div class="section">
30712      <h2 class="section-title">Async Run Status</h2>
30713
30714      <div class="ep-card">
30715        <div class="ep-header">
30716          <span class="method get">GET</span>
30717          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
30718          <span class="auth-badge protected">Protected</span>
30719          <span class="ep-desc">Poll scan completion</span>
30720          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30721        </div>
30722        <div class="ep-body">
30723          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
30724          <details class="schema"><summary>Response schema</summary>
30725<div class="schema-block">// Running
30726{ "state": "running",  "elapsed_secs": number }
30727
30728// Complete
30729{ "state": "complete", "run_id": string }
30730
30731// Failed
30732{ "state": "failed",   "message": string }</div></details>
30733          <p class="curl-heading">Example</p>
30734          <div class="curl-wrap">
30735            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30736  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
30737            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
30738          </div>
30739        </div>
30740      </div>
30741
30742      <div class="ep-card">
30743        <div class="ep-header">
30744          <span class="method get">GET</span>
30745          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
30746          <span class="auth-badge protected">Protected</span>
30747          <span class="ep-desc">Poll PDF generation readiness</span>
30748          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30749        </div>
30750        <div class="ep-body">
30751          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
30752          <details class="schema"><summary>Response schema</summary>
30753<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
30754          <p class="curl-heading">Example</p>
30755          <div class="curl-wrap">
30756            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30757  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
30758            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
30759          </div>
30760        </div>
30761      </div>
30762
30763      <div class="ep-card">
30764        <div class="ep-header">
30765          <span class="method post">POST</span>
30766          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
30767          <span class="auth-badge protected">Protected</span>
30768          <span class="ep-desc">Cancel a running scan</span>
30769          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30770        </div>
30771        <div class="ep-body">
30772          <p class="ep-desc-full">Signals a running async scan to stop. Returns <code>200 OK</code> if cancellation was accepted or the scan was already cancelled. Returns <code>404</code> if the run ID is unknown or the scan has already completed.</p>
30773          <p class="curl-heading">Example</p>
30774          <div class="curl-wrap">
30775            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
30776  -H "Authorization: Bearer $SLOC_API_KEY" \
30777  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
30778            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
30779          </div>
30780        </div>
30781      </div>
30782    </div>
30783
30784    <!-- Run Management -->
30785    <div class="section">
30786      <h2 class="section-title">Run Management</h2>
30787
30788      <div class="ep-card">
30789        <div class="ep-header">
30790          <span class="method get">GET</span>
30791          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
30792          <span class="auth-badge protected">Protected</span>
30793          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
30794          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30795        </div>
30796        <div class="ep-body">
30797          <p class="ep-desc-full">Returns a <code>.zip</code> archive containing every artifact stored for the run: HTML report, PDF, JSON result, CSV, Excel workbook, and scan config TOML. Useful for offline archiving or migration.</p>
30798          <p class="params-heading">Path Parameters</p>
30799          <table class="params">
30800            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30801            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
30802          </table>
30803          <details class="schema"><summary>Response</summary>
30804<div class="schema-block">200 OK — Content-Type: application/zip
30805Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
30806
30807404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
30808          <p class="curl-heading">Example</p>
30809          <div class="curl-wrap">
30810            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30811  -o run.zip \
30812  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
30813            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
30814          </div>
30815        </div>
30816      </div>
30817
30818      <div class="ep-card">
30819        <div class="ep-header">
30820          <span class="method delete">DELETE</span>
30821          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
30822          <span class="auth-badge protected">Protected</span>
30823          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
30824          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30825        </div>
30826        <div class="ep-body">
30827          <p class="ep-desc-full">Removes all on-disk artifacts for the run (HTML, PDF, JSON, CSV, Excel, scan config), purges the entry from the in-memory cache, and removes it from the persisted scan registry. <strong>This action is irreversible.</strong></p>
30828          <p class="params-heading">Path Parameters</p>
30829          <table class="params">
30830            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30831            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID to delete</td></tr>
30832          </table>
30833          <details class="schema"><summary>Response</summary>
30834<div class="schema-block">204 No Content — run successfully deleted
30835
30836500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
30837          <p class="curl-heading">Example</p>
30838          <div class="curl-wrap">
30839            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
30840  -H "Authorization: Bearer $SLOC_API_KEY" \
30841  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
30842            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
30843          </div>
30844        </div>
30845      </div>
30846
30847      <div class="ep-card">
30848        <div class="ep-header">
30849          <span class="method post">POST</span>
30850          <span class="ep-path">/api/runs/cleanup</span>
30851          <span class="auth-badge protected">Protected</span>
30852          <span class="ep-desc">Bulk delete runs older than N days</span>
30853          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30854        </div>
30855        <div class="ep-body">
30856          <p class="ep-desc-full">One-shot age-based cleanup. Deletes all on-disk artifacts and registry entries for runs whose timestamp is older than <code>older_than_days</code> days. For automated recurring cleanup, use the Retention Policy endpoints instead.</p>
30857          <p class="params-heading">Request Body (application/json)</p>
30858          <table class="params">
30859            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30860            <tr><td class="pt-name">older_than_days</td><td class="pt-type">integer</td><td><span class="pt-opt">optional</span></td><td>Delete runs older than this many days. Default: <code>30</code>. Minimum: <code>1</code>.</td></tr>
30861          </table>
30862          <details class="schema"><summary>Response schema</summary>
30863<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
30864          <p class="curl-heading">Example — delete runs older than 60 days</p>
30865          <div class="curl-wrap">
30866            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
30867  -H "Authorization: Bearer $SLOC_API_KEY" \
30868  -H "Content-Type: application/json" \
30869  -d '{"older_than_days":60}' \
30870  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
30871            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
30872          </div>
30873        </div>
30874      </div>
30875    </div>
30876
30877    <!-- Retention Policy -->
30878    <div class="section">
30879      <h2 class="section-title">Retention Policy</h2>
30880
30881      <div class="ep-card">
30882        <div class="ep-header">
30883          <span class="method get">GET</span>
30884          <span class="ep-path">/api/cleanup-policy</span>
30885          <span class="auth-badge protected">Protected</span>
30886          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
30887          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30888        </div>
30889        <div class="ep-body">
30890          <p class="ep-desc-full">Returns the configured auto-cleanup policy (if any) together with the timestamp and count from the last background cleanup pass. Useful for monitoring whether the policy is running as expected.</p>
30891          <details class="schema"><summary>Response schema</summary>
30892<div class="schema-block">{
30893  "policy": {
30894    "enabled":       boolean,
30895    "max_age_days":  number | null,   // delete runs older than N days
30896    "max_run_count": number | null,   // keep only the N most recent runs
30897    "interval_hours": number          // hours between background passes
30898  } | null,
30899  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
30900  "last_run_deleted": number | null   // runs deleted in last pass
30901}</div></details>
30902          <p class="curl-heading">Example</p>
30903          <div class="curl-wrap">
30904            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
30905  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30906            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
30907          </div>
30908        </div>
30909      </div>
30910
30911      <div class="ep-card">
30912        <div class="ep-header">
30913          <span class="method post">POST</span>
30914          <span class="ep-path">/api/cleanup-policy</span>
30915          <span class="auth-badge protected">Protected</span>
30916          <span class="ep-desc">Save or update the retention policy</span>
30917          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30918        </div>
30919        <div class="ep-body">
30920          <p class="ep-desc-full">Persists a new retention policy to <code>cleanup_policy.json</code>. If <code>enabled</code> is <code>true</code>, the existing background task is stopped and a new one is started at the given interval. Both rules apply when set — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>
30921          <p class="params-heading">Request Body (application/json)</p>
30922          <table class="params">
30923            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
30924            <tr><td class="pt-name">enabled</td><td class="pt-type">boolean</td><td><span class="pt-req">required</span></td><td>Whether to activate the background cleanup task</td></tr>
30925            <tr><td class="pt-name">max_age_days</td><td class="pt-type">integer | null</td><td><span class="pt-opt">optional</span></td><td>Delete runs older than N days. Omit or <code>null</code> to disable age-based cleanup.</td></tr>
30926            <tr><td class="pt-name">max_run_count</td><td class="pt-type">integer | null</td><td><span class="pt-opt">optional</span></td><td>Keep only the N most recent runs. Omit or <code>null</code> to disable count-based cleanup.</td></tr>
30927            <tr><td class="pt-name">interval_hours</td><td class="pt-type">integer</td><td><span class="pt-req">required</span></td><td>Hours between background cleanup passes. Minimum: <code>1</code>.</td></tr>
30928          </table>
30929          <details class="schema"><summary>Response</summary>
30930<div class="schema-block">204 No Content — policy saved and task (re)started
30931
30932500 Internal Server Error — { "error": string }</div></details>
30933          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
30934          <div class="curl-wrap">
30935            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
30936  -H "Authorization: Bearer $SLOC_API_KEY" \
30937  -H "Content-Type: application/json" \
30938  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
30939  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30940            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
30941          </div>
30942        </div>
30943      </div>
30944
30945      <div class="ep-card">
30946        <div class="ep-header">
30947          <span class="method post">POST</span>
30948          <span class="ep-path">/api/cleanup-policy/run-now</span>
30949          <span class="auth-badge protected">Protected</span>
30950          <span class="ep-desc">Trigger an immediate cleanup pass</span>
30951          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30952        </div>
30953        <div class="ep-body">
30954          <p class="ep-desc-full">Executes the configured retention policy immediately, outside of the normal background schedule. Returns the number of runs deleted. The policy must already be saved (via <code>POST /api/cleanup-policy</code>) before calling this endpoint, but does not need to be enabled.</p>
30955          <details class="schema"><summary>Response schema</summary>
30956<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
30957          <p class="curl-heading">Example</p>
30958          <div class="curl-wrap">
30959            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
30960  -H "Authorization: Bearer $SLOC_API_KEY" \
30961  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
30962            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
30963          </div>
30964        </div>
30965      </div>
30966
30967      <div class="ep-card">
30968        <div class="ep-header">
30969          <span class="method delete">DELETE</span>
30970          <span class="ep-path">/api/cleanup-policy</span>
30971          <span class="auth-badge protected">Protected</span>
30972          <span class="ep-desc">Remove the retention policy and stop the background task</span>
30973          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30974        </div>
30975        <div class="ep-body">
30976          <p class="ep-desc-full">Clears the saved retention policy and stops the background cleanup task if it is running. Does not delete any existing scan runs.</p>
30977          <details class="schema"><summary>Response</summary>
30978<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
30979          <p class="curl-heading">Example</p>
30980          <div class="curl-wrap">
30981            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
30982  -H "Authorization: Bearer $SLOC_API_KEY" \
30983  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
30984            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
30985          </div>
30986        </div>
30987      </div>
30988    </div>
30989
30990    <!-- Scan Profiles -->
30991    <div class="section">
30992      <h2 class="section-title">Scan Profiles</h2>
30993
30994      <div class="ep-card">
30995        <div class="ep-header">
30996          <span class="method get">GET</span>
30997          <span class="ep-path">/api/scan-profiles</span>
30998          <span class="auth-badge protected">Protected</span>
30999          <span class="ep-desc">List saved scan profiles</span>
31000          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31001        </div>
31002        <div class="ep-body">
31003          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31004          <details class="schema"><summary>Response schema</summary>
31005<div class="schema-block">{
31006  "profiles": [{
31007    "id":         string,   // UUID
31008    "name":       string,
31009    "created_at": string,   // ISO-8601
31010    "params":     object
31011  }]
31012}</div></details>
31013          <p class="curl-heading">Example</p>
31014          <div class="curl-wrap">
31015            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31016  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31017            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31018          </div>
31019        </div>
31020      </div>
31021
31022      <div class="ep-card">
31023        <div class="ep-header">
31024          <span class="method post">POST</span>
31025          <span class="ep-path">/api/scan-profiles</span>
31026          <span class="auth-badge protected">Protected</span>
31027          <span class="ep-desc">Save a scan profile</span>
31028          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31029        </div>
31030        <div class="ep-body">
31031          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31032          <p class="params-heading">Request Body (application/json)</p>
31033          <table class="params">
31034            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31035            <tr><td class="pt-name">name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Human-readable profile name</td></tr>
31036            <tr><td class="pt-name">params</td><td class="pt-type">object</td><td><span class="pt-req">required</span></td><td>Arbitrary scan parameter object</td></tr>
31037          </table>
31038          <details class="schema"><summary>Response schema</summary>
31039<div class="schema-block">{ "ok": true }</div></details>
31040          <p class="curl-heading">Example</p>
31041          <div class="curl-wrap">
31042            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31043  -H "Authorization: Bearer $SLOC_API_KEY" \
31044  -H "Content-Type: application/json" \
31045  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31046  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31047            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31048          </div>
31049        </div>
31050      </div>
31051
31052      <div class="ep-card">
31053        <div class="ep-header">
31054          <span class="method delete">DELETE</span>
31055          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31056          <span class="auth-badge protected">Protected</span>
31057          <span class="ep-desc">Delete a scan profile</span>
31058          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31059        </div>
31060        <div class="ep-body">
31061          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31062          <p class="params-heading">Path Parameters</p>
31063          <table class="params">
31064            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31065            <tr><td class="pt-name">id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Profile UUID from <code>GET /api/scan-profiles</code></td></tr>
31066          </table>
31067          <details class="schema"><summary>Response schema</summary>
31068<div class="schema-block">{ "ok": true }</div></details>
31069          <p class="curl-heading">Example</p>
31070          <div class="curl-wrap">
31071            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31072  -H "Authorization: Bearer $SLOC_API_KEY" \
31073  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31074            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31075          </div>
31076        </div>
31077      </div>
31078    </div>
31079
31080    <!-- Scheduled Scans -->
31081    <div class="section">
31082      <h2 class="section-title">Scheduled Scans</h2>
31083
31084      <div class="ep-card">
31085        <div class="ep-header">
31086          <span class="method get">GET</span>
31087          <span class="ep-path">/api/schedules</span>
31088          <span class="auth-badge protected">Protected</span>
31089          <span class="ep-desc">List configured schedules</span>
31090          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31091        </div>
31092        <div class="ep-body">
31093          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31094          <p class="curl-heading">Example</p>
31095          <div class="curl-wrap">
31096            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31097  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31098            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31099          </div>
31100        </div>
31101      </div>
31102
31103      <div class="ep-card">
31104        <div class="ep-header">
31105          <span class="method post">POST</span>
31106          <span class="ep-path">/api/schedules</span>
31107          <span class="auth-badge protected">Protected</span>
31108          <span class="ep-desc">Create a schedule</span>
31109          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31110        </div>
31111        <div class="ep-body">
31112          <p class="ep-desc-full">Creates a new scheduled scan. Use the <a href="/integrations">Integrations UI</a> to configure the full field set interactively.</p>
31113          <p class="curl-heading">Example</p>
31114          <div class="curl-wrap">
31115            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31116  -H "Authorization: Bearer $SLOC_API_KEY" \
31117  -H "Content-Type: application/json" \
31118  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31119  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31120            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31121          </div>
31122        </div>
31123      </div>
31124
31125      <div class="ep-card">
31126        <div class="ep-header">
31127          <span class="method delete">DELETE</span>
31128          <span class="ep-path">/api/schedules</span>
31129          <span class="auth-badge protected">Protected</span>
31130          <span class="ep-desc">Delete a schedule</span>
31131          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31132        </div>
31133        <div class="ep-body">
31134          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31135          <p class="curl-heading">Example</p>
31136          <div class="curl-wrap">
31137            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31138  -H "Authorization: Bearer $SLOC_API_KEY" \
31139  -H "Content-Type: application/json" \
31140  -d '{"id":"&lt;schedule_id&gt;"}' \
31141  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31142            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31143          </div>
31144        </div>
31145      </div>
31146    </div>
31147
31148    <!-- Git Browser -->
31149    <div class="section">
31150      <h2 class="section-title">Git Browser</h2>
31151
31152      <div class="ep-card">
31153        <div class="ep-header">
31154          <span class="method get">GET</span>
31155          <span class="ep-path">/api/git/refs</span>
31156          <span class="auth-badge protected">Protected</span>
31157          <span class="ep-desc">List git refs for a repository</span>
31158          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31159        </div>
31160        <div class="ep-body">
31161          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31162          <p class="params-heading">Query Parameters</p>
31163          <table class="params">
31164            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31165            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31166          </table>
31167          <p class="curl-heading">Example</p>
31168          <div class="curl-wrap">
31169            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31170  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31171            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31172          </div>
31173        </div>
31174      </div>
31175
31176      <div class="ep-card">
31177        <div class="ep-header">
31178          <span class="method get">GET</span>
31179          <span class="ep-path">/api/git/scan-ref</span>
31180          <span class="auth-badge protected">Protected</span>
31181          <span class="ep-desc">SLOC-scan a specific git ref</span>
31182          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31183        </div>
31184        <div class="ep-body">
31185          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31186          <p class="params-heading">Query Parameters</p>
31187          <table class="params">
31188            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31189            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31190            <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
31191          </table>
31192          <p class="curl-heading">Example</p>
31193          <div class="curl-wrap">
31194            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31195  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
31196            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31197          </div>
31198        </div>
31199      </div>
31200
31201      <div class="ep-card">
31202        <div class="ep-header">
31203          <span class="method get">GET</span>
31204          <span class="ep-path">/api/git/compare-refs</span>
31205          <span class="auth-badge protected">Protected</span>
31206          <span class="ep-desc">Compare SLOC across two git refs</span>
31207          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31208        </div>
31209        <div class="ep-body">
31210          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31211          <p class="params-heading">Query Parameters</p>
31212          <table class="params">
31213            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31214            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31215            <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
31216            <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
31217          </table>
31218          <p class="curl-heading">Example</p>
31219          <div class="curl-wrap">
31220            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31221  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&amp;base=v1.0&amp;head=main"</pre>
31222            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31223          </div>
31224        </div>
31225      </div>
31226    </div>
31227
31228    <!-- Webhooks -->
31229    <div class="section">
31230      <h2 class="section-title">Webhooks</h2>
31231      <p class="webhook-note">Webhook receivers are public endpoints authenticated by per-schedule HMAC secrets, not by the server API key. Configure secrets in <a href="/integrations">Integrations</a>.</p>
31232
31233      <div class="ep-card">
31234        <div class="ep-header">
31235          <span class="method post">POST</span>
31236          <span class="ep-path">/webhooks/github</span>
31237          <span class="auth-badge hmac">HMAC</span>
31238          <span class="ep-desc">GitHub push event receiver</span>
31239          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31240        </div>
31241        <div class="ep-body">
31242          <p class="ep-desc-full">Receives GitHub <code>push</code> events and triggers an SLOC scan. Authenticated via <code>X-Hub-Signature-256</code> HMAC-SHA256.</p>
31243          <p class="params-heading">Required Headers</p>
31244          <table class="params">
31245            <tr><th>Header</th><th>Value</th></tr>
31246            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31247            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31248            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31249          </table>
31250        </div>
31251      </div>
31252
31253      <div class="ep-card">
31254        <div class="ep-header">
31255          <span class="method post">POST</span>
31256          <span class="ep-path">/webhooks/gitlab</span>
31257          <span class="auth-badge hmac">HMAC</span>
31258          <span class="ep-desc">GitLab push event receiver</span>
31259          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31260        </div>
31261        <div class="ep-body">
31262          <p class="ep-desc-full">Receives GitLab <code>Push Hook</code> events. Authenticated via <code>X-Gitlab-Token</code> matching the per-schedule secret.</p>
31263          <p class="params-heading">Required Headers</p>
31264          <table class="params">
31265            <tr><th>Header</th><th>Value</th></tr>
31266            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31267            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31268            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31269          </table>
31270        </div>
31271      </div>
31272
31273      <div class="ep-card">
31274        <div class="ep-header">
31275          <span class="method post">POST</span>
31276          <span class="ep-path">/webhooks/bitbucket</span>
31277          <span class="auth-badge hmac">HMAC</span>
31278          <span class="ep-desc">Bitbucket push event receiver</span>
31279          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31280        </div>
31281        <div class="ep-body">
31282          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31283          <p class="params-heading">Required Headers</p>
31284          <table class="params">
31285            <tr><th>Header</th><th>Value</th></tr>
31286            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31287            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31288          </table>
31289        </div>
31290      </div>
31291    </div>
31292
31293    <!-- Config -->
31294    <div class="section">
31295      <h2 class="section-title">Config Import / Export</h2>
31296
31297      <div class="ep-card">
31298        <div class="ep-header">
31299          <span class="method get">GET</span>
31300          <span class="ep-path">/export-config</span>
31301          <span class="auth-badge protected">Protected</span>
31302          <span class="ep-desc">Export server configuration as JSON</span>
31303          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31304        </div>
31305        <div class="ep-body">
31306          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31307          <p class="curl-heading">Example</p>
31308          <div class="curl-wrap">
31309            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31310  -o config.json \
31311  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31312            <button class="curl-copy-btn" data-target="c-export">Copy</button>
31313          </div>
31314        </div>
31315      </div>
31316
31317      <div class="ep-card">
31318        <div class="ep-header">
31319          <span class="method post">POST</span>
31320          <span class="ep-path">/import-config</span>
31321          <span class="auth-badge protected">Protected</span>
31322          <span class="ep-desc">Import server configuration</span>
31323          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31324        </div>
31325        <div class="ep-body">
31326          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31327          <p class="curl-heading">Example</p>
31328          <div class="curl-wrap">
31329            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31330  -H "Authorization: Bearer $SLOC_API_KEY" \
31331  -H "Content-Type: application/json" \
31332  -d @config.json \
31333  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31334            <button class="curl-copy-btn" data-target="c-import">Copy</button>
31335          </div>
31336        </div>
31337      </div>
31338    </div>
31339
31340    <!-- CI Ingest -->
31341    <div class="section">
31342      <h2 class="section-title">CI Ingest</h2>
31343
31344      <div class="ep-card">
31345        <div class="ep-header">
31346          <span class="method post">POST</span>
31347          <span class="ep-path">/api/ingest</span>
31348          <span class="auth-badge protected">Protected</span>
31349          <span class="ep-desc">Push a pre-computed scan result from CI</span>
31350          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31351        </div>
31352        <div class="ep-body">
31353          <p class="ep-desc-full">Accepts a pre-computed <code>AnalysisRun</code> JSON (produced by <code>oxide-sloc analyze --json-out result.json</code>) and stores it as if a server-side scan had been run. Use <code>oxide-sloc send result.json --webhook-url &lt;server&gt;/api/ingest</code> for the canonical CLI workflow.</p>
31354          <p class="params-heading">Query Parameters</p>
31355          <table class="params">
31356            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31357            <tr><td class="pt-name">label</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Display name shown in View Reports (defaults to the scanned root path)</td></tr>
31358          </table>
31359          <p class="params-heading">Request Body (application/json)</p>
31360          <p style="margin:0 0 8px;font-size:13px;color:var(--muted);">Full <code>AnalysisRun</code> JSON as produced by the CLI <code>--json-out</code> flag.</p>
31361          <details class="schema"><summary>Response schema</summary>
31362<div class="schema-block">// 201 Created
31363{
31364  "run_id":   string,  // UUID of the ingested run
31365  "view_url": string   // relative URL to the report page
31366}</div></details>
31367          <p class="curl-heading">Example</p>
31368          <div class="curl-wrap">
31369            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31370  -H "Authorization: Bearer $SLOC_API_KEY" \
31371  -H "Content-Type: application/json" \
31372  -d @result.json \
31373  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31374            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31375          </div>
31376        </div>
31377      </div>
31378    </div>
31379
31380    <!-- Artifact Download -->
31381    <div class="section">
31382      <h2 class="section-title">Artifact Download</h2>
31383
31384      <div class="ep-card">
31385        <div class="ep-header">
31386          <span class="method get">GET</span>
31387          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31388          <span class="auth-badge protected">Protected</span>
31389          <span class="ep-desc">Download or view a scan artifact</span>
31390          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31391        </div>
31392        <div class="ep-body">
31393          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31394          <p class="params-heading">Path Parameters</p>
31395          <table class="params">
31396            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31397            <tr><td class="pt-name">artifact</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>One of: <code>html</code> (rendered report), <code>pdf</code> (PDF export), <code>json</code> (raw AnalysisRun), <code>scan-config</code> (TOML config used)</td></tr>
31398            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
31399          </table>
31400          <p class="params-heading">Query Parameters</p>
31401          <table class="params">
31402            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31403            <tr><td class="pt-name">download</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Pass <code>1</code> to force a <code>Content-Disposition: attachment</code> download header</td></tr>
31404          </table>
31405          <p class="curl-heading">Example — download JSON result</p>
31406          <div class="curl-wrap">
31407            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31408  -o result.json \
31409  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
31410            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31411          </div>
31412        </div>
31413      </div>
31414    </div>
31415
31416    <!-- Embed Widget -->
31417    <div class="section">
31418      <h2 class="section-title">Embed Widget</h2>
31419
31420      <div class="ep-card">
31421        <div class="ep-header">
31422          <span class="method get">GET</span>
31423          <span class="ep-path">/embed/summary</span>
31424          <span class="auth-badge protected">Protected</span>
31425          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31426          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31427        </div>
31428        <div class="ep-body">
31429          <p class="ep-desc-full">Returns a self-contained HTML snippet suitable for embedding in an <code>&lt;iframe&gt;</code>. Shows key metrics (code lines, file count, language breakdown) for the specified or most recent run.</p>
31430          <p class="params-heading">Query Parameters</p>
31431          <table class="params">
31432            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31433            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-opt">optional</span></td><td>Run to display; defaults to the most recent scan</td></tr>
31434            <tr><td class="pt-name">theme</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Pass <code>dark</code> for a dark-themed widget</td></tr>
31435          </table>
31436          <p class="curl-heading">Example</p>
31437          <div class="curl-wrap">
31438            <pre class="curl-block" data-curl-id="c-embed">&lt;iframe src="<span class="base-url-slot">http://127.0.0.1:4317</span>/embed/summary?theme=dark"
31439        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
31440            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31441          </div>
31442        </div>
31443      </div>
31444    </div>
31445
31446    <!-- Confluence Integration -->
31447    <div class="section">
31448      <h2 class="section-title">Confluence Integration</h2>
31449
31450      <div class="ep-card">
31451        <div class="ep-header">
31452          <span class="method get">GET</span>
31453          <span class="ep-path">/api/confluence/config</span>
31454          <span class="auth-badge protected">Protected</span>
31455          <span class="ep-desc">Get current Confluence configuration</span>
31456          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31457        </div>
31458        <div class="ep-body">
31459          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31460          <details class="schema"><summary>Response schema</summary>
31461<div class="schema-block">{
31462  "configured":     boolean,
31463  "tier":           "cloud" | "server",
31464  "base_url":       string,
31465  "username":       string,
31466  "api_token_set":  boolean,
31467  "space_key":      string,
31468  "parent_page_id": string | null,
31469  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
31470}</div></details>
31471          <p class="curl-heading">Example</p>
31472          <div class="curl-wrap">
31473            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31474  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31475            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31476          </div>
31477        </div>
31478      </div>
31479
31480      <div class="ep-card">
31481        <div class="ep-header">
31482          <span class="method post">POST</span>
31483          <span class="ep-path">/api/confluence/config</span>
31484          <span class="auth-badge protected">Protected</span>
31485          <span class="ep-desc">Save Confluence configuration</span>
31486          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31487        </div>
31488        <div class="ep-body">
31489          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31490          <p class="params-heading">Request Body (application/json)</p>
31491          <table class="params">
31492            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31493            <tr><td class="pt-name">tier</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td><code>cloud</code> (default) or <code>server</code></td></tr>
31494            <tr><td class="pt-name">base_url</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Confluence base URL (e.g. <code>https://myorg.atlassian.net</code>)</td></tr>
31495            <tr><td class="pt-name">username</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Atlassian account email / server username</td></tr>
31496            <tr><td class="pt-name">credential</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>API token or password; blank to keep existing</td></tr>
31497            <tr><td class="pt-name">space_key</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Confluence space key (e.g. <code>ENG</code>)</td></tr>
31498            <tr><td class="pt-name">parent_page_id</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Page ID to create reports under</td></tr>
31499            <tr><td class="pt-name">schedule_auto_post</td><td class="pt-type">object</td><td><span class="pt-opt">optional</span></td><td>Map of schedule UUID → boolean for auto-posting on webhook trigger</td></tr>
31500          </table>
31501          <details class="schema"><summary>Response schema</summary>
31502<div class="schema-block">{ "ok": true }</div></details>
31503          <p class="curl-heading">Example</p>
31504          <div class="curl-wrap">
31505            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31506  -H "Authorization: Bearer $SLOC_API_KEY" \
31507  -H "Content-Type: application/json" \
31508  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31509  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31510            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31511          </div>
31512        </div>
31513      </div>
31514
31515      <div class="ep-card">
31516        <div class="ep-header">
31517          <span class="method post">POST</span>
31518          <span class="ep-path">/api/confluence/test</span>
31519          <span class="auth-badge protected">Protected</span>
31520          <span class="ep-desc">Test Confluence connection</span>
31521          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31522        </div>
31523        <div class="ep-body">
31524          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31525          <details class="schema"><summary>Response schema</summary>
31526<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
31527          <p class="curl-heading">Example</p>
31528          <div class="curl-wrap">
31529            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
31530  -H "Authorization: Bearer $SLOC_API_KEY" \
31531  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
31532            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
31533          </div>
31534        </div>
31535      </div>
31536
31537      <div class="ep-card">
31538        <div class="ep-header">
31539          <span class="method post">POST</span>
31540          <span class="ep-path">/api/confluence/post</span>
31541          <span class="auth-badge protected">Protected</span>
31542          <span class="ep-desc">Publish a scan report to Confluence</span>
31543          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31544        </div>
31545        <div class="ep-body">
31546          <p class="ep-desc-full">Creates or updates a Confluence page containing the SLOC metrics for the specified run. Requires Confluence to be configured via <code>POST /api/confluence/config</code>.</p>
31547          <p class="params-heading">Request Body (application/json)</p>
31548          <table class="params">
31549            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31550            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run whose metrics to publish</td></tr>
31551            <tr><td class="pt-name">page_title</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Title for the Confluence page</td></tr>
31552            <tr><td class="pt-name">report_url</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>URL to the HTML report, included as a link in the page</td></tr>
31553          </table>
31554          <details class="schema"><summary>Response schema</summary>
31555<div class="schema-block">// 200 OK
31556{ "ok": true, "page_id": string }
31557
31558// 400 / 502 on error
31559{ "ok": false, "error": string }</div></details>
31560          <p class="curl-heading">Example</p>
31561          <div class="curl-wrap">
31562            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
31563  -H "Authorization: Bearer $SLOC_API_KEY" \
31564  -H "Content-Type: application/json" \
31565  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
31566  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
31567            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
31568          </div>
31569        </div>
31570      </div>
31571
31572      <div class="ep-card">
31573        <div class="ep-header">
31574          <span class="method get">GET</span>
31575          <span class="ep-path">/api/confluence/wiki-markup</span>
31576          <span class="auth-badge protected">Protected</span>
31577          <span class="ep-desc">Get Confluence wiki markup for a run</span>
31578          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31579        </div>
31580        <div class="ep-body">
31581          <p class="ep-desc-full">Returns the Confluence Storage Format (XHTML) markup that would be posted for the given run, so you can preview or extend it before publishing.</p>
31582          <p class="params-heading">Query Parameters</p>
31583          <table class="params">
31584            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31585            <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run to generate markup for</td></tr>
31586          </table>
31587          <p class="curl-heading">Example</p>
31588          <div class="curl-wrap">
31589            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31590  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
31591            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
31592          </div>
31593        </div>
31594      </div>
31595    </div>
31596
31597    <!-- Authentication -->
31598    <div class="section">
31599      <h2 class="section-title">Authentication</h2>
31600      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
31601
31602      <div class="ep-card">
31603        <div class="ep-header">
31604          <span class="method get">GET</span>
31605          <span class="ep-path">/auth/login</span>
31606          <span class="auth-badge public">Public</span>
31607          <span class="ep-desc">Login page</span>
31608          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31609        </div>
31610        <div class="ep-body">
31611          <p class="ep-desc-full">Returns the HTML login form. Redirects to <code>/</code> immediately when no API key is configured on the server.</p>
31612          <p class="params-heading">Query Parameters</p>
31613          <table class="params">
31614            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31615            <tr><td class="pt-name">next</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>URL to redirect to after a successful login</td></tr>
31616            <tr><td class="pt-name">error</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Pass <code>1</code> to display an invalid-credentials error</td></tr>
31617          </table>
31618        </div>
31619      </div>
31620
31621      <div class="ep-card">
31622        <div class="ep-header">
31623          <span class="method post">POST</span>
31624          <span class="ep-path">/auth/login</span>
31625          <span class="auth-badge public">Public</span>
31626          <span class="ep-desc">Submit credentials and get a session cookie</span>
31627          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31628        </div>
31629        <div class="ep-body">
31630          <p class="ep-desc-full">Validates the submitted API key and sets a <code>sloc_session</code> cookie on success. The cookie is <code>HttpOnly; SameSite=Strict</code> and is accepted by all protected endpoints in lieu of an <code>Authorization</code> or <code>X-API-Key</code> header.</p>
31631          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
31632          <table class="params">
31633            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31634            <tr><td class="pt-name">key</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>API key to validate</td></tr>
31635            <tr><td class="pt-name">next</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Redirect target on success (must start with <code>/</code>)</td></tr>
31636          </table>
31637          <p class="curl-heading">Example</p>
31638          <div class="curl-wrap">
31639            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
31640  -d "key=$SLOC_API_KEY&amp;next=/" \
31641  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
31642            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
31643          </div>
31644        </div>
31645      </div>
31646    </div>
31647
31648    <!-- Coverage Suggestion -->
31649    <div class="section">
31650      <h2 class="section-title">Coverage Suggestion</h2>
31651
31652      <div class="ep-card">
31653        <div class="ep-header">
31654          <span class="method get">GET</span>
31655          <span class="ep-path">/api/suggest-coverage</span>
31656          <span class="auth-badge protected">Protected</span>
31657          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
31658          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31659        </div>
31660        <div class="ep-body">
31661          <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML, coverage.py JSON) and returns the first one found, along with a hint for how to generate it if not present.</p>
31662          <p class="params-heading">Query Parameters</p>
31663          <table class="params">
31664            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31665            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Absolute path to the project root to inspect</td></tr>
31666          </table>
31667          <details class="schema"><summary>Response schema</summary>
31668<div class="schema-block">{
31669  "found": string | null,  // absolute path to the coverage file, if detected
31670  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
31671  "hint":  string | null   // shell command to generate coverage if not found
31672}</div></details>
31673          <p class="curl-heading">Example</p>
31674          <div class="curl-wrap">
31675            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31676  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
31677            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
31678          </div>
31679        </div>
31680      </div>
31681    </div>
31682
31683  </div>
31684
31685  <footer class="site-footer">
31686    local code analysis - metrics, history and reports
31687    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
31688    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31689    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31690    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31691    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
31692  </footer>
31693
31694  <script nonce="{{ csp_nonce }}">
31695    (function () {
31696      var base = window.location.origin;
31697      document.getElementById('base-url').textContent = base;
31698      document.querySelectorAll('.base-url-slot').forEach(function (el) {
31699        el.textContent = base;
31700      });
31701
31702      document.querySelectorAll('.ep-header').forEach(function (hdr) {
31703        hdr.addEventListener('click', function () {
31704          hdr.closest('.ep-card').classList.toggle('open');
31705        });
31706      });
31707
31708      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
31709        btn.addEventListener('click', function () {
31710          var targetId = btn.dataset.target;
31711          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
31712          if (!pre) return;
31713          navigator.clipboard.writeText(pre.textContent).then(function () {
31714            btn.textContent = 'Copied!';
31715            btn.classList.add('copied');
31716            setTimeout(function () {
31717              btn.textContent = 'Copy';
31718              btn.classList.remove('copied');
31719            }, 2000);
31720          });
31721        });
31722      });
31723
31724      var storageKey = 'oxide-sloc-theme';
31725      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
31726      var themeBtn = document.getElementById('theme-toggle');
31727      if (themeBtn) {
31728        themeBtn.addEventListener('click', function () {
31729          var dark = document.body.classList.toggle('dark-theme');
31730          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
31731        });
31732      }
31733      (function() {
31734        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'}];
31735        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);});}
31736        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31737        var btn=document.getElementById('settings-btn');if(!btn)return;
31738        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31739        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>';
31740        document.body.appendChild(m);
31741        var g=document.getElementById('scheme-grid');
31742        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);});
31743        var cl=document.getElementById('settings-close');
31744        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);
31745        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');});
31746        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31747        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31748      })();
31749      (function randomizeWatermarks() {
31750        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31751        if (!wms.length) return;
31752        var placed = [];
31753        function tooClose(top, left) {
31754          for (var i = 0; i < placed.length; i++) {
31755            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31756            if (dt < 16 && dl < 12) return true;
31757          }
31758          return false;
31759        }
31760        function pick(leftBand) {
31761          for (var attempt = 0; attempt < 50; attempt++) {
31762            var top = Math.random() * 88 + 2;
31763            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31764            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31765          }
31766          var top = Math.random() * 88 + 2;
31767          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31768          placed.push([top, left]); return [top, left];
31769        }
31770        var half = Math.floor(wms.length / 2);
31771        wms.forEach(function (img, i) {
31772          var pos = pick(i < half);
31773          var size = Math.floor(Math.random() * 100 + 120);
31774          var rot = (Math.random() * 360).toFixed(1);
31775          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31776          img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
31777        });
31778      })();
31779      (function spawnCodeParticles() {
31780        var container = document.getElementById('code-particles');
31781        if (!container) return;
31782        var snippets = [
31783          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31784          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31785          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31786          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31787          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31788        ];
31789        var count = 38;
31790        for (var i = 0; i < count; i++) {
31791          (function(idx) {
31792            var el = document.createElement('span');
31793            el.className = 'code-particle';
31794            el.textContent = snippets[idx % snippets.length];
31795            var left = Math.random() * 94 + 2;
31796            var top = Math.random() * 88 + 6;
31797            var dur = (Math.random() * 10 + 9).toFixed(1);
31798            var delay = (Math.random() * 18).toFixed(1);
31799            var rot = (Math.random() * 26 - 13).toFixed(1);
31800            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31801            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31802            container.appendChild(el);
31803          })(i);
31804        }
31805      })();
31806    }());
31807  </script>
31808</body>
31809</html>
31810"##,
31811    ext = "html"
31812)]
31813struct ApiDocsTemplate {
31814    has_api_key: bool,
31815    csp_nonce: String,
31816    version: &'static str,
31817}
31818
31819#[cfg(test)]
31820mod form_config_tests {
31821    use super::*;
31822    use sloc_config::{
31823        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
31824    };
31825
31826    fn blank_form() -> AnalyzeForm {
31827        AnalyzeForm {
31828            path: ".".to_string(),
31829            git_repo: None,
31830            git_ref: None,
31831            mixed_line_policy: None,
31832            python_docstrings_as_comments: None,
31833            generated_file_detection: None,
31834            minified_file_detection: None,
31835            vendor_directory_detection: None,
31836            include_lockfiles: None,
31837            binary_file_behavior: None,
31838            output_dir: None,
31839            report_title: None,
31840            report_header_footer: None,
31841            include_globs: None,
31842            exclude_globs: None,
31843            submodule_breakdown: None,
31844            coverage_file: None,
31845            continuation_line_policy: None,
31846            blank_in_block_comment_policy: None,
31847            count_compiler_directives: None,
31848            style_col_threshold: None,
31849            style_analysis_enabled: None,
31850            style_score_threshold: None,
31851            style_lang_scope: None,
31852            cocomo_mode: None,
31853            complexity_alert: None,
31854            exclude_duplicates: None,
31855            activity_window: None,
31856        }
31857    }
31858
31859    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
31860        let mut cfg = sloc_config::AppConfig::default();
31861        apply_form_to_config(&mut cfg, form);
31862        cfg
31863    }
31864
31865    // ── activity_window (git hotspots — on by default) ──
31866
31867    #[test]
31868    fn extract_long_commit_picks_super_repo_by_short_prefix() {
31869        // A pretty-printed JSON tail containing several submodule git_commit_long
31870        // values plus the super-repo's; the helper must return the one whose hash
31871        // starts with the known short SHA, ignoring the others and any null value.
31872        let dir = tempfile::tempdir().unwrap();
31873        let path = dir.path().join("result.json");
31874        let body = r#"{
31875  "submodules": [
31876    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
31877    { "git_commit_long": null }
31878  ],
31879  "git_commit_short": "4c2cd9b",
31880  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
31881}"#;
31882        std::fs::write(&path, body).unwrap();
31883        assert_eq!(
31884            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
31885            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
31886        );
31887        // No match for an unrelated short SHA, and empty short yields None.
31888        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
31889        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
31890    }
31891
31892    #[test]
31893    fn activity_window_defaults_on_when_field_blank() {
31894        // Blank form field keeps the config default (90 days).
31895        let cfg = apply(&blank_form());
31896        assert_eq!(cfg.analysis.activity_window_days, Some(90));
31897    }
31898
31899    #[test]
31900    fn activity_window_override_sets_days() {
31901        let mut form = blank_form();
31902        form.activity_window = Some("30".to_string());
31903        let cfg = apply(&form);
31904        assert_eq!(cfg.analysis.activity_window_days, Some(30));
31905    }
31906
31907    #[test]
31908    fn activity_window_zero_disables() {
31909        // An explicit 0 from the form disables hotspots (overrides the default-on).
31910        let mut form = blank_form();
31911        form.activity_window = Some("0".to_string());
31912        let cfg = apply(&form);
31913        assert_eq!(cfg.analysis.activity_window_days, Some(0));
31914    }
31915
31916    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
31917
31918    #[test]
31919    fn python_docstrings_false_when_unchecked() {
31920        // Checkbox absent in form data (unchecked) → field must be false.
31921        let cfg = apply(&blank_form());
31922        assert!(
31923            !cfg.analysis.python_docstrings_as_comments,
31924            "absent python_docstrings_as_comments must map to false"
31925        );
31926    }
31927
31928    #[test]
31929    fn python_docstrings_true_when_checked() {
31930        // Browser sends "on" (no value= attr on the checkbox).
31931        let mut form = blank_form();
31932        form.python_docstrings_as_comments = Some("on".to_string());
31933        let cfg = apply(&form);
31934        assert!(cfg.analysis.python_docstrings_as_comments);
31935    }
31936
31937    #[test]
31938    fn python_docstrings_true_for_any_non_none_value() {
31939        // The handler uses .is_some() — any non-None value means "checked".
31940        let mut form = blank_form();
31941        form.python_docstrings_as_comments = Some("true".to_string());
31942        assert!(apply(&form).analysis.python_docstrings_as_comments);
31943    }
31944
31945    // ── submodule_breakdown (checkbox with value="enabled") ──
31946
31947    #[test]
31948    fn submodule_breakdown_false_when_unchecked() {
31949        let cfg = apply(&blank_form());
31950        assert!(
31951            !cfg.discovery.submodule_breakdown,
31952            "absent submodule_breakdown must map to false"
31953        );
31954    }
31955
31956    #[test]
31957    fn submodule_breakdown_true_when_value_enabled() {
31958        let mut form = blank_form();
31959        form.submodule_breakdown = Some("enabled".to_string());
31960        assert!(apply(&form).discovery.submodule_breakdown);
31961    }
31962
31963    #[test]
31964    fn submodule_breakdown_false_for_wrong_value() {
31965        // If somehow a value other than "enabled" is sent, it must still be false.
31966        let mut form = blank_form();
31967        form.submodule_breakdown = Some("on".to_string());
31968        assert!(
31969            !apply(&form).discovery.submodule_breakdown,
31970            "submodule_breakdown only becomes true for the exact value 'enabled'"
31971        );
31972    }
31973
31974    // ── generated_file_detection (select: "enabled" | "disabled") ──
31975
31976    #[test]
31977    fn generated_detection_true_when_enabled() {
31978        let mut form = blank_form();
31979        form.generated_file_detection = Some("enabled".to_string());
31980        assert!(apply(&form).analysis.generated_file_detection);
31981    }
31982
31983    #[test]
31984    fn generated_detection_false_when_disabled() {
31985        let mut form = blank_form();
31986        form.generated_file_detection = Some("disabled".to_string());
31987        assert!(!apply(&form).analysis.generated_file_detection);
31988    }
31989
31990    #[test]
31991    fn generated_detection_true_when_absent() {
31992        // None != Some("disabled") → true (safe default)
31993        assert!(
31994            apply(&blank_form()).analysis.generated_file_detection,
31995            "absent field must default to true (detection on)"
31996        );
31997    }
31998
31999    // ── minified_file_detection ──
32000
32001    #[test]
32002    fn minified_detection_false_when_disabled() {
32003        let mut form = blank_form();
32004        form.minified_file_detection = Some("disabled".to_string());
32005        assert!(!apply(&form).analysis.minified_file_detection);
32006    }
32007
32008    #[test]
32009    fn minified_detection_true_when_enabled() {
32010        let mut form = blank_form();
32011        form.minified_file_detection = Some("enabled".to_string());
32012        assert!(apply(&form).analysis.minified_file_detection);
32013    }
32014
32015    #[test]
32016    fn minified_detection_true_when_absent() {
32017        assert!(apply(&blank_form()).analysis.minified_file_detection);
32018    }
32019
32020    // ── vendor_directory_detection ──
32021
32022    #[test]
32023    fn vendor_detection_false_when_disabled() {
32024        let mut form = blank_form();
32025        form.vendor_directory_detection = Some("disabled".to_string());
32026        assert!(!apply(&form).analysis.vendor_directory_detection);
32027    }
32028
32029    #[test]
32030    fn vendor_detection_true_when_enabled() {
32031        let mut form = blank_form();
32032        form.vendor_directory_detection = Some("enabled".to_string());
32033        assert!(apply(&form).analysis.vendor_directory_detection);
32034    }
32035
32036    #[test]
32037    fn vendor_detection_true_when_absent() {
32038        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32039    }
32040
32041    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32042
32043    #[test]
32044    fn lockfiles_false_when_absent() {
32045        // None == Some("enabled") is false → lockfiles off (correct safe default)
32046        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32047    }
32048
32049    #[test]
32050    fn lockfiles_false_when_disabled() {
32051        let mut form = blank_form();
32052        form.include_lockfiles = Some("disabled".to_string());
32053        assert!(!apply(&form).analysis.include_lockfiles);
32054    }
32055
32056    #[test]
32057    fn lockfiles_true_when_enabled() {
32058        let mut form = blank_form();
32059        form.include_lockfiles = Some("enabled".to_string());
32060        assert!(apply(&form).analysis.include_lockfiles);
32061    }
32062
32063    // ── count_compiler_directives ──
32064
32065    #[test]
32066    fn compiler_directives_true_when_absent() {
32067        assert!(
32068            apply(&blank_form()).analysis.count_compiler_directives,
32069            "absent count_compiler_directives must default to true"
32070        );
32071    }
32072
32073    #[test]
32074    fn compiler_directives_true_when_enabled() {
32075        let mut form = blank_form();
32076        form.count_compiler_directives = Some("enabled".to_string());
32077        assert!(apply(&form).analysis.count_compiler_directives);
32078    }
32079
32080    #[test]
32081    fn compiler_directives_false_when_disabled() {
32082        let mut form = blank_form();
32083        form.count_compiler_directives = Some("disabled".to_string());
32084        assert!(!apply(&form).analysis.count_compiler_directives);
32085    }
32086
32087    // ── mixed_line_policy (enum select) ──
32088
32089    #[test]
32090    fn mixed_policy_unchanged_when_absent() {
32091        // None → if-let does nothing → stays at config default (CodeOnly)
32092        assert_eq!(
32093            apply(&blank_form()).analysis.mixed_line_policy,
32094            MixedLinePolicy::CodeOnly
32095        );
32096    }
32097
32098    #[test]
32099    fn mixed_policy_code_only() {
32100        let mut form = blank_form();
32101        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32102        assert_eq!(
32103            apply(&form).analysis.mixed_line_policy,
32104            MixedLinePolicy::CodeOnly
32105        );
32106    }
32107
32108    #[test]
32109    fn mixed_policy_code_and_comment() {
32110        let mut form = blank_form();
32111        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32112        assert_eq!(
32113            apply(&form).analysis.mixed_line_policy,
32114            MixedLinePolicy::CodeAndComment
32115        );
32116    }
32117
32118    #[test]
32119    fn mixed_policy_comment_only() {
32120        let mut form = blank_form();
32121        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32122        assert_eq!(
32123            apply(&form).analysis.mixed_line_policy,
32124            MixedLinePolicy::CommentOnly
32125        );
32126    }
32127
32128    #[test]
32129    fn mixed_policy_separate_mixed_category() {
32130        let mut form = blank_form();
32131        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32132        assert_eq!(
32133            apply(&form).analysis.mixed_line_policy,
32134            MixedLinePolicy::SeparateMixedCategory
32135        );
32136    }
32137
32138    // ── binary_file_behavior (enum select) ──
32139
32140    #[test]
32141    fn binary_behavior_skip_when_absent() {
32142        assert_eq!(
32143            apply(&blank_form()).analysis.binary_file_behavior,
32144            BinaryFileBehavior::Skip
32145        );
32146    }
32147
32148    #[test]
32149    fn binary_behavior_skip() {
32150        let mut form = blank_form();
32151        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32152        assert_eq!(
32153            apply(&form).analysis.binary_file_behavior,
32154            BinaryFileBehavior::Skip
32155        );
32156    }
32157
32158    #[test]
32159    fn binary_behavior_fail() {
32160        let mut form = blank_form();
32161        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32162        assert_eq!(
32163            apply(&form).analysis.binary_file_behavior,
32164            BinaryFileBehavior::Fail
32165        );
32166    }
32167
32168    // ── continuation_line_policy (enum select) ──
32169
32170    #[test]
32171    fn continuation_policy_each_physical_when_absent() {
32172        assert_eq!(
32173            apply(&blank_form()).analysis.continuation_line_policy,
32174            ContinuationLinePolicy::EachPhysicalLine
32175        );
32176    }
32177
32178    #[test]
32179    fn continuation_policy_collapse_to_logical() {
32180        let mut form = blank_form();
32181        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32182        assert_eq!(
32183            apply(&form).analysis.continuation_line_policy,
32184            ContinuationLinePolicy::CollapseToLogical
32185        );
32186    }
32187
32188    // ── blank_in_block_comment_policy (enum select) ──
32189
32190    #[test]
32191    fn blank_in_block_comment_count_as_comment_when_absent() {
32192        assert_eq!(
32193            apply(&blank_form()).analysis.blank_in_block_comment_policy,
32194            BlankInBlockCommentPolicy::CountAsComment
32195        );
32196    }
32197
32198    #[test]
32199    fn blank_in_block_comment_count_as_blank() {
32200        let mut form = blank_form();
32201        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32202        assert_eq!(
32203            apply(&form).analysis.blank_in_block_comment_policy,
32204            BlankInBlockCommentPolicy::CountAsBlank
32205        );
32206    }
32207
32208    // ── style_col_threshold ──
32209
32210    #[test]
32211    fn style_threshold_80() {
32212        let mut form = blank_form();
32213        form.style_col_threshold = Some("80".to_string());
32214        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32215    }
32216
32217    #[test]
32218    fn style_threshold_100() {
32219        let mut form = blank_form();
32220        form.style_col_threshold = Some("100".to_string());
32221        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32222    }
32223
32224    #[test]
32225    fn style_threshold_120() {
32226        let mut form = blank_form();
32227        form.style_col_threshold = Some("120".to_string());
32228        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32229    }
32230
32231    #[test]
32232    fn style_threshold_invalid_value_leaves_default() {
32233        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
32234        let mut cfg = sloc_config::AppConfig::default();
32235        let mut form = blank_form();
32236        form.style_col_threshold = Some("42".to_string());
32237        apply_form_to_config(&mut cfg, &form);
32238        assert_eq!(
32239            cfg.analysis.style_col_threshold, 80,
32240            "invalid threshold must not change config"
32241        );
32242    }
32243
32244    #[test]
32245    fn style_threshold_non_numeric_leaves_default() {
32246        let mut cfg = sloc_config::AppConfig::default();
32247        let mut form = blank_form();
32248        form.style_col_threshold = Some("large".to_string());
32249        apply_form_to_config(&mut cfg, &form);
32250        assert_eq!(cfg.analysis.style_col_threshold, 80);
32251    }
32252
32253    #[test]
32254    fn style_threshold_zero_leaves_default() {
32255        let mut cfg = sloc_config::AppConfig::default();
32256        let mut form = blank_form();
32257        form.style_col_threshold = Some("0".to_string());
32258        apply_form_to_config(&mut cfg, &form);
32259        assert_eq!(cfg.analysis.style_col_threshold, 80);
32260    }
32261
32262    #[test]
32263    fn style_threshold_absent_leaves_default() {
32264        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32265    }
32266
32267    // ── style_score_threshold ──
32268
32269    #[test]
32270    fn style_score_threshold_zero_when_absent() {
32271        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32272    }
32273
32274    #[test]
32275    fn style_score_threshold_set_to_valid_value() {
32276        let mut form = blank_form();
32277        form.style_score_threshold = Some("70".to_string());
32278        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32279    }
32280
32281    #[test]
32282    fn style_score_threshold_clamps_to_100_when_over() {
32283        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
32284        let mut form = blank_form();
32285        form.style_score_threshold = Some("200".to_string());
32286        assert_eq!(
32287            apply(&form).analysis.style_score_threshold,
32288            100,
32289            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32290        );
32291    }
32292
32293    // ── coverage_file ──
32294
32295    #[test]
32296    fn coverage_file_none_when_absent() {
32297        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32298    }
32299
32300    #[test]
32301    fn coverage_file_none_when_whitespace_only() {
32302        let mut form = blank_form();
32303        form.coverage_file = Some("   ".to_string());
32304        assert!(
32305            apply(&form).analysis.coverage_file.is_none(),
32306            "whitespace-only coverage_file must be treated as None"
32307        );
32308    }
32309
32310    #[test]
32311    fn coverage_file_set_when_non_empty() {
32312        let mut form = blank_form();
32313        form.coverage_file = Some("coverage/lcov.info".to_string());
32314        assert_eq!(
32315            apply(&form).analysis.coverage_file,
32316            Some(std::path::PathBuf::from("coverage/lcov.info"))
32317        );
32318    }
32319
32320    #[test]
32321    fn coverage_file_trims_whitespace() {
32322        let mut form = blank_form();
32323        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
32324        assert_eq!(
32325            apply(&form).analysis.coverage_file,
32326            Some(std::path::PathBuf::from("coverage/lcov.info"))
32327        );
32328    }
32329
32330    // ── report_title ──
32331
32332    #[test]
32333    fn report_title_unchanged_when_absent() {
32334        let original = sloc_config::AppConfig::default().reporting.report_title;
32335        assert_eq!(apply(&blank_form()).reporting.report_title, original);
32336    }
32337
32338    #[test]
32339    fn report_title_unchanged_when_whitespace_only() {
32340        let original = sloc_config::AppConfig::default().reporting.report_title;
32341        let mut form = blank_form();
32342        form.report_title = Some("   ".to_string());
32343        assert_eq!(
32344            apply(&form).reporting.report_title,
32345            original,
32346            "whitespace-only title must not overwrite the default"
32347        );
32348    }
32349
32350    #[test]
32351    fn report_title_updated_and_trimmed() {
32352        let mut form = blank_form();
32353        form.report_title = Some("  My Project  ".to_string());
32354        assert_eq!(apply(&form).reporting.report_title, "My Project");
32355    }
32356
32357    // ── report_header_footer ──
32358
32359    #[test]
32360    fn header_footer_none_when_absent() {
32361        assert!(apply(&blank_form())
32362            .reporting
32363            .report_header_footer
32364            .is_none());
32365    }
32366
32367    #[test]
32368    fn header_footer_none_when_whitespace_only() {
32369        let mut form = blank_form();
32370        form.report_header_footer = Some("  ".to_string());
32371        assert!(apply(&form).reporting.report_header_footer.is_none());
32372    }
32373
32374    #[test]
32375    fn header_footer_set_and_trimmed() {
32376        let mut form = blank_form();
32377        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
32378        assert_eq!(
32379            apply(&form).reporting.report_header_footer,
32380            Some("Confidential — Internal Use".to_string())
32381        );
32382    }
32383
32384    // ── include_globs / exclude_globs ──
32385
32386    #[test]
32387    fn include_globs_empty_when_absent() {
32388        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32389    }
32390
32391    #[test]
32392    fn include_globs_newline_separated() {
32393        let mut form = blank_form();
32394        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32395        assert_eq!(
32396            apply(&form).discovery.include_globs,
32397            vec!["src/**/*.rs", "tests/**/*.rs"]
32398        );
32399    }
32400
32401    #[test]
32402    fn exclude_globs_comma_separated() {
32403        let mut form = blank_form();
32404        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32405        assert_eq!(
32406            apply(&form).discovery.exclude_globs,
32407            vec!["vendor/**", "node_modules/**"]
32408        );
32409    }
32410
32411    #[test]
32412    fn globs_mixed_separators() {
32413        let mut form = blank_form();
32414        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32415        assert_eq!(
32416            apply(&form).discovery.exclude_globs,
32417            vec!["a/**", "b/**", "c/**"]
32418        );
32419    }
32420
32421    // ── split_patterns unit tests ──
32422
32423    #[test]
32424    fn split_patterns_none_is_empty() {
32425        assert!(split_patterns(None).is_empty());
32426    }
32427
32428    #[test]
32429    fn split_patterns_empty_string_is_empty() {
32430        assert!(split_patterns(Some("")).is_empty());
32431    }
32432
32433    #[test]
32434    fn split_patterns_whitespace_only_is_empty() {
32435        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
32436    }
32437
32438    #[test]
32439    fn split_patterns_newlines() {
32440        assert_eq!(
32441            split_patterns(Some("a/**\nb/**\nc/**")),
32442            vec!["a/**", "b/**", "c/**"]
32443        );
32444    }
32445
32446    #[test]
32447    fn split_patterns_commas() {
32448        assert_eq!(
32449            split_patterns(Some("a/**,b/**,c/**")),
32450            vec!["a/**", "b/**", "c/**"]
32451        );
32452    }
32453
32454    #[test]
32455    fn split_patterns_mixed() {
32456        assert_eq!(
32457            split_patterns(Some("a/**\nb/**,c/**")),
32458            vec!["a/**", "b/**", "c/**"]
32459        );
32460    }
32461
32462    #[test]
32463    fn split_patterns_trims_whitespace() {
32464        assert_eq!(
32465            split_patterns(Some("  a/**  \n  b/**  ")),
32466            vec!["a/**", "b/**"]
32467        );
32468    }
32469
32470    #[test]
32471    fn split_patterns_filters_empty_entries() {
32472        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32473    }
32474
32475    #[test]
32476    fn split_patterns_single_entry() {
32477        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32478    }
32479}
32480
32481#[cfg(test)]
32482mod utility_tests {
32483    use super::*;
32484    use std::net::IpAddr;
32485    use std::time::Duration;
32486
32487    // ── sanitize_project_label ────────────────────────────────────────────────
32488
32489    #[test]
32490    fn sanitize_simple_name() {
32491        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32492    }
32493
32494    #[test]
32495    fn sanitize_uppercased_lowercased() {
32496        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32497    }
32498
32499    #[test]
32500    fn sanitize_path_extracts_filename() {
32501        assert_eq!(
32502            sanitize_project_label("/home/user/my-project"),
32503            "my-project"
32504        );
32505    }
32506
32507    #[test]
32508    fn sanitize_path_uses_last_component() {
32509        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32510    }
32511
32512    #[test]
32513    fn sanitize_spaces_become_hyphens() {
32514        assert_eq!(sanitize_project_label("my project"), "my-project");
32515    }
32516
32517    #[test]
32518    fn sanitize_non_ascii_become_hyphens() {
32519        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32520    }
32521
32522    #[test]
32523    fn sanitize_all_special_chars_gives_project() {
32524        assert_eq!(sanitize_project_label("!@#$%^"), "project");
32525    }
32526
32527    #[test]
32528    fn sanitize_empty_string_gives_project() {
32529        assert_eq!(sanitize_project_label(""), "project");
32530    }
32531
32532    #[test]
32533    fn sanitize_leading_trailing_hyphens_stripped() {
32534        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
32535    }
32536
32537    #[test]
32538    fn sanitize_alphanumeric_preserved() {
32539        assert_eq!(sanitize_project_label("repo123"), "repo123");
32540    }
32541
32542    #[test]
32543    fn sanitize_dots_become_hyphens() {
32544        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
32545    }
32546
32547    #[test]
32548    fn sanitize_mixed_slashes_uses_filename() {
32549        // The Windows path separator — on all platforms Path::file_name still works
32550        assert_eq!(sanitize_project_label("project-name"), "project-name");
32551    }
32552
32553    // ── IpRateLimiter ─────────────────────────────────────────────────────────
32554
32555    #[test]
32556    fn rate_limiter_allows_first_request() {
32557        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
32558        let ip: IpAddr = "127.0.0.1".parse().unwrap();
32559        assert!(rl.is_allowed(ip));
32560    }
32561
32562    #[test]
32563    fn rate_limiter_blocks_after_limit_reached() {
32564        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
32565        let ip: IpAddr = "10.0.0.1".parse().unwrap();
32566        assert!(rl.is_allowed(ip));
32567        assert!(rl.is_allowed(ip));
32568        assert!(rl.is_allowed(ip));
32569        assert!(!rl.is_allowed(ip), "4th request must be blocked");
32570    }
32571
32572    #[test]
32573    fn rate_limiter_allows_requests_up_to_limit() {
32574        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
32575        let ip: IpAddr = "10.0.0.2".parse().unwrap();
32576        for _ in 0..5 {
32577            assert!(rl.is_allowed(ip));
32578        }
32579        assert!(!rl.is_allowed(ip), "6th request must be blocked");
32580    }
32581
32582    #[test]
32583    fn rate_limiter_different_ips_are_independent() {
32584        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
32585        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
32586        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
32587        assert!(rl.is_allowed(ip1));
32588        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
32589        assert!(rl.is_allowed(ip2), "ip2 must be independent");
32590    }
32591
32592    #[test]
32593    fn rate_limiter_auth_failure_not_locked_below_threshold() {
32594        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32595        let ip: IpAddr = "10.0.0.3".parse().unwrap();
32596        rl.record_auth_failure(ip);
32597        rl.record_auth_failure(ip);
32598        assert!(
32599            !rl.is_auth_locked_out(ip),
32600            "not locked at 2 failures when threshold is 3"
32601        );
32602    }
32603
32604    #[test]
32605    fn rate_limiter_auth_failure_locked_at_threshold() {
32606        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
32607        let ip: IpAddr = "10.0.0.4".parse().unwrap();
32608        rl.record_auth_failure(ip);
32609        rl.record_auth_failure(ip);
32610        rl.record_auth_failure(ip);
32611        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
32612    }
32613
32614    #[test]
32615    fn rate_limiter_auth_failure_different_ips_independent() {
32616        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
32617        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
32618        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
32619        rl.record_auth_failure(ip1);
32620        rl.record_auth_failure(ip1);
32621        assert!(rl.is_auth_locked_out(ip1));
32622        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
32623    }
32624
32625    #[test]
32626    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
32627        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
32628        let ip: IpAddr = "127.0.0.2".parse().unwrap();
32629        for _ in 0..100 {
32630            assert!(rl.is_allowed(ip));
32631        }
32632    }
32633
32634    // ── strip_unc_prefix ──────────────────────────────────────────────────────
32635
32636    #[test]
32637    fn strip_unc_plain_path_unchanged() {
32638        let p = PathBuf::from("C:\\Users\\user\\project");
32639        let result = strip_unc_prefix(p.clone());
32640        assert_eq!(result, p);
32641    }
32642
32643    #[test]
32644    fn strip_unc_with_drive_prefix_stripped() {
32645        let p = PathBuf::from(r"\\?\C:\Users\user\project");
32646        let result = strip_unc_prefix(p);
32647        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
32648    }
32649
32650    #[test]
32651    fn strip_unc_with_network_prefix_stripped() {
32652        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
32653        let result = strip_unc_prefix(p);
32654        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
32655    }
32656
32657    #[test]
32658    fn strip_unc_linux_path_unchanged() {
32659        let p = PathBuf::from("/home/user/project");
32660        let result = strip_unc_prefix(p.clone());
32661        assert_eq!(result, p);
32662    }
32663
32664    // ── remote_to_commit_url ──────────────────────────────────────────────────
32665
32666    #[test]
32667    fn remote_to_commit_url_github_https() {
32668        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
32669        assert_eq!(
32670            url,
32671            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32672        );
32673    }
32674
32675    #[test]
32676    fn remote_to_commit_url_github_ssh() {
32677        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
32678        assert_eq!(
32679            url,
32680            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
32681        );
32682    }
32683
32684    #[test]
32685    fn remote_to_commit_url_gitlab_uses_dash_commit() {
32686        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
32687        assert_eq!(
32688            url,
32689            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
32690        );
32691    }
32692
32693    #[test]
32694    fn remote_to_commit_url_bitbucket_uses_commits() {
32695        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
32696        assert_eq!(
32697            url,
32698            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
32699        );
32700    }
32701
32702    #[test]
32703    fn remote_to_commit_url_unknown_scheme_returns_none() {
32704        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
32705        assert!(url.is_none());
32706    }
32707
32708    #[test]
32709    fn remote_to_commit_url_ssh_gitlab() {
32710        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
32711        assert!(url.is_some());
32712        let u = url.unwrap();
32713        assert!(
32714            u.contains("/-/commit/sha123"),
32715            "gitlab ssh must use /-/commit/"
32716        );
32717    }
32718
32719    // ── git_clone_dest ────────────────────────────────────────────────────────
32720
32721    #[test]
32722    fn git_clone_dest_github_url_produces_safe_name() {
32723        let dir = PathBuf::from("/tmp/clones");
32724        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32725        let name = dest.file_name().unwrap().to_string_lossy();
32726        assert!(!name.is_empty());
32727        assert!(
32728            name.chars()
32729                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
32730            "clone dest must only contain safe chars, got: {name}"
32731        );
32732    }
32733
32734    #[test]
32735    fn git_clone_dest_is_inside_clones_dir() {
32736        let dir = PathBuf::from("/tmp/clones");
32737        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
32738        assert!(
32739            dest.starts_with(&dir),
32740            "clone dest must be inside clones_dir"
32741        );
32742    }
32743
32744    #[test]
32745    fn git_clone_dest_truncates_to_80_chars_max() {
32746        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
32747        let dir = PathBuf::from("/tmp/clones");
32748        let dest = git_clone_dest(&long_url, &dir);
32749        let name = dest.file_name().unwrap().to_string_lossy();
32750        assert!(
32751            name.len() <= 80,
32752            "clone dest name must be at most 80 chars, got {} chars: {name}",
32753            name.len()
32754        );
32755    }
32756
32757    #[test]
32758    fn git_clone_dest_special_chars_replaced_with_underscore() {
32759        let dir = PathBuf::from("/tmp/clones");
32760        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
32761        let name = dest.file_name().unwrap().to_string_lossy();
32762        assert!(
32763            !name.contains('@') && !name.contains(':') && !name.contains('/'),
32764            "special chars must be replaced in clone dest, got: {name}"
32765        );
32766    }
32767
32768    #[test]
32769    fn git_clone_dest_different_urls_differ() {
32770        let dir = PathBuf::from("/tmp/clones");
32771        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
32772        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
32773        assert_ne!(
32774            a, b,
32775            "different repos must produce different clone dest names"
32776        );
32777    }
32778
32779    #[test]
32780    fn git_clone_dest_same_url_same_result() {
32781        let dir = PathBuf::from("/tmp/clones");
32782        let url = "https://github.com/owner/repo.git";
32783        assert_eq!(
32784            git_clone_dest(url, &dir),
32785            git_clone_dest(url, &dir),
32786            "same URL must always give same clone dest"
32787        );
32788    }
32789
32790    // ── fmt_delta ─────────────────────────────────────────────────────────────
32791
32792    #[test]
32793    fn fmt_delta_positive_has_plus_prefix() {
32794        assert_eq!(fmt_delta(5), "+5");
32795    }
32796
32797    #[test]
32798    fn fmt_delta_negative_no_plus_prefix() {
32799        assert_eq!(fmt_delta(-3), "-3");
32800    }
32801
32802    #[test]
32803    fn fmt_delta_zero() {
32804        assert_eq!(fmt_delta(0), "0");
32805    }
32806
32807    // ── delta_class ───────────────────────────────────────────────────────────
32808
32809    #[test]
32810    fn delta_class_positive_is_pos() {
32811        assert_eq!(delta_class(1), "pos");
32812    }
32813
32814    #[test]
32815    fn delta_class_negative_is_neg() {
32816        assert_eq!(delta_class(-1), "neg");
32817    }
32818
32819    #[test]
32820    fn delta_class_zero_is_zero_class() {
32821        assert_eq!(delta_class(0), "zero");
32822    }
32823
32824    // ── fmt_pct ───────────────────────────────────────────────────────────────
32825
32826    #[test]
32827    fn fmt_pct_zero_baseline_returns_em_dash() {
32828        assert_eq!(fmt_pct(100, 0), "\u{2014}");
32829    }
32830
32831    #[test]
32832    fn fmt_pct_positive_delta_has_plus_sign() {
32833        let result = fmt_pct(10, 100);
32834        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
32835    }
32836
32837    #[test]
32838    fn fmt_pct_negative_delta_no_plus_sign() {
32839        let result = fmt_pct(-10, 100);
32840        assert!(!result.starts_with('+'), "unexpected + in: {result}");
32841        assert!(result.contains('%'));
32842    }
32843
32844    #[test]
32845    fn fmt_pct_near_zero_returns_pm_zero() {
32846        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
32847    }
32848
32849    // ── summary_delta ─────────────────────────────────────────────────────────
32850
32851    #[test]
32852    fn summary_delta_no_prev_returns_dash_na() {
32853        let (display, class) = summary_delta(10, None);
32854        assert_eq!(display, "\u{2014}");
32855        assert_eq!(class, "na");
32856    }
32857
32858    #[test]
32859    fn summary_delta_increase_is_positive() {
32860        let (display, class) = summary_delta(15, Some(10));
32861        assert_eq!(display, "+5");
32862        assert_eq!(class, "pos");
32863    }
32864
32865    #[test]
32866    fn summary_delta_decrease_is_negative() {
32867        let (display, class) = summary_delta(5, Some(10));
32868        assert_eq!(display, "-5");
32869        assert_eq!(class, "neg");
32870    }
32871
32872    // ── nth_weekday_of_month ──────────────────────────────────────────────────
32873
32874    #[test]
32875    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
32876        use chrono::Datelike;
32877        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
32878        assert_eq!(d.year(), 2024);
32879        assert_eq!(d.month(), 1);
32880        assert_eq!(d.weekday(), chrono::Weekday::Mon);
32881        assert!(d.day() <= 7);
32882    }
32883
32884    #[test]
32885    fn nth_weekday_second_sunday_march_2024_is_10th() {
32886        use chrono::Datelike;
32887        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
32888        assert_eq!(d.weekday(), chrono::Weekday::Sun);
32889        assert_eq!(d.month(), 3);
32890        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
32891    }
32892
32893    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
32894
32895    #[test]
32896    fn is_pacific_dst_july_is_true() {
32897        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32898        assert!(is_pacific_dst(dt), "July must be PDT");
32899    }
32900
32901    #[test]
32902    fn is_pacific_dst_january_is_false() {
32903        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32904        assert!(!is_pacific_dst(dt), "January must be PST");
32905    }
32906
32907    #[test]
32908    fn fmt_la_time_summer_shows_pdt() {
32909        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
32910        let result = fmt_la_time(dt);
32911        assert!(
32912            result.ends_with("PDT"),
32913            "summer must use PDT, got: {result}"
32914        );
32915    }
32916
32917    #[test]
32918    fn fmt_la_time_winter_shows_pst() {
32919        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
32920        let result = fmt_la_time(dt);
32921        assert!(
32922            result.ends_with("PST"),
32923            "winter must use PST, got: {result}"
32924        );
32925    }
32926
32927    #[test]
32928    fn fmt_la_time_meta_summer_shows_pdt() {
32929        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
32930        let result = fmt_la_time_meta(dt);
32931        assert!(
32932            result.ends_with("PDT"),
32933            "meta summer must use PDT, got: {result}"
32934        );
32935    }
32936
32937    #[test]
32938    fn fmt_la_time_meta_winter_shows_pst() {
32939        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
32940        let result = fmt_la_time_meta(dt);
32941        assert!(
32942            result.ends_with("PST"),
32943            "meta winter must use PST, got: {result}"
32944        );
32945    }
32946
32947    // ── fmt_git_date ──────────────────────────────────────────────────────────
32948
32949    #[test]
32950    fn fmt_git_date_valid_iso_returns_some() {
32951        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
32952    }
32953
32954    #[test]
32955    fn fmt_git_date_invalid_returns_none() {
32956        assert!(fmt_git_date("not-a-date").is_none());
32957    }
32958
32959    // ── format_number ─────────────────────────────────────────────────────────
32960
32961    #[test]
32962    fn format_number_zero() {
32963        assert_eq!(format_number(0), "0");
32964    }
32965
32966    #[test]
32967    fn format_number_three_digits_no_comma() {
32968        assert_eq!(format_number(999), "999");
32969    }
32970
32971    #[test]
32972    fn format_number_four_digits_has_comma() {
32973        assert_eq!(format_number(1000), "1,000");
32974    }
32975
32976    #[test]
32977    fn format_number_seven_digits_two_commas() {
32978        assert_eq!(format_number(1_234_567), "1,234,567");
32979    }
32980
32981    #[test]
32982    fn format_number_one_million() {
32983        assert_eq!(format_number(1_000_000), "1,000,000");
32984    }
32985
32986    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
32987
32988    #[test]
32989    fn badge_text_px_empty_is_zero() {
32990        assert_eq!(badge_text_px(""), 0);
32991    }
32992
32993    #[test]
32994    fn badge_text_px_narrow_chars_smaller_than_normal() {
32995        assert!(
32996            badge_text_px("if") < badge_text_px("ab"),
32997            "'if' must be narrower than 'ab'"
32998        );
32999    }
33000
33001    #[test]
33002    fn badge_text_px_m_is_wider_than_a() {
33003        assert!(
33004            badge_text_px("m") > badge_text_px("a"),
33005            "'m' must be wider than 'a'"
33006        );
33007    }
33008
33009    #[test]
33010    fn render_badge_svg_contains_label_and_value() {
33011        let svg = render_badge_svg("coverage", "95%", "#4c1");
33012        assert!(svg.contains("coverage") && svg.contains("95%"));
33013    }
33014
33015    #[test]
33016    fn render_badge_svg_contains_color() {
33017        let svg = render_badge_svg("sloc", "12K", "#e05d44");
33018        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33019    }
33020
33021    #[test]
33022    fn render_badge_svg_escapes_ampersand_in_label() {
33023        let svg = render_badge_svg("test&label", "ok", "#4c1");
33024        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33025    }
33026
33027    // ── build_pdf_filename ────────────────────────────────────────────────────
33028
33029    #[test]
33030    fn build_pdf_filename_slugifies_title() {
33031        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33032        assert!(
33033            name.starts_with("my_project_report_")
33034                && std::path::Path::new(&name)
33035                    .extension()
33036                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33037        );
33038    }
33039
33040    #[test]
33041    fn build_pdf_filename_uses_last_run_id_segment() {
33042        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33043        assert!(name.contains("ABCD"), "must use last segment of run_id");
33044    }
33045
33046    #[test]
33047    fn build_pdf_filename_empty_title_uses_report_prefix() {
33048        let name = build_pdf_filename("", "abc-def-9999");
33049        assert!(
33050            name.starts_with("report_")
33051                && std::path::Path::new(&name)
33052                    .extension()
33053                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33054        );
33055    }
33056
33057    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33058
33059    #[test]
33060    fn swap_chart_js_replaces_inline_block() {
33061        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33062        let result = swap_inline_chart_js_for_static(html.to_string());
33063        assert!(result.contains(r#"src="/static/chart-report.js""#));
33064        assert!(!result.contains("inline source"));
33065    }
33066
33067    #[test]
33068    fn swap_chart_js_no_head_returns_unchanged() {
33069        let html = "<body>no head here</body>";
33070        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33071    }
33072
33073    #[test]
33074    fn swap_chart_js_no_script_in_head_unchanged() {
33075        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33076        let result = swap_inline_chart_js_for_static(html.to_string());
33077        assert!(!result.contains("chart-report.js"));
33078    }
33079
33080    // ── patch_html_nonce ──────────────────────────────────────────────────────
33081
33082    #[test]
33083    fn patch_html_nonce_replaces_old_nonce() {
33084        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33085        let result = patch_html_nonce(html, "new-nonce-456");
33086        assert!(result.contains(r#"nonce="new-nonce-456""#));
33087        assert!(!result.contains("old-nonce-123"));
33088    }
33089
33090    #[test]
33091    fn patch_html_nonce_injects_into_bare_style() {
33092        let html = "<style>body{color:red;}</style>";
33093        let result = patch_html_nonce(html, "fresh-nonce");
33094        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33095    }
33096
33097    #[test]
33098    fn patch_html_nonce_injects_into_bare_script() {
33099        let html = "<script>console.log(1);</script>";
33100        let result = patch_html_nonce(html, "abc");
33101        assert!(result.contains(r#"<script nonce="abc">"#));
33102    }
33103
33104    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
33105
33106    #[test]
33107    fn is_html_report_file_result_html_matches() {
33108        let dir = tempfile::tempdir().unwrap();
33109        let path = dir.path().join("result_20240101.html");
33110        std::fs::write(&path, b"<html></html>").unwrap();
33111        assert!(is_html_report_file(&path));
33112    }
33113
33114    #[test]
33115    fn is_html_report_file_report_html_matches() {
33116        let dir = tempfile::tempdir().unwrap();
33117        let path = dir.path().join("report_abc.html");
33118        std::fs::write(&path, b"<html></html>").unwrap();
33119        assert!(is_html_report_file(&path));
33120    }
33121
33122    #[test]
33123    fn is_html_report_file_index_html_does_not_match() {
33124        let dir = tempfile::tempdir().unwrap();
33125        let path = dir.path().join("index.html");
33126        std::fs::write(&path, b"<html></html>").unwrap();
33127        assert!(!is_html_report_file(&path));
33128    }
33129
33130    #[test]
33131    fn is_html_report_file_nonexistent_returns_false() {
33132        assert!(!is_html_report_file(Path::new(
33133            "/nonexistent/result_xyz.html"
33134        )));
33135    }
33136
33137    #[test]
33138    fn find_html_report_in_dir_finds_result_html() {
33139        let dir = tempfile::tempdir().unwrap();
33140        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33141        assert!(find_html_report_in_dir(dir.path()).is_some());
33142    }
33143
33144    #[test]
33145    fn find_html_report_in_dir_empty_returns_none() {
33146        let dir = tempfile::tempdir().unwrap();
33147        assert!(find_html_report_in_dir(dir.path()).is_none());
33148    }
33149
33150    #[test]
33151    fn find_html_report_in_tree_finds_in_subdir() {
33152        let dir = tempfile::tempdir().unwrap();
33153        let subdir = dir.path().join("run-001");
33154        std::fs::create_dir_all(&subdir).unwrap();
33155        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33156        assert!(find_html_report_in_tree(dir.path()).is_some());
33157    }
33158
33159    // ── derive_project_label ──────────────────────────────────────────────────
33160
33161    #[test]
33162    fn derive_project_label_with_git_repo_and_ref() {
33163        let label = derive_project_label(
33164            Some("https://github.com/owner/my-repo.git"),
33165            Some("main"),
33166            "/fallback/path",
33167        );
33168        assert!(!label.is_empty(), "label must not be empty");
33169        assert!(
33170            label.contains("my") || label.contains("repo"),
33171            "got: {label}"
33172        );
33173    }
33174
33175    #[test]
33176    fn derive_project_label_fallback_to_path() {
33177        let label = derive_project_label(None, None, "/path/to/myproject");
33178        assert_eq!(label, "myproject");
33179    }
33180
33181    #[test]
33182    fn derive_project_label_empty_git_fields_use_path() {
33183        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33184        assert_eq!(label, "cool-app");
33185    }
33186
33187    // ── derive_file_stem ──────────────────────────────────────────────────────
33188
33189    #[test]
33190    fn derive_file_stem_with_commit_appends_sha() {
33191        assert_eq!(
33192            derive_file_stem("myproject", Some("a1b2c3")),
33193            "myproject_a1b2c3"
33194        );
33195    }
33196
33197    #[test]
33198    fn derive_file_stem_without_commit_returns_label() {
33199        assert_eq!(derive_file_stem("myproject", None), "myproject");
33200    }
33201
33202    #[test]
33203    fn derive_file_stem_empty_commit_returns_label() {
33204        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33205    }
33206
33207    // ── split_patterns ────────────────────────────────────────────────────────
33208
33209    #[test]
33210    fn split_patterns_none_is_empty() {
33211        assert!(split_patterns(None).is_empty());
33212    }
33213
33214    #[test]
33215    fn split_patterns_empty_string_is_empty() {
33216        assert!(split_patterns(Some("")).is_empty());
33217    }
33218
33219    #[test]
33220    fn split_patterns_comma_separated() {
33221        assert_eq!(
33222            split_patterns(Some("foo,bar,baz")),
33223            vec!["foo", "bar", "baz"]
33224        );
33225    }
33226
33227    #[test]
33228    fn split_patterns_newline_separated() {
33229        assert_eq!(
33230            split_patterns(Some("foo\nbar\nbaz")),
33231            vec!["foo", "bar", "baz"]
33232        );
33233    }
33234
33235    #[test]
33236    fn split_patterns_trims_whitespace() {
33237        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
33238    }
33239
33240    // ── make_git_label ────────────────────────────────────────────────────────
33241
33242    #[test]
33243    fn make_git_label_empty_repo_empty_result() {
33244        assert_eq!(make_git_label("", "main"), "");
33245    }
33246
33247    #[test]
33248    fn make_git_label_empty_ref_empty_result() {
33249        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33250    }
33251
33252    #[test]
33253    fn make_git_label_basic_format() {
33254        assert_eq!(
33255            make_git_label("https://github.com/owner/my-repo.git", "main"),
33256            "my-repo_at_main_sloc"
33257        );
33258    }
33259
33260    #[test]
33261    fn make_git_label_slash_in_ref_replaced() {
33262        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33263        assert!(
33264            !label.contains('/'),
33265            "slash in ref must be replaced: {label}"
33266        );
33267    }
33268
33269    // ── format_dir_size ───────────────────────────────────────────────────────
33270
33271    #[test]
33272    fn format_dir_size_bytes() {
33273        assert_eq!(format_dir_size(500), "500 B");
33274    }
33275
33276    #[test]
33277    fn format_dir_size_kilobytes() {
33278        assert_eq!(format_dir_size(2048), "2 KB");
33279    }
33280
33281    #[test]
33282    fn format_dir_size_megabytes() {
33283        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33284    }
33285
33286    #[test]
33287    fn format_dir_size_gigabytes() {
33288        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33289    }
33290
33291    #[test]
33292    fn format_dir_size_zero() {
33293        assert_eq!(format_dir_size(0), "0 B");
33294    }
33295
33296    // ── civil_from_days ───────────────────────────────────────────────────────
33297
33298    #[test]
33299    fn civil_from_days_epoch() {
33300        assert_eq!(civil_from_days(0), (1970, 1, 1));
33301    }
33302
33303    #[test]
33304    fn civil_from_days_one_year_later() {
33305        assert_eq!(civil_from_days(365), (1971, 1, 1));
33306    }
33307
33308    #[test]
33309    fn civil_from_days_31_days_is_feb_1_1970() {
33310        assert_eq!(civil_from_days(31), (1970, 2, 1));
33311    }
33312
33313    // ── format_system_time ────────────────────────────────────────────────────
33314
33315    #[test]
33316    fn format_system_time_unix_epoch_formats_correctly() {
33317        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33318    }
33319
33320    #[test]
33321    fn format_system_time_31_days_after_epoch() {
33322        let t = UNIX_EPOCH + Duration::from_hours(744);
33323        assert_eq!(format_system_time(t), "1970-02-01 00:00");
33324    }
33325
33326    #[test]
33327    fn format_system_time_before_epoch_returns_dash() {
33328        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33329            assert_eq!(format_system_time(before), "-");
33330        }
33331    }
33332
33333    // ── detect_language_name ──────────────────────────────────────────────────
33334
33335    #[test]
33336    fn detect_language_name_dot_c() {
33337        assert_eq!(detect_language_name("main.c"), Some("C"));
33338    }
33339
33340    #[test]
33341    fn detect_language_name_dot_h() {
33342        assert_eq!(detect_language_name("defs.h"), Some("C"));
33343    }
33344
33345    #[test]
33346    fn detect_language_name_dot_cpp() {
33347        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33348    }
33349
33350    #[test]
33351    fn detect_language_name_dot_py() {
33352        assert_eq!(detect_language_name("script.py"), Some("Python"));
33353    }
33354
33355    #[test]
33356    fn detect_language_name_dot_ps1() {
33357        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33358    }
33359
33360    #[test]
33361    fn detect_language_name_dot_cs() {
33362        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33363    }
33364
33365    #[test]
33366    fn detect_language_name_dot_sh() {
33367        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33368    }
33369
33370    #[test]
33371    fn detect_language_name_unknown_txt() {
33372        assert_eq!(detect_language_name("notes.txt"), None);
33373    }
33374
33375    // ── language_icon_file ────────────────────────────────────────────────────
33376
33377    #[test]
33378    fn language_icon_file_c() {
33379        assert_eq!(language_icon_file("C"), Some("c.png"));
33380    }
33381
33382    #[test]
33383    fn language_icon_file_python() {
33384        assert_eq!(language_icon_file("Python"), Some("python.png"));
33385    }
33386
33387    #[test]
33388    fn language_icon_file_dockerfile() {
33389        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33390    }
33391
33392    #[test]
33393    fn language_icon_file_rust_is_none() {
33394        assert!(language_icon_file("Rust").is_none());
33395    }
33396
33397    #[test]
33398    fn language_icon_file_unknown_is_none() {
33399        assert!(language_icon_file("Fortran").is_none());
33400    }
33401
33402    // ── language_inline_svg ───────────────────────────────────────────────────
33403
33404    #[test]
33405    fn language_inline_svg_rust_is_svg() {
33406        let svg = language_inline_svg("Rust").unwrap();
33407        assert!(svg.starts_with("<svg"));
33408    }
33409
33410    #[test]
33411    fn language_inline_svg_typescript_is_some() {
33412        assert!(language_inline_svg("TypeScript").is_some());
33413    }
33414
33415    #[test]
33416    fn language_inline_svg_unknown_is_none() {
33417        assert!(language_inline_svg("Fortran").is_none());
33418    }
33419
33420    // ── classify_preview_file ─────────────────────────────────────────────────
33421
33422    #[test]
33423    fn classify_preview_file_c_supported() {
33424        assert!(matches!(
33425            classify_preview_file("main.c"),
33426            PreviewKind::Supported
33427        ));
33428    }
33429
33430    #[test]
33431    fn classify_preview_file_python_supported() {
33432        assert!(matches!(
33433            classify_preview_file("script.py"),
33434            PreviewKind::Supported
33435        ));
33436    }
33437
33438    #[test]
33439    fn classify_preview_file_png_skipped() {
33440        assert!(matches!(
33441            classify_preview_file("image.png"),
33442            PreviewKind::Skipped
33443        ));
33444    }
33445
33446    #[test]
33447    fn classify_preview_file_zip_skipped() {
33448        assert!(matches!(
33449            classify_preview_file("archive.zip"),
33450            PreviewKind::Skipped
33451        ));
33452    }
33453
33454    #[test]
33455    fn classify_preview_file_min_js_skipped() {
33456        assert!(matches!(
33457            classify_preview_file("bundle.min.js"),
33458            PreviewKind::Skipped
33459        ));
33460    }
33461
33462    #[test]
33463    fn classify_preview_file_rs_unsupported() {
33464        assert!(matches!(
33465            classify_preview_file("main.rs"),
33466            PreviewKind::Unsupported
33467        ));
33468    }
33469
33470    // ── preview_relative_path ─────────────────────────────────────────────────
33471
33472    #[test]
33473    fn preview_relative_path_strips_root() {
33474        let root = PathBuf::from("/project");
33475        let path = PathBuf::from("/project/src/main.c");
33476        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33477    }
33478
33479    #[test]
33480    fn preview_relative_path_unrooted_includes_filename() {
33481        let root = PathBuf::from("/other");
33482        let path = PathBuf::from("/project/src/main.c");
33483        let result = preview_relative_path(&root, &path);
33484        assert!(result.contains("main.c"));
33485    }
33486
33487    #[test]
33488    fn preview_relative_path_uses_forward_slashes() {
33489        let root = PathBuf::from("/project");
33490        let path = PathBuf::from("/project/a/b/c.py");
33491        assert!(!preview_relative_path(&root, &path).contains('\\'));
33492    }
33493
33494    // ── wildcard_match ────────────────────────────────────────────────────────
33495
33496    #[test]
33497    fn wildcard_match_exact_equal() {
33498        assert!(wildcard_match("foo", "foo"));
33499    }
33500
33501    #[test]
33502    fn wildcard_match_exact_mismatch() {
33503        assert!(!wildcard_match("foo", "bar"));
33504    }
33505
33506    #[test]
33507    fn wildcard_match_star_suffix() {
33508        assert!(wildcard_match("*.rs", "main.rs"));
33509    }
33510
33511    #[test]
33512    fn wildcard_match_star_middle_requires_suffix() {
33513        assert!(!wildcard_match("a*b", "ac"));
33514    }
33515
33516    #[test]
33517    fn wildcard_match_question_mark_single_char() {
33518        assert!(wildcard_match("f?o", "foo"));
33519    }
33520
33521    #[test]
33522    fn wildcard_match_double_star_nested() {
33523        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33524    }
33525
33526    #[test]
33527    fn wildcard_match_star_directory_entry() {
33528        assert!(wildcard_match("vendor/*", "vendor/crate"));
33529    }
33530
33531    #[test]
33532    fn wildcard_match_no_cross_prefix() {
33533        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
33534    }
33535
33536    // ── should_skip_preview_directory ────────────────────────────────────────
33537
33538    #[test]
33539    fn should_skip_empty_relative_is_false() {
33540        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
33541    }
33542
33543    #[test]
33544    fn should_skip_matching_pattern() {
33545        assert!(should_skip_preview_directory(
33546            "vendor",
33547            &["vendor".to_string()]
33548        ));
33549    }
33550
33551    #[test]
33552    fn should_skip_non_matching() {
33553        assert!(!should_skip_preview_directory(
33554            "src",
33555            &["vendor".to_string()]
33556        ));
33557    }
33558
33559    #[test]
33560    fn should_skip_wildcard_prefix() {
33561        assert!(should_skip_preview_directory(
33562            "target/debug",
33563            &["target*".to_string()]
33564        ));
33565    }
33566
33567    // ── should_include_preview_file ───────────────────────────────────────────
33568
33569    #[test]
33570    fn should_include_empty_relative_always_true() {
33571        assert!(should_include_preview_file("", &[], &[]));
33572    }
33573
33574    #[test]
33575    fn should_include_no_patterns_includes_all() {
33576        assert!(should_include_preview_file("src/main.c", &[], &[]));
33577    }
33578
33579    #[test]
33580    fn should_include_excluded_by_pattern() {
33581        assert!(!should_include_preview_file(
33582            "vendor/lib.c",
33583            &[],
33584            &["vendor/*".to_string()]
33585        ));
33586    }
33587
33588    #[test]
33589    fn should_include_include_pattern_filters() {
33590        assert!(!should_include_preview_file(
33591            "tests/test_foo.c",
33592            &["src/*".to_string()],
33593            &[]
33594        ));
33595    }
33596
33597    // ── escape_html ───────────────────────────────────────────────────────────
33598
33599    #[test]
33600    fn escape_html_ampersand() {
33601        assert_eq!(escape_html("a&b"), "a&amp;b");
33602    }
33603
33604    #[test]
33605    fn escape_html_angle_brackets() {
33606        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
33607    }
33608
33609    #[test]
33610    fn escape_html_double_quote() {
33611        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
33612    }
33613
33614    #[test]
33615    fn escape_html_single_quote() {
33616        assert_eq!(escape_html("it's"), "it&#39;s");
33617    }
33618
33619    #[test]
33620    fn escape_html_plain_text_unchanged() {
33621        assert_eq!(escape_html("hello world"), "hello world");
33622    }
33623
33624    // ── sum_added / removed / unmodified code lines ───────────────────────────
33625
33626    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
33627        sloc_core::ScanComparison {
33628            summary: sloc_core::SummaryDelta {
33629                baseline_run_id: "base".to_string(),
33630                current_run_id: "curr".to_string(),
33631                baseline_timestamp: chrono::Utc::now(),
33632                current_timestamp: chrono::Utc::now(),
33633                baseline_files: 4,
33634                current_files: 4,
33635                files_analyzed_delta: 0,
33636                baseline_code: 330,
33637                current_code: 400,
33638                code_lines_delta: 70,
33639                baseline_comments: 0,
33640                current_comments: 0,
33641                comment_lines_delta: 0,
33642                blank_lines_delta: 0,
33643                total_lines_delta: 70,
33644                coverage_lines_hit_delta: None,
33645                coverage_line_pct_delta: None,
33646                baseline_coverage_line_pct: None,
33647                current_coverage_line_pct: None,
33648            },
33649            file_deltas: vec![
33650                sloc_core::FileDelta {
33651                    relative_path: "added.rs".to_string(),
33652                    language: Some("Rust".to_string()),
33653                    status: FileChangeStatus::Added,
33654                    baseline_code: 0,
33655                    current_code: 100,
33656                    code_delta: 100,
33657                    baseline_comment: 0,
33658                    current_comment: 0,
33659                    comment_delta: 0,
33660                    baseline_blank: 0,
33661                    current_blank: 0,
33662                    blank_delta: 0,
33663                    total_delta: 100,
33664                },
33665                sloc_core::FileDelta {
33666                    relative_path: "removed.rs".to_string(),
33667                    language: Some("Rust".to_string()),
33668                    status: FileChangeStatus::Removed,
33669                    baseline_code: 50,
33670                    current_code: 0,
33671                    code_delta: -50,
33672                    baseline_comment: 0,
33673                    current_comment: 0,
33674                    comment_delta: 0,
33675                    baseline_blank: 0,
33676                    current_blank: 0,
33677                    blank_delta: 0,
33678                    total_delta: -50,
33679                },
33680                sloc_core::FileDelta {
33681                    relative_path: "modified.rs".to_string(),
33682                    language: Some("Rust".to_string()),
33683                    status: FileChangeStatus::Modified,
33684                    baseline_code: 80,
33685                    current_code: 100,
33686                    code_delta: 20,
33687                    baseline_comment: 0,
33688                    current_comment: 0,
33689                    comment_delta: 0,
33690                    baseline_blank: 0,
33691                    current_blank: 0,
33692                    blank_delta: 0,
33693                    total_delta: 20,
33694                },
33695                sloc_core::FileDelta {
33696                    relative_path: "unchanged.rs".to_string(),
33697                    language: Some("Rust".to_string()),
33698                    status: FileChangeStatus::Unchanged,
33699                    baseline_code: 200,
33700                    current_code: 200,
33701                    code_delta: 0,
33702                    baseline_comment: 0,
33703                    current_comment: 0,
33704                    comment_delta: 0,
33705                    baseline_blank: 0,
33706                    current_blank: 0,
33707                    blank_delta: 0,
33708                    total_delta: 0,
33709                },
33710            ],
33711            files_added: 1,
33712            files_removed: 1,
33713            files_modified: 1,
33714            files_unchanged: 1,
33715        }
33716    }
33717
33718    #[test]
33719    fn sum_added_counts_added_and_positive_modified() {
33720        let cmp = make_mixed_scan_comparison();
33721        assert_eq!(sum_added_code_lines(&cmp), 120);
33722    }
33723
33724    #[test]
33725    fn sum_removed_counts_removed_baseline() {
33726        let cmp = make_mixed_scan_comparison();
33727        assert_eq!(sum_removed_code_lines(&cmp), 50);
33728    }
33729
33730    #[test]
33731    fn sum_unmodified_counts_unchanged_files() {
33732        let cmp = make_mixed_scan_comparison();
33733        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
33734    }
33735
33736    // ── detect_coverage_tool ──────────────────────────────────────────────────
33737
33738    #[test]
33739    fn detect_coverage_tool_rust_project() {
33740        let dir = tempfile::tempdir().unwrap();
33741        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
33742        let (tool, cmd) = detect_coverage_tool(dir.path());
33743        assert_eq!(tool, Some("cargo-llvm-cov"));
33744        assert!(cmd.is_some());
33745    }
33746
33747    #[test]
33748    fn detect_coverage_tool_java_gradle() {
33749        let dir = tempfile::tempdir().unwrap();
33750        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
33751        let (tool, _) = detect_coverage_tool(dir.path());
33752        assert_eq!(tool, Some("jacoco"));
33753    }
33754
33755    #[test]
33756    fn detect_coverage_tool_python_pyproject() {
33757        let dir = tempfile::tempdir().unwrap();
33758        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
33759        let (tool, _) = detect_coverage_tool(dir.path());
33760        assert_eq!(tool, Some("pytest-cov"));
33761    }
33762
33763    #[test]
33764    fn detect_coverage_tool_unknown_project() {
33765        let dir = tempfile::tempdir().unwrap();
33766        let (tool, cmd) = detect_coverage_tool(dir.path());
33767        assert!(tool.is_none() && cmd.is_none());
33768    }
33769
33770    // ── sanitize_path_str / display_path ─────────────────────────────────────
33771
33772    #[test]
33773    fn sanitize_path_str_unc_drive_stripped() {
33774        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
33775    }
33776
33777    #[test]
33778    fn sanitize_path_str_unc_network_stripped() {
33779        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
33780    }
33781
33782    #[test]
33783    fn sanitize_path_str_plain_path_unchanged() {
33784        assert_eq!(
33785            sanitize_path_str("/home/user/project"),
33786            "/home/user/project"
33787        );
33788    }
33789
33790    #[test]
33791    fn display_path_plain_linux_unchanged() {
33792        assert_eq!(
33793            display_path(Path::new("/home/user/project")),
33794            "/home/user/project"
33795        );
33796    }
33797
33798    #[test]
33799    fn display_path_unc_drive_stripped() {
33800        let result = display_path(Path::new(r"\\?\C:\Users\user"));
33801        assert_eq!(result, r"C:\Users\user");
33802    }
33803
33804    #[test]
33805    fn display_path_unc_network_stripped() {
33806        let result = display_path(Path::new(r"\\?\UNC\server\share"));
33807        assert_eq!(result, r"\\server\share");
33808    }
33809}
33810
33811#[cfg(test)]
33812mod coverage_boost_unit_tests {
33813    use super::*;
33814    use std::path::{Path, PathBuf};
33815
33816    // Both scenarios live in one test (sequential, under a Tokio runtime) because
33817    // load_runtime_security_config spawns a pruning task and mutates process-global
33818    // env vars — parallel sub-tests would race on both.
33819    #[tokio::test]
33820    async fn runtime_security_config_scenarios() {
33821        std::env::remove_var("SLOC_API_KEYS");
33822        std::env::remove_var("SLOC_API_KEY");
33823        std::env::remove_var("SLOC_TLS_CERT");
33824        std::env::remove_var("SLOC_TLS_KEY");
33825        std::env::remove_var("SLOC_TRUST_PROXY");
33826        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33827        let cfg = load_runtime_security_config(false);
33828        assert!(cfg.api_keys.is_empty());
33829        assert!(!cfg.tls_enabled);
33830        assert!(!cfg.trust_proxy);
33831
33832        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
33833        std::env::set_var("SLOC_TRUST_PROXY", "1");
33834        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
33835        std::env::set_var("SLOC_RATE_LIMIT", "250");
33836        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
33837        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
33838        let cfg = load_runtime_security_config(true);
33839        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
33840        assert!(cfg.trust_proxy);
33841        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
33842        std::env::remove_var("SLOC_API_KEYS");
33843        std::env::remove_var("SLOC_TRUST_PROXY");
33844        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
33845        std::env::remove_var("SLOC_RATE_LIMIT");
33846        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
33847        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
33848    }
33849
33850    #[test]
33851    fn cors_layer_builds_both_modes() {
33852        let _ = build_cors_layer(true);
33853        let _ = build_cors_layer(false);
33854    }
33855
33856    #[test]
33857    fn primary_lan_ip_callable() {
33858        // May be Some or None depending on the host; both are valid.
33859        let _ = primary_lan_ip();
33860    }
33861
33862    #[test]
33863    fn safe_redirect_allows_relative_rejects_absolute() {
33864        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
33865        assert_eq!(safe_redirect("https://evil.example/x"), "/");
33866        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
33867        assert_eq!(default_redirect(), "/view-reports");
33868    }
33869
33870    #[test]
33871    fn tarball_size_caps_env_override() {
33872        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
33873        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
33874        let (c, d) = parse_tarball_size_caps();
33875        assert_eq!(c, 1024 * 1024);
33876        assert_eq!(d, 2 * 1024 * 1024);
33877        std::env::remove_var("SLOC_MAX_TARBALL_MB");
33878        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
33879        let (c2, _) = parse_tarball_size_caps();
33880        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
33881    }
33882
33883    #[test]
33884    fn upload_path_helpers() {
33885        let base = upload_base_dir();
33886        let staged = upload_staging_path("abc123");
33887        assert!(staged.starts_with(&base));
33888        assert!(
33889            is_upload_tmp_path(&staged),
33890            "staging path is an upload tmp path"
33891        );
33892        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
33893    }
33894
33895    #[test]
33896    fn git_clones_dir_env_override() {
33897        std::env::remove_var("SLOC_GIT_CLONES_DIR");
33898        let def = resolve_git_clones_dir(Path::new("/out"));
33899        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
33900        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
33901        assert_eq!(
33902            resolve_git_clones_dir(Path::new("/out")),
33903            PathBuf::from("/custom/clones")
33904        );
33905        std::env::remove_var("SLOC_GIT_CLONES_DIR");
33906    }
33907
33908    #[test]
33909    fn html_report_file_detection() {
33910        let dir = std::env::temp_dir().join("sloc_html_detect");
33911        let _ = std::fs::create_dir_all(&dir);
33912        let good = dir.join("report_x.html");
33913        std::fs::write(&good, "<html></html>").unwrap();
33914        let bad = dir.join("notes.txt");
33915        std::fs::write(&bad, "x").unwrap();
33916        assert!(is_html_report_file(&good));
33917        assert!(!is_html_report_file(&bad));
33918        assert!(find_html_report_in_dir(&dir).is_some());
33919        let _ = std::fs::remove_dir_all(&dir);
33920    }
33921
33922    #[test]
33923    fn multi_delta_class_and_format() {
33924        assert_eq!(multi_delta_class(5), "pos");
33925        assert_eq!(multi_delta_class(-5), "neg");
33926        assert_eq!(multi_delta_class(0), "zero");
33927        assert_eq!(multi_fmt_delta(3), "+3");
33928        assert_eq!(multi_fmt_delta(-3), "-3");
33929        assert_eq!(multi_fmt_delta(0), "0");
33930    }
33931
33932    #[test]
33933    fn git_clone_dest_sanitizes() {
33934        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
33935        assert!(dest.starts_with("/clones"));
33936        let name = dest.file_name().unwrap().to_str().unwrap();
33937        assert!(name
33938            .chars()
33939            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
33940    }
33941}
33942
33943#[cfg(test)]
33944mod tests_private {
33945    use super::*;
33946    use std::io::Read;
33947
33948    #[test]
33949    fn size_limit_reader_zero_remaining_returns_error() {
33950        let data = b"hello world";
33951        let mut reader = SizeLimitReader {
33952            inner: &data[..],
33953            remaining: 0,
33954        };
33955        let mut buf = [0u8; 4];
33956        assert!(reader.read(&mut buf).is_err());
33957    }
33958
33959    #[test]
33960    fn size_limit_reader_counts_bytes() {
33961        let data = b"hello world";
33962        let mut reader = SizeLimitReader {
33963            inner: &data[..],
33964            remaining: 5,
33965        };
33966        let mut buf = [0u8; 4];
33967        let n = reader.read(&mut buf).unwrap();
33968        assert_eq!(n, 4);
33969        assert_eq!(reader.remaining, 1);
33970    }
33971
33972    #[test]
33973    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
33974        let uuid = "12345678-1234-1234-1234-123456789012";
33975        let (id, path) = resolve_or_create_staging(Some(uuid));
33976        assert_eq!(id, uuid);
33977        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
33978    }
33979
33980    #[test]
33981    fn resolve_or_create_staging_with_none_creates_new() {
33982        let (id1, _) = resolve_or_create_staging(None);
33983        let (id2, _) = resolve_or_create_staging(None);
33984        assert_ne!(id1, id2);
33985    }
33986
33987    #[test]
33988    fn resolve_or_create_staging_with_path_separator_creates_new() {
33989        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
33990        let (id, _) = resolve_or_create_staging(Some("has/slash"));
33991        assert_ne!(id, "has/slash");
33992    }
33993
33994    #[test]
33995    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
33996        use std::net::IpAddr;
33997        use std::str::FromStr;
33998        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
33999        let ip = IpAddr::from_str("192.168.1.1").unwrap();
34000        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34001    }
34002
34003    #[test]
34004    fn is_auth_locked_out_expired_entry_removed() {
34005        use std::net::IpAddr;
34006        use std::str::FromStr;
34007        let limiter = IpRateLimiter::new(
34008            Duration::from_mins(1),
34009            100,
34010            1, // 1 failure triggers lockout
34011            Duration::from_millis(1),
34012        );
34013        let ip = IpAddr::from_str("192.168.1.2").unwrap();
34014        limiter.record_auth_failure(ip);
34015        // Wait for the 1ms window to expire
34016        std::thread::sleep(Duration::from_millis(10));
34017        // Expired entry should be removed, returning false
34018        assert!(!limiter.is_auth_locked_out(ip));
34019    }
34020
34021    #[test]
34022    fn is_auth_locked_out_within_window_returns_true() {
34023        use std::net::IpAddr;
34024        use std::str::FromStr;
34025        let limiter = IpRateLimiter::new(
34026            Duration::from_mins(1),
34027            100,
34028            2, // 2 failures triggers lockout
34029            Duration::from_hours(1),
34030        );
34031        let ip = IpAddr::from_str("192.168.1.3").unwrap();
34032        limiter.record_auth_failure(ip);
34033        limiter.record_auth_failure(ip);
34034        assert!(limiter.is_auth_locked_out(ip));
34035    }
34036
34037    // ── output_folder_hint ───────────────────────────────────────────────────────
34038
34039    #[test]
34040    fn output_folder_hint_strips_json_subdir() {
34041        use std::path::Path;
34042        let path = Path::new("/output/scan1/json/result.json");
34043        let hint = output_folder_hint(path);
34044        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34045    }
34046
34047    #[test]
34048    fn output_folder_hint_strips_html_subdir() {
34049        use std::path::Path;
34050        let path = Path::new("/output/scan1/html/report.html");
34051        let hint = output_folder_hint(path);
34052        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34053    }
34054
34055    #[test]
34056    fn output_folder_hint_strips_pdf_subdir() {
34057        use std::path::Path;
34058        let path = Path::new("/output/scan1/pdf/report.pdf");
34059        let hint = output_folder_hint(path);
34060        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34061    }
34062
34063    #[test]
34064    fn output_folder_hint_strips_excel_subdir() {
34065        use std::path::Path;
34066        let path = Path::new("/output/scan1/excel/report.xlsx");
34067        let hint = output_folder_hint(path);
34068        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34069    }
34070
34071    #[test]
34072    fn output_folder_hint_flat_layout_returns_direct_parent() {
34073        use std::path::Path;
34074        let path = Path::new("/output/scan1/result.json");
34075        let hint = output_folder_hint(path);
34076        assert!(
34077            hint.ends_with("scan1"),
34078            "expected direct parent, got: {hint}"
34079        );
34080    }
34081
34082    #[test]
34083    fn output_folder_hint_other_subdir_name_not_stripped() {
34084        use std::path::Path;
34085        // "data" is not one of the named artifact subdirs — parent is kept as-is
34086        let path = Path::new("/output/scan1/data/result.json");
34087        let hint = output_folder_hint(path);
34088        assert!(
34089            hint.ends_with("data"),
34090            "non-artifact subdir must not be stripped, got: {hint}"
34091        );
34092    }
34093
34094    // ── find_file_by_ext ─────────────────────────────────────────────────────────
34095
34096    #[test]
34097    fn find_file_by_ext_finds_matching_file() {
34098        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34099        let _ = fs::create_dir_all(&dir);
34100        let f = dir.join("report.pdf");
34101        let _ = fs::write(&f, b"dummy");
34102        let result = find_file_by_ext(&dir, "pdf");
34103        assert!(result.is_some(), "expected to find report.pdf");
34104        let _ = fs::remove_dir_all(&dir);
34105    }
34106
34107    #[test]
34108    fn find_file_by_ext_returns_none_for_missing_ext() {
34109        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34110        let _ = fs::create_dir_all(&dir);
34111        let f = dir.join("report.json");
34112        let _ = fs::write(&f, b"{}");
34113        let result = find_file_by_ext(&dir, "pdf");
34114        assert!(result.is_none());
34115        let _ = fs::remove_dir_all(&dir);
34116    }
34117
34118    #[test]
34119    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34120        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34121        assert!(find_file_by_ext(dir, "json").is_none());
34122    }
34123
34124    // ── collect_result_json_candidates ───────────────────────────────────────────
34125
34126    #[test]
34127    fn collect_result_json_candidates_flat_root() {
34128        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34129        let _ = fs::create_dir_all(&root);
34130        let _ = fs::write(root.join("result.json"), b"{}");
34131        let candidates = collect_result_json_candidates(&root);
34132        assert!(!candidates.is_empty(), "should find result.json at root");
34133        let _ = fs::remove_dir_all(&root);
34134    }
34135
34136    #[test]
34137    fn collect_result_json_candidates_legacy_subdir() {
34138        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34139        let sub = root.join("scanA");
34140        let _ = fs::create_dir_all(&sub);
34141        let _ = fs::write(sub.join("result.json"), b"{}");
34142        let candidates = collect_result_json_candidates(&root);
34143        assert!(
34144            !candidates.is_empty(),
34145            "should find result.json in legacy subdir"
34146        );
34147        let _ = fs::remove_dir_all(&root);
34148    }
34149
34150    #[test]
34151    fn collect_result_json_candidates_structured_json_subdir() {
34152        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34153        let json_sub = root.join("scanB").join("json");
34154        let _ = fs::create_dir_all(&json_sub);
34155        let _ = fs::write(json_sub.join("result.json"), b"{}");
34156        let candidates = collect_result_json_candidates(&root);
34157        assert!(
34158            !candidates.is_empty(),
34159            "should find result.json inside <subdir>/json/"
34160        );
34161        let _ = fs::remove_dir_all(&root);
34162    }
34163
34164    #[test]
34165    fn collect_result_json_candidates_empty_dir() {
34166        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34167        let _ = fs::create_dir_all(&root);
34168        let candidates = collect_result_json_candidates(&root);
34169        assert!(candidates.is_empty());
34170        let _ = fs::remove_dir_all(&root);
34171    }
34172}